V13-FE-006: consolidate approved UI and contract hardening
@@ -0,0 +1,91 @@
|
||||
## KBX UX/AX Review
|
||||
|
||||
- [ ] 기존 9개 Screen Type 중 하나를 사용했다.
|
||||
- [ ] 신규 Component보다 기존 KBX Component 재사용을 먼저 검토했다.
|
||||
- [ ] 업무 모듈에서 PrimeVue/AG Grid를 직접 import하지 않는다.
|
||||
- [ ] 주요 명령 위치가 KBX Command Bar 규칙과 같다.
|
||||
- [ ] Keyboard 흐름(F2/F3/F8/Tab/Enter/Esc)을 검토했다.
|
||||
- [ ] 입력 가능한 대량 데이터 화면의 Excel 정책을 정의했다.
|
||||
- [ ] 정상 건을 사용자가 불필요하게 확인하는 단계가 없는지 검토했다.
|
||||
- [ ] 오류 메시지가 원인과 다음 행동을 설명한다.
|
||||
- [ ] Client validation을 업무 정합성의 최종 방어선으로 사용하지 않는다.
|
||||
- [ ] 상태 변경, Audit, Concurrency, Idempotency 영향도를 검토했다.
|
||||
- [ ] AI가 없어도 동일 업무를 수행할 수 있다.
|
||||
- [ ] AI Action은 Proposal → Validation → Command 경계를 지킨다.
|
||||
- [ ] 현장 화면은 실제 장비/네트워크 조건의 수용시험 항목을 정의했다.
|
||||
|
||||
## Architecture
|
||||
|
||||
- [ ] `node scripts/validate-kbx.mjs` 통과
|
||||
- [ ] Screen ID / Version 갱신 여부 검토
|
||||
- [ ] 기술부채 또는 KBX 예외가 있다면 이유와 재검토 시점을 기록했다.
|
||||
|
||||
|
||||
## Production readiness
|
||||
- [ ] Long operations do not block the page with a modal spinner
|
||||
- [ ] Mutation retries are idempotent or explicitly disabled
|
||||
- [ ] 409/version conflicts cannot silently overwrite newer data
|
||||
- [ ] Stale operational data exposes freshness/reload where material
|
||||
- [ ] Unexpected errors expose a correlation reference, not stack traces
|
||||
- [ ] Degraded/read-only behavior is defined for affected workflows
|
||||
|
||||
## Component verification
|
||||
- [ ] 공통 Component 변경이면 `COMMON-DS-001`에서 Default/Readonly/Disabled/Error/Loading 상태를 확인했다.
|
||||
- [ ] Keyboard/Focus 계약 변경이면 E2E scenario를 갱신했다.
|
||||
- [ ] 의도된 시각 변경이면 Compact/Comfortable/Touch baseline 변경 사유를 기록했다.
|
||||
- [ ] 공통 Component의 동작/표현 변경이면 Component Version을 검토했다.
|
||||
- [ ] 색상만으로 상태를 전달하거나 Focus Indicator를 제거하지 않았다.
|
||||
|
||||
## Design ↔ Code parity / release
|
||||
- [ ] Design Token 변경은 `packages/kbx-ui/src/tokens/source/kbx.tokens.json`에서 시작했다.
|
||||
- [ ] Semantic/Component Token 값 변경이면 Visual Regression 영향도를 검토했다.
|
||||
- [ ] Core Component 상태가 `COMMON-DS-001`과 Figma contract에 모두 존재한다.
|
||||
- [ ] 새 raw color/px literal을 추가하지 않았다. 필요한 경우 먼저 Token 승격을 검토했다.
|
||||
- [ ] Component/Screen/Token 공개 계약 변경이면 `generated/release-impact.json`의 요구 bump를 확인했다.
|
||||
- [ ] Breaking 변경이면 Migration Guide를 작성했다.
|
||||
|
||||
## API contract / Problem governance
|
||||
- [ ] 업무 모듈에서 raw `/api/...`, `axios`, `fetch`를 직접 사용하지 않는다.
|
||||
- [ ] 신규/변경 Endpoint는 `contracts/api/kbx.api.json`과 동일한 Method/Route/Permission을 가진다.
|
||||
- [ ] Mutation Retry 가능 여부와 Idempotency 정책을 정의했다.
|
||||
- [ ] Validation/Business/Conflict/Permission/NotFound/Integration/System 오류를 KBX Problem으로 표현한다.
|
||||
- [ ] API 공개 계약 변경이면 `generated/release-impact.json`의 SemVer 요구수준을 확인했다.
|
||||
- [ ] Host에서 실제 Swashbuckle OpenAPI snapshot diff를 수행했다.
|
||||
|
||||
## Authorization / Sensitive Data
|
||||
- [ ] 신규 Permission은 `contracts/authorization/kbx.authorization.json`에 등록했습니다.
|
||||
- [ ] Frontend 숨김/Disabled만으로 보안을 처리하지 않고 Backend 최종 검증이 있습니다.
|
||||
- [ ] Sensitive Field는 기본 Masking이며 전체보기/비마스킹 Export 권한을 구분했습니다.
|
||||
- [ ] Sensitive 원문을 Telemetry/AI Context에 넣지 않았습니다.
|
||||
- [ ] 전체보기 또는 민감 데이터 공개가 필요한 경우 Audit 경로를 정의했습니다.
|
||||
|
||||
## KBX v18 Scenario / Test Data
|
||||
|
||||
- [ ] 변경된 Golden Screen/업무 경계의 canonical scenario를 갱신했다.
|
||||
- [ ] Fixture는 synthetic-only이며 Production dump를 포함하지 않는다.
|
||||
- [ ] `idempotency=required` Command의 replay scenario가 있다.
|
||||
- [ ] Host에서 실행한 경우 Scenario Evidence/Correlation ID를 남겼다.
|
||||
|
||||
## External integration / resilience
|
||||
- [ ] Business State와 Integration State를 분리했다.
|
||||
- [ ] 새 외부연계는 `contracts/integrations/kbx.integrations.json`에 등록했다.
|
||||
- [ ] at-least-once 전달은 idempotency 경계를 가진다.
|
||||
- [ ] 짧은 transient retry는 bounded Polly pipeline이고 장기 retry는 Hangfire가 소유한다.
|
||||
- [ ] permanent failure는 사용자 업무 재실행이 아니라 Operations Exception으로 노출된다.
|
||||
- [ ] 실패주입 Scenario가 있다.
|
||||
|
||||
|
||||
## External data / provenance
|
||||
- [ ] 외부 Provider 응답을 화면이 직접 해석하지 않고 canonical normalizer를 거칩니다.
|
||||
- [ ] providerObservedAt / receivedAt / ingestedAt 의미를 혼합하지 않았습니다.
|
||||
- [ ] Stale/Expired 데이터가 최신값처럼 보이지 않습니다.
|
||||
- [ ] request_descriptor에는 Secret/Token이 없고 raw payload 저장은 명시적 검토 없이는 금지합니다.
|
||||
- [ ] KRX 승인 서비스의 TTL/Schema를 공식 서비스 명세 없이 추정하지 않았습니다.
|
||||
|
||||
## Configuration / deployment governance
|
||||
- [ ] 신규 설정은 `contracts/configuration/kbx.configuration.json`에 등록했고 직접 `Environment.GetEnvironmentVariable()`을 사용하지 않았다.
|
||||
- [ ] Secret에는 기본값/예제값을 넣지 않았고 generated env example도 빈 값이다.
|
||||
- [ ] Production은 `predeploy` migration + HTTPS 원칙을 유지한다.
|
||||
- [ ] 환경별 다른 바이너리를 다시 빌드하지 않고 동일 Release Artifact를 승격한다.
|
||||
- [ ] destructive migration이 필요하다면 명시적 승인 marker와 Migration Guide가 있다.
|
||||
- [ ] 배포 전 Configuration Validation / Migration Dry Run / Release Governance evidence를 확인했다.
|
||||
@@ -0,0 +1,56 @@
|
||||
name: KBX Quality Gate
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
architecture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Validate KBX architecture
|
||||
run: node scripts/validate-kbx.mjs
|
||||
- name: Ensure generated manifests are committed
|
||||
run: git diff --exit-code -- generated/ apps/web/src/registry/screens.generated.ts packages/kbx-ui/src/tokens/kbx.css packages/kbx-contracts/src/generated/ backend/Shared/Contracts/Generated/ backend/Shared/Authorization/Generated/ backend/Shared/Telemetry/Generated/ backend/Shared/Experiments/Generated/ backend/Shared/Testing/Generated/ backend/Shared/Integrations/Generated/ backend/Shared/Providers/Generated/ backend/Shared/ExternalData/Generated/ backend/Shared/Configuration/Generated/ design/figma/ contracts/api/openapi.kbx.json deploy/kbx/
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Install and execute FE runtime evidence when lockfile exists
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f pnpm-lock.yaml ]; then
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm typecheck:web
|
||||
pnpm test:unit
|
||||
pnpm build:web:demo
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
pnpm test:e2e
|
||||
else
|
||||
node scripts/report-fe-runtime-readiness.mjs
|
||||
echo "pnpm-lock.yaml is absent; reproducible browser-runtime evidence is blocked and is not reported as PASS."
|
||||
fi
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Build/test when solution exists
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
solutions=( *.sln *.slnx )
|
||||
if [ ${#solutions[@]} -gt 0 ]; then
|
||||
dotnet restore "${solutions[0]}"
|
||||
dotnet build "${solutions[0]}" --no-restore -c Release
|
||||
dotnet test "${solutions[0]}" --no-build -c Release
|
||||
else
|
||||
echo "Reference starter has no .NET solution file; backend compile gate is deferred to host repository."
|
||||
fi
|
||||
@@ -0,0 +1,54 @@
|
||||
name: KBX Release Readiness
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: Target environment (Staging or Production)
|
||||
required: true
|
||||
default: Staging
|
||||
|
||||
jobs:
|
||||
release-readiness:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout immutable candidate
|
||||
uses: actions/checkout@v4
|
||||
- name: Validate KBX contracts and configuration governance
|
||||
run: node scripts/validate-kbx.mjs
|
||||
- name: Frontend runtime evidence gate
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
if [ -f pnpm-lock.yaml ]; then
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm typecheck:web
|
||||
pnpm test:unit
|
||||
pnpm build:web:demo
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
pnpm test:e2e
|
||||
else
|
||||
node scripts/report-fe-runtime-readiness.mjs
|
||||
echo "Frontend runtime evidence is BLOCKED because the immutable candidate has no pnpm-lock.yaml."
|
||||
exit 1
|
||||
fi
|
||||
- name: Verify generated configuration/deployment artifacts
|
||||
run: git diff --exit-code -- generated/configuration-manifest.json packages/kbx-contracts/src/generated/configurationCatalog.ts backend/Shared/Configuration/Generated/KbxConfigurationCatalog.g.cs deploy/kbx/
|
||||
- name: Build/test when host repository is available
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
solutions=( *.sln *.slnx )
|
||||
if [ ${#solutions[@]} -gt 0 ]; then
|
||||
dotnet restore "${solutions[0]}"
|
||||
dotnet build "${solutions[0]}" --no-restore -c Release
|
||||
dotnet test "${solutions[0]}" --no-build -c Release
|
||||
else
|
||||
echo "Starter: host build is deferred."
|
||||
fi
|
||||
- name: Configuration validation gate
|
||||
run: echo "Host must bind target-environment secrets/config and call KbxConfigurationStartupValidator.ValidateOrThrow before promotion."
|
||||
- name: Database migration dry-run gate
|
||||
run: echo "Host must execute DbUp validation/dry-run against an environment-equivalent database before applying migrations."
|
||||
- name: Migration application policy
|
||||
run: echo "Production migrations are pre-deploy; application startup schema mutation is forbidden."
|
||||
@@ -0,0 +1,17 @@
|
||||
# KBX Foundation v60 — Status Canonical Contract Hardening
|
||||
|
||||
## 핵심
|
||||
|
||||
v60은 상태 Chip을 더 만드는 릴리스가 아니다. v59에서 만든 Status Dictionary를 **Backend/API canonical value → FE Catalog → Grid/Filter/Drawer/Workflow → Demo/Static Reference**까지 하나의 계약으로 닫는다.
|
||||
|
||||
- OMS 주문 Aggregate API는 `DRAFT / CONFIRMED / ALLOCATED / PICKING / CHECKED / SHIPPED` canonical code를 반환한다.
|
||||
- 한국어 `작성 / 확정 / 출고완료`는 API 상태값이 아니라 FE Status Catalog의 display label이다.
|
||||
- 신규 주문의 저장 전 상태는 FE-local `NEW`, 첫 저장 성공 이후 서버 상태는 `DRAFT`다.
|
||||
- 주문 Search Select와 URL/저장조건 검증은 Grid와 같은 shipment Status Catalog를 사용한다.
|
||||
- Demo Order Projection은 `shipmentStatus` 하나만 Source of Truth로 사용한다.
|
||||
- Static HTML/JavaScript Reference도 canonical raw + familiar display를 동일하게 재현한다.
|
||||
- 과거 한국어 display value로 저장된 Static Reference 검색조건은 canonical value로 안전하게 migration한다.
|
||||
|
||||
## QA
|
||||
|
||||
`node scripts/validate-kbx.mjs`와 `node scripts/validate-status-canonical-v60.mjs`로 계약을 검증한다. Browser Runtime은 lockfile/node_modules/registry 접근이 확보되기 전까지 별도 Runtime readiness에서 PASS로 간주하지 않는다.
|
||||
@@ -0,0 +1 @@
|
||||
VITE_KBX_DEMO_MODE=true
|
||||
@@ -0,0 +1,135 @@
|
||||
# KBX FE Reference Lab v55
|
||||
|
||||
v55는 T01~T09 Reference 중 T08/T09의 운영 Truth를 강화한다.
|
||||
|
||||
- T08: 6단계 `파일/매핑/검증/미리보기/반영/결과`, 검증 오류 첫/다음 Focus 이동
|
||||
- T09: Scan buffer와 최근 서버 확인 Scan을 분리하고 Offline에서는 authoritative mutation을 차단
|
||||
- 기존 Home/T02/T04/T05 v54 work-surface regression을 유지
|
||||
- Light/Dark/forced-colors와 strict CSP 경계를 유지
|
||||
|
||||
---
|
||||
|
||||
# KBX FE Reference Lab v53
|
||||
|
||||
Backend/API 없이 브라우저에서 즉시 실행하는 KBX 업무 UI 원형이다. 목적은 별도 Demo UI를 만드는 것이 아니라 **T01~T09의 실제 HTML/CSS/JavaScript 상호작용, Theme State, Keyboard, Recovery, WMS Mobile**을 빠르게 재현하는 것이다.
|
||||
|
||||
|
||||
## v53 Golden Screen Interaction QA
|
||||
|
||||
- Home module rail에 화면/확인필요/즐겨찾기/열린업무 Context를 함께 표시
|
||||
- ArrowLeft/ArrowRight/Home/End로 module rail을 mouse 없이 탐색
|
||||
- module 선택 후 Work Explorer로 scroll + focus landing
|
||||
- Vue Home과 동일한 module attention pressure를 JavaScript Reference에서도 계산
|
||||
- `:focus-visible`과 forced-colors active cue 유지
|
||||
- Reference는 Domain Commit을 흉내 내지 않으며 Server/Domain truth 경계를 유지
|
||||
- 정적 계약 증적: `scripts/validate-fe-golden-interaction-v53.mjs`
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
cd apps/web/fe-reference
|
||||
python3 -m http.server 4173
|
||||
```
|
||||
|
||||
운영 Domain을 흉내 내는 제품이 아니다. Permission, Domain Validation, DB Commit, Audit, Concurrency, Idempotency는 Server 최종 책임이다.
|
||||
|
||||
## v50 Operational Truth / Home Overflow QA
|
||||
|
||||
- Home Attention 기본 8개, 최대 priority surface 24개
|
||||
- 총 건수 >24이면 `전체 보기`라고 과장하지 않고 `우선 24개 보기`
|
||||
- 남은 항목은 `작업 센터 전체 / 알림 센터 전체`로 이동
|
||||
- Work Center open → Esc close → Home trigger Focus restore
|
||||
- KBX Business Light/Dark에서 동일한 Label/Action/Focus 유지
|
||||
- 실제 Template 코드의 non-retryable error는 `[다시 시도]`를 숨기는 공통 계약을 사용
|
||||
- 증적: `docs/evidence/v50-browser-operational-truth.json` (16/16)
|
||||
- Preview: `previews/v50-home-overflow-dark-1440x900.png`
|
||||
|
||||
|
||||
## v46 검증 범위
|
||||
|
||||
- KBX Business Light / Dark + Forced Colors 의미 체계 유지
|
||||
- clean Workspace 위치만 `sessionStorage`에 복원
|
||||
- Query/Hash/Dirty Form·Grid 값은 session snapshot에서 제외
|
||||
- 복원 tab `↺` + Home `복원` 표시, 실제 진입 시 resumed marker 제거
|
||||
- Home/no-active-tab 상태에서도 첫 Workspace Tab keyboard entry 보장
|
||||
- Storage API 차단 시 Preference/Resume 없이 fail-soft 기동
|
||||
- T09 WMS common state boundary의 Error → `[작업 다시 불러오기]` recovery loop
|
||||
- Browser Reference QA 20/20: clean resume, dirty exclusion, Dark Theme, 1280px overflow, runtime error 0
|
||||
|
||||
## v44 검증 범위
|
||||
|
||||
- KBX Business Light / Dark semantic theme
|
||||
- Windows Forced Colors system-color fallback
|
||||
- Home: 확인 필요 → 바로 시작 → 모듈별 업무
|
||||
- Header: 작업/알림 **비모달 System Panel**, Esc, Focus Restore
|
||||
- Help/AI/Suggestion/Detail: Drawer contract 유지
|
||||
- T01: 검색 → Loading/Empty/Error/Ready → 선택/Bulk/Drawer → Empty 복구
|
||||
- T02: Master CRUD + Dirty Guard + Lookup
|
||||
- T03: Header/Detail + Validation-safe F8
|
||||
- T04: Fast Entry/Paste/Fill Down
|
||||
- T05: Master→Location→재고이력
|
||||
- T06: Actionable Queue
|
||||
- T07: Reconcile/Resolution
|
||||
- T08: Excel Import + Background Job
|
||||
- T09: 390×844 WMS Mobile + Scanner/Network/Duplicate Guard
|
||||
|
||||
## Theme 원칙
|
||||
|
||||
Light/Dark는 표현 모드이며 업무 상태나 Permission의 Source of Truth가 아니다. Forced Colors가 활성화되면 OS `Canvas`, `CanvasText`, `Highlight`, `LinkText`를 우선한다. Theme 변경으로 Layout/Density/Keyboard 문법을 변경하지 않는다.
|
||||
|
||||
## QA 증적
|
||||
|
||||
- `previews/v44-home-light-1440x900.png`
|
||||
- `previews/v44-home-dark-1440x900.png`
|
||||
- `previews/v44-runtime-panel-dark-1440x900.png`
|
||||
- `previews/v44-t01-dark-1440x900.png`
|
||||
- `previews/v44-t01-forced-colors-1440x900.png`
|
||||
- `previews/v44-t09-dark-390x844.png`
|
||||
- `docs/evidence/v44-theme-state-browser.json` — 14/14
|
||||
- `docs/evidence/v44-browser-matrix.json` — 28/28
|
||||
|
||||
실행환경의 localhost/file navigation 제약 때문에 Browser QA는 동일 HTML/CSS/JS asset을 Playwright inline harness로 실행했다. 실제 Vue/Vite/PrimeVue/AG Grid runtime E2E와 혼동하지 않는다.
|
||||
|
||||
## 검증
|
||||
|
||||
```bash
|
||||
node scripts/validate-fe-reference.mjs
|
||||
node scripts/validate-fe-state-navigation-v44.mjs
|
||||
node scripts/validate-kbx.mjs
|
||||
```
|
||||
|
||||
|
||||
## v45 Safe Preference QA
|
||||
|
||||
- T01 `기본조건 저장`, `마지막 조회조건 기억` 실제 JavaScript 동작
|
||||
- 판매채널/상태/오류유형만 저장하고 자유검색어는 저장하지 않음
|
||||
- 저장값 allow-list 복원, 잘못된 값은 기본값으로 복구
|
||||
- Light/Dark Theme와 개인화 UI 동시 검증
|
||||
- 증적: `docs/evidence/v45-browser-preference-theme.json` (14/14)
|
||||
|
||||
## v46 Safe Workspace Resume QA
|
||||
|
||||
- clean Workspace tab만 `sessionStorage` 복원
|
||||
- Query/Hash, Dirty Form/Grid 값은 복원 snapshot에서 제외
|
||||
- Home `바로 시작`에 `복원` 상태 표시
|
||||
- active tab이 없는 Home에서도 첫 Workspace tab `tabindex=0`
|
||||
- Storage API 차단 시 Home/Shell fail-soft
|
||||
- KBX Business Light/Dark와 resume marker semantic parity
|
||||
- 증적: `docs/evidence/v46-browser-resume-theme.json` (20/20)
|
||||
- Preview: `previews/v46-home-resume-dark-1440x900.png`
|
||||
|
||||
|
||||
## v47 Actionable Problem
|
||||
|
||||
T01에서 예외 주문을 선택해 출고지시하면 업무규칙 Problem을 인라인으로 표시하고 `문제 N건만 선택`으로 복구한다. 단순 성공 Toast로 거짓 완료시키지 않는다. Light/Dark는 동일 semantic state를 사용한다.
|
||||
|
||||
## v48 Template Completion QA
|
||||
|
||||
- T05 재고현황: 실제 창고/품목 검색, Loading, Empty, 조건 초기화, Master→Location→History
|
||||
- T06 작업 Queue: 실제 작업자/통합검색/빠른필터, Selection, Empty Recovery
|
||||
- T07 Reconcile: 실제 검색/불일치 필터, Selection, Reference 재처리 상태변경
|
||||
- T08 Excel Import: 실제 Browser File input/Drop, `.xlsx/.xls`/20MB guard, 6-step visual flow
|
||||
- Search Panel의 조회 버튼은 Enter/F3뿐 아니라 Mouse click도 동일 실행 경로를 사용한다.
|
||||
- KBX Business Light/Dark semantic theme는 동일 업무 상태와 조작 계약을 유지한다.
|
||||
- Browser 증적: `docs/evidence/v48-browser-template-completion.json` (30/30)
|
||||
- Preview: `previews/v48-t05-inventory-dark-1440x900.png`, `previews/v48-t08-file-dark-1440x900.png`
|
||||
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; frame-src 'none'; worker-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<!-- FE Reference Lab v53 compatibility baseline retained for regression gates. -->
|
||||
<!-- FE Reference Lab v54 compatibility baseline retained for regression gates. -->
|
||||
<!-- FE Reference Lab v55 compatibility baseline retained for regression gates. -->
|
||||
<title>KBX FE Reference Lab v60 · Canonical Status Contract · Runtime Evidence · Grid/Filter/Workflow Truth · T01~T09</title>
|
||||
<link rel="stylesheet" href="./kbx-fe.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="kbx-app" data-density="compact" data-nav="expanded" data-module="COMMON">
|
||||
<a class="skip-link" href="#workspace">본문 바로가기</a>
|
||||
|
||||
<header class="global-header">
|
||||
<div class="brand-area">
|
||||
<button id="homeButton" class="brand" type="button" aria-label="KBX 홈으로 이동">
|
||||
<strong>KBX</strong><span>업무 플랫폼</span>
|
||||
</button>
|
||||
<label class="module-select"><span class="sr-only">업무 모듈</span>
|
||||
<select id="moduleSelect"><option value="ALL">전체 모듈</option><option value="OMS">OMS</option><option value="ERP">ERP</option><option value="WMS">WMS</option><option value="COMMON">공통</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<button id="globalSearchButton" class="global-search" type="button" aria-keyshortcuts="Control+K">
|
||||
<span>메뉴명 · 화면코드 · 업무명 검색</span><kbd>Ctrl K</kbd>
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button id="jobsButton" type="button" data-panel="jobs" aria-controls="systemPanel" aria-expanded="false">작업 <b>2</b></button>
|
||||
<button id="noticeButton" type="button" data-panel="notice" aria-controls="systemPanel" aria-expanded="false">알림 <b>4</b></button>
|
||||
<button id="densityButton" type="button" title="화면 밀도 전환">Compact</button>
|
||||
<button id="themeButton" type="button" title="KBX Business 테마 전환" aria-label="어두운 테마로 변경"><span aria-hidden="true">☀</span> <span>Light</span></button>
|
||||
<button type="button">사용자</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="shell-body">
|
||||
<aside id="sideNav" class="side-nav" aria-label="업무 메뉴">
|
||||
<div class="side-nav-top">
|
||||
<button id="sideHomeButton" class="nav-home active" type="button"><span aria-hidden="true">⌂</span><span class="nav-label">홈</span></button>
|
||||
<button id="collapseButton" class="collapse-button" type="button" aria-label="메뉴 접기" aria-expanded="true">‹</button>
|
||||
</div>
|
||||
<button id="sideSearchButton" class="side-search" type="button" aria-keyshortcuts="Control+K"><span class="nav-label">메뉴·화면 검색</span><kbd class="nav-label">Ctrl K</kbd><span class="collapsed-only" aria-hidden="true">⌕</span></button>
|
||||
<nav id="moduleSwitcher" class="module-switcher" aria-label="업무 모듈"></nav>
|
||||
<div id="favoriteNav" class="personal-nav"></div>
|
||||
<div id="recentNav" class="personal-nav"></div>
|
||||
<div id="menuNav" class="menu-nav"></div>
|
||||
</aside>
|
||||
|
||||
<main class="workspace-shell">
|
||||
<nav id="workspaceTabs" class="workspace-tabs" role="tablist" aria-label="열린 업무"></nav>
|
||||
<div id="workspace" class="workspace" tabindex="-1"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="menuSearchDialog" class="menu-search-dialog" aria-labelledby="menuSearchTitle">
|
||||
<div class="search-dialog-shell">
|
||||
<header><strong id="menuSearchTitle">업무 화면 검색</strong><button id="searchCloseButton" type="button" aria-label="닫기">×</button></header>
|
||||
<label class="search-input-wrap"><span class="sr-only">검색어</span><input id="menuSearchInput" autocomplete="off" placeholder="예: 주문, 재고, OMS-ORD-001" role="combobox" aria-controls="menuSearchResults" /></label>
|
||||
<div id="menuSearchResults" class="menu-search-results" role="listbox" aria-label="검색 결과"></div>
|
||||
<footer><span>↑↓ 이동 · Enter 열기 · Esc 닫기</span><span>화면코드 검색 지원</span></footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="unsavedDialog" class="confirm-dialog" aria-labelledby="unsavedTitle">
|
||||
<form method="dialog" class="confirm-shell">
|
||||
<header><strong id="unsavedTitle">저장하지 않은 변경사항이 있습니다.</strong></header>
|
||||
<p id="unsavedMessage">변경사항을 저장하거나 버린 뒤 화면을 닫을 수 있습니다.</p>
|
||||
<footer>
|
||||
<button id="unsavedStay" value="cancel" class="btn" type="submit">계속 편집</button>
|
||||
<button id="unsavedDiscard" value="discard" class="btn danger" type="button">변경 버리기</button>
|
||||
<button id="unsavedSave" value="save" class="btn primary" type="button">저장 후 닫기</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
|
||||
<dialog id="lookupDialog" class="lookup-dialog" aria-labelledby="lookupTitle">
|
||||
<div class="lookup-dialog-shell">
|
||||
<header><div><strong id="lookupTitle">업무 조회</strong><small id="lookupSubtitle">F2 조회</small></div><button id="lookupCloseButton" type="button" aria-label="닫기">×</button></header>
|
||||
<form id="lookupSearchForm" class="lookup-search">
|
||||
<label><span class="sr-only">검색어</span><input id="lookupSearchInput" autocomplete="off" maxlength="80" placeholder="코드 또는 명칭" /></label>
|
||||
<button class="btn" type="submit">조회</button>
|
||||
</form>
|
||||
<div id="lookupResults" class="lookup-results" role="listbox" aria-label="조회 결과"></div>
|
||||
<footer><span id="lookupStatus">↑↓ 이동 · Enter 선택 · Esc 닫기</span><button id="lookupSelectButton" class="btn primary" type="button">선택</button></footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="quantityDialog" class="confirm-dialog" aria-labelledby="quantityTitle">
|
||||
<form method="dialog" class="confirm-shell" id="quantityForm">
|
||||
<header><strong id="quantityTitle">피킹 수량 입력</strong></header>
|
||||
<label class="dialog-field"><span>수량</span><input id="quantityInput" type="number" inputmode="numeric" min="0" step="1" /></label>
|
||||
<p class="dialog-help" id="quantityHelp">필요수량을 초과할 수 없습니다.</p>
|
||||
<footer><button class="btn" value="cancel" type="submit">취소</button><button id="quantityApply" class="btn primary" type="button">적용</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="exceptionDialog" class="confirm-dialog" aria-labelledby="exceptionTitle">
|
||||
<form method="dialog" class="confirm-shell" id="exceptionForm">
|
||||
<header><strong id="exceptionTitle">재고부족 신고</strong></header>
|
||||
<fieldset class="exception-options"><legend>사유</legend><label><input type="radio" name="exceptionReason" value="실물 없음" checked /> 실물 없음</label><label><input type="radio" name="exceptionReason" value="수량 부족" /> 수량 부족</label><label><input type="radio" name="exceptionReason" value="위치 오류" /> 위치 오류</label><label><input type="radio" name="exceptionReason" value="상품 이상" /> 상품 이상</label></fieldset>
|
||||
<label class="dialog-field"><span>메모</span><textarea id="exceptionMemo" rows="3" maxlength="200" placeholder="선택 입력"></textarea></label>
|
||||
<footer><button class="btn" value="cancel" type="submit">취소</button><button id="exceptionApply" class="btn primary" type="button">등록</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<aside id="systemPanel" class="system-panel" hidden tabindex="-1" role="region" aria-labelledby="systemPanelTitle">
|
||||
<header><strong id="systemPanelTitle">업무 센터</strong><button id="systemPanelClose" type="button" aria-label="패널 닫기">×</button></header>
|
||||
<div id="systemPanelBody" class="system-panel-body"></div>
|
||||
</aside>
|
||||
|
||||
<aside id="drawer" class="drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="drawerTitle" tabindex="-1">
|
||||
<header><strong id="drawerTitle">상세</strong><button id="drawerClose" type="button" aria-label="닫기">×</button></header>
|
||||
<div id="drawerBody" class="drawer-body"></div>
|
||||
</aside>
|
||||
<div id="drawerBackdrop" class="drawer-backdrop" hidden></div>
|
||||
<div id="toastRegion" class="toast-region" aria-live="polite" aria-atomic="true"></div>
|
||||
<div id="liveRegion" class="sr-only" aria-live="polite" aria-atomic="true"></div>
|
||||
</div>
|
||||
<script src="./kbx-fe.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,186 @@
|
||||
:root{
|
||||
--blue-50:#eff6ff;--blue-100:#dbeafe;--blue-600:#2563eb;--blue-700:#1d4ed8;
|
||||
--gray-0:#fff;--gray-25:#fcfcfd;--gray-50:#f8fafc;--gray-100:#f1f5f9;--gray-200:#e2e8f0;--gray-300:#cbd5e1;--gray-400:#94a3b8;--gray-500:#64748b;--gray-600:#475569;--gray-700:#334155;--gray-800:#1e293b;--gray-900:#0f172a;
|
||||
--red-50:#fff1f2;--red-200:#fecdd3;--red-600:#e11d48;--amber-50:#fffbeb;--amber-200:#fde68a;--amber-700:#b45309;--green-50:#f0fdf4;--green-200:#bbf7d0;--green-700:#15803d;
|
||||
--surface:var(--gray-0);--muted:var(--gray-50);--border:var(--gray-200);--border-strong:var(--gray-300);--text:var(--gray-900);--text-muted:var(--gray-500);--primary:var(--blue-600);--danger:var(--red-600);--warning:var(--amber-700);--success:var(--green-700);
|
||||
--space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;--space-5:20px;--space-6:24px;--radius:4px;--shadow:0 12px 34px rgba(15,23,42,.14);
|
||||
--header-h:56px;--tabs-h:40px;--nav-w:220px;--nav-collapsed:56px;--control-h:34px;--row-h:34px;--font:14px;--grid-font:13px;
|
||||
}
|
||||
.kbx-app[data-theme="dark"]{
|
||||
color-scheme:dark;
|
||||
--surface:#111827;--muted:#0b1220;--border:#334155;--border-strong:#64748b;--text:#e5e7eb;--text-muted:#94a3b8;--primary:#60a5fa;--danger:#fb7185;--warning:#fbbf24;--success:#34d399;
|
||||
--blue-50:#102a43;--blue-100:#153b5c;--gray-0:#111827;--gray-25:#111827;--gray-50:#0b1220;--gray-100:#172033;--gray-200:#334155;--gray-300:#475569;--gray-400:#64748b;--gray-500:#94a3b8;--gray-600:#cbd5e1;--gray-700:#e2e8f0;--gray-800:#e5e7eb;--gray-900:#f8fafc;
|
||||
--red-50:#3b1118;--red-200:#9f1239;--red-600:#fb7185;--amber-50:#33270b;--amber-200:#854d0e;--amber-700:#fbbf24;--green-50:#062e24;--green-200:#166534;--green-700:#34d399;--shadow:0 18px 48px rgba(0,0,0,.42);
|
||||
}
|
||||
*{box-sizing:border-box}html,body,#app{height:100%;margin:0}body{font-family:Pretendard,"Apple SD Gothic Neo","Noto Sans KR",Arial,sans-serif;font-size:var(--font);color:var(--text);background:var(--muted)}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer}.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}.skip-link{position:fixed;top:-60px;left:8px;z-index:999;padding:8px 12px;background:var(--text);color:#fff;border-radius:4px}.skip-link:focus{top:8px}
|
||||
.kbx-app{height:100%;display:flex;flex-direction:column;color:var(--text);background:var(--muted)}.kbx-app[data-density="comfortable"]{--control-h:38px;--row-h:40px;--font:14px;--grid-font:14px}
|
||||
.global-header{height:var(--header-h);display:grid;grid-template-columns:var(--nav-w) minmax(320px,620px) 1fr;align-items:center;gap:16px;padding:0 12px;border-bottom:1px solid var(--border);background:var(--surface);z-index:40}.brand-area{display:flex;align-items:center;gap:8px;min-width:0}.brand{height:36px;border:0;background:transparent;display:flex;align-items:baseline;gap:8px;padding:0 8px;border-radius:4px}.brand:hover{background:var(--muted)}.brand strong{font-size:17px}.brand span{font-size:12px;color:var(--text-muted)}.module-select{padding-left:8px;border-left:1px solid var(--border)}.module-select select{height:32px;width:88px;border:1px solid var(--border);border-radius:4px;background:var(--surface);padding:0 8px}.global-search{height:36px;display:flex;align-items:center;justify-content:space-between;border:1px solid var(--border-strong);border-radius:4px;background:var(--muted);color:var(--text-muted);padding:0 12px;text-align:left}.global-search:hover{border-color:var(--gray-400);background:var(--surface)}.global-search kbd,.side-search kbd{font-size:11px;border:1px solid var(--border);border-radius:3px;padding:2px 5px;background:var(--surface)}.header-actions{display:flex;justify-content:flex-end;gap:4px}.header-actions button{height:32px;border:0;background:transparent;border-radius:4px;padding:0 9px}.header-actions button:hover{background:var(--muted)}.header-actions b{color:var(--primary);margin-left:3px}
|
||||
.shell-body{flex:1;min-height:0;display:flex}.side-nav{width:var(--nav-w);min-width:var(--nav-w);padding:8px;border-right:1px solid var(--border);background:var(--surface);overflow:auto;transition:width .16s ease,min-width .16s ease}.side-nav.collapsed{width:var(--nav-collapsed);min-width:var(--nav-collapsed);overflow:hidden}.side-nav-top{position:sticky;top:-8px;z-index:3;display:flex;gap:4px;padding:8px 0 8px;background:var(--surface)}.nav-home{flex:1;height:32px;display:flex;align-items:center;gap:8px;padding:0 8px;border:0;border-radius:4px;background:transparent;text-align:left}.nav-home:hover,.nav-home.active{background:var(--blue-50);color:var(--primary)}.collapse-button{width:28px;height:28px;border:1px solid var(--border);border-radius:4px;background:var(--surface)}.side-search{width:100%;height:34px;display:flex;align-items:center;justify-content:space-between;border:1px solid var(--border);border-radius:4px;background:var(--muted);color:var(--text-muted);padding:0 8px}.module-switcher{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:8px}.module-switcher button{height:28px;border:1px solid var(--border);border-radius:4px;background:var(--surface);font-size:12px}.module-switcher button.active{border-color:var(--primary);color:var(--primary);background:var(--blue-50);font-weight:600}.personal-nav,.menu-nav{margin-top:12px}.personal-nav h2,.menu-group h2{margin:0 8px 4px;font-size:12px;font-weight:600;color:var(--text-muted)}.nav-row{display:flex;align-items:center;border-radius:4px}.nav-row:hover{background:var(--muted)}.nav-row .nav-entry{min-width:0;flex:1;height:32px;border:0;background:transparent;text-align:left;padding:0 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nav-row .nav-entry.active{color:var(--primary);font-weight:600}.nav-row .star{width:28px;height:28px;border:0;background:transparent;color:var(--gray-400);border-radius:4px}.nav-row .star.on{color:var(--primary)}.collapsed .nav-label,.collapsed .personal-nav,.collapsed .menu-nav h2,.collapsed .nav-row .star{display:none}.collapsed .side-nav-top{flex-direction:column}.collapsed .nav-home{width:38px;flex:none;justify-content:center;padding:0}.collapsed .collapse-button{width:38px}.collapsed .side-search{width:38px;justify-content:center;padding:0}.collapsed .module-switcher{grid-template-columns:1fr}.collapsed .module-switcher button{width:38px;height:34px}.collapsed .menu-nav{display:block}.collapsed .menu-group{margin:0}.collapsed .nav-row .nav-entry{width:38px;flex:none;text-align:center;padding:0;font-size:0}.collapsed .nav-row .nav-entry::first-letter{font-size:12px}.collapsed-only{display:none}.collapsed .collapsed-only{display:inline}
|
||||
.workspace-shell{flex:1;min-width:0;display:flex;flex-direction:column}.workspace-tabs{height:var(--tabs-h);display:flex;align-items:flex-end;gap:1px;padding:0 8px;border-bottom:1px solid var(--border);background:var(--muted);overflow-x:auto}.tab-shell{height:36px;display:flex;align-items:center;max-width:220px;border:1px solid transparent;border-bottom:0}.tab-shell.active{border-color:var(--border);background:var(--surface)}.tab-main{min-width:0;height:100%;display:flex;align-items:center;gap:6px;border:0;background:transparent;padding:0 8px;color:var(--text-muted)}.tab-shell.active .tab-main{color:var(--text);font-weight:500}.tab-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tab-close,.tab-pin{width:26px;height:100%;border:0;background:transparent;color:var(--text-muted)}.tab-close:hover,.tab-pin:hover{background:var(--muted)}.tab-dirty{color:var(--warning);font-size:10px}.workspace-tabs .empty-tabs{margin:auto;color:var(--text-muted);font-size:12px;padding-bottom:9px}.workspace{flex:1;min-height:0;overflow:auto;background:var(--surface)}
|
||||
.home-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:16px}.home-heading{display:grid;grid-template-columns:1fr minmax(320px,520px);align-items:center;gap:24px;min-height:76px;border-bottom:1px solid var(--border)}.home-heading p,.home-heading small{margin:0;color:var(--text-muted);font-size:12px}.home-heading h1{margin:1px 0 3px;font-size:20px}.home-search{height:40px;display:flex;align-items:center;justify-content:space-between;border:1px solid var(--border-strong);background:var(--surface);border-radius:4px;padding:0 12px;color:var(--text-muted)}.attention-zone{border:1px solid var(--amber-200);background:var(--amber-50)}.section-head{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--muted)}.attention-zone .section-head{background:transparent;border-color:var(--amber-200)}.section-head h2{font-size:16px;margin:0}.section-head small{color:var(--text-muted)}.attention-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px;padding:8px}.attention-item{min-height:44px;display:grid;grid-template-columns:72px minmax(0,1fr) auto 14px;align-items:center;gap:8px;border:1px solid var(--border);border-radius:4px;background:var(--surface);padding:4px 8px;text-align:left}.attention-item.critical{border-color:var(--red-200);background:var(--red-50)}.attention-source{font-size:12px;font-weight:600;color:var(--text-muted)}.attention-item.critical .attention-source{color:var(--danger)}.attention-copy{min-width:0;display:grid}.attention-copy b,.attention-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.attention-copy b{font-weight:500}.attention-copy small{font-size:12px;color:var(--text-muted)}.attention-action{font-size:12px;color:var(--text-muted)}.home-section{border:1px solid var(--border);background:var(--surface)}.continue-list{display:flex;flex-direction:column}.continue-item{min-height:42px;display:grid;grid-template-columns:72px minmax(0,1fr) auto 14px;align-items:center;gap:8px;border:0;border-bottom:1px solid var(--border);background:var(--surface);padding:0 12px;text-align:left}.continue-item:last-child{border-bottom:0}.continue-item:hover{background:var(--muted)}.continue-source{font-size:12px;color:var(--text-muted);font-weight:600}.continue-item[data-source="dirty"] .continue-source{color:var(--warning)}.continue-item[data-source="favorite"] .continue-source{color:var(--primary)}.continue-copy{display:grid;min-width:0}.continue-copy small{color:var(--text-muted);font-size:12px}.work-explorer{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.module-column{border-right:1px solid var(--border);border-bottom:1px solid var(--border)}.module-column:nth-child(2n){border-right:0}.module-title{height:44px;display:flex;align-items:center;gap:8px;padding:0 12px;border-bottom:1px solid var(--border)}.module-title strong{font-size:16px}.module-title span,.module-title small{font-size:12px;color:var(--text-muted)}.module-title small{margin-left:auto}.group-block{padding:8px}.group-block h3{font-size:12px;color:var(--text-muted);margin:0 4px 4px}.home-menu-row{display:flex;align-items:center;min-height:36px;border-radius:4px}.home-menu-row:hover{background:var(--muted)}.home-menu-row .open-screen{flex:1;min-width:0;height:36px;border:0;background:transparent;text-align:left;padding:0 8px;display:flex;align-items:center;justify-content:space-between}.home-menu-row .open-screen small{font-size:12px;color:var(--text-muted)}.home-menu-row .fav{width:28px;height:28px;border:0;background:transparent;color:var(--gray-400)}.home-menu-row .fav.on{color:var(--primary)}
|
||||
.screen{height:100%;min-height:0;display:flex;flex-direction:column;background:var(--surface)}.page-header{min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:4px 16px;border-bottom:1px solid var(--border)}.page-identity{min-width:0}.breadcrumb{font-size:12px;color:var(--text-muted);margin-bottom:2px}.title-row{display:flex;align-items:center;gap:8px}.title-row h1{font-size:20px;margin:0;font-weight:600}.status-chip,.dirty-chip{font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 6px;background:var(--muted)}.dirty-chip{color:var(--warning);border-color:var(--amber-200);background:var(--amber-50)}.page-tools{display:flex;gap:4px}.page-tools button{height:28px;border:1px solid var(--border);border-radius:4px;background:var(--surface);padding:0 8px;font-size:12px}.command-bar{min-height:44px;display:flex;align-items:center;gap:8px;padding:0 16px;border-bottom:1px solid var(--border);background:var(--surface);position:sticky;top:0;z-index:6}.command-group{display:flex;gap:6px}.command-separator{height:22px;width:1px;background:var(--border)}.btn{height:var(--control-h);border:1px solid var(--border-strong);border-radius:4px;background:var(--surface);padding:0 12px}.btn:hover{background:var(--muted)}.btn.primary{border-color:var(--primary);background:var(--primary);color:#fff}.btn.danger{border-color:var(--red-200);color:var(--danger);background:var(--red-50)}.btn:disabled{opacity:.5;cursor:not-allowed}.screen-body{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px;padding:8px 16px 12px}.search-panel{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 12px;padding:8px;border:1px solid var(--border);background:var(--muted)}.search-fields{display:flex;align-items:center;gap:8px 16px;flex-wrap:wrap}.field-inline{display:flex;align-items:center;gap:6px}.field-inline>span{font-size:13px;color:var(--gray-700);white-space:nowrap}.control{height:var(--control-h);border:1px solid var(--border-strong);border-radius:4px;background:var(--surface);padding:0 8px;min-width:110px}.control.medium{min-width:180px}.control.wide{min-width:240px}.search-actions{display:flex;gap:4px}.quick-filter{display:flex;align-items:stretch;border:1px solid var(--border);background:var(--surface)}.quick-filter button{min-width:96px;min-height:44px;border:0;border-right:1px solid var(--border);background:var(--surface);text-align:left;padding:5px 10px}.quick-filter button:last-child{border-right:0}.quick-filter button.active{box-shadow:inset 0 -2px var(--primary);background:var(--blue-50)}.quick-filter b{display:block;font-size:16px}.quick-filter small{color:var(--text-muted)}.quick-filter .danger b{color:var(--danger)}.context-bar{min-height:34px;display:flex;align-items:center;gap:12px;padding:0 10px;border:1px solid var(--border);background:var(--muted);font-size:12px}.context-bar strong{font-size:13px}.bulk-bar[hidden]{display:none!important}.bulk-bar{min-height:42px;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--blue-100);background:var(--blue-50)}.bulk-bar strong{margin-right:6px}.grid-wrap{flex:1;min-height:220px;border:1px solid var(--border);overflow:auto;background:var(--surface)}.data-grid{width:100%;border-collapse:separate;border-spacing:0;font-size:var(--grid-font)}.data-grid th,.data-grid td{height:var(--row-h);padding:0 8px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);white-space:nowrap}.data-grid th{height:36px;position:sticky;top:0;z-index:2;background:var(--muted);font-weight:600;text-align:left}.data-grid th:last-child,.data-grid td:last-child{border-right:0}.data-grid tr:hover td{background:var(--blue-50)}.data-grid .num{text-align:right}.data-grid .center{text-align:center}.data-grid .link{color:var(--primary);text-decoration:none}.data-grid .warn{color:var(--warning);font-weight:600}.data-grid .error{color:var(--danger);font-weight:600}.data-grid td[contenteditable="true"]{background:var(--amber-50);outline:0}.data-grid td[contenteditable="true"]:focus{box-shadow:inset 0 0 0 2px var(--primary)}.summary-bar{min-height:34px;display:flex;align-items:center;gap:18px;padding:0 10px;border:1px solid var(--border);border-top:0;color:var(--text-muted);font-size:12px}.summary-bar strong{color:var(--text)}
|
||||
.form-section{border:1px solid var(--border);background:var(--surface)}.form-section h2{height:38px;display:flex;align-items:center;margin:0;padding:0 12px;border-bottom:1px solid var(--border);background:var(--muted);font-size:15px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 20px;padding:12px}.form-field{display:grid;grid-template-columns:96px minmax(0,1fr);align-items:center;gap:8px;min-height:34px}.form-field label{font-size:13px;color:var(--gray-700)}.form-field .required::after{content:" *";color:var(--danger)}.lookup{display:grid;grid-template-columns:100px minmax(120px,1fr) 58px;gap:4px}.lookup input,.form-field input,.form-field select{height:var(--control-h);border:1px solid var(--border-strong);border-radius:4px;background:var(--surface);padding:0 8px;min-width:0}.lookup button{height:var(--control-h);border:1px solid var(--border-strong);border-radius:4px;background:var(--surface)}.transaction-layout{display:flex;flex-direction:column;gap:8px;min-height:0;flex:1}.master-layout{display:grid;grid-template-columns:minmax(280px,32%) minmax(0,1fr);gap:8px;min-height:0;flex:1}.master-pane,.detail-pane{border:1px solid var(--border);min-height:0;overflow:auto}.master-pane .search-panel{border:0;border-bottom:1px solid var(--border)}.detail-pane{padding:12px;display:flex;flex-direction:column;gap:10px}.split-layout{display:grid;grid-template-columns:minmax(260px,40%) minmax(0,1fr);gap:8px;min-height:0;flex:1}.pane{border:1px solid var(--border);min-height:0;overflow:auto}.pane-header{height:38px;display:flex;align-items:center;justify-content:space-between;padding:0 10px;border-bottom:1px solid var(--border);background:var(--muted)}.queue-summary{display:grid;grid-template-columns:repeat(5,1fr);border:1px solid var(--border)}.queue-summary button{min-height:58px;border:0;border-right:1px solid var(--border);background:var(--surface);text-align:left;padding:7px 10px}.queue-summary button:last-child{border-right:0}.queue-summary b{display:block;font-size:18px}.queue-summary .danger b{color:var(--danger)}.reconcile-summary{display:flex;gap:20px;padding:8px 10px;border:1px solid var(--border);background:var(--muted);font-size:13px}.reconcile-summary .mismatch strong{color:var(--danger)}.import-steps{display:grid;grid-template-columns:repeat(6,1fr);border:1px solid var(--border)}.import-step{min-height:42px;display:flex;align-items:center;justify-content:center;gap:6px;border-right:1px solid var(--border);background:var(--muted);font-size:13px}.import-step:last-child{border-right:0}.import-step.active{background:var(--blue-50);color:var(--primary);font-weight:600}.drop-zone{min-height:230px;border:1px dashed var(--border-strong);display:grid;place-items:center;text-align:center;background:var(--gray-25)}.drop-zone strong{display:block;font-size:16px;margin-bottom:8px}.validation-cards{display:flex;gap:8px}.validation-card{min-width:120px;border:1px solid var(--border);padding:8px 10px}.validation-card b{display:block;font-size:18px}.validation-card.error b{color:var(--danger)}
|
||||
.mobile-stage{background:var(--gray-100);padding:20px;min-height:100%;display:flex;justify-content:center;align-items:flex-start}.wms-phone{width:390px;min-height:720px;background:var(--surface);border:1px solid var(--border-strong);box-shadow:0 8px 24px rgba(15,23,42,.1);display:flex;flex-direction:column}.wms-header{min-height:64px;display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border)}.wms-header h1{font-size:20px;margin:0}.network{font-size:12px;color:var(--success)}.wms-content{flex:1;padding:16px;display:flex;flex-direction:column;gap:14px}.scan-location{font-size:36px;font-weight:700;letter-spacing:.02em}.scan-box{height:110px;border:2px solid var(--border-strong);display:grid;place-items:center;font-weight:700;background:var(--muted)}.qty-grid{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--border)}.qty-grid div{text-align:center;padding:10px;border-right:1px solid var(--border)}.qty-grid div:last-child{border-right:0}.qty-grid b{display:block;font-size:24px}.wms-actions{position:sticky;bottom:0;display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px 16px;border-top:1px solid var(--border);background:var(--surface)}.wms-actions .btn{height:54px;font-size:16px;font-weight:600}.wms-actions .full{grid-column:1/-1}.error-panel{border:1px solid var(--red-200);background:var(--red-50);padding:12px}.error-panel strong{display:block;color:var(--danger);margin-bottom:4px}
|
||||
.menu-search-dialog{width:min(680px,calc(100vw - 32px));padding:0;border:1px solid var(--border-strong);border-radius:6px;box-shadow:var(--shadow)}.menu-search-dialog::backdrop{background:rgba(15,23,42,.35)}.search-dialog-shell header{height:46px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--border)}.search-dialog-shell header button{width:30px;height:30px;border:0;background:transparent;font-size:20px}.search-input-wrap{display:block;padding:10px;border-bottom:1px solid var(--border)}.search-input-wrap input{width:100%;height:40px;border:1px solid var(--border-strong);border-radius:4px;padding:0 12px}.menu-search-results{max-height:420px;overflow:auto}.search-result{width:100%;min-height:48px;display:grid;grid-template-columns:82px minmax(0,1fr) auto;align-items:center;gap:10px;border:0;border-bottom:1px solid var(--border);background:var(--surface);padding:5px 12px;text-align:left}.search-result.active,.search-result:hover{background:var(--blue-50)}.search-result .code{font-size:11px;color:var(--text-muted)}.search-result .copy{display:grid}.search-result .copy small{color:var(--text-muted);font-size:12px}.search-result .module{font-size:12px;color:var(--text-muted)}.search-dialog-shell footer{height:34px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;background:var(--muted);color:var(--text-muted);font-size:11px}
|
||||
.drawer{position:fixed;z-index:90;top:56px;right:0;bottom:0;width:min(560px,92vw);background:var(--surface);border-left:1px solid var(--border);box-shadow:-10px 0 24px rgba(15,23,42,.1);transform:translateX(100%);transition:transform .18s ease;display:flex;flex-direction:column}.drawer.open{transform:translateX(0)}.drawer header{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--border)}.drawer header button{width:30px;height:30px;border:0;background:transparent;font-size:20px}.drawer-body{flex:1;overflow:auto;padding:14px}.detail-list{display:grid;gap:0;border:1px solid var(--border)}.detail-row{display:grid;grid-template-columns:110px 1fr;min-height:36px;border-bottom:1px solid var(--border);align-items:center}.detail-row:last-child{border-bottom:0}.detail-row span{color:var(--text-muted);padding:0 10px}.detail-row strong{font-weight:500}.toast-region{position:fixed;right:16px;bottom:16px;z-index:120;display:grid;gap:8px}.toast{min-width:260px;max-width:420px;padding:10px 12px;border:1px solid var(--border-strong);background:var(--surface);box-shadow:var(--shadow);border-radius:4px}.toast.success{border-color:var(--green-200);background:var(--green-50)}.toast.error{border-color:var(--red-200);background:var(--red-50)}
|
||||
button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[contenteditable="true"]:focus-visible{outline:2px solid var(--primary);outline-offset:1px}
|
||||
@media(max-width:1100px){.global-header{grid-template-columns:var(--nav-w) minmax(260px,1fr) auto}.header-actions button:nth-child(-n+2){display:none}.work-explorer{grid-template-columns:1fr}.module-column{border-right:0}.attention-grid{grid-template-columns:1fr}.queue-summary{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:820px){.global-header{grid-template-columns:auto 1fr auto}.module-select,.brand span,.global-search kbd,.header-actions button:not(#densityButton):not(#themeButton){display:none}.side-nav{width:56px;min-width:56px}.side-nav .nav-label,.side-nav .personal-nav,.side-nav .menu-nav h2,.side-nav .nav-row .star{display:none}.side-nav .module-switcher{grid-template-columns:1fr}.home-heading{grid-template-columns:1fr}.home-page{padding:12px}.form-grid{grid-template-columns:1fr}.master-layout,.split-layout{grid-template-columns:1fr}.master-pane{max-height:280px}}
|
||||
|
||||
/* v38 FE interaction hardening */
|
||||
.section-head>div{display:grid;gap:2px}.section-head>.section-actions{display:flex;grid-auto-flow:column;align-items:center;gap:8px}.section-count{font-size:12px;color:var(--text-muted);white-space:nowrap}.section-toggle{min-height:28px;padding:0 8px;border:1px solid var(--amber-200);border-radius:4px;background:var(--surface);color:var(--text);font-size:12px;font-weight:600}.section-toggle:hover{background:var(--muted)}.attention-centers{display:flex;align-items:center;gap:4px}.attention-centers .section-toggle{border-color:var(--border)}.page-tools button[aria-pressed="true"]{color:var(--primary);border-color:var(--blue-100);background:var(--blue-50)}
|
||||
.search-panel.expanded{align-items:start}.search-advanced{grid-column:1/-1;display:flex;align-items:center;gap:8px 16px;padding-top:8px;border-top:1px solid var(--border)}.checkbox-inline{min-height:var(--control-h)}
|
||||
.data-state{min-height:220px;display:grid;place-items:center;align-content:center;gap:7px;border:1px solid var(--border);background:var(--gray-25);text-align:center;padding:24px}.data-state strong{font-size:16px}.data-state span{max-width:560px;color:var(--text-muted);font-size:13px}.data-state.error{border-color:var(--red-200);background:var(--red-50)}.data-state.error strong{color:var(--danger)}.data-state.loading strong::before{content:"";display:inline-block;width:12px;height:12px;margin-right:8px;border:2px solid var(--gray-300);border-top-color:var(--primary);border-radius:50%;vertical-align:-2px;animation:kbx-spin .7s linear infinite}@keyframes kbx-spin{to{transform:rotate(360deg)}}
|
||||
.row-link,.text-action{border:0;background:transparent;color:var(--primary);padding:0;text-align:left}.row-link:hover,.text-action:hover{text-decoration:underline}.selected-row td{background:var(--blue-50)!important}.checkbox-control{display:flex;align-items:center;gap:6px;font-size:13px}.detail-status{display:flex;gap:6px;margin-bottom:10px}.status-chip.danger{border-color:var(--red-200);background:var(--red-50);color:var(--danger)}
|
||||
.workflow-strip{min-height:38px;display:flex;align-items:center;gap:0;border:1px solid var(--border);background:var(--muted);font-size:12px}.workflow-strip span{padding:0 10px;border-right:1px solid var(--border);color:var(--text-muted)}.workflow-strip span.active{color:var(--primary);font-weight:600}.workflow-strip strong{margin-left:auto;padding:0 10px;font-weight:500}.detail-grid-section{display:flex;min-height:230px;flex:1;flex-direction:column;border:1px solid var(--border)}.detail-grid-section .grid-wrap{border:0}.cell-editor,.fast-cell{width:100%;height:calc(var(--row-h) - 6px);border:1px solid transparent;background:transparent;padding:0 4px}.cell-editor:hover,.fast-cell:hover{border-color:var(--border-strong);background:var(--surface)}.cell-editor:focus,.fast-cell:focus{border-color:var(--primary);background:var(--surface);outline:1px solid var(--primary)}.cell-editor.num,.fast-cell.num{text-align:right}.fast-cell.invalid{border-color:var(--red-200);background:var(--red-50)}
|
||||
.history-pane{min-height:190px}.queue-summary button.active{box-shadow:inset 0 -2px var(--primary);background:var(--blue-50)}.queue-summary button.danger.active{box-shadow:inset 0 -2px var(--danger);background:var(--red-50)}.context-bar .text-action{margin-left:auto}
|
||||
.inline-actions,.wizard-actions{display:flex;align-items:center;gap:6px}.inline-actions{padding:8px;border:1px solid var(--border);border-top:0}.wizard-actions{justify-content:flex-end;padding-top:8px}.import-step.done{background:var(--green-50);color:var(--success)}.drop-zone.dragging{border-color:var(--primary);background:var(--blue-50)}.commit-panel{max-width:760px;width:100%;margin:8px auto 0}.commit-body{padding:16px}.commit-body dl{display:flex;gap:8px;margin:12px 0}.commit-body dl div{min-width:130px;border:1px solid var(--border);padding:8px}.commit-body dt{font-size:12px;color:var(--text-muted)}.commit-body dd{margin:2px 0 0;font-size:18px;font-weight:600}.warning-copy{padding:8px;border:1px solid var(--amber-200);background:var(--amber-50);color:var(--warning)}
|
||||
.wms-label{font-size:12px;color:var(--text-muted);font-weight:600}.wms-item p{margin:4px 0 0;color:var(--text-muted)}.scan-box{cursor:text;text-align:center}.scan-box strong{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis}.scan-box small{display:block;margin-top:6px;color:var(--text-muted);font-weight:400}.scan-box.scan-error{border-color:var(--danger);background:var(--red-50)}.success-panel{display:grid;gap:4px;padding:10px 12px;border:1px solid var(--green-200);background:var(--green-50)}.success-panel strong{color:var(--success)}
|
||||
.confirm-dialog{width:min(480px,calc(100vw - 32px));padding:0;border:1px solid var(--border-strong);border-radius:6px;box-shadow:var(--shadow)}.confirm-dialog::backdrop{background:rgba(15,23,42,.35)}.confirm-shell{margin:0}.confirm-shell>header{min-height:48px;display:flex;align-items:center;padding:0 14px;border-bottom:1px solid var(--border)}.confirm-shell>p,.dialog-field,.exception-options{margin:0;padding:14px}.confirm-shell>footer{display:flex;justify-content:flex-end;gap:6px;padding:10px 14px;border-top:1px solid var(--border);background:var(--muted)}.dialog-field{display:grid;gap:6px}.dialog-field>span,.dialog-help{font-size:13px;color:var(--text-muted)}.dialog-field input,.dialog-field textarea{width:100%;border:1px solid var(--border-strong);border-radius:4px;padding:8px}.exception-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;border:0}.exception-options legend{padding:0 0 8px;font-size:13px;color:var(--text-muted)}.exception-options label{display:flex;align-items:center;gap:6px}.dialog-help{padding:0 14px 14px!important}
|
||||
.drawer-backdrop{position:fixed;z-index:85;inset:56px 0 0;background:rgba(15,23,42,.18)}.drawer-actions{position:sticky;bottom:0;display:flex;justify-content:flex-end;gap:6px;margin:16px -14px -14px;padding:10px 14px;border-top:1px solid var(--border);background:var(--surface)}.search-empty{padding:20px;color:var(--text-muted);text-align:center}
|
||||
|
||||
@media(max-width:820px){.search-advanced{flex-direction:column;align-items:stretch}.workflow-strip{overflow:auto}.workflow-strip strong{display:none}.exception-options{grid-template-columns:1fr}}
|
||||
|
||||
/* v39 FE product-parity hardening */
|
||||
.lookup-dialog{width:min(720px,calc(100vw - 32px));padding:0;border:1px solid var(--border-strong);border-radius:6px;box-shadow:var(--shadow)}
|
||||
.lookup-dialog::backdrop{background:rgba(15,23,42,.35)}
|
||||
.lookup-dialog-shell{display:grid;grid-template-rows:auto auto auto auto;background:var(--surface)}
|
||||
.lookup-dialog-shell>header{min-height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--border)}
|
||||
.lookup-dialog-shell>header>div{display:grid;gap:2px}.lookup-dialog-shell>header small{font-size:12px;color:var(--text-muted)}
|
||||
.lookup-dialog-shell>header button{width:32px;height:32px;border:0;background:transparent;font-size:20px}
|
||||
.lookup-search{display:grid;grid-template-columns:1fr auto;gap:6px;padding:10px;border-bottom:1px solid var(--border);background:var(--muted)}
|
||||
.lookup-search input{width:100%;height:var(--control-h);border:1px solid var(--border-strong);border-radius:4px;padding:0 10px;background:var(--surface)}
|
||||
.lookup-results{min-height:180px;max-height:52vh;overflow:auto;background:var(--surface)}.lookup-result{width:100%;min-height:42px;display:grid;grid-template-columns:110px minmax(0,1fr);align-items:center;gap:10px;padding:4px 12px;border:0;border-bottom:1px solid var(--border);background:var(--surface);text-align:left}
|
||||
.lookup-result:hover,.lookup-result.active{background:var(--blue-50)}.lookup-result .code{font-variant-numeric:tabular-nums;color:var(--text-muted)}.lookup-result .copy{display:grid;min-width:0}.lookup-result .copy b{font-weight:500}.lookup-result .copy small{font-size:12px;color:var(--text-muted)}
|
||||
.lookup-dialog-shell>footer{min-height:44px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 10px;border-top:1px solid var(--border);background:var(--muted);font-size:12px;color:var(--text-muted)}
|
||||
.lookup input[readonly]{background:var(--muted);color:var(--text-muted)}.lookup-trigger{min-width:58px}
|
||||
.order-data-row:focus{outline:2px solid var(--primary);outline-offset:-2px}.order-data-row:focus td{background:var(--blue-50)}
|
||||
.utility-panel{display:grid;gap:12px}.utility-panel h2,.utility-panel h3,.utility-panel p{margin:0}.utility-panel h2{font-size:18px}.utility-panel h3{font-size:14px;margin-top:4px}.utility-panel ol{margin:0;padding-left:22px;display:grid;gap:6px}.utility-kicker{font-size:12px;color:var(--text-muted)}
|
||||
.shortcut-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;border:1px solid var(--border);background:var(--border)}.shortcut-list div{display:grid;grid-template-columns:52px 1fr;min-height:34px;align-items:center;background:var(--surface)}.shortcut-list dt{padding:0 8px;font-weight:600}.shortcut-list dd{margin:0;color:var(--text-muted)}
|
||||
.utility-note{padding:10px 12px;border:1px solid var(--border);background:var(--muted);font-size:12px;color:var(--text-muted);line-height:1.5}.utility-actions{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.ai-attention{display:grid;gap:6px}.ai-attention button{min-height:48px;display:grid;grid-template-columns:90px 52px minmax(0,1fr);align-items:center;gap:8px;border:1px solid var(--border);background:var(--surface);padding:5px 10px;text-align:left}.ai-attention button:hover{background:var(--muted)}.ai-attention b{font-size:16px}.ai-attention small{color:var(--text-muted)}
|
||||
.suggestion-types{display:flex;gap:12px;padding:10px;border:1px solid var(--border)}.suggestion-types legend{padding:0 4px;color:var(--text-muted);font-size:12px}.suggestion-types label{display:flex;align-items:center;gap:5px}.suggestion-copy{display:grid;gap:6px}.suggestion-copy span{font-size:13px;color:var(--text-muted)}.suggestion-copy textarea{width:100%;resize:vertical;border:1px solid var(--border-strong);border-radius:4px;padding:8px}.suggestion-context{display:grid;grid-template-columns:auto 1fr;align-items:start;gap:2px 7px}.suggestion-context small{grid-column:2;color:var(--text-muted)}
|
||||
.system-row{width:100%;min-height:52px;display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid var(--border);background:var(--surface);padding:6px 10px;text-align:left}.system-row+ .system-row{border-top:0}.system-row span:first-child{display:grid;gap:2px}.system-row small{font-size:12px;color:var(--text-muted)}.system-row.critical{border-color:var(--red-200);background:var(--red-50)}.system-row.static{cursor:default}
|
||||
.import-steps.six{grid-template-columns:repeat(6,1fr)}.import-review{display:flex;flex-wrap:wrap;gap:8px 14px;padding:10px;border:1px solid var(--border);background:var(--muted);font-size:12px}.job-progress-head{display:flex;align-items:end;justify-content:space-between;gap:12px}.job-progress-head>div{display:grid;gap:3px}.job-progress-head small{color:var(--text-muted)}.job-progress-head>b{font-size:22px}.progress-track{height:12px;overflow:hidden;border:1px solid var(--border-strong);background:var(--gray-100)}.progress-track span{display:block;height:100%;background:var(--primary);transition:width .16s linear}.result-actions{display:flex;gap:6px;justify-content:flex-end;margin-top:12px}
|
||||
.network.unstable{color:var(--warning)}.network.offline{color:var(--danger)}
|
||||
|
||||
@media(max-width:1280px){
|
||||
.global-header{grid-template-columns:var(--nav-w) minmax(280px,1fr) auto;gap:8px}.header-actions button{padding:0 6px}.page-header{position:sticky;top:0;z-index:11}.command-bar{overflow-x:auto;white-space:nowrap}.screen-body{min-width:0}.search-panel{align-items:flex-start}.search-fields{flex-wrap:wrap}.home-page{max-width:none}.work-explorer{grid-template-columns:1fr 1fr}.grid-wrap{max-width:100%;overflow:auto}
|
||||
}
|
||||
@media(max-width:900px){
|
||||
.page-header{min-height:52px;height:auto;align-items:flex-start;gap:8px;padding-top:6px;padding-bottom:6px}.page-tools{flex-wrap:wrap;justify-content:flex-end}.command-bar{position:sticky;top:52px;z-index:10}.quick-filter{overflow-x:auto}.quick-filter button{min-width:96px}.context-bar,.summary-bar{overflow-x:auto;white-space:nowrap}.import-steps.six{overflow-x:auto;grid-template-columns:repeat(6,minmax(92px,1fr))}.validation-cards{overflow-x:auto}.lookup-dialog-shell>footer{align-items:flex-start;flex-direction:column}
|
||||
}
|
||||
@media(max-width:620px){
|
||||
.attention-item,.continue-item{grid-template-columns:64px minmax(0,1fr) 14px}.attention-action{display:none}.suggestion-types{flex-direction:column}.shortcut-list{grid-template-columns:1fr}.ai-attention button{grid-template-columns:78px 44px minmax(0,1fr)}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
|
||||
.drawer[aria-hidden="true"]{visibility:hidden;pointer-events:none}
|
||||
@media(max-width:767px){
|
||||
.kbx-app[data-mode="wms-mobile"] .global-header,.kbx-app[data-mode="wms-mobile"] .side-nav,.kbx-app[data-mode="wms-mobile"] .workspace-tabs{display:none}
|
||||
.kbx-app[data-mode="wms-mobile"] .shell-body,.kbx-app[data-mode="wms-mobile"] .workspace-shell,.kbx-app[data-mode="wms-mobile"] .workspace{height:100%;min-height:100%;width:100%}
|
||||
.kbx-app[data-mode="wms-mobile"] .mobile-stage{min-height:100%;padding:0;background:var(--surface)}
|
||||
.kbx-app[data-mode="wms-mobile"] .wms-phone{width:100%;min-height:100%;border:0;box-shadow:none}
|
||||
.kbx-app[data-mode="wms-mobile"] .wms-content{min-height:0}
|
||||
}
|
||||
|
||||
/* v40 FE completion-loop hardening */
|
||||
.validation-summary{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--red-200);background:var(--red-50);color:var(--danger)}
|
||||
.validation-summary strong{margin-right:4px}.validation-summary button{min-height:28px;border:1px solid var(--red-200);border-radius:4px;background:var(--surface);color:var(--danger);padding:0 8px;text-align:left}.validation-summary button:hover,.validation-summary button:focus-visible{background:var(--red-50)}
|
||||
.action-result{min-height:38px;display:flex;align-items:center;gap:10px;padding:6px 10px;border:1px solid var(--green-200);background:var(--green-50);color:var(--green-700)}.action-result span{min-width:0;flex:1}.action-result .text-action{margin-left:auto}
|
||||
.master-pane .context-bar{border-top:0}.detail-pane>.pane-header{border-bottom:1px solid var(--border)}
|
||||
.data-grid input[type="checkbox"]{width:16px;height:16px;vertical-align:middle}.detail-grid-section .pane-header .text-action{margin-left:auto}
|
||||
@media(max-width:900px){.validation-summary{align-items:flex-start;flex-direction:column}.action-result{align-items:flex-start;flex-wrap:wrap}.action-result .text-action{margin-left:0}}
|
||||
|
||||
/* v43 KBX Business Theme: hierarchy first, decoration second */
|
||||
.brand strong{color:var(--primary)}.nav-row .nav-entry.active{box-shadow:inset 4px 0 0 var(--primary);padding-left:10px}.attention-zone{border-color:var(--amber-200)}.attention-item{border-left:4px solid var(--amber-200)}.attention-item.critical{border-left-color:var(--danger)}.continue-source{justify-self:start;border:1px solid var(--border);border-radius:999px;background:var(--muted);padding:3px 8px}.continue-item[data-source="dirty"] .continue-source{background:var(--amber-50);border-color:var(--amber-200)}.continue-item[data-source="favorite"] .continue-source{background:var(--blue-50);border-color:var(--blue-100)}
|
||||
.kbx-app[data-theme="dark"] .global-search:hover,.kbx-app[data-theme="dark"] .home-search:hover{background:var(--blue-50)}
|
||||
|
||||
/* v44 OS high-contrast bridge: OS colors override Light/Dark without changing workflow geometry. */
|
||||
@media (forced-colors: active){
|
||||
:root,.kbx-app[data-theme="dark"],.kbx-app[data-theme="light"]{--surface:Canvas;--surface-muted:Canvas;--surface-subtle:Canvas;--surface-hover:Highlight;--text:CanvasText;--muted:CanvasText;--border:ButtonBorder;--border-strong:CanvasText;--primary:LinkText;--success:CanvasText;--warning:CanvasText;--danger:CanvasText;--info-surface:Canvas;--warning-surface:Canvas;--danger-surface:Canvas}
|
||||
button,input,select,textarea,a{forced-color-adjust:auto}.active,[aria-current="page"],[aria-pressed="true"],[aria-selected="true"]{outline:1px solid Highlight;outline-offset:-2px}
|
||||
}
|
||||
|
||||
/* v44 non-modal runtime centers: preserve workspace context while inspecting jobs/notifications. */
|
||||
.system-panel{position:fixed;z-index:82;right:8px;top:60px;width:min(420px,calc(100vw - 24px));max-height:70vh;overflow:auto;border:1px solid var(--border);border-radius:6px;background:var(--surface);box-shadow:var(--shadow)}
|
||||
.system-panel[hidden]{display:none}.system-panel>header{min-height:36px;display:flex;align-items:center;justify-content:space-between;padding:0 8px;border-bottom:1px solid var(--border)}.system-panel>header button{width:28px;height:28px;border:0;border-radius:4px;background:transparent;color:var(--text)}.system-panel>header button:hover{background:var(--muted)}.system-panel-body{padding:8px}
|
||||
|
||||
/* v45 safe screen preference: keep repetitive filters without persisting free-text business data. */
|
||||
.search-preference{min-height:34px;display:grid;grid-template-columns:18px auto;align-items:center;column-gap:6px;margin-left:auto;padding:0 4px;color:var(--text-muted);font-size:12px}.search-preference input{width:16px;height:16px;margin:0}.search-preference span{color:var(--text);font-size:13px}.search-preference small{grid-column:2;max-width:320px;color:var(--text-muted)}
|
||||
@media(max-width:900px){.search-preference{margin-left:0}}
|
||||
|
||||
.tab-resumed{font-size:var(--font-sm);font-weight:700;color:var(--primary);line-height:1}
|
||||
|
||||
|
||||
.problem-feedback{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-3);padding:var(--space-3);border:1px solid var(--info-border);border-left:4px solid var(--primary);background:var(--info-surface)}
|
||||
.problem-feedback.warning{border-color:var(--warning-border);border-left-color:var(--warning);background:var(--warning-surface)}
|
||||
.problem-feedback.danger{border-color:var(--danger-border);border-left-color:var(--danger);background:var(--danger-surface)}
|
||||
.problem-copy{display:grid;gap:4px;min-width:0}.problem-heading{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.problem-heading span{font-size:12px;font-weight:600;color:var(--text-muted)}.problem-copy p,.problem-copy small{margin:0;color:var(--text-muted);font-size:13px;line-height:1.5}.problem-actions{display:flex;align-items:center;justify-content:flex-end;gap:4px;flex-wrap:wrap}
|
||||
@media(max-width:48rem){.problem-feedback{flex-direction:column}.problem-actions{width:100%;justify-content:flex-start}}
|
||||
|
||||
|
||||
/* v51 FE presentation workbench: module identity is chrome-only; business status keeps semantic colors. */
|
||||
:root{--workspace:#f5f7fa;--shell:#fff;--nav-surface:#fbfcfe;--section-heading:#f7f9fc;--module-accent:#175cd3;--module-accent-surface:#eff8ff;--module-accent-border:#b2ddff;--shell-shadow:0 1px 2px rgba(16,24,40,.05)}
|
||||
.kbx-app[data-theme="dark"]{--workspace:#0a1020;--shell:#111827;--nav-surface:#0d1524;--section-heading:#151f31;--module-accent:#84adff;--module-accent-surface:#102a43;--module-accent-border:#285b8f;--shell-shadow:0 1px 2px rgba(0,0,0,.42)}
|
||||
[data-module="OMS"]{--module-accent:#175cd3;--module-accent-surface:#eff8ff;--module-accent-border:#b2ddff}
|
||||
[data-module="ERP"]{--module-accent:#6938ef;--module-accent-surface:#f4f3ff;--module-accent-border:#d9d6fe}
|
||||
[data-module="WMS"]{--module-accent:#087e8b;--module-accent-surface:#ecfdff;--module-accent-border:#a5f0fc}
|
||||
[data-module="COMMON"]{--module-accent:#475467;--module-accent-surface:#f2f4f7;--module-accent-border:#d0d5dd}
|
||||
.kbx-app[data-theme="dark"] [data-module="OMS"],.kbx-app[data-theme="dark"][data-module="OMS"]{--module-accent:#84adff;--module-accent-surface:#102a43;--module-accent-border:#285b8f}
|
||||
.kbx-app[data-theme="dark"] [data-module="ERP"],.kbx-app[data-theme="dark"][data-module="ERP"]{--module-accent:#b9a2ff;--module-accent-surface:#241b45;--module-accent-border:#6d5bd0}
|
||||
.kbx-app[data-theme="dark"] [data-module="WMS"],.kbx-app[data-theme="dark"][data-module="WMS"]{--module-accent:#63c5da;--module-accent-surface:#0d2f36;--module-accent-border:#1f6672}
|
||||
.kbx-app[data-theme="dark"] [data-module="COMMON"],.kbx-app[data-theme="dark"][data-module="COMMON"]{--module-accent:#b3b8c4;--module-accent-surface:#172033;--module-accent-border:#475467}
|
||||
.kbx-app{background:var(--workspace)}.global-header{background:var(--shell);box-shadow:var(--shell-shadow)}.brand strong,.header-actions b{color:var(--module-accent)}.global-search:hover{border-color:var(--module-accent);background:var(--module-accent-surface)}.side-nav{background:var(--nav-surface)}.side-nav-top{background:var(--nav-surface)}.nav-home:hover,.nav-home.active,.module-switcher button.active{background:var(--module-accent-surface);color:var(--module-accent);border-color:var(--module-accent-border)}.nav-row .nav-entry.active{color:var(--module-accent);box-shadow:inset 4px 0 0 var(--module-accent)}.nav-row .star.on{color:var(--module-accent)}.workspace-shell,.workspace{background:var(--workspace)}.tab-shell.active{box-shadow:inset 0 2px 0 var(--module-accent)}
|
||||
.screen{background:var(--workspace)}.page-header{background:var(--shell);box-shadow:var(--shell-shadow);border-left:4px solid var(--module-accent)}.status-chip{border-color:var(--module-accent-border);background:var(--module-accent-surface);color:var(--module-accent)}.command-bar{background:var(--shell);box-shadow:var(--shell-shadow)}.screen-body{background:var(--workspace)}
|
||||
.home-page{max-width:1280px}.home-pulse{min-height:40px;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--border);background:var(--surface)}.home-pulse button{min-height:40px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:0 8px;padding:4px 12px;border:0;border-right:1px solid var(--border);background:transparent;text-align:left}.home-pulse button:last-child{border-right:0}.home-pulse button:hover:not(:disabled){background:var(--muted)}.home-pulse button:disabled{cursor:default}.home-pulse span{font-size:12px;color:var(--text-muted)}.home-pulse b{grid-row:1 / span 2;grid-column:2;font-size:16px;font-variant-numeric:tabular-nums}.home-pulse small{font-size:12px;color:var(--module-accent)}.module-column .module-title{box-shadow:inset 4px 0 0 var(--module-accent);background:var(--section-heading)}.module-column .module-title strong{color:var(--module-accent)}
|
||||
.screen[data-template="T01"] .search-panel{box-shadow:inset 4px 0 0 var(--module-accent)}.screen[data-template="T02"] .master-pane,.screen[data-template="T02"] .detail-pane,.screen[data-template="T03"] .form-section,.screen[data-template="T03"] .grid-wrap,.screen[data-template="T04"] .grid-wrap,.screen[data-template="T05"] .pane,.screen[data-template="T06"] .grid-wrap,.screen[data-template="T07"] .grid-wrap,.screen[data-template="T08"] .import-stage{box-shadow:var(--shell-shadow)}.screen[data-template="T02"] .detail-pane,.screen[data-template="T03"] .form-section,.screen[data-template="T04"] .fast-guide,.screen[data-template="T05"] .master-pane,.screen[data-template="T06"] .queue-summary,.screen[data-template="T07"] .reconcile-summary,.screen[data-template="T08"] .import-steps{border-top:2px solid var(--module-accent)}.screen[data-template="T06"] .queue-summary{background:var(--surface)}.screen[data-template="T07"] .reconcile-summary{background:var(--surface)}.screen[data-template="T08"] .import-steps{background:var(--section-heading)}
|
||||
@media(max-width:820px){.home-pulse{grid-template-columns:repeat(2,minmax(0,1fr))}.home-pulse button:nth-child(2){border-right:0}.home-pulse button:nth-child(-n+2){border-bottom:1px solid var(--border)}}
|
||||
@media(forced-colors:active){:root,.kbx-app[data-theme="dark"],.kbx-app[data-theme="light"]{--workspace:Canvas;--shell:Canvas;--nav-surface:Canvas;--section-heading:Canvas;--module-accent:Highlight;--module-accent-surface:Canvas;--module-accent-border:Highlight;--shell-shadow:none}}
|
||||
|
||||
/* v52 operational navigation: Home is a launcher, resume point, exception ingress, and full work explorer. */
|
||||
.home-module-rail{display:grid;grid-template-columns:auto repeat(5,minmax(0,1fr));align-items:stretch;border:1px solid var(--border);background:var(--surface)}
|
||||
.home-module-label{display:flex;align-items:center;padding:0 var(--space-3);border-right:1px solid var(--border);background:var(--section-heading);font-size:.75rem;font-weight:600;color:var(--text-muted)}
|
||||
.home-module-rail button{min-width:0;min-height:var(--control-h);display:grid;grid-template-columns:minmax(0,1fr);align-content:center;gap:var(--space-1);padding:var(--space-2) var(--space-3);border:0;border-right:1px solid var(--border);background:transparent;text-align:left}
|
||||
.home-module-rail button:last-child{border-right:0}.home-module-rail button:hover{background:var(--muted)}
|
||||
.home-module-rail button b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.875rem}.home-module-rail button small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.75rem;color:var(--text-muted);font-variant-numeric:tabular-nums}
|
||||
.home-module-rail button[aria-pressed="true"]{box-shadow:inset 0 -.125rem 0 var(--module-accent);background:var(--module-accent-surface);color:var(--module-accent)}
|
||||
.home-module-rail button:focus-visible{outline:.125rem solid var(--module-accent);outline-offset:-.125rem;position:relative;z-index:1}
|
||||
.home-module-rail button[data-module="OMS"],.home-module-rail button[data-module="ERP"],.home-module-rail button[data-module="WMS"],.home-module-rail button[data-module="COMMON"]{border-top:.125rem solid var(--module-accent)}
|
||||
#homeExplorer:focus-visible{outline:.125rem solid var(--module-accent);outline-offset:var(--space-1)}
|
||||
@media(max-width:64rem){.home-module-rail{grid-template-columns:repeat(2,minmax(0,1fr))}.home-module-label{grid-column:1 / -1;min-height:var(--control-h);border-right:0;border-bottom:1px solid var(--border)}.home-module-rail button{border-bottom:1px solid var(--border)}.home-module-rail button:nth-last-child(-n+2){border-bottom:0}}
|
||||
@media(max-width:40rem){.home-module-rail{grid-template-columns:1fr}.home-module-rail button{border-right:0}.home-module-rail button:nth-last-child(2){border-bottom:1px solid var(--border)}}
|
||||
@media(forced-colors:active){.home-module-rail button[aria-pressed="true"]{outline:1px solid Highlight;outline-offset:-1px;box-shadow:none}}
|
||||
|
||||
/* v54 — operational triage lanes + record wayfinding + denser work surfaces */
|
||||
.attention-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--space-2);padding:var(--space-2)}
|
||||
.attention-lane{min-width:0;border:1px solid var(--border);border-top:.125rem solid var(--border-strong);border-radius:var(--radius);background:var(--surface);overflow:hidden}
|
||||
.attention-lane[data-tone="danger"]{border-top-color:var(--danger)}.attention-lane[data-tone="warning"]{border-top-color:var(--warning)}.attention-lane[data-tone="info"]{border-top-color:var(--primary)}
|
||||
.attention-lane>header{min-height:var(--control-h);display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);padding:var(--space-1) var(--space-2);border-bottom:1px solid var(--border);background:var(--muted)}
|
||||
.attention-lane>header>div{display:grid;min-width:0}.attention-lane>header strong{font-size:.8125rem}.attention-lane>header small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted);font-size:.75rem}.attention-lane>header>b{font-size:1rem}
|
||||
.attention-lane-items{display:flex;flex-direction:column}.attention-lane .attention-item{min-height:var(--control-h);grid-template-columns:auto minmax(0,1fr) auto .875rem;border:0;border-bottom:1px solid var(--border);border-radius:0}.attention-lane .attention-item:last-child{border-bottom:0}.attention-lane .attention-item.critical{box-shadow:inset .125rem 0 0 var(--danger)}
|
||||
.attention-lane-more{width:100%;min-height:1.75rem;border:0;border-top:1px solid var(--border);background:var(--muted);color:var(--text-muted);font-size:.75rem}.attention-lane-more:hover{background:var(--muted);color:var(--primary)}
|
||||
.record-navigator{display:flex;align-items:center;gap:var(--space-1);margin-left:auto}.record-position{padding-right:var(--space-1);font-size:.75rem;color:var(--text-muted);white-space:nowrap}.record-position strong{color:var(--text)}.record-navigator button{min-height:1.75rem;padding:0 var(--space-2);border:1px solid var(--border);border-radius:var(--radius);background:var(--surface);color:var(--text);font-size:.75rem}.record-navigator button:hover:not(:disabled){border-color:var(--primary);background:var(--muted)}.record-navigator button:disabled{opacity:.45}.record-navigator button:focus-visible,.attention-lane-more:focus-visible{outline:.125rem solid var(--primary);outline-offset:-.125rem}
|
||||
.detail-pane>.pane-header{gap:var(--space-3)}.detail-pane>.pane-header>div{display:grid;min-width:0}.detail-pane>.pane-header>div span{color:var(--text-muted);font-size:.75rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.fast-entry-surface{min-height:0;flex:1;border:1px solid var(--border);border-top:.125rem solid var(--module-accent);background:var(--surface);overflow:auto}.fast-entry-surface>.pane-header>div{display:grid;min-width:0}.fast-entry-surface>.pane-header>div span{font-size:.75rem;color:var(--text-muted)}
|
||||
@media(max-width:68.75rem){.attention-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:40rem){.attention-grid{grid-template-columns:1fr}.record-navigator .record-position{display:none}}
|
||||
@media(forced-colors:active){.attention-lane[data-tone],.fast-entry-surface{border-top-color:CanvasText}.record-navigator button:focus-visible,.attention-lane-more:focus-visible{outline-color:Highlight}}
|
||||
|
||||
/* v55 operational recovery surfaces */
|
||||
.import-error-nav{display:flex;align-items:center;justify-content:flex-end;gap:.5rem;padding:.5rem .625rem;border:.0625rem solid var(--border);border-bottom:0;background:var(--muted);font-size:.75rem}.import-error-nav>span{margin-right:auto}.grid tbody tr[data-import-error-row][aria-current="true"]{outline:.125rem solid var(--primary);outline-offset:-.125rem}.scan-truth{display:grid;gap:.375rem;padding:.5rem .625rem;border:.0625rem solid var(--border);border-left:.25rem solid var(--success);background:var(--muted);font-size:.75rem}.scan-truth.blocked{border-left-color:var(--danger);background:var(--red-50)}.scan-truth>div{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.5rem}.scan-truth span{color:var(--text-muted)}.scan-truth b{text-align:right;overflow-wrap:anywhere}
|
||||
@media(forced-colors:active){.import-error-nav,.scan-truth{border-color:CanvasText}.grid tbody tr[data-import-error-row][aria-current="true"]{outline:.125rem solid Highlight}}
|
||||
|
||||
/* v56 interaction closure: import stage transitions must not drop keyboard focus. */
|
||||
.import-stage-focus:focus-visible{outline:.125rem solid var(--primary);outline-offset:.125rem}
|
||||
@media(forced-colors:active){.import-stage-focus:focus-visible{outline-color:Highlight}}
|
||||
|
||||
/* v59 status dictionary parity: status is text + semantic surface, never color-only. */
|
||||
.business-status{--status-dot:var(--text-muted);--status-surface:var(--surface);--status-border:var(--border);display:inline-flex;align-items:center;gap:6px;min-height:24px;padding:0 8px;border:1px solid var(--status-border);border-radius:999px;background:var(--status-surface);font-size:12px;font-weight:600;white-space:nowrap}
|
||||
.business-status__dot{width:6px;height:6px;border-radius:50%;background:var(--status-dot)}
|
||||
.business-status[data-semantic="ready"],.business-status[data-semantic="pending"],.business-status[data-semantic="processing"]{--status-dot:var(--primary);--status-surface:var(--blue-50);--status-border:var(--blue-100)}
|
||||
.business-status[data-semantic="completed"]{--status-dot:var(--success);--status-surface:var(--green-50);--status-border:var(--green-200)}
|
||||
.business-status[data-semantic="warning"],.business-status[data-semantic="hold"]{--status-dot:var(--warning);--status-surface:var(--amber-50);--status-border:var(--amber-200)}
|
||||
.business-status[data-semantic="error"]{--status-dot:var(--danger);--status-surface:var(--red-50);--status-border:var(--red-200)}
|
||||
.business-status[data-semantic="disabled"]{color:var(--text-muted);background:var(--muted)}
|
||||
.business-status[data-unknown="true"]{border-style:dashed}
|
||||
@media(forced-colors:active){.business-status{forced-color-adjust:auto;border-color:CanvasText;background:Canvas}.business-status__dot{background:CanvasText}.business-status[data-unknown="true"]{border-color:Highlight}}
|
||||
@@ -0,0 +1,799 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const screens = [
|
||||
{ id:'HOME', title:'홈', module:'COMMON', group:'홈', type:'home', template:'HOME', path:'#home', keywords:['시작','메뉴'] },
|
||||
{ id:'OMS-ORD-001', title:'주문관리', module:'OMS', group:'주문', type:'list', template:'T01', path:'#orders', keywords:['주문조회','출고대기','오류'] },
|
||||
{ id:'ERP-MST-ITEM-001', title:'품목관리', module:'ERP', group:'기준정보', type:'master', template:'T02', path:'#items', keywords:['품목','상품','SKU'] },
|
||||
{ id:'OMS-ORD-002', title:'주문등록', module:'OMS', group:'주문', type:'transaction', template:'T03', path:'#order-new', keywords:['신규주문','등록'] },
|
||||
{ id:'ERP-PRICE-001', title:'품목단가 일괄입력', module:'ERP', group:'기준정보', type:'fast-entry', template:'T04', path:'#prices', keywords:['단가','Fast Entry','붙여넣기'] },
|
||||
{ id:'ERP-INV-001', title:'재고현황', module:'ERP', group:'재고', type:'master-detail', template:'T05', path:'#inventory', keywords:['현재고','가용재고','로케이션'] },
|
||||
{ id:'WMS-WORK-001', title:'오늘 물류작업', module:'WMS', group:'작업관리', type:'queue', template:'T06', path:'#wms-work', keywords:['피킹','입고','예외','Queue'] },
|
||||
{ id:'COMMON-REC-001', title:'재고·출고 대사', module:'COMMON', group:'운영', type:'reconcile', template:'T07', path:'#reconcile', keywords:['대사','불일치','정합성'] },
|
||||
{ id:'OMS-ORD-003', title:'주문 Excel 업로드', module:'OMS', group:'주문', type:'import', template:'T08', path:'#import', keywords:['엑셀','업로드','매핑'] },
|
||||
{ id:'WMS-PICK-001', title:'출고 피킹', module:'WMS', group:'현장업무', type:'wms-mobile', template:'T09', path:'#picking', keywords:['PDA','스캔','바코드'] },
|
||||
];
|
||||
|
||||
const orders = [
|
||||
{no:'ORD-260811-1024',channel:'쿠팡',time:'16:42',buyer:'김민수',item:'런닝화 외 1',qty:3,amount:128000,stock:'정상',status:'READY',exception:''},
|
||||
{no:'ORD-260811-1023',channel:'스마트스토어',time:'16:39',buyer:'이지은',item:'셔츠 BLACK',qty:1,amount:48000,stock:'⚠ 부족',status:'HOLD',exception:'재고부족'},
|
||||
{no:'ORD-260811-1022',channel:'자사몰',time:'16:31',buyer:'박서준',item:'운동화 270',qty:2,amount:196000,stock:'정상',status:'CONFIRMED',exception:''},
|
||||
{no:'ORD-260811-1021',channel:'쿠팡',time:'16:28',buyer:'최유진',item:'양말 3PACK',qty:4,amount:36000,stock:'정상',status:'READY',exception:''},
|
||||
{no:'ORD-260811-1018',channel:'11번가',time:'16:12',buyer:'정하늘',item:'후드티 GRAY',qty:1,amount:69000,stock:'정상',status:'ERROR',exception:'송장실패'},
|
||||
{no:'ORD-260811-1014',channel:'쿠팡',time:'15:57',buyer:'한도윤',item:'운동화 BLACK',qty:2,amount:178000,stock:'정상',status:'READY',exception:''},
|
||||
{no:'ORD-260811-1007',channel:'자사몰',time:'15:43',buyer:'오서연',item:'스니커즈 WHITE',qty:1,amount:82000,stock:'정상',status:'NEW',exception:''},
|
||||
{no:'ORD-260811-1002',channel:'스마트스토어',time:'15:31',buyer:'윤지호',item:'양말 WHITE',qty:5,amount:45000,stock:'정상',status:'READY',exception:'주소확인'},
|
||||
];
|
||||
|
||||
const masterItems = [
|
||||
{id:'ABC001',name:'남성 운동화',group:'신발',spec:'BLACK / 270',unit:'EA',warehouse:'서울물류센터',warehouseCode:'WH01',barcode:'8801234567890',lot:true,expiry:false,status:'사용'},
|
||||
{id:'ABC002',name:'러닝화',group:'신발',spec:'WHITE / 265',unit:'EA',warehouse:'서울물류센터',warehouseCode:'WH01',barcode:'8801234567891',lot:false,expiry:false,status:'사용'},
|
||||
{id:'ABC003',name:'양말',group:'의류',spec:'WHITE / FREE',unit:'PACK',warehouse:'부산물류센터',warehouseCode:'WH02',barcode:'8801234567892',lot:false,expiry:false,status:'사용'},
|
||||
{id:'ABC004',name:'셔츠',group:'의류',spec:'BLACK / L',unit:'EA',warehouse:'서울물류센터',warehouseCode:'WH01',barcode:'8801234567893',lot:false,expiry:false,status:'사용'},
|
||||
{id:'ABC005',name:'후드티',group:'의류',spec:'GRAY / XL',unit:'EA',warehouse:'인천물류센터',warehouseCode:'WH03',barcode:'8801234567894',lot:false,expiry:false,status:'사용'},
|
||||
];
|
||||
|
||||
const inventoryItems = [
|
||||
{id:'ABC001',name:'운동화',stock:1280,locations:[['서울센터','A-01-01',300,100,20],['서울센터','A-01-02',530,0,0],['부산센터','B-03-01',450,30,10]]},
|
||||
{id:'ABC002',name:'양말',stock:721,locations:[['서울센터','A-02-01',421,50,0],['부산센터','B-02-07',300,0,10]]},
|
||||
{id:'ABC003',name:'셔츠',stock:312,locations:[['서울센터','A-05-03',212,20,5],['인천센터','C-01-01',100,0,0]]},
|
||||
{id:'ABC004',name:'후드티',stock:187,locations:[['서울센터','A-06-02',87,10,0],['부산센터','B-06-01',100,0,0]]},
|
||||
];
|
||||
|
||||
const queueRows = [
|
||||
{no:'WK-260811-184',type:'피킹',wave:'W260811-12',worker:'김작업',progress:'72%',sla:'17:10',status:'진행',exception:false},
|
||||
{no:'WK-260811-182',type:'검수',wave:'W260811-11',worker:'박물류',progress:'91%',sla:'16:55',status:'지연',exception:true},
|
||||
{no:'WK-260811-176',type:'입고',wave:'-',worker:'이현장',progress:'38%',sla:'17:30',status:'진행',exception:false},
|
||||
{no:'WK-260811-171',type:'피킹',wave:'W260811-10',worker:'최창고',progress:'100%',sla:'16:30',status:'완료',exception:false},
|
||||
];
|
||||
|
||||
const reconcileRows = [
|
||||
{no:'ORD-001',expected:10,actual:10,diff:0,reason:'-',status:'정상'},
|
||||
{no:'ORD-002',expected:10,actual:9,diff:-1,reason:'피킹부족',status:'확인필요'},
|
||||
{no:'ORD-003',expected:5,actual:0,diff:-5,reason:'연계실패',status:'미처리'},
|
||||
{no:'ORD-004',expected:12,actual:12,diff:0,reason:'-',status:'정상'},
|
||||
];
|
||||
|
||||
const state = {
|
||||
activeId:'HOME', module:'ALL', collapsed:false, density:'compact', theme:'light', homeAttentionExpanded:false,
|
||||
favorites:new Set(['OMS-ORD-001','ERP-INV-001','WMS-WORK-001']), recents:['OMS-ORD-001','ERP-MST-ITEM-001','ERP-INV-001'], tabs:[],
|
||||
selectedOrders:new Set(), orderQuickFilter:'all', orderAdvanced:false, orderDataState:'ready', commandProblem:null, searchIndex:0,
|
||||
orderSearch:{channel:'all',status:'all',keyword:'',exception:'all'}, orderRememberSearch:false, orderSavedDefaults:null,
|
||||
selectedMasterId:'ABC001', masterQuery:'', masterMode:'edit', masterDraft:null, masterErrors:{}, pendingMasterId:null,
|
||||
transactionStatus:'DRAFT', transactionErrors:[], transactionHeader:{orderDate:'2026-08-11',channel:'직접주문',customerCode:'10001',customerName:'대한상사',owner:'홍길동',warehouseCode:'WH01',warehouseName:'서울물류센터',receiver:'홍길동',phone:'010-1234-5678',postal:'06236',address:'서울특별시 강남구 테헤란로'},
|
||||
transactionLines:[{code:'ABC001',name:'운동화',spec:'BLACK / 270',available:83,qty:2,price:98000,remark:''},{code:'XYZ003',name:'양말',spec:'WHITE',available:721,qty:3,price:9000,remark:''}],
|
||||
inventoryItemId:'ABC001', inventorySearch:{warehouse:'all',keyword:''}, inventoryDataState:'ready',
|
||||
queueFilter:'all', queueSearch:{worker:'',keyword:''}, queueDataState:'ready', selectedQueueRows:new Set(), queueActionResult:'',
|
||||
mismatchOnly:true, reconcileSearch:{date:'2026-08-11',keyword:''}, reconcileDataState:'ready', selectedReconcileRows:new Set(), reconcileActionResult:'',
|
||||
importStep:1, importFocusStep:0, importFileName:'', importFileSize:0, importFileType:'', importJobStatus:'idle', importProgress:0, importProcessed:0, importErrorIndex:0,
|
||||
pickingQty:4, pickingRequired:6, scanBuffer:'', scanError:'', networkStatus:'online', pendingSync:0, lastScanValue:'', lastScanAt:0, lastConfirmedScanValue:'', lastConfirmedScanAt:0,
|
||||
pendingCloseId:null, pendingNavigationId:null, pendingNavigationOptions:null, pendingUnsavedMode:null, workspaceDirtyDiscarded:0,
|
||||
lookupEntity:'', lookupField:'', lookupIndex:0,
|
||||
fastRows:[
|
||||
{code:'ABC001',name:'운동화',price:'98,000',date:'2026-08-11',remark:''},
|
||||
{code:'ABC002',name:'러닝화',price:'89,000',date:'2026-08-11',remark:''},
|
||||
{code:'ABC003',name:'양말',price:'9,000',date:'2026-08-11',remark:''},
|
||||
{code:'ABC004',name:'셔츠',price:'48,000',date:'2026-08-11',remark:''},
|
||||
...Array.from({length:8},()=>({code:'',name:'',price:'',date:'',remark:''})),
|
||||
],
|
||||
};
|
||||
|
||||
const $ = (selector, root=document) => root.querySelector(selector);
|
||||
const $$ = (selector, root=document) => [...root.querySelectorAll(selector)];
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
||||
const orderStatusDefinitions={
|
||||
NEW:{label:'신규',semantic:'pending'},DRAFT:{label:'작성',semantic:'draft'},CONFIRMED:{label:'확정',semantic:'pending'},ALLOCATED:{label:'할당',semantic:'processing'},PICKING:{label:'피킹',semantic:'processing'},CHECKED:{label:'검수',semantic:'processing'},SHIPPED:{label:'출고완료',semantic:'completed'},HOLD:{label:'보류',semantic:'hold'},ERROR:{label:'오류',semantic:'error'},READY:{label:'출고대기',semantic:'ready'}
|
||||
};
|
||||
const shipmentStatusDefinitions={...orderStatusDefinitions,CONFIRMED:{label:'출고지시',semantic:'processing'}};
|
||||
const legacyStatusDefinitions={
|
||||
'정상':{label:'정상',semantic:'ready'},'사용':{label:'사용',semantic:'completed'},'할당완료':{label:'할당완료',semantic:'completed'},'출고완료':{label:'출고완료',semantic:'completed'},'완료':{label:'완료',semantic:'completed'},
|
||||
'신규':{label:'신규',semantic:'pending'},'출고대기':{label:'출고대기',semantic:'ready'},'미처리':{label:'미처리',semantic:'pending'},'확인필요':{label:'확인필요',semantic:'warning'},'진행':{label:'진행',semantic:'processing'},
|
||||
'보류':{label:'보류',semantic:'hold'},'부족':{label:'부족',semantic:'warning'},'재고부족':{label:'재고부족',semantic:'warning'},'지연':{label:'지연',semantic:'error'},'오류':{label:'오류',semantic:'error'},'사용중지':{label:'사용중지',semantic:'disabled'}
|
||||
};
|
||||
function resolveStatus(value,context='generic'){
|
||||
const raw=String(value??'').replace('⚠ ','');
|
||||
const dictionary=context==='shipment'?shipmentStatusDefinitions:context==='lifecycle'?orderStatusDefinitions:null;
|
||||
const definition=dictionary?.[raw]||orderStatusDefinitions[raw]||legacyStatusDefinitions[raw];
|
||||
return definition?{...definition,raw,unknown:false}:{label:`정의되지 않음 · ${raw||'빈 값'}`,semantic:'warning',raw,unknown:true};
|
||||
}
|
||||
function statusSemantic(value,context='generic'){return resolveStatus(value,context).semantic;}
|
||||
function statusLabel(value,context='generic'){return resolveStatus(value,context).label;}
|
||||
function statusChip(value,context='generic'){
|
||||
const resolved=resolveStatus(value,context);
|
||||
return `<span class="business-status" data-semantic="${resolved.semantic}" ${resolved.unknown?'data-unknown="true"':''}><span class="business-status__dot" aria-hidden="true"></span>${esc(resolved.label)}</span>`;
|
||||
}
|
||||
function canonicalOrderStatus(value){
|
||||
const raw=String(value??'all');
|
||||
const legacy={신규:'NEW',출고대기:'READY',할당완료:'CONFIRMED',보류:'HOLD',오류:'ERROR',출고완료:'SHIPPED'};
|
||||
const normalized=legacy[raw]||raw;
|
||||
return ['all','NEW','READY','CONFIRMED','HOLD','ERROR','SHIPPED'].includes(normalized)?normalized:'all';
|
||||
}
|
||||
const knownIds = new Set(screens.map(x=>x.id));
|
||||
const app=$('#app'), workspace=$('#workspace'), sideNav=$('#sideNav'), tabs=$('#workspaceTabs');
|
||||
const searchDialog=$('#menuSearchDialog'), searchInput=$('#menuSearchInput'), searchResults=$('#menuSearchResults');
|
||||
const unsavedDialog=$('#unsavedDialog'), lookupDialog=$('#lookupDialog'), lookupSearchInput=$('#lookupSearchInput'), lookupResults=$('#lookupResults'), quantityDialog=$('#quantityDialog'), exceptionDialog=$('#exceptionDialog');
|
||||
const drawer=$('#drawer'), drawerTitle=$('#drawerTitle'), drawerBody=$('#drawerBody'), drawerBackdrop=$('#drawerBackdrop');
|
||||
const systemPanel=$('#systemPanel'),systemPanelTitle=$('#systemPanelTitle'),systemPanelBody=$('#systemPanelBody');
|
||||
let drawerRestoreFocus=null,systemPanelRestoreFocus=null;
|
||||
let lookupRestoreFocus=null;
|
||||
let importTimer=null;
|
||||
const lookupData={
|
||||
customer:[
|
||||
{id:'c-10001',code:'10001',name:'대한상사',secondary:'서울 · 정상'},
|
||||
{id:'c-10002',code:'10002',name:'대한유통',secondary:'경기 · 정상'},
|
||||
{id:'c-10003',code:'10003',name:'대한물류',secondary:'인천 · 정상'},
|
||||
{id:'c-20001',code:'20001',name:'한빛상사',secondary:'부산 · 정상'},
|
||||
],
|
||||
warehouse:[
|
||||
{id:'w-WH01',code:'WH01',name:'서울물류센터',secondary:'상온 · 사용'},
|
||||
{id:'w-WH02',code:'WH02',name:'부산물류센터',secondary:'상온 · 사용'},
|
||||
{id:'w-WH03',code:'WH03',name:'인천물류센터',secondary:'상온 · 사용'},
|
||||
],
|
||||
};
|
||||
|
||||
function screen(id){ return screens.find(x=>x.id===id) || screens[0]; }
|
||||
function labelModule(module){ return module==='COMMON'?'공통':module; }
|
||||
function iconFor(item){ return ({T01:'조',T02:'기',T03:'등',T04:'입',T05:'재',T06:'작',T07:'대',T08:'엑',T09:'피'})[item.template] || '·'; }
|
||||
function announce(message){ $('#liveRegion').textContent=message; }
|
||||
function toast(message,tone='success'){
|
||||
const el=document.createElement('div'); el.className=`toast ${tone}`; el.textContent=message; $('#toastRegion').append(el); announce(message); setTimeout(()=>el.remove(),2600);
|
||||
}
|
||||
function persist(){
|
||||
try{ localStorage.setItem('kbx-fe-reference-state',JSON.stringify({favorites:[...state.favorites],density:state.density,collapsed:state.collapsed,theme:state.theme,orderRememberSearch:state.orderRememberSearch,orderSavedDefaults:state.orderSavedDefaults})); }catch{}
|
||||
}
|
||||
function persistWorkspaceSession(){
|
||||
const payload={tabs:state.tabs.filter(tab=>!tab.dirty).slice(0,10).map(tab=>({id:tab.id,pinned:Boolean(tab.pinned)})),dirtyDiscardedCount:Math.min(10,state.tabs.filter(tab=>tab.dirty).length)};
|
||||
try{sessionStorage.setItem('kbx-fe-reference-workspace-v1',JSON.stringify(payload));}catch{}
|
||||
}
|
||||
function restoreWorkspaceSession(){
|
||||
let raw='';try{raw=sessionStorage.getItem('kbx-fe-reference-workspace-v1')||'';}catch{return;}
|
||||
if(!raw||raw.length>16000)return;
|
||||
try{const value=JSON.parse(raw);if(!Array.isArray(value.tabs))return;const seen=new Set();state.tabs=value.tabs.slice(0,10).flatMap(item=>{const id=typeof item?.id==='string'&&knownIds.has(item.id)&&item.id!=='HOME'?item.id:'';if(!id||seen.has(id))return [];seen.add(id);return [{id,title:screen(id).title,pinned:Boolean(item.pinned),dirty:false,resumed:true}];});state.workspaceDirtyDiscarded=Math.min(10,Math.max(0,Number(value.dirtyDiscardedCount)||0));}catch{}
|
||||
}
|
||||
function restore(){
|
||||
let raw='';
|
||||
try{ raw=localStorage.getItem('kbx-fe-reference-state')||''; }catch{return;}
|
||||
if(!raw)return;
|
||||
try{
|
||||
const value=JSON.parse(raw);
|
||||
if(Array.isArray(value.favorites))state.favorites=new Set(value.favorites.filter(id=>knownIds.has(id)&&id!=='HOME'));
|
||||
if(['compact','comfortable'].includes(value.density))state.density=value.density;
|
||||
if(typeof value.collapsed==='boolean')state.collapsed=value.collapsed;
|
||||
if(['light','dark'].includes(value.theme))state.theme=value.theme;
|
||||
if(typeof value.orderRememberSearch==='boolean')state.orderRememberSearch=value.orderRememberSearch;
|
||||
if(value.orderSavedDefaults&&typeof value.orderSavedDefaults==='object'){
|
||||
const d=value.orderSavedDefaults;
|
||||
const safe={channel:['all','쿠팡','스마트스토어','자사몰','11번가'].includes(d.channel)?d.channel:'all',status:canonicalOrderStatus(d.status),exception:['all','재고부족','주소확인','송장실패'].includes(d.exception)?d.exception:'all'};
|
||||
state.orderSavedDefaults=safe;state.orderSearch={...state.orderSearch,...safe,keyword:''};
|
||||
}
|
||||
}catch{}
|
||||
}
|
||||
function markDirty(id,dirty=true){ const tab=state.tabs.find(x=>x.id===id); if(tab){tab.dirty=dirty;tab.resumed=false;persistWorkspaceSession();renderTabs();} }
|
||||
function addRecent(id){ if(id==='HOME')return; state.recents=[id,...state.recents.filter(x=>x!==id)].slice(0,6); }
|
||||
function setHash(s){ if(location.hash!==s.path)location.hash=s.path; }
|
||||
function openScreen(id,{focus=true,updateHash=true,force=false}={}){
|
||||
const target=screen(id);
|
||||
const current=state.tabs.find(t=>t.id===state.activeId);
|
||||
if(!force&&target.id!==state.activeId&¤t?.dirty){
|
||||
state.pendingNavigationId=target.id;
|
||||
state.pendingNavigationOptions={focus,updateHash};
|
||||
state.pendingUnsavedMode='navigate';
|
||||
$('#unsavedMessage').textContent=`${current.title}에 저장하지 않은 변경사항이 있습니다. 이동하기 전에 저장하거나 변경을 버리세요.`;
|
||||
if(!unsavedDialog.open)unsavedDialog.showModal();
|
||||
return false;
|
||||
}
|
||||
state.activeId=target.id; addRecent(target.id);
|
||||
const existingTab=state.tabs.find(t=>t.id===target.id);if(existingTab)existingTab.resumed=false;
|
||||
if(target.id!=='HOME'&&!existingTab)state.tabs.push({id:target.id,title:target.title,pinned:false,dirty:false,resumed:false});
|
||||
persistWorkspaceSession();if(updateHash)setHash(target); renderAll(); if(focus)workspace.focus({preventScroll:true});
|
||||
return true;
|
||||
}
|
||||
function closeTabNow(id){
|
||||
const index=state.tabs.findIndex(t=>t.id===id); if(index<0)return;
|
||||
const wasActive=state.activeId===id; state.tabs.splice(index,1);persistWorkspaceSession();
|
||||
if(wasActive){ const fallback=state.tabs[index-1] || state.tabs[index] || null; openScreen(fallback?.id || 'HOME'); }
|
||||
else renderTabs();
|
||||
}
|
||||
function requestCloseTab(id){
|
||||
const tab=state.tabs.find(t=>t.id===id); if(!tab)return;
|
||||
if(!tab.dirty){closeTabNow(id);return;}
|
||||
state.pendingCloseId=id; state.pendingUnsavedMode='close';
|
||||
$('#unsavedMessage').textContent=`${tab.title}에 저장하지 않은 변경사항이 있습니다.`;
|
||||
if(!unsavedDialog.open)unsavedDialog.showModal();
|
||||
}
|
||||
|
||||
function groupScreens(list=screens.filter(x=>x.id!=='HOME')){
|
||||
const map=new Map();
|
||||
list.forEach(item=>{ if(!map.has(item.module))map.set(item.module,new Map());const groups=map.get(item.module);if(!groups.has(item.group))groups.set(item.group,[]);groups.get(item.group).push(item); });
|
||||
return map;
|
||||
}
|
||||
function isFav(id){return state.favorites.has(id)}
|
||||
function toggleFavorite(id){ if(!knownIds.has(id)||id==='HOME')return; isFav(id)?state.favorites.delete(id):state.favorites.add(id);persist();renderNavigation();if(state.activeId==='HOME')renderHome(); }
|
||||
|
||||
function navRow(item,favorite=true,icon=''){
|
||||
const collapsedLabel=icon||iconFor(item);
|
||||
return `<div class="nav-row" data-screen="${esc(item.id)}"><button class="nav-entry ${state.activeId===item.id?'active':''}" type="button" title="${esc(item.title)}" ${state.activeId===item.id?'aria-current="page"':''}>${state.collapsed?esc(collapsedLabel):esc(item.title)}</button>${favorite?`<button class="star ${isFav(item.id)?'on':''}" type="button" aria-label="${isFav(item.id)?'즐겨찾기 해제':'즐겨찾기 추가'}" aria-pressed="${isFav(item.id)}">${isFav(item.id)?'★':'☆'}</button>`:''}</div>`;
|
||||
}
|
||||
function bindNavRow(row){ const id=row.dataset.screen; $('.nav-entry',row).onclick=()=>openScreen(id);const star=$('.star',row);if(star)star.onclick=e=>{e.stopPropagation();toggleFavorite(id)}; }
|
||||
function renderNavigation(){
|
||||
app.dataset.density=state.density;app.dataset.theme=state.theme;app.dataset.nav=state.collapsed?'collapsed':'expanded';app.dataset.module=state.module==='ALL'?(screen(state.activeId).module||'COMMON'):state.module;document.documentElement.style.colorScheme=state.theme;sideNav.classList.toggle('collapsed',state.collapsed);
|
||||
$('#collapseButton').textContent=state.collapsed?'›':'‹';$('#collapseButton').setAttribute('aria-label',state.collapsed?'메뉴 펼치기':'메뉴 접기');$('#collapseButton').setAttribute('aria-expanded',String(!state.collapsed));
|
||||
$('#densityButton').textContent=state.density==='compact'?'Compact':'Comfortable';
|
||||
const themeButton=$('#themeButton');if(themeButton){themeButton.innerHTML=state.theme==='dark'?'<span aria-hidden="true">☾</span> <span>Dark</span>':'<span aria-hidden="true">☀</span> <span>Light</span>';themeButton.setAttribute('aria-label',state.theme==='dark'?'밝은 테마로 변경':'어두운 테마로 변경');}
|
||||
const modules=['OMS','ERP','WMS','COMMON'];
|
||||
$('#moduleSwitcher').innerHTML=modules.map(m=>`<button type="button" class="${state.module===m?'active':''}" data-module="${m}" aria-pressed="${state.module===m}" title="${esc(labelModule(m))}">${state.collapsed?(m==='COMMON'?'공':m[0]):esc(labelModule(m))}</button>`).join('');
|
||||
$$('#moduleSwitcher button').forEach(button=>button.onclick=()=>{state.module=button.dataset.module;$('#moduleSelect').value=state.module;renderNavigation();if(state.activeId==='HOME')renderHome();});
|
||||
$('#moduleSelect').value=state.module;
|
||||
const favoriteItems=screens.filter(x=>isFav(x.id)&&x.id!=='HOME'&&(state.module==='ALL'||x.module===state.module));
|
||||
$('#favoriteNav').innerHTML=favoriteItems.length?`<h2>즐겨찾기</h2>${favoriteItems.slice(0,6).map(item=>navRow(item)).join('')}`:'';
|
||||
const recentItems=state.recents.map(screen).filter(x=>x.id!=='HOME'&&(state.module==='ALL'||x.module===state.module));
|
||||
$('#recentNav').innerHTML=recentItems.length?`<h2>최근메뉴</h2>${recentItems.slice(0,4).map(item=>navRow(item,false,'↺')).join('')}`:'';
|
||||
const visible=screens.filter(x=>x.id!=='HOME'&&(state.module==='ALL'||x.module===state.module));const grouped=groupScreens(visible);
|
||||
$('#menuNav').innerHTML=[...grouped.entries()].map(([module,groups])=>`<section class="menu-group"><h2>${esc(labelModule(module))} 업무</h2>${[...groups.entries()].map(([group,items])=>`<div class="menu-subgroup"><h2>${esc(group)}</h2>${items.map(item=>navRow(item)).join('')}</div>`).join('')}</section>`).join('');
|
||||
$$('.nav-row',sideNav).forEach(bindNavRow);$('#sideHomeButton').classList.toggle('active',state.activeId==='HOME');$('#sideHomeButton').setAttribute('aria-current',state.activeId==='HOME'?'page':'false');
|
||||
}
|
||||
|
||||
function renderTabs(){
|
||||
if(!state.tabs.length){tabs.innerHTML='<div class="empty-tabs">열린 업무가 없습니다.</div>';return;}
|
||||
const focusableId=state.tabs.some(t=>t.id===state.activeId)?state.activeId:state.tabs[0]?.id;
|
||||
tabs.innerHTML=state.tabs.map(t=>`<div class="tab-shell ${state.activeId===t.id?'active':''}" data-tab="${esc(t.id)}"><button class="tab-main" role="tab" aria-selected="${state.activeId===t.id}" tabindex="${focusableId===t.id?'0':'-1'}" type="button">${t.resumed&&!t.dirty?'<span class="tab-resumed" title="이전 세션에서 복원된 업무 위치" aria-label="이전 세션에서 복원됨">↺</span>':''}<span class="tab-title">${esc(t.title)}</span>${t.dirty?'<span class="tab-dirty" title="저장하지 않은 변경" aria-label="저장하지 않은 변경">●</span>':''}</button><button class="tab-pin" type="button" aria-label="${t.pinned?'고정 해제':'고정'}" aria-pressed="${t.pinned}">${t.pinned?'◆':'◇'}</button><button class="tab-close" type="button" aria-label="${esc(t.title)} 닫기">×</button></div>`).join('');
|
||||
const shells=$$('.tab-shell',tabs);
|
||||
shells.forEach((shell,index)=>{
|
||||
const id=shell.dataset.tab;const main=$('.tab-main',shell);
|
||||
main.onclick=()=>openScreen(id);$('.tab-close',shell).onclick=e=>{e.stopPropagation();requestCloseTab(id)};
|
||||
$('.tab-pin',shell).onclick=e=>{e.stopPropagation();const tab=state.tabs.find(x=>x.id===id);if(tab){tab.pinned=!tab.pinned;persistWorkspaceSession();renderTabs();}};
|
||||
main.onkeydown=e=>{let next=index;if(e.key==='ArrowRight')next=(index+1)%shells.length;else if(e.key==='ArrowLeft')next=(index-1+shells.length)%shells.length;else if(e.key==='Home')next=0;else if(e.key==='End')next=shells.length-1;else return;e.preventDefault();const target=shells[next]?.dataset.tab;if(target){openScreen(target,{focus:false});$('.tab-main',shells[next])?.focus();}};
|
||||
});
|
||||
}
|
||||
|
||||
const btn=(label,cls='',id='',disabled=false)=>`<button ${id?`id="${esc(id)}"`:''} type="button" class="btn ${cls}" ${disabled?'disabled aria-disabled="true"':''}>${esc(label)}</button>`;
|
||||
function defaultCommands(primary='저장 F8',prefix='default'){
|
||||
return `<div class="command-group">${btn('조회 F3','',''+prefix+'Search')}${btn('신규','',''+prefix+'New')}${btn(primary,'primary',''+prefix+'Save')}</div><span class="command-separator"></span><div class="command-group">${btn('엑셀 ▼')}${btn('더보기 ⋯')}</div>`;
|
||||
}
|
||||
function pageShell(s,body,commands=''){
|
||||
return `<section class="screen" data-screen-id="${esc(s.id)}" data-template="${esc(s.template)}" data-module="${esc(s.module)}"><header class="page-header"><div class="page-identity"><div class="breadcrumb">${esc(s.module)} > ${esc(s.group)}</div><div class="title-row"><h1>${esc(s.title)}</h1><span class="status-chip">${esc(s.template)}</span></div></div><div class="page-tools"><button id="pageFavorite" type="button" aria-label="${isFav(s.id)?'즐겨찾기 해제':'즐겨찾기 추가'}" aria-pressed="${isFav(s.id)}">${isFav(s.id)?'★':'☆'}</button><button id="pageHelp" type="button">도움말</button><button id="pageAi" type="button">AI</button><button id="pageSuggestion" type="button">제안</button></div></header>${commands?`<div class="command-bar">${commands}</div>`:''}<div class="screen-body">${body}</div></section>`;
|
||||
}
|
||||
function setBackgroundInert(value){for(const el of [$('.global-header'),$('.shell-body')]){if(!el)continue;value?el.setAttribute('inert',''):el.removeAttribute('inert');}}
|
||||
function openDrawerContent(title,html){
|
||||
drawerRestoreFocus=document.activeElement instanceof HTMLElement?document.activeElement:null;drawerTitle.textContent=title;drawerBody.innerHTML=html;
|
||||
drawer.classList.add('open');drawer.setAttribute('aria-hidden','false');drawerBackdrop.hidden=false;setBackgroundInert(true);setTimeout(()=>$('#drawerClose').focus(),0);
|
||||
}
|
||||
function applyIntent(intent){
|
||||
if(intent==='order-stock'){state.orderQuickFilter='stock';state.selectedOrders.clear();state.orderDataState='ready';return openScreen('OMS-ORD-001');}
|
||||
if(intent==='order-errors'){state.orderQuickFilter='error';state.selectedOrders.clear();state.orderDataState='ready';return openScreen('OMS-ORD-001');}
|
||||
if(intent==='reconcile-mismatch'){state.mismatchOnly=true;return openScreen('COMMON-REC-001');}
|
||||
if(intent==='import-progress'){state.importStep=state.importJobStatus==='completed'?6:Math.max(1,state.importStep);return openScreen('OMS-ORD-003');}
|
||||
}
|
||||
function showUtilityPanel(s,kind){
|
||||
if(kind==='help'){openDrawerContent(`${s.title} 도움말`,`<section class="utility-panel"><p class="utility-kicker">화면 도움말 · ${esc(s.id)}</p><h2>${esc(s.title)}</h2><p>이 화면은 ${esc(s.group)} 업무를 표준 ${esc(s.template)} 문법으로 처리합니다.</p><h3>기본 사용법</h3><ol><li>조회 조건 또는 업무 대상을 확인합니다.</li><li>필요한 행이나 값을 선택합니다.</li><li>Command Bar의 업무 동사를 실행합니다.</li></ol><h3>단축키</h3><dl class="shortcut-list"><div><dt>F2</dt><dd>Lookup</dd></div><div><dt>F3</dt><dd>조회</dd></div><div><dt>F8</dt><dd>저장</dd></div><div><dt>Esc</dt><dd>현재 Context 닫기</dd></div></dl><div class="utility-note">화면의 Disabled 상태는 UX 표현이며 최종 권한과 업무 규칙은 서버에서 재검증해야 합니다.</div></section>`);return;}
|
||||
if(kind==='ai'){openDrawerContent(`${s.title} AI 도우미`,`<section class="utility-panel"><p class="utility-kicker">현재 화면 · ${esc(s.id)}</p><h2>확인할 항목</h2><div class="ai-attention"><button type="button" data-ai-intent="order-stock"><span>재고부족</span><b>7건</b><small>주문관리에서 보기</small></button><button type="button" data-ai-intent="order-errors"><span>처리오류</span><b>16건</b><small>오류 주문만 보기</small></button><button type="button" data-ai-intent="reconcile-mismatch"><span>대사 불일치</span><b>43건</b><small>불일치만 보기</small></button></div><h3>빠른 질문</h3><div class="utility-actions"><button class="btn" type="button" data-ai-intent="order-stock">왜 출고가 안 된 주문이 있어?</button><button class="btn" type="button" data-ai-intent="reconcile-mismatch">불일치만 보여줘</button></div><div class="utility-note">AI는 설명·제안만 수행합니다. 실제 변경은 구조화된 Proposal과 사용자 확인 후 Domain Command로 실행해야 합니다.</div></section>`);$$('[data-ai-intent]',drawerBody).forEach(button=>button.onclick=()=>{const intent=button.dataset.aiIntent;closeDrawer();setTimeout(()=>applyIntent(intent),0);});return;}
|
||||
openDrawerContent(`${s.title} 제안`,`<form id="suggestionForm" class="utility-panel"><p class="utility-kicker">사용자 제안 · ${esc(s.id)}</p><h2>이 화면에 의견 보내기</h2><fieldset class="suggestion-types"><legend>유형</legend><label><input type="radio" name="type" value="불편" checked> 불편</label><label><input type="radio" name="type" value="오류"> 오류</label><label><input type="radio" name="type" value="개선 제안"> 개선 제안</label></fieldset><label class="suggestion-copy"><span>내용</span><textarea id="suggestionText" required maxlength="500" rows="7" placeholder="업무 중 불편했던 상황과 기대하는 동작을 적어주세요."></textarea></label><label class="suggestion-context"><input type="checkbox" checked> 현재 화면 정보 포함 <small>ScreenId, Route, Version, 필터 구조만 포함 · 업무 민감데이터 제외</small></label><div class="drawer-actions"><button class="btn primary" type="submit">보내기</button></div></form>`);$('#suggestionForm').onsubmit=e=>{e.preventDefault();const value=$('#suggestionText').value.trim();if(!value){$('#suggestionText').focus();return;}closeDrawer();toast('화면 의견을 접수했습니다. 업무 민감데이터는 포함하지 않았습니다.');};setTimeout(()=>$('#suggestionText')?.focus(),0);
|
||||
}
|
||||
function closeSystemPanel(restoreFocus=true){
|
||||
if(systemPanel.hidden)return;systemPanel.hidden=true;$('#jobsButton').setAttribute('aria-expanded','false');$('#noticeButton').setAttribute('aria-expanded','false');
|
||||
const restore=systemPanelRestoreFocus;systemPanelRestoreFocus=null;if(restoreFocus)restore?.focus();
|
||||
}
|
||||
function showSystemPanel(kind){
|
||||
const trigger=kind==='jobs'?$('#jobsButton'):$('#noticeButton');
|
||||
if(!systemPanel.hidden&&trigger.getAttribute('aria-expanded')==='true'){closeSystemPanel();return;}
|
||||
closeDrawer();systemPanelRestoreFocus=document.activeElement instanceof HTMLElement?document.activeElement:null;systemPanelTitle.textContent=kind==='jobs'?'작업 센터':'알림 센터';
|
||||
$('#jobsButton').setAttribute('aria-expanded',String(kind==='jobs'));$('#noticeButton').setAttribute('aria-expanded',String(kind!=='jobs'));
|
||||
if(kind==='jobs'){const progress=state.importJobStatus==='running'?`${state.importProgress}%`:'완료';systemPanelBody.innerHTML=`<section class="utility-panel"><p class="utility-kicker">백그라운드 작업</p><h2>진행 중 / 최근 작업</h2><button class="system-row" type="button" data-system-intent="import-progress"><span><b>Excel 주문 Import</b><small>${esc(progress)} · 정상 ${state.importProcessed.toLocaleString('ko-KR')}건</small></span><span>열기 ›</span></button><div class="system-row static"><span><b>송장 재처리</b><small>대기 · 자동 재시도</small></span><span>대기</span></div></section>`;}else{systemPanelBody.innerHTML=`<section class="utility-panel"><p class="utility-kicker">업무 알림</p><h2>확인 필요 4건</h2><button class="system-row critical" type="button" data-system-intent="order-errors"><span><b>송장 발급 실패 3건</b><small>원인 확인 후 재처리</small></span><span>열기 ›</span></button><button class="system-row" type="button" data-system-intent="order-stock"><span><b>재고부족 주문 7건</b><small>예외 주문만 확인</small></span><span>열기 ›</span></button></section>`;}
|
||||
systemPanel.hidden=false;$$('[data-system-intent]',systemPanelBody).forEach(button=>button.onclick=()=>{const intent=button.dataset.systemIntent;closeSystemPanel(false);setTimeout(()=>applyIntent(intent),0);});setTimeout(()=>systemPanel.focus(),0);
|
||||
}
|
||||
function bindPageTools(s){
|
||||
const fav=$('#pageFavorite');if(fav)fav.onclick=()=>toggleFavorite(s.id);
|
||||
const help=$('#pageHelp');if(help)help.onclick=()=>showUtilityPanel(s,'help');
|
||||
const ai=$('#pageAi');if(ai)ai.onclick=()=>showUtilityPanel(s,'ai');
|
||||
const suggestion=$('#pageSuggestion');if(suggestion)suggestion.onclick=()=>showUtilityPanel(s,'suggestion');
|
||||
}
|
||||
function grid(cols,rows,{caption='업무 데이터',className=''}={}){
|
||||
return `<div class="grid-wrap ${className}"><table class="data-grid"><caption class="sr-only">${esc(caption)}</caption><thead><tr>${cols.map(c=>`<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
}
|
||||
function formField(label,value,{required=false,name='',readonly=false}={}){
|
||||
return `<div class="form-field"><label class="${required?'required':''}">${esc(label)}</label><input ${name?`data-field="${esc(name)}"`:''} value="${esc(value)}" ${readonly?'readonly':''}></div>`;
|
||||
}
|
||||
function lookupField(label,code,name,{field='',entity=field}={}){
|
||||
return `<div class="form-field"><label>${esc(label)}</label><div class="lookup" data-lookup="${esc(entity)}" data-lookup-field="${esc(field)}"><input ${field?`data-field="${esc(field)}Code"`:''} value="${esc(code)}" autocomplete="off"><input ${field?`data-field="${esc(field)}Name"`:''} value="${esc(name)}" readonly><button type="button" class="lookup-trigger" title="F2 조회" aria-label="${esc(label)} 조회" aria-keyshortcuts="F2">검색</button></div></div>`;
|
||||
}
|
||||
function checkboxField(label,checked,{field=''}={}){return `<div class="form-field"><label>${esc(label)}</label><label class="checkbox-control"><input ${field?`data-field="${esc(field)}"`:''} type="checkbox" ${checked?'checked':''}> 사용</label></div>`;}
|
||||
|
||||
function lookupItems(){
|
||||
const list=lookupData[state.lookupEntity]||[];const q=(lookupSearchInput.value||'').trim().toLowerCase();
|
||||
return list.filter(item=>!q||`${item.code} ${item.name} ${item.secondary}`.toLowerCase().includes(q));
|
||||
}
|
||||
function renderLookupResults(){
|
||||
const list=lookupItems();if(state.lookupIndex>=list.length)state.lookupIndex=Math.max(0,list.length-1);
|
||||
lookupResults.innerHTML=list.length?list.map((item,index)=>`<button type="button" class="lookup-result ${index===state.lookupIndex?'active':''}" data-index="${index}" role="option" aria-selected="${index===state.lookupIndex}"><span class="code">${esc(item.code)}</span><span class="copy"><b>${esc(item.name)}</b><small>${esc(item.secondary)}</small></span></button>`).join(''):'<div class="search-empty">조회된 항목이 없습니다.</div>';
|
||||
$('#lookupStatus').textContent=`${list.length}건 · ↑↓ 이동 · Enter 선택 · Esc 닫기`;
|
||||
$$('.lookup-result',lookupResults).forEach((button,index)=>{button.onmousemove=()=>{state.lookupIndex=index;renderLookupResults();};button.onclick=()=>{state.lookupIndex=index;selectLookup();};});
|
||||
$('#lookupSelectButton').disabled=!list.length;
|
||||
}
|
||||
function openLookup(root){
|
||||
if(!root)return;state.lookupEntity=root.dataset.lookup||'';state.lookupField=root.dataset.lookupField||'';state.lookupIndex=0;lookupRestoreFocus=document.activeElement instanceof HTMLElement?document.activeElement:null;
|
||||
const label=root.closest('.form-field')?.querySelector('label')?.textContent?.trim()||'업무';$('#lookupTitle').textContent=`${label} 검색`;$('#lookupSubtitle').textContent='F2 · 코드/명칭 조회';lookupSearchInput.value='';renderLookupResults();
|
||||
if(!lookupDialog.open)lookupDialog.showModal();setTimeout(()=>lookupSearchInput.focus(),0);
|
||||
}
|
||||
function focusAfterLookup(root){
|
||||
const focusables=$$('input:not([disabled]),select:not([disabled]),button:not([disabled]),textarea:not([disabled])',workspace);
|
||||
const current=root?.querySelector('button.lookup-trigger');const index=focusables.indexOf(current);(focusables[index+1]||lookupRestoreFocus)?.focus();
|
||||
}
|
||||
function applyLookupToActive(field,item){
|
||||
if(state.activeId==='ERP-MST-ITEM-001'&&field==='warehouse'&&state.masterDraft){state.masterDraft.warehouseCode=item.code;state.masterDraft.warehouse=item.name;}
|
||||
if(state.activeId==='OMS-ORD-002'){
|
||||
if(field==='customer'){state.transactionHeader.customerCode=item.code;state.transactionHeader.customerName=item.name;}
|
||||
if(field==='warehouse'){state.transactionHeader.warehouseCode=item.code;state.transactionHeader.warehouseName=item.name;}
|
||||
}
|
||||
}
|
||||
function selectLookup(){
|
||||
const list=lookupItems();const item=list[state.lookupIndex];if(!item)return;const root=$(`.lookup[data-lookup-field="${CSS.escape(state.lookupField)}"]`,workspace);
|
||||
if(root){const code=$(`[data-field="${CSS.escape(state.lookupField)}Code"]`,root);const name=$(`[data-field="${CSS.escape(state.lookupField)}Name"]`,root);if(code)code.value=item.code;if(name)name.value=item.name;applyLookupToActive(state.lookupField,item);markDirty(state.activeId,true);}
|
||||
lookupDialog.close();announce(`${item.code} ${item.name}을 선택했습니다.`);toast(`${item.name}을 선택했습니다.`);setTimeout(()=>focusAfterLookup(root),0);
|
||||
}
|
||||
function bindLookups(){
|
||||
$$('.lookup',workspace).forEach(root=>{const trigger=$('.lookup-trigger',root);if(trigger)trigger.onclick=()=>openLookup(root);$$('input',root).forEach(input=>input.onkeydown=e=>{if(e.key==='F2'){e.preventDefault();openLookup(root);}});});
|
||||
}
|
||||
function dataState(kind,title,detail,retryId=''){
|
||||
const action=retryId?btn('다시 조회','primary',retryId):'';
|
||||
return `<section class="data-state ${esc(kind)}" role="status"><strong>${esc(title)}</strong><span>${esc(detail)}</span>${action}</section>`;
|
||||
}
|
||||
|
||||
|
||||
function homeAttentionItems(){
|
||||
return [
|
||||
{source:'실패 작업',title:'송장 발급 실패 3건',detail:'오류 주문 필터로 바로 이동',id:'OMS-ORD-001',critical:true,intent:'order-errors'},
|
||||
{source:'예외',title:'재고부족 주문 7건',detail:'재고부족 필터를 적용해 이동',id:'OMS-ORD-001',critical:true,intent:'order-stock'},
|
||||
{source:'중요 알림',title:'대사 불일치 43건',detail:'불일치만 표시해 이동',id:'COMMON-REC-001',critical:false,intent:'reconcile-mismatch'},
|
||||
{source:'진행 작업',title:state.importJobStatus==='running'?`Excel 주문 반영 ${state.importProgress}%`:state.importJobStatus==='completed'?'Excel 주문 반영 완료':'Excel 주문 업로드 대기',detail:'작업상태 보기',id:'OMS-ORD-003',critical:false,intent:'import-progress'},
|
||||
{source:'미저장',title:'주문등록 수정 중',detail:'저장하지 않은 변경사항 이어서 편집',id:'OMS-ORD-002',critical:false,intent:''},
|
||||
{source:'알림',title:'주소 확인 필요 4건',detail:'배송지 오류 주문만 확인',id:'OMS-ORD-001',critical:false,intent:'order-errors'},
|
||||
{source:'알림',title:'SKU 매칭 실패 2건',detail:'상품 매핑 오류 주문만 확인',id:'OMS-ORD-001',critical:false,intent:'order-errors'},
|
||||
{source:'진행 작업',title:'피킹 작업 1건 지연',detail:'현재 물류작업 Queue에서 확인',id:'WMS-WORK-001',critical:false,intent:''},
|
||||
{source:'알림',title:'품목 바코드 확인 1건',detail:'품목 기준정보에서 확인',id:'ERP-MST-ITEM-001',critical:false,intent:''},
|
||||
{source:'진행 작업',title:'재고 실사 작업 확인',detail:'물류 작업 Queue에서 확인',id:'WMS-WORK-001',critical:false,intent:''},
|
||||
...Array.from({length:18},(_,index)=>({source:index%3===0?'진행 작업':'알림',title:`추가 확인 업무 ${index+1}`,detail:index%3===0?'작업 센터에서 전체 진행상태 확인':'알림 센터에서 전체 알림 확인',id:index%3===0?'WMS-WORK-001':'OMS-ORD-001',critical:false,intent:''})),
|
||||
];
|
||||
}
|
||||
|
||||
function attention(source,title,detail,id,critical=false,intent=''){return `<button type="button" class="attention-item ${critical?'critical':''}" data-screen="${esc(id)}" ${intent?`data-intent="${esc(intent)}"`:''}><span class="attention-source">${esc(source)}</span><span class="attention-copy"><b>${esc(title)}</b><small>${esc(detail)}</small></span><span class="attention-action">열기</span><span aria-hidden="true">›</span></button>`;}
|
||||
function attentionTriageGroups(items,expanded){
|
||||
const perGroup=expanded?6:2;
|
||||
const groups=[
|
||||
{key:'urgent',label:'긴급·예외',description:'실패와 중요 알림부터 처리',tone:'danger',match:item=>item.critical},
|
||||
{key:'draft',label:'미저장',description:'중단한 편집을 이어서 처리',tone:'warning',match:item=>item.source==='미저장'},
|
||||
{key:'running',label:'진행 작업',description:'대량·연계 작업 상태 확인',tone:'info',match:item=>item.source==='진행 작업'},
|
||||
{key:'notice',label:'알림',description:'업무 후속 확인 항목',tone:'default',match:item=>item.source==='알림'},
|
||||
];
|
||||
return groups.map(group=>{const all=items.filter(group.match);return {...group,count:all.length,items:all.slice(0,perGroup)}}).filter(group=>group.count);
|
||||
}
|
||||
function attentionLane(group){return `<section class="attention-lane" data-tone="${esc(group.tone)}"><header><div><strong>${esc(group.label)}</strong><small>${esc(group.description)}</small></div><b>${group.count}</b></header><div class="attention-lane-items">${group.items.map(item=>attention(item.source,item.title,item.detail,item.id,item.critical,item.intent)).join('')}</div>${group.count>group.items.length?`<button type="button" class="attention-lane-more" data-attention-lane="${esc(group.key)}">나머지 ${group.count-group.items.length}개 보기</button>`:''}</section>`;}
|
||||
function continueItem(source,title,detail,id,type){return `<button type="button" class="continue-item" data-screen="${esc(id)}" data-source="${esc(type)}"><span class="continue-source">${esc(source)}</span><span class="continue-copy"><b>${esc(title)}</b><small>${esc(detail)}</small></span><span class="attention-action">${type==='dirty'?'이어서':'열기'}</span><span aria-hidden="true">›</span></button>`;}
|
||||
function homeMenuRow(item){return `<div class="home-menu-row" data-screen="${esc(item.id)}"><button class="open-screen" type="button"><span>${esc(item.title)}</span><small>${esc(item.template)}</small></button><button class="fav ${isFav(item.id)?'on':''}" type="button" aria-label="${isFav(item.id)?'즐겨찾기 해제':'즐겨찾기 추가'}" aria-pressed="${isFav(item.id)}">${isFav(item.id)?'★':'☆'}</button></div>`;}
|
||||
function renderHome(){
|
||||
const allScreens=screens.filter(x=>x.id!=='HOME');const visible=allScreens.filter(x=>state.module==='ALL'||x.module===state.module);const grouped=groupScreens(visible);
|
||||
const moduleKeys=['OMS','ERP','WMS','COMMON'].filter(module=>allScreens.some(item=>item.module===module));
|
||||
const allAttention=homeAttentionItems();
|
||||
const moduleAttentionCount=module=>allAttention.filter(item=>screen(item.id).module===module).length;
|
||||
const moduleRail=`<nav class="home-module-rail" aria-label="업무 영역 바로가기"><span class="home-module-label">업무영역</span><button type="button" data-home-module="ALL" aria-pressed="${state.module==='ALL'}"><b>전체</b><small>${allScreens.length}개 화면 · 확인 ${allAttention.length}</small></button>${moduleKeys.map(module=>{const items=allScreens.filter(item=>item.module===module);const favoriteCount=items.filter(item=>isFav(item.id)).length;const openCount=state.tabs.filter(tab=>screen(tab.id).module===module).length;return `<button type="button" data-home-module="${esc(module)}" data-module="${esc(module)}" aria-pressed="${state.module===module}"><b>${esc(labelModule(module))}</b><small>${items.length}개 · 확인 ${moduleAttentionCount(module)} · ★ ${favoriteCount} · 열림 ${openCount}</small></button>`;}).join('')}</nav>`;
|
||||
const dirtyTab=state.tabs.find(t=>t.dirty);const resumedTab=state.tabs.find(t=>t.resumed&&!t.dirty);const regularOpen=state.tabs.find(t=>!t.dirty&&!t.resumed);
|
||||
const attentionCap=24;const legacyCollapsedAttention=allAttention.slice(0,8);const triageGroups=attentionTriageGroups(allAttention,state.homeAttentionExpanded);const visibleAttention=triageGroups.flatMap(group=>group.items).slice(0,attentionCap);const attentionOverflow=Math.max(allAttention.length-visibleAttention.length,0);const attentionToggleLabel=state.homeAttentionExpanded?'상위 8개만 보기':allAttention.length<=attentionCap?`전체 ${allAttention.length}개 보기`:`우선 ${attentionCap}개 보기`;const attentionCenters=state.homeAttentionExpanded&&attentionOverflow?`<div class="attention-centers" aria-label="나머지 확인 항목"><button id="homeJobsAll" class="section-toggle" type="button">작업 센터 전체</button><button id="homeNoticesAll" class="section-toggle" type="button">알림 센터 전체</button></div>`:'';
|
||||
workspace.innerHTML=`<section class="home-page"><header class="home-heading"><div><p>KBX 업무 시작</p><h1>홈</h1><small>검색하고, 이어서 처리하고, 예외만 확인하는 한국형 업무 시작점${state.workspaceDirtyDiscarded?` · 이전 미저장 ${state.workspaceDirtyDiscarded}개는 안전상 복원 제외`:''}</small></div><button class="home-search" id="homeSearch" type="button" aria-keyshortcuts="Control+K"><span>업무 화면 검색</span><kbd>Ctrl K</kbd></button></header>
|
||||
<nav class="home-pulse" aria-label="업무 시작 상태"><button id="homePulseScreens" type="button"><span>사용 가능 화면</span><b>${allScreens.length}</b><small>검색</small></button><button id="homePulseDirty" type="button" ${dirtyTab?'':'disabled'}><span>미저장 업무</span><b>${state.tabs.filter(tab=>tab.dirty).length}</b><small>${dirtyTab?'이어서':'없음'}</small></button><button id="homePulseJobs" type="button"><span>진행 작업</span><b>2</b><small>작업 센터</small></button><button id="homePulseNotices" type="button"><span>새 알림</span><b>4</b><small>알림 센터</small></button></nav>
|
||||
${moduleRail}
|
||||
<section class="attention-zone"><div class="section-head"><div><h2>확인 필요</h2><small>정상 8,224건은 자동 처리 · 사용자는 예외와 실패만 확인</small></div><div class="section-actions"><span class="section-count">전체 ${allAttention.length}개 · ${visibleAttention.length}개 표시</span><button id="homeAttentionToggle" class="section-toggle" type="button" aria-controls="homeAttentionGrid" aria-expanded="${state.homeAttentionExpanded}">${attentionToggleLabel}</button>${attentionCenters}</div></div><div id="homeAttentionGrid" class="attention-grid">${triageGroups.map(attentionLane).join('')}</div></section>
|
||||
<section class="home-section"><div class="section-head"><div><h2>바로 시작</h2><small>미저장 → 열린 업무 → 즐겨찾기 → 최근 순으로 중복 없이 연결</small></div><span class="section-count">업무 문맥 유지</span></div><div class="continue-list">${continueItem('미저장',dirtyTab?.title||'주문등록',dirtyTab?`${screen(dirtyTab.id).module} · 저장하지 않은 변경`:'OMS · 주문 · ORD 임시작성',dirtyTab?.id||'OMS-ORD-002','dirty')}${resumedTab?continueItem('복원',resumedTab.title,`${screen(resumedTab.id).module} · 이전 세션의 업무 위치`,resumedTab.id,'open'):continueItem('열림',regularOpen?.title||'품목관리',regularOpen?`${screen(regularOpen.id).module} · 열린 업무`:'ERP · 기준정보',regularOpen?.id||'ERP-MST-ITEM-001','open')}${continueItem('즐겨찾기','재고현황','ERP · 재고','ERP-INV-001','favorite')}${continueItem('최근','오늘 물류작업','WMS · 작업관리','WMS-WORK-001','recent')}</div></section>
|
||||
<section id="homeExplorer" class="home-section" tabindex="-1"><div class="section-head"><div><h2>${state.module==='ALL'?'전체 업무':`${esc(labelModule(state.module))} 업무`}</h2><small>신규 화면은 빈 Canvas가 아니라 T01~T09 원형에서 시작 · 검증된 업무 문법을 재사용</small></div><div class="section-actions">${state.module!=='ALL'?'<button id="homeShowAll" class="section-toggle" type="button">전체 업무 보기</button>':''}<span class="section-count">${visible.length}개 화면</span></div></div><div class="work-explorer">${[...grouped.entries()].map(([module,groups])=>`<section class="module-column" data-module="${esc(module)}"><div class="module-title"><strong>${esc(labelModule(module))}</strong><span>${module==='OMS'?'주문·채널':module==='ERP'?'기준정보·구매·재고':module==='WMS'?'물류 작업':'공통 운영'}</span><small>${[...groups.values()].flat().length}개</small></div>${[...groups.entries()].map(([group,items])=>`<div class="group-block"><h3>${esc(group)}</h3>${items.map(homeMenuRow).join('')}</div>`).join('')}</section>`).join('')}</div></section>
|
||||
</section>`;
|
||||
$('#homeSearch').onclick=openSearch;$('#homePulseScreens').onclick=openSearch;const pulseDirty=$('#homePulseDirty');if(pulseDirty&&!pulseDirty.disabled)pulseDirty.onclick=()=>openScreen(dirtyTab.id);$('#homePulseJobs').onclick=()=>showSystemPanel('jobs');$('#homePulseNotices').onclick=()=>showSystemPanel('notices');const railButtons=$$('[data-home-module]',workspace);railButtons.forEach((button,index)=>{button.onclick=()=>{state.module=button.dataset.homeModule;$('#moduleSelect').value=state.module;renderNavigation();renderHome();setTimeout(()=>{$('#homeExplorer')?.scrollIntoView({block:'start',behavior:'smooth'});$('#homeExplorer')?.focus({preventScroll:true});},0);};button.onkeydown=e=>{let next=index;if(e.key==='ArrowRight')next=(index+1)%railButtons.length;else if(e.key==='ArrowLeft')next=(index-1+railButtons.length)%railButtons.length;else if(e.key==='Home')next=0;else if(e.key==='End')next=railButtons.length-1;else return;e.preventDefault();railButtons[next]?.focus();};});const showAll=$('#homeShowAll');if(showAll)showAll.onclick=()=>{state.module='ALL';$('#moduleSelect').value='ALL';renderNavigation();renderHome();setTimeout(()=>$('#homeExplorer')?.focus({preventScroll:true}),0);};const attentionToggle=$('#homeAttentionToggle');if(attentionToggle)attentionToggle.onclick=()=>{state.homeAttentionExpanded=!state.homeAttentionExpanded;renderHome();};const jobsAll=$('#homeJobsAll');if(jobsAll)jobsAll.onclick=()=>showSystemPanel('jobs');const noticesAll=$('#homeNoticesAll');if(noticesAll)noticesAll.onclick=()=>showSystemPanel('notices');$$('.attention-item',workspace).forEach(b=>b.onclick=()=>b.dataset.intent?applyIntent(b.dataset.intent):openScreen(b.dataset.screen));$$('.attention-lane-more',workspace).forEach(button=>button.onclick=()=>{if(button.dataset.attentionLane==='running')showSystemPanel('jobs');else if(button.dataset.attentionLane==='notice')showSystemPanel('notices');else{state.homeAttentionExpanded=true;renderHome();}});$$('.continue-item',workspace).forEach(b=>b.onclick=()=>openScreen(b.dataset.screen));$$('.home-menu-row',workspace).forEach(row=>{$('.open-screen',row).onclick=()=>openScreen(row.dataset.screen);$('.fav',row).onclick=()=>toggleFavorite(row.dataset.screen);});
|
||||
}
|
||||
|
||||
function filteredOrders(){
|
||||
let visible=orders;
|
||||
if(state.orderQuickFilter==='new')visible=visible.filter(x=>x.status==='NEW');
|
||||
else if(state.orderQuickFilter==='ready')visible=visible.filter(x=>x.status==='READY');
|
||||
else if(state.orderQuickFilter==='stock')visible=visible.filter(x=>x.stock.includes('부족'));
|
||||
else if(state.orderQuickFilter==='error')visible=visible.filter(x=>x.exception||x.status==='ERROR');
|
||||
if(state.orderSearch.channel!=='all')visible=visible.filter(x=>x.channel===state.orderSearch.channel);
|
||||
if(state.orderSearch.status!=='all')visible=visible.filter(x=>x.status===state.orderSearch.status);
|
||||
if(state.orderSearch.exception!=='all')visible=visible.filter(x=>x.exception===state.orderSearch.exception || (state.orderSearch.exception==='재고부족'&&x.stock.includes('부족')));
|
||||
const keyword=state.orderSearch.keyword.trim().toLowerCase();
|
||||
if(keyword)visible=visible.filter(x=>`${x.no} ${x.buyer} ${x.item} ${x.channel} ${x.exception}`.toLowerCase().includes(keyword));
|
||||
return visible;
|
||||
}
|
||||
function searchOption(value,label,current){return `<option value="${esc(value)}" ${current===value?'selected':''}>${esc(label)}</option>`;}
|
||||
function problemSurface(problem){
|
||||
if(!problem)return '';
|
||||
const kind={ 'business-rule':'업무 규칙', permission:'권한', 'not-found':'데이터', integration:'외부 연계', system:'시스템' }[problem.type]||'업무 오류';
|
||||
const tone=['permission','system'].includes(problem.type)?'danger':problem.type==='integration'?(problem.retryable?'warning':'danger'):'warning';
|
||||
const actions=(problem.actions||[]).map(action=>`<button type="button" class="btn secondary" data-problem-action="${esc(action.id)}">${esc(action.label)}</button>`).join('');
|
||||
return `<section class="problem-feedback ${tone}" role="alert" data-problem-type="${esc(problem.type)}"><div class="problem-copy"><div class="problem-heading"><span>${esc(kind)}</span><strong>${esc(problem.title)}</strong></div>${problem.detail?`<p>${esc(problem.detail)}</p>`:''}${problem.correlationId?`<small>참조번호 ${esc(problem.correlationId)}</small>`:''}</div><div class="problem-actions">${actions}${problem.retryable?'<button id="problemRetry" type="button" class="btn secondary">다시 시도</button>':''}<button id="problemDismiss" type="button" class="btn ghost">닫기</button></div></section>`;
|
||||
}
|
||||
function clearProblem(){state.commandProblem=null;}
|
||||
|
||||
function activeOrderSearchCount(){return [state.orderSearch.channel!=='all',state.orderSearch.status!=='all',Boolean(state.orderSearch.keyword.trim()),state.orderSearch.exception!=='all'].filter(Boolean).length;}
|
||||
function safeOrderDefaults(){return {channel:['all','쿠팡','스마트스토어','자사몰','11번가'].includes(state.orderSearch.channel)?state.orderSearch.channel:'all',status:canonicalOrderStatus(state.orderSearch.status),exception:['all','재고부족','주소확인','송장실패'].includes(state.orderSearch.exception)?state.orderSearch.exception:'all'};}
|
||||
function saveOrderDefaults(message){state.orderSavedDefaults=safeOrderDefaults();persist();toast(message||'현재 조회조건을 기본조건으로 저장했습니다. 자유검색어는 저장하지 않습니다.');}
|
||||
function orderSearchPanel(){
|
||||
const advanced=state.orderAdvanced?`<div class="search-advanced"><label class="field-inline"><span>택배사</span><select class="control" disabled title="Reference Lab: 택배사 데이터셋 미포함"><option>전체</option></select></label><label class="field-inline"><span>창고</span><select class="control" disabled title="Reference Lab: 창고 검색 데이터셋 미포함"><option>전체</option></select></label><label class="field-inline"><span>오류유형</span><select id="orderException" class="control">${searchOption('all','전체',state.orderSearch.exception)}${searchOption('재고부족','재고부족',state.orderSearch.exception)}${searchOption('주소확인','주소확인',state.orderSearch.exception)}${searchOption('송장실패','송장실패',state.orderSearch.exception)}</select></label><label class="search-preference"><input id="rememberOrderSearch" type="checkbox" ${state.orderRememberSearch?'checked':''}><span>마지막 조회조건 기억</span><small>주문자·주문번호·상품 자유검색어는 저장하지 않음</small></label></div>`:'';
|
||||
const activeCount=activeOrderSearchCount();
|
||||
return `<form class="search-panel ${state.orderAdvanced?'expanded':''}" id="orderSearchForm"><div class="search-fields"><label class="field-inline"><span>기간</span><input id="orderPeriod" class="control medium" value="2026-08-11 ~ 2026-08-11" readonly aria-label="주문기간"></label><label class="field-inline"><span>판매채널</span><select id="orderChannel" class="control">${searchOption('all','전체',state.orderSearch.channel)}${searchOption('쿠팡','쿠팡',state.orderSearch.channel)}${searchOption('스마트스토어','스마트스토어',state.orderSearch.channel)}${searchOption('자사몰','자사몰',state.orderSearch.channel)}${searchOption('11번가','11번가',state.orderSearch.channel)}</select></label><label class="field-inline"><span>상태</span><select id="orderStatus" class="control">${searchOption('all','전체',state.orderSearch.status)}${searchOption('NEW','신규',state.orderSearch.status)}${searchOption('READY','출고대기',state.orderSearch.status)}${searchOption('CONFIRMED','출고지시',state.orderSearch.status)}${searchOption('SHIPPED','출고완료',state.orderSearch.status)}${searchOption('HOLD','보류',state.orderSearch.status)}${searchOption('ERROR','오류',state.orderSearch.status)}</select></label><label class="field-inline"><span>통합검색</span><input id="orderKeyword" class="control wide" value="${esc(state.orderSearch.keyword)}" placeholder="주문번호/주문자/상품" maxlength="80"></label></div><div class="search-actions">${btn('조회','primary','searchExecute')}${btn(state.orderAdvanced?`상세조건 ${activeCount?`(${activeCount})`:''} 닫기`:`상세조건${activeCount?` ${activeCount}`:''}`,'','toggleAdvanced')}${btn('초기화','','searchReset')}${btn('기본조건 저장','','saveOrderDefaults')}</div>${advanced}</form>`;
|
||||
}
|
||||
function captureOrderSearch(){
|
||||
state.orderSearch.channel=$('#orderChannel')?.value||'all';
|
||||
state.orderSearch.status=$('#orderStatus')?.value||'all';
|
||||
state.orderSearch.keyword=$('#orderKeyword')?.value||'';
|
||||
state.orderSearch.exception=$('#orderException')?.value||state.orderSearch.exception||'all';
|
||||
const remember=$('#rememberOrderSearch');if(remember)state.orderRememberSearch=remember.checked;
|
||||
}
|
||||
function orderRows(){
|
||||
return filteredOrders().map((order,index)=>`<tr class="order-data-row ${state.selectedOrders.has(order.no)?'selected-row':''}" data-order-row="${esc(order.no)}" tabindex="${index===0?'0':'-1'}"><td class="center"><input type="checkbox" aria-label="${esc(order.no)} 선택" data-order="${esc(order.no)}" ${state.selectedOrders.has(order.no)?'checked':''}></td><td><a href="#" class="link order-link" data-order="${esc(order.no)}">${esc(order.no)}</a></td><td>${esc(order.channel)}</td><td class="center">${esc(order.time)}</td><td>${esc(order.buyer)}</td><td>${esc(order.item)}</td><td class="num">${order.qty.toLocaleString('ko-KR')}</td><td class="num">₩${order.amount.toLocaleString('ko-KR')}</td><td class="center">${statusChip(order.stock)}</td><td class="center">${statusChip(order.status,'shipment')}</td><td class="${order.exception?'error':''}">${esc(order.exception||'-')}</td></tr>`).join('');
|
||||
}
|
||||
function renderOrderData(){
|
||||
if(state.orderDataState==='loading')return dataState('loading','조회 중...','기존 조회조건을 유지한 채 데이터를 불러오고 있습니다.');
|
||||
if(state.orderDataState==='empty')return dataState('empty','조회된 주문이 없습니다.','조회조건을 변경하거나 초기화해 보세요.','orderRetry');
|
||||
if(state.orderDataState==='error')return dataState('error','주문을 조회하지 못했습니다.','네트워크 연결을 확인한 후 다시 시도하세요. 입력한 조회조건은 보존됩니다.','orderRetry');
|
||||
const visible=filteredOrders();
|
||||
if(!visible.length)return dataState('empty','현재 필터에 해당하는 주문이 없습니다.','다른 빠른 필터를 선택해 보세요.');
|
||||
const allSelected=visible.every(x=>state.selectedOrders.has(x.no));
|
||||
const table=grid([`<input>`,'주문번호','채널','주문시각','주문자','대표상품','수량','주문금액','재고','출고상태','오류'],orderRows(),{caption:'주문 조회 결과'}).replace('<th scope="col"><input></th>',`<th scope="col" class="center"><input id="selectVisibleOrders" type="checkbox" aria-label="현재 표시된 주문 전체 선택" ${allSelected?'checked':''}></th>`);
|
||||
return table;
|
||||
}
|
||||
function renderT01(s){
|
||||
const selection=state.selectedOrders.size;
|
||||
const quick=[['all','전체','8,241'],['new','신규','2,138'],['ready','출고대기','927'],['stock','재고부족','7'],['error','처리오류','16']];
|
||||
const body=`${problemSurface(state.commandProblem)}${orderSearchPanel()}<div class="quick-filter" role="group" aria-label="주문 빠른 필터">${quick.map(([key,label,count])=>`<button type="button" data-order-filter="${key}" class="${state.orderQuickFilter===key?'active':''} ${['stock','error'].includes(key)?'danger':''}" aria-pressed="${state.orderQuickFilter===key}"><small>${esc(label)}</small><b>${esc(count)}</b></button>`).join('')}</div><div class="context-bar"><strong>오늘 주문</strong><span>조회 8,241건</span><span>표시 ${filteredOrders().length}건</span><span>선택 <b id="selectionContext">${selection}</b>건</span><span>오류 16건</span></div><div id="bulkBar" class="bulk-bar" ${selection?'':'hidden'}><strong><span id="bulkCount">${selection}</span>건 선택</strong>${btn('출고지시','primary','bulkShip')}${btn('보류')}${btn('담당자 변경')}${btn('택배사 변경')}</div><div id="orderDataRegion">${renderOrderData()}</div><div class="summary-bar"><span>전체 8,241건</span><span>선택 <strong id="selectionSummary">${selection}</strong>건</span><span>상품수량 12,824</span><span>금액 <strong>₩182,420,300</strong></span></div>`;
|
||||
const commands=`<div class="command-group">${btn('조회 F3','','commandSearch')}${btn('신규','','commandNew')}${btn('주문복사')}</div><span class="command-separator"></span><div class="command-group">${btn('출고지시','primary','commandShip',selection===0)}${btn('보류','','commandHold',selection===0)}</div><span class="command-separator"></span><div class="command-group">${btn('인쇄')}${btn('엑셀 ▼')}</div>`;
|
||||
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT01();
|
||||
}
|
||||
function executeOrderSearch(){ clearProblem();captureOrderSearch();if(state.orderRememberSearch){state.orderSavedDefaults=safeOrderDefaults();persist();}state.selectedOrders.clear();state.orderDataState='loading';renderT01(screen('OMS-ORD-001'));setTimeout(()=>{state.orderDataState='ready';renderT01(screen('OMS-ORD-001'));toast(`현재 조건으로 ${filteredOrders().length}건을 조회했습니다.${state.orderRememberSearch?' 안전한 조회조건을 기억했습니다.':''}`);},260); }
|
||||
function bindT01(){
|
||||
$('#orderSearchForm').onsubmit=e=>{e.preventDefault();executeOrderSearch()};$('#searchExecute').onclick=executeOrderSearch;$('#commandSearch').onclick=executeOrderSearch;
|
||||
$('#toggleAdvanced').onclick=()=>{captureOrderSearch();state.orderAdvanced=!state.orderAdvanced;renderT01(screen('OMS-ORD-001'));};
|
||||
$('#searchReset').onclick=()=>{clearProblem();state.orderQuickFilter='all';state.orderAdvanced=false;state.orderSearch={channel:'all',status:'all',keyword:'',exception:'all'};state.selectedOrders.clear();state.orderDataState='ready';renderT01(screen('OMS-ORD-001'));toast('조회조건을 초기화했습니다.');};
|
||||
const saveDefaults=$('#saveOrderDefaults');if(saveDefaults)saveDefaults.onclick=()=>{captureOrderSearch();saveOrderDefaults();renderT01(screen('OMS-ORD-001'));};
|
||||
const remember=$('#rememberOrderSearch');if(remember)remember.onchange=()=>{captureOrderSearch();state.orderRememberSearch=remember.checked;if(remember.checked)state.orderSavedDefaults=safeOrderDefaults();persist();toast(remember.checked?'마지막 조회조건 기억을 켰습니다. 자유검색어는 저장하지 않습니다.':'마지막 조회조건 기억을 껐습니다.');renderT01(screen('OMS-ORD-001'));};
|
||||
const retry=$('#orderRetry');if(retry)retry.onclick=executeOrderSearch;
|
||||
$$('[data-order-filter]',workspace).forEach(button=>button.onclick=()=>{clearProblem();state.orderQuickFilter=button.dataset.orderFilter;state.selectedOrders.clear();state.orderDataState='ready';renderT01(screen('OMS-ORD-001'));announce(`${button.textContent.trim()} 필터를 적용했습니다.`);});
|
||||
$$('input[data-order]',workspace).forEach(cb=>cb.onchange=()=>{cb.checked?state.selectedOrders.add(cb.dataset.order):state.selectedOrders.delete(cb.dataset.order);renderT01(screen('OMS-ORD-001'));});
|
||||
const all=$('#selectVisibleOrders');if(all)all.onchange=()=>{filteredOrders().forEach(order=>all.checked?state.selectedOrders.add(order.no):state.selectedOrders.delete(order.no));renderT01(screen('OMS-ORD-001'));};
|
||||
$$('.order-link',workspace).forEach(link=>link.onclick=e=>{e.preventDefault();showOrderDetail(link.dataset.order);});
|
||||
const orderRows=$$('.order-data-row',workspace);orderRows.forEach((row,index)=>row.onkeydown=e=>{
|
||||
if(['INPUT','A','BUTTON'].includes(e.target?.tagName))return;
|
||||
if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();const next=Math.max(0,Math.min(orderRows.length-1,index+(e.key==='ArrowDown'?1:-1)));orderRows.forEach((x,i)=>x.tabIndex=i===next?0:-1);orderRows[next]?.focus();}
|
||||
else if(e.key===' '){e.preventDefault();const no=row.dataset.orderRow;no&&(state.selectedOrders.has(no)?state.selectedOrders.delete(no):state.selectedOrders.add(no));renderT01(screen('OMS-ORD-001'));setTimeout(()=>$$('.order-data-row',workspace)[index]?.focus(),0);}
|
||||
else if(e.key==='Enter'){e.preventDefault();showOrderDetail(row.dataset.orderRow);}
|
||||
});
|
||||
for(const id of ['bulkShip','commandShip']){const b=$('#'+id);if(b)b.onclick=()=>{if(!state.selectedOrders.size)return;const selected=orders.filter(order=>state.selectedOrders.has(order.no));const blocked=selected.filter(order=>Boolean(order.exception));if(blocked.length){state.commandProblem={type:'business-rule',title:'출고할 수 없는 주문이 포함되어 있습니다.',detail:`${blocked.length}건은 재고·주소·송장 예외를 먼저 해결해야 합니다. 정상 주문은 이 Reference Lab에서 임의 처리하지 않습니다.`,actions:[{id:'show-blocked',label:`문제 ${blocked.length}건만 선택`}],retryable:false};renderT01(screen('OMS-ORD-001'));return;}toast(`${state.selectedOrders.size}건 출고지시를 요청했습니다.`);};}const dismiss=$('#problemDismiss');if(dismiss)dismiss.onclick=()=>{clearProblem();renderT01(screen('OMS-ORD-001'));};$$('[data-problem-action="show-blocked"]',workspace).forEach(button=>button.onclick=()=>{const blocked=orders.filter(order=>state.selectedOrders.has(order.no)&&order.exception);state.selectedOrders=new Set(blocked.map(order=>order.no));state.orderQuickFilter='all';state.orderSearch.exception='all';clearProblem();renderT01(screen('OMS-ORD-001'));announce(`문제 주문 ${blocked.length}건만 선택했습니다.`);});
|
||||
const newButton=$('#commandNew');if(newButton)newButton.onclick=()=>openScreen('OMS-ORD-002');
|
||||
}
|
||||
function showOrderDetail(orderNo){
|
||||
const order=orders.find(x=>x.no===orderNo);if(!order)return;drawerRestoreFocus=document.activeElement instanceof HTMLElement?document.activeElement:null;
|
||||
openDrawerContent(`주문 ${order.no}`,`<div class="detail-status">${statusChip(order.status,'shipment')}${order.exception?`<span class="status-chip danger">${esc(order.exception)}</span>`:''}</div><div class="detail-list"><div class="detail-row"><span>판매채널</span><strong>${esc(order.channel)}</strong></div><div class="detail-row"><span>주문자</span><strong>${esc(order.buyer)} / 010-****-1234</strong></div><div class="detail-row"><span>대표상품</span><strong>${esc(order.item)}</strong></div><div class="detail-row"><span>주문금액</span><strong>₩${order.amount.toLocaleString('ko-KR')}</strong></div><div class="detail-row"><span>예외</span><strong>${esc(order.exception||'없음')}</strong></div></div><h3>처리이력</h3><div class="detail-list"><div class="detail-row"><span>16:42</span><strong>주문수집</strong></div><div class="detail-row"><span>16:43</span><strong>결제확인</strong></div><div class="detail-row"><span>16:45</span><strong>재고할당</strong></div></div><div class="drawer-actions">${btn('전체 상세')}${btn('출고지시','primary','drawerShip',Boolean(order.exception))}</div>`);
|
||||
}
|
||||
function closeDrawer(){drawer.classList.remove('open');drawer.setAttribute('aria-hidden','true');drawerBackdrop.hidden=true;setBackgroundInert(false);const restore=drawerRestoreFocus;drawerRestoreFocus=null;restore?.focus();}
|
||||
|
||||
function masterDraftFrom(item){return item?{...item}:{id:'',name:'',group:'',spec:'',unit:'EA',warehouse:'',warehouseCode:'',barcode:'',lot:false,expiry:false,status:'사용'};}
|
||||
function loadMasterDraft(id){const item=masterItems.find(x=>x.id===id);state.selectedMasterId=item?.id||'';state.masterMode='edit';state.masterDraft=masterDraftFrom(item||masterItems[0]);state.masterErrors={};}
|
||||
function validateMasterDraft(){
|
||||
const draft=state.masterDraft||masterDraftFrom();const errors={};
|
||||
if(!draft.id.trim())errors.id='품목코드를 입력하세요.';
|
||||
if(!draft.name.trim())errors.name='품목명을 입력하세요.';
|
||||
if(!draft.warehouseCode.trim())errors.warehouse='기본창고를 선택하세요.';
|
||||
if(state.masterMode==='new'&&masterItems.some(item=>item.id===draft.id.trim()))errors.id='이미 사용 중인 품목코드입니다.';
|
||||
state.masterErrors=errors;return Object.keys(errors).length===0;
|
||||
}
|
||||
function masterValidationSummary(){const entries=Object.entries(state.masterErrors);return entries.length?`<div class="validation-summary" role="alert"><strong>저장할 수 없습니다. ${entries.length}개 항목을 확인하세요.</strong>${entries.map(([field,message])=>`<button type="button" data-master-error="${esc(field)}">${esc(message)}</button>`).join('')}</div>`:'';}
|
||||
function performMasterTarget(target){
|
||||
if(target==='__NEW__'){state.selectedMasterId='';state.masterMode='new';state.masterDraft=masterDraftFrom();state.masterErrors={};renderT02(screen('ERP-MST-ITEM-001'));setTimeout(()=>$('[data-field="code"]',workspace)?.focus(),0);return;}
|
||||
if(target==='__COPY__'){const source=state.masterDraft||masterItems.find(x=>x.id===state.selectedMasterId)||masterItems[0];state.selectedMasterId='';state.masterMode='new';state.masterDraft={...masterDraftFrom(source),id:'',barcode:'',status:'사용'};state.masterErrors={};renderT02(screen('ERP-MST-ITEM-001'));setTimeout(()=>$('[data-field="code"]',workspace)?.focus(),0);return;}
|
||||
loadMasterDraft(target);renderT02(screen('ERP-MST-ITEM-001'));
|
||||
}
|
||||
function requestMasterTarget(target){const tab=state.tabs.find(x=>x.id==='ERP-MST-ITEM-001');if(tab?.dirty){state.pendingMasterId=target;state.pendingUnsavedMode='master-select';$('#unsavedMessage').textContent='현재 품목에 저장하지 않은 변경사항이 있습니다. 저장하거나 변경을 버린 뒤 다른 품목으로 전환하세요.';if(!unsavedDialog.open)unsavedDialog.showModal();return;}performMasterTarget(target);}
|
||||
function saveMaster(){
|
||||
if(!validateMasterDraft()){renderT02(screen('ERP-MST-ITEM-001'));setTimeout(()=>$('[data-master-error]',workspace)?.focus(),0);return false;}
|
||||
const draft={...state.masterDraft,id:state.masterDraft.id.trim(),name:state.masterDraft.name.trim(),warehouseCode:state.masterDraft.warehouseCode.trim()};
|
||||
if(state.masterMode==='new'){masterItems.push(draft);state.selectedMasterId=draft.id;state.masterMode='edit';}
|
||||
else{const current=masterItems.find(x=>x.id===state.selectedMasterId);if(current)Object.assign(current,draft);}
|
||||
state.masterDraft={...draft};state.masterErrors={};markDirty('ERP-MST-ITEM-001',false);renderT02(screen('ERP-MST-ITEM-001'));toast('품목을 저장했습니다.');return true;
|
||||
}
|
||||
function renderT02(s){
|
||||
if(!state.masterDraft)loadMasterDraft(state.selectedMasterId);
|
||||
const draft=state.masterDraft;
|
||||
const q=state.masterQuery.trim().toLowerCase();const visible=masterItems.filter(x=>!q||`${x.id} ${x.name} ${x.group}`.toLowerCase().includes(q));
|
||||
const rows=visible.map(x=>`<tr class="${x.id===state.selectedMasterId?'selected-row':''}"><td><button type="button" class="row-link master-select" data-item="${esc(x.id)}">${esc(x.id)}</button></td><td>${esc(x.name)}</td><td>${esc(x.group)}</td><td class="center">${statusChip(x.status)}</td></tr>`).join('');
|
||||
const modeLabel=state.masterMode==='new'?'신규 품목':'품목 상세';
|
||||
const currentIndex=visible.findIndex(item=>item.id===state.selectedMasterId);const dirty=Boolean(state.tabs.find(tab=>tab.id==='ERP-MST-ITEM-001')?.dirty);const navigator=state.masterMode==='new'?'<span class="record-position">신규 입력</span>':`<nav class="record-navigator" aria-label="품목 이전·다음 이동"><span class="record-position">품목 <strong>${currentIndex>=0?currentIndex+1:0} / ${visible.length}</strong></span><button id="masterPrev" type="button" ${dirty||currentIndex<=0?'disabled':''}>‹ 이전</button><button id="masterNext" type="button" ${dirty||currentIndex<0||currentIndex>=visible.length-1?'disabled':''}>다음 ›</button></nav>`;
|
||||
const body=`${masterValidationSummary()}<div class="master-layout"><aside class="master-pane"><form class="search-panel" id="masterSearchForm"><div class="search-fields"><label class="field-inline"><span>검색</span><input id="masterQueryInput" class="control medium" value="${esc(state.masterQuery)}" placeholder="품목코드/품목명"></label></div><div class="search-actions">${btn('조회','primary','masterSearch')}</div></form><div class="context-bar"><strong>품목 목록</strong><span>표시 ${visible.length}건</span><span>${state.masterMode==='new'?'신규 입력 중':`선택 ${esc(state.selectedMasterId)}`}</span></div>${grid(['품목코드','품목명','그룹','상태'],rows,{caption:'품목 목록'})}</aside><main class="detail-pane"><div class="pane-header"><div><strong>${modeLabel}</strong><span>${state.masterMode==='new'?'고유 식별자와 Barcode는 새로 입력':'목록을 바꾸기 전에 미저장 상태를 확인'}</span></div>${navigator}</div><section class="form-section"><h2>기본정보</h2><div class="form-grid">${formField('품목코드',draft.id,{required:true,name:'code',readonly:state.masterMode!=='new'})}${formField('품목명',draft.name,{required:true,name:'name'})}${formField('품목그룹',draft.group,{name:'group'})}${formField('규격',draft.spec,{name:'spec'})}${formField('단위',draft.unit,{name:'unit'})}</div></section><section class="form-section"><h2>물류정보</h2><div class="form-grid">${lookupField('기본창고',draft.warehouseCode,draft.warehouse,{field:'warehouse'})}${formField('바코드',draft.barcode,{name:'barcode'})}${checkboxField('LOT 관리',draft.lot,{field:'lot'})}${checkboxField('유통기한 관리',draft.expiry,{field:'expiry'})}</div></section><div class="summary-bar"><span>상태 ${statusChip(draft.status)}</span><span>Mode <strong>${esc(state.masterMode)}</strong></span><span>Version 17</span><button id="auditButton" type="button" class="text-action">변경이력</button></div></main></div>`;
|
||||
const commands=`<div class="command-group">${btn('조회 F3','','masterSearchTop')}${btn('신규','','masterNew')}${btn('저장 F8','primary','masterSave')}${btn('복사','','masterCopy')}</div><span class="command-separator"></span><div class="command-group">${btn(draft.status==='사용'?'사용중지':'사용재개','danger','masterSuspend')}${btn('엑셀 ▼')}</div>`;
|
||||
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT02();
|
||||
}
|
||||
function bindT02(){
|
||||
bindLookups();
|
||||
$$('.master-select',workspace).forEach(button=>button.onclick=()=>requestMasterTarget(button.dataset.item));
|
||||
$$('[data-field]',workspace).forEach(input=>{if(input.readOnly)return;const event=input.type==='checkbox'?'change':'input';input.addEventListener(event,()=>{if(!state.masterDraft)return;const key=input.dataset.field;if(key==='warehouseName')return;if(key==='code')state.masterDraft.id=input.value;else if(key==='warehouseCode'){state.masterDraft.warehouseCode=input.value;state.masterDraft.warehouse='';}else state.masterDraft[key]=input.type==='checkbox'?input.checked:input.value;state.masterErrors={};markDirty('ERP-MST-ITEM-001',true);});});
|
||||
$$('[data-master-error]',workspace).forEach(button=>button.onclick=()=>{const field=button.dataset.masterError;const target=field==='warehouse'?$('.lookup-trigger',workspace):$(`[data-field="${CSS.escape(field==='id'?'code':field)}"]`,workspace);target?.focus();});
|
||||
const runSearch=()=>{state.masterQuery=$('#masterQueryInput')?.value||'';renderT02(screen('ERP-MST-ITEM-001'));};
|
||||
$('#masterSearchForm').onsubmit=e=>{e.preventDefault();runSearch();};$('#masterSearch').onclick=runSearch;$('#masterSearchTop').onclick=runSearch;
|
||||
$('#masterSave').onclick=saveMaster;$('#masterNew').onclick=()=>requestMasterTarget('__NEW__');$('#masterCopy').onclick=()=>requestMasterTarget('__COPY__');
|
||||
$('#masterSuspend').onclick=()=>{state.masterDraft.status=state.masterDraft.status==='사용'?'중지':'사용';markDirty('ERP-MST-ITEM-001',true);renderT02(screen('ERP-MST-ITEM-001'));};
|
||||
const visible=masterItems.filter(x=>!state.masterQuery.trim()||`${x.id} ${x.name} ${x.group}`.toLowerCase().includes(state.masterQuery.trim().toLowerCase()));const index=visible.findIndex(item=>item.id===state.selectedMasterId);const prev=$('#masterPrev'),next=$('#masterNext');if(prev)prev.onclick=()=>visible[index-1]&&requestMasterTarget(visible[index-1].id);if(next)next.onclick=()=>visible[index+1]&&requestMasterTarget(visible[index+1].id);
|
||||
$('#auditButton').onclick=()=>showUtilityPanel(screen('ERP-MST-ITEM-001'),'help');
|
||||
}
|
||||
|
||||
function transactionTotals(){const valid=state.transactionLines.filter(line=>line.code);return {items:valid.length,qty:valid.reduce((sum,line)=>sum+Number(line.qty||0),0),amount:valid.reduce((sum,line)=>sum+Number(line.qty||0)*Number(line.price||0),0)};}
|
||||
function validateTransaction(){
|
||||
const h=state.transactionHeader;const errors=[];
|
||||
if(!h.orderDate.trim())errors.push({field:'orderDate',message:'주문일을 입력하세요.'});
|
||||
if(!h.customerCode.trim())errors.push({field:'customerCode',message:'거래처를 선택하세요.'});
|
||||
if(!h.warehouseCode.trim())errors.push({field:'warehouseCode',message:'출고창고를 선택하세요.'});
|
||||
if(!h.receiver.trim())errors.push({field:'receiver',message:'수취인을 입력하세요.'});
|
||||
if(!h.address.trim())errors.push({field:'address',message:'주소를 입력하세요.'});
|
||||
state.transactionLines.forEach((line,index)=>{if(!line.code)return;if(Number(line.qty)<=0)errors.push({row:index,field:'qty',message:`${index+1}행 수량은 0보다 커야 합니다.`});if(Number(line.price)<0)errors.push({row:index,field:'price',message:`${index+1}행 단가는 0 이상이어야 합니다.`});});
|
||||
if(!state.transactionLines.some(line=>line.code))errors.push({field:'lines',message:'품목을 1개 이상 입력하세요.'});
|
||||
state.transactionErrors=errors;return errors.length===0;
|
||||
}
|
||||
function transactionValidationSummary(){return state.transactionErrors.length?`<div class="validation-summary" role="alert"><strong>저장할 수 없습니다. ${state.transactionErrors.length}개 항목을 확인하세요.</strong>${state.transactionErrors.map((error,index)=>`<button type="button" data-transaction-error="${index}">${esc(error.message)}</button>`).join('')}</div>`:'';}
|
||||
function resetTransaction(){state.transactionStatus='NEW';state.transactionErrors=[];state.transactionHeader={orderDate:'2026-08-11',channel:'직접주문',customerCode:'',customerName:'',owner:'홍길동',warehouseCode:'WH01',warehouseName:'서울물류센터',receiver:'',phone:'',postal:'',address:''};state.transactionLines=[{code:'',name:'',spec:'',available:0,qty:1,price:0,remark:''}];markDirty('OMS-ORD-002',true);renderT03(screen('OMS-ORD-002'));setTimeout(()=>$('[data-field="customerCode"]',workspace)?.focus(),0);}
|
||||
function renderT03(s){
|
||||
const h=state.transactionHeader;const totals=transactionTotals();
|
||||
const lineRows=state.transactionLines.map((line,rowIndex)=>`<tr><td><input class="cell-editor" data-line-row="${rowIndex}" data-line-field="code" value="${esc(line.code)}" aria-label="${rowIndex+1}행 품목코드"></td><td>${esc(line.name||fastName(line.code))}</td><td>${esc(line.spec)}</td><td class="num">${Number(line.available||0).toLocaleString('ko-KR')}</td><td class="num"><input class="cell-editor num" data-line-row="${rowIndex}" data-line-field="qty" inputmode="decimal" value="${esc(line.qty)}" aria-label="${rowIndex+1}행 수량"></td><td class="num"><input class="cell-editor num" data-line-row="${rowIndex}" data-line-field="price" inputmode="decimal" value="${Number(line.price||0).toLocaleString('ko-KR')}" aria-label="${rowIndex+1}행 단가"></td><td class="num">${(Number(line.qty||0)*Number(line.price||0)).toLocaleString('ko-KR')}</td><td><input class="cell-editor" data-line-row="${rowIndex}" data-line-field="remark" value="${esc(line.remark||'')}" aria-label="${rowIndex+1}행 비고"></td></tr>`).join('');
|
||||
const body=`${transactionValidationSummary()}<div class="transaction-layout"><section class="workflow-strip">${['NEW','DRAFT','CONFIRMED','ALLOCATED','PICKING','CHECKED','SHIPPED'].map(status=>`<span class="${state.transactionStatus===status?'active':''}">${esc(statusLabel(status,'lifecycle'))}</span>`).join('')}<strong>현재: ${esc(statusLabel(state.transactionStatus,'lifecycle'))} · ${state.transactionStatus==='NEW'?'다음: 저장':state.transactionStatus==='DRAFT'?'다음: 확정':'확정 이후 주요 값은 서버 업무 규칙으로 제한'}</strong></section><section class="form-section"><h2>기본정보</h2><div class="form-grid">${formField('주문일',h.orderDate,{required:true,name:'orderDate'})}${formField('판매채널',h.channel,{name:'channel'})}${lookupField('거래처',h.customerCode,h.customerName,{field:'customer'})}${formField('담당자',h.owner,{name:'owner'})}${lookupField('출고창고',h.warehouseCode,h.warehouseName,{field:'warehouse'})}</div></section><section class="form-section"><h2>배송정보</h2><div class="form-grid">${formField('수취인',h.receiver,{required:true,name:'receiver'})}${formField('연락처',h.phone,{name:'phone'})}${formField('우편번호',h.postal,{name:'postal'})}${formField('주소',h.address,{required:true,name:'address'})}</div></section><section class="detail-grid-section"><div class="pane-header"><strong>상품</strong><span>코드·수량·단가 직접 편집 · Enter 다음 셀</span><button id="transactionAddLine" type="button" class="text-action">+ 행추가</button></div>${grid(['품목코드','품목명','규격','가용재고','수량','단가','금액','비고'],lineRows,{caption:'주문 품목 상세'})}</section><div class="summary-bar"><span>품목 <strong id="transactionItemCount">${totals.items}종</strong></span><span>총수량 <strong id="transactionQty">${totals.qty.toLocaleString('ko-KR')}</strong></span><span>주문금액 <strong id="transactionAmount">₩${totals.amount.toLocaleString('ko-KR')}</strong></span><span>상태 <strong>${esc(statusLabel(state.transactionStatus,'lifecycle'))}</strong></span></div></div>`;
|
||||
const commands=`<div class="command-group">${btn('신규','','transactionNew')}${btn('저장 F8','primary','saveTransaction')}${btn('주문복사','','transactionCopy')}</div><span class="command-separator"></span><div class="command-group">${btn('확정','','confirmTransaction',state.transactionStatus!=='DRAFT')}${btn('엑셀 ▼')}</div>`;
|
||||
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT03();
|
||||
}
|
||||
function bindT03(){
|
||||
bindLookups();
|
||||
$$('[data-field]',workspace).forEach(input=>{if(input.readOnly)return;input.addEventListener('input',()=>{const key=input.dataset.field;if(key==='customerCode'){state.transactionHeader.customerCode=input.value;state.transactionHeader.customerName='';}else if(key==='warehouseCode'){state.transactionHeader.warehouseCode=input.value;state.transactionHeader.warehouseName='';}else if(key in state.transactionHeader)state.transactionHeader[key]=input.value;state.transactionErrors=[];markDirty('OMS-ORD-002',true);});});
|
||||
const editors=$$('.cell-editor',workspace);editors.forEach((editor,index)=>{editor.oninput=()=>{const line=state.transactionLines[Number(editor.dataset.lineRow)];const field=editor.dataset.lineField;let value=editor.value;if(['qty','price'].includes(field))value=Number(String(value).replaceAll(',',''))||0;line[field]=value;if(field==='code')line.name=fastName(String(value).trim());state.transactionErrors=[];markDirty('OMS-ORD-002',true);const totals=transactionTotals();$('#transactionItemCount').textContent=`${totals.items}종`;$('#transactionQty').textContent=totals.qty.toLocaleString('ko-KR');$('#transactionAmount').textContent=`₩${totals.amount.toLocaleString('ko-KR')}`;};editor.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();editors[Math.min(index+1,editors.length-1)]?.focus();}};});
|
||||
$$('[data-transaction-error]',workspace).forEach(button=>button.onclick=()=>{const error=state.transactionErrors[Number(button.dataset.transactionError)];if(!error)return;if(Number.isInteger(error.row))$(`[data-line-row="${error.row}"][data-line-field="${CSS.escape(error.field)}"]`,workspace)?.focus();else if(error.field==='customerCode')$('.lookup[data-lookup-field="customer"] .lookup-trigger',workspace)?.focus();else if(error.field==='warehouseCode')$('.lookup[data-lookup-field="warehouse"] .lookup-trigger',workspace)?.focus();else $(`[data-field="${CSS.escape(error.field)}"]`,workspace)?.focus();});
|
||||
$('#transactionAddLine').onclick=()=>{state.transactionLines.push({code:'',name:'',spec:'',available:0,qty:1,price:0,remark:''});markDirty('OMS-ORD-002',true);renderT03(screen('OMS-ORD-002'));setTimeout(()=>$$('.cell-editor',workspace).at(-4)?.focus(),0);};
|
||||
$('#saveTransaction').onclick=()=>{if(!validateTransaction()){renderT03(screen('OMS-ORD-002'));return;}state.transactionStatus='DRAFT';markDirty('OMS-ORD-002',false);renderT03(screen('OMS-ORD-002'));toast('주문을 저장했습니다. 서버 응답 상태 DRAFT를 사용자 Label 작성으로 표시합니다.');};
|
||||
$('#confirmTransaction').onclick=()=>{const tab=state.tabs.find(x=>x.id==='OMS-ORD-002');if(tab?.dirty){toast('저장하지 않은 변경이 있습니다. 먼저 저장하세요.','error');return;}if(!validateTransaction()){renderT03(screen('OMS-ORD-002'));return;}state.transactionStatus='CONFIRMED';renderT03(screen('OMS-ORD-002'));toast('서버 응답 상태 CONFIRMED를 사용자 Label 확정으로 표시합니다.');};
|
||||
$('#transactionNew').onclick=()=>{const tab=state.tabs.find(x=>x.id==='OMS-ORD-002');if(tab?.dirty){toast('현재 주문의 미저장 변경을 먼저 저장하거나 버리세요.','error');return;}resetTransaction();};
|
||||
$('#transactionCopy').onclick=()=>{if(state.tabs.find(x=>x.id==='OMS-ORD-002')?.dirty){toast('미저장 변경을 먼저 처리하세요.','error');return;}state.transactionStatus='NEW';state.transactionErrors=[];state.transactionHeader={...state.transactionHeader,receiver:'',phone:'',postal:'',address:''};state.transactionLines=state.transactionLines.map(line=>({...line}));markDirty('OMS-ORD-002',true);renderT03(screen('OMS-ORD-002'));toast('주문을 복사했습니다. 수취/배송정보를 새 주문에 맞게 입력하세요.');};
|
||||
}
|
||||
|
||||
function fastName(code){return ({ABC001:'운동화',ABC002:'러닝화',ABC003:'양말',ABC004:'셔츠'})[code]||'';}
|
||||
function fastErrorCount(){return state.fastRows.reduce((sum,row)=>sum+(row.code&&(!row.price||Number(String(row.price).replaceAll(',',''))<=0)?1:0),0);}
|
||||
function renderT04(s){
|
||||
const rows=state.fastRows.map((row,index)=>`<tr><td class="center">${index+1}</td><td><input class="fast-cell" data-row="${index}" data-field="code" value="${esc(row.code)}" aria-label="${index+1}행 품목코드"></td><td>${esc(row.name||fastName(row.code))}</td><td class="num"><input class="fast-cell num ${row.code&&(!row.price||Number(String(row.price).replaceAll(',',''))<=0)?'invalid':''}" data-row="${index}" data-field="price" value="${esc(row.price)}" aria-label="${index+1}행 단가"></td><td><input class="fast-cell" data-row="${index}" data-field="date" value="${esc(row.date)}" aria-label="${index+1}행 적용일"></td><td><input class="fast-cell" data-row="${index}" data-field="remark" value="${esc(row.remark)}" aria-label="${index+1}행 비고"></td></tr>`).join('');
|
||||
const inputCount=state.fastRows.filter(x=>x.code).length;const errorCount=fastErrorCount();
|
||||
const body=`<div class="context-bar"><strong>Fast Entry</strong><span>Enter = 다음 입력 셀</span><span>Ctrl+V = 다중 Cell 붙여넣기</span><span>F2 = 품목 Lookup</span><span class="${errorCount?'error':''}">오류 ${errorCount}개</span></div><section class="fast-entry-surface"><div class="pane-header"><div><strong>품목 단가 입력</strong><span>품목·단가를 연속 입력하고 오류를 해소한 뒤 F8로 저장</span></div><span>입력 ${inputCount}건 · 오류 ${errorCount}건</span></div>${grid(['#','품목코드','품목명','단가','적용일','비고'],rows,{caption:'품목단가 빠른 입력'})}</section><div class="summary-bar"><span>입력 ${inputCount}행</span><span>오류 <strong>${errorCount}</strong></span><span>빈 행 ${state.fastRows.length-inputCount}</span></div>`;
|
||||
const commands=`<div class="command-group">${btn('저장 F8','primary','fastSave',errorCount>0)}${btn('행추가','','fastAdd')}${btn('행복제','','fastDuplicate')}${btn('Fill Down','','fastFill')}</div><span class="command-separator"></span><div class="command-group">${btn('엑셀 붙여넣기')}${btn('엑셀 ▼')}</div>`;
|
||||
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT04();
|
||||
}
|
||||
function bindT04(){
|
||||
const cells=$$('.fast-cell',workspace);
|
||||
cells.forEach((cell,index)=>{
|
||||
cell.oninput=()=>{const row=state.fastRows[Number(cell.dataset.row)];row[cell.dataset.field]=cell.value;if(cell.dataset.field==='code')row.name=fastName(cell.value.trim());markDirty('ERP-PRICE-001',true);};
|
||||
cell.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();cells[Math.min(index+1,cells.length-1)]?.focus();}};
|
||||
cell.onpaste=e=>{const text=e.clipboardData?.getData('text/plain');if(!text||(!text.includes('\t')&&!text.includes('\n')))return;e.preventDefault();const matrix=text.replace(/\r/g,'').split('\n').filter(Boolean).map(line=>line.split('\t'));const startRow=Number(cell.dataset.row);const fieldOrder=['code','price','date','remark'];const startField=Math.max(0,fieldOrder.indexOf(cell.dataset.field));matrix.forEach((values,rowOffset)=>values.forEach((value,colOffset)=>{const target=state.fastRows[startRow+rowOffset];const field=fieldOrder[startField+colOffset];if(target&&field){target[field]=value.trim();if(field==='code')target.name=fastName(target[field]);}}));markDirty('ERP-PRICE-001',true);renderT04(screen('ERP-PRICE-001'));toast(`${matrix.length}행 붙여넣기를 적용했습니다.`);};
|
||||
});
|
||||
$('#fastAdd').onclick=()=>{state.fastRows.push({code:'',name:'',price:'',date:'',remark:''});markDirty('ERP-PRICE-001',true);renderT04(screen('ERP-PRICE-001'));};
|
||||
$('#fastDuplicate').onclick=()=>{const last=[...state.fastRows].reverse().find(x=>x.code);if(last){state.fastRows.push({...last,code:'',name:''});markDirty('ERP-PRICE-001',true);renderT04(screen('ERP-PRICE-001'));}};
|
||||
$('#fastFill').onclick=()=>{const source=state.fastRows.find(x=>x.date)?.date||'2026-08-11';state.fastRows.forEach(row=>{if(row.code&&!row.date)row.date=source;});markDirty('ERP-PRICE-001',true);renderT04(screen('ERP-PRICE-001'));toast('적용일을 Fill Down 했습니다.');};
|
||||
$('#fastSave').onclick=()=>{markDirty('ERP-PRICE-001',false);toast('오류 없는 변경 행을 저장했습니다.');};
|
||||
}
|
||||
|
||||
function inventoryVisibleItems(){
|
||||
const keyword=state.inventorySearch.keyword.trim().toLocaleLowerCase('ko-KR');
|
||||
return inventoryItems.filter(item=>{
|
||||
const matchesKeyword=!keyword||`${item.id} ${item.name}`.toLocaleLowerCase('ko-KR').includes(keyword);
|
||||
const matchesWarehouse=state.inventorySearch.warehouse==='all'||item.locations.some(location=>location[0]===state.inventorySearch.warehouse);
|
||||
return matchesKeyword&&matchesWarehouse;
|
||||
});
|
||||
}
|
||||
function executeInventorySearch(){
|
||||
const warehouse=$('#inventoryWarehouse')?.value??state.inventorySearch.warehouse;const keyword=$('#inventoryKeyword')?.value??state.inventorySearch.keyword;
|
||||
state.inventorySearch={warehouse,keyword:keyword.trim()};state.inventoryDataState='loading';renderT05(screen('ERP-INV-001'));
|
||||
setTimeout(()=>{state.inventoryDataState='ready';const visible=inventoryVisibleItems();if(!visible.some(item=>item.id===state.inventoryItemId))state.inventoryItemId=visible[0]?.id||'';renderT05(screen('ERP-INV-001'));announce(`재고현황 ${visible.length}개 품목을 조회했습니다.`);},220);
|
||||
}
|
||||
function renderT05(s){
|
||||
const visibleItems=inventoryVisibleItems();let item=visibleItems.find(x=>x.id===state.inventoryItemId)||visibleItems[0]||null;if(item&&state.inventoryItemId!==item.id)state.inventoryItemId=item.id;
|
||||
const search=`<form class="search-panel" id="inventorySearch"><div class="search-fields"><label class="field-inline"><span>창고</span><select id="inventoryWarehouse" class="control"><option value="all" ${state.inventorySearch.warehouse==='all'?'selected':''}>전체</option><option ${state.inventorySearch.warehouse==='서울센터'?'selected':''}>서울센터</option><option ${state.inventorySearch.warehouse==='부산센터'?'selected':''}>부산센터</option><option ${state.inventorySearch.warehouse==='인천센터'?'selected':''}>인천센터</option></select></label><label class="field-inline"><span>품목</span><input id="inventoryKeyword" class="control wide" value="${esc(state.inventorySearch.keyword)}" placeholder="품목코드/품목명"></label></div><div class="search-actions">${btn('조회','primary','inventorySearchInline')}</div></form>`;
|
||||
let content='';
|
||||
if(state.inventoryDataState==='loading')content=dataState('loading','재고 조회 중...','창고와 품목 조건을 유지한 채 재고 Projection을 조회하고 있습니다.');
|
||||
else if(!item)content=dataState('empty','조회된 재고 품목이 없습니다.','조건을 초기화하고 다시 조회해 보세요.','inventoryReset');
|
||||
else{
|
||||
const leftRows=visibleItems.map(x=>`<tr class="${x.id===item.id?'selected-row':''}"><td><button class="row-link inventory-select" data-item="${esc(x.id)}" type="button">${esc(x.id)}</button></td><td>${esc(x.name)}</td><td class="num">${x.stock.toLocaleString('ko-KR')}</td></tr>`).join('');
|
||||
const rightRows=item.locations.filter(row=>state.inventorySearch.warehouse==='all'||row[0]===state.inventorySearch.warehouse).map(row=>`<tr><td>${esc(row[0])}</td><td>${esc(row[1])}</td><td class="num">${Number(row[2]).toLocaleString('ko-KR')}</td><td class="num">${Number(row[3]).toLocaleString('ko-KR')}</td><td class="num">${Number(row[4]).toLocaleString('ko-KR')}</td></tr>`).join('');
|
||||
const history=[['16:31','출고할당','','20',item.stock-20,'ORD-260811-1022'],['14:02','입고','100','',item.stock,'RCV-260811-0081'],['11:24','출고','','12',item.stock-100,'ORD-260811-0931']].map(row=>`<tr>${row.map((value,index)=>`<td class="${index>=2&&index<=4?'num':''}">${esc(value)}</td>`).join('')}</tr>`).join('');
|
||||
content=`<div class="context-bar"><strong>현재 조회</strong><span>품목 <b>${visibleItems.length}</b>개</span><span>창고 ${esc(state.inventorySearch.warehouse==='all'?'전체':state.inventorySearch.warehouse)}</span><button id="inventoryReset" type="button" class="text-action">조건 초기화</button></div><div class="split-layout"><section class="pane"><div class="pane-header"><strong>품목</strong><span>현재고</span></div>${grid(['품목코드','품목명','현재고'],leftRows,{caption:'재고 품목 목록'})}</section><section class="pane"><div class="pane-header"><strong>창고 / Location</strong><span>${esc(item.id)} ${esc(item.name)} · ${item.stock.toLocaleString('ko-KR')}개</span></div>${grid(['창고','Location','현재고','할당','보류'],rightRows||`<tr><td colspan="5" class="center">선택한 창고의 재고 위치가 없습니다.</td></tr>`,{caption:'품목별 창고 로케이션 재고'})}</section></div><section class="pane history-pane"><div class="pane-header"><strong>재고이력</strong><span>현재고 근거 추적 · ${esc(item.id)}</span></div>${grid(['일시','업무','입고','출고','잔량','참조번호'],history,{caption:'재고 이력'})}</section>`;
|
||||
}
|
||||
workspace.innerHTML=pageShell(s,`${search}${content}`,`<div class="command-group">${btn('조회 F3','','inventorySearchButton')}</div><span class="command-separator"></span><div class="command-group">${btn('재고이동')}${btn('엑셀 ▼')}</div>`);bindPageTools(s);
|
||||
$$('.inventory-select',workspace).forEach(button=>button.onclick=()=>{state.inventoryItemId=button.dataset.item;renderT05(s);announce(`${button.dataset.item} 재고 상세를 표시했습니다.`);});
|
||||
$('#inventorySearch').onsubmit=e=>{e.preventDefault();executeInventorySearch();};$('#inventorySearchButton').onclick=executeInventorySearch;const inventoryInline=$('#inventorySearchInline');if(inventoryInline)inventoryInline.onclick=executeInventorySearch;
|
||||
const reset=$('#inventoryReset');if(reset)reset.onclick=()=>{state.inventorySearch={warehouse:'all',keyword:''};state.inventoryItemId=inventoryItems[0].id;state.inventoryDataState='ready';renderT05(s);announce('재고 조회조건을 초기화했습니다.');};
|
||||
}
|
||||
|
||||
function queueVisibleRows(){
|
||||
const keyword=state.queueSearch.keyword.trim().toLocaleLowerCase('ko-KR');const worker=state.queueSearch.worker.trim().toLocaleLowerCase('ko-KR');
|
||||
return queueRows.filter(row=>{
|
||||
const quick=state.queueFilter==='all'||state.queueFilter==='exception'&&row.exception||state.queueFilter===row.type;
|
||||
const workerMatch=!worker||row.worker.toLocaleLowerCase('ko-KR').includes(worker);
|
||||
const keywordMatch=!keyword||`${row.no} ${row.type} ${row.wave} ${row.status}`.toLocaleLowerCase('ko-KR').includes(keyword);
|
||||
return quick&&workerMatch&&keywordMatch;
|
||||
});
|
||||
}
|
||||
function executeQueueSearch(){
|
||||
state.queueSearch={worker:($('#queueWorker')?.value??state.queueSearch.worker).trim(),keyword:($('#queueKeyword')?.value??state.queueSearch.keyword).trim()};state.selectedQueueRows.clear();state.queueActionResult='';state.queueDataState='loading';renderT06(screen('WMS-WORK-001'));setTimeout(()=>{state.queueDataState='ready';renderT06(screen('WMS-WORK-001'));announce(`작업 Queue ${queueVisibleRows().length}건을 조회했습니다.`);},220);
|
||||
}
|
||||
function renderT06(s){
|
||||
const visible=queueVisibleRows();
|
||||
const selectedCount=state.selectedQueueRows.size;const allSelected=visible.length>0&&visible.every(row=>state.selectedQueueRows.has(row.no));
|
||||
const rows=visible.map(row=>`<tr class="${state.selectedQueueRows.has(row.no)?'selected-row':''}"><td class="center"><input type="checkbox" data-queue-row="${esc(row.no)}" aria-label="${esc(row.no)} 선택" ${state.selectedQueueRows.has(row.no)?'checked':''}></td><td>${esc(row.no)}</td><td>${esc(row.type)}</td><td>${esc(row.wave)}</td><td>${esc(row.worker)}</td><td class="num">${esc(row.progress)}</td><td>${esc(row.sla)}</td><td class="center">${statusChip(row.status)}</td></tr>`).join('');
|
||||
const table=grid(['선택','작업번호','작업유형','Wave','작업자','진행률','SLA','상태'],rows,{caption:'물류 작업 대기열'}).replace('<th scope="col">선택</th>',`<th scope="col" class="center"><input id="queueSelectAll" type="checkbox" aria-label="현재 표시 작업 전체 선택" ${allSelected?'checked':''}></th>`);
|
||||
const summary=[['입고','입고대기','218'],['적치','적치대기','43'],['피킹','피킹대기','927'],['검수','검수대기','122'],['exception','예외','12']];
|
||||
const result=state.queueActionResult?`<div class="action-result" role="status"><strong>최근 처리</strong><span>${esc(state.queueActionResult)}</span><button id="queueResultClear" type="button" class="text-action">닫기</button></div>`:'';
|
||||
const search=`<form class="search-panel" id="queueSearchForm"><div class="search-fields"><label class="field-inline"><span>작업자</span><input id="queueWorker" class="control medium" value="${esc(state.queueSearch.worker)}" placeholder="작업자명"></label><label class="field-inline"><span>통합검색</span><input id="queueKeyword" class="control wide" value="${esc(state.queueSearch.keyword)}" placeholder="작업번호/Wave/상태"></label></div><div class="search-actions">${btn('조회','primary','queueSearchInline')}</div></form>`;
|
||||
let queueContent='';if(state.queueDataState==='loading')queueContent=dataState('loading','작업 Queue 조회 중...','현재 필터와 검색조건을 유지하고 최신 작업 상태를 조회합니다.');else if(!visible.length)queueContent=dataState('empty','처리할 작업이 없습니다.','검색조건 또는 예외 필터를 초기화해 보세요.','queueClearSearch');else queueContent=`<div class="context-bar"><strong>지금 처리할 업무</strong><span>표시 ${visible.length}건</span><span>선택 <b>${selectedCount}</b>건</span><span>지연 ${visible.filter(row=>row.status==='지연').length}건</span><button id="queueReset" type="button" class="text-action">빠른필터 해제</button></div>${selectedCount?`<div class="bulk-bar"><strong>${selectedCount}건 선택</strong>${btn('작업자 배정','primary','queueAssign')}${btn('Wave 생성','','queueWave')}</div>`:''}${result}${table}`;
|
||||
const body=`${search}<div class="queue-summary" role="group" aria-label="작업 유형 빠른 필터">${summary.map(([key,label,count])=>`<button type="button" data-queue-filter="${esc(key)}" class="${state.queueFilter===key?'active':''} ${key==='exception'?'danger':''}" aria-pressed="${state.queueFilter===key}"><span>${esc(label)}</span><b>${esc(count)}</b></button>`).join('')}</div>${queueContent}`;
|
||||
const commands=`<div class="command-group">${btn('조회 F3','','queueSearch')}</div><span class="command-separator"></span><div class="command-group">${btn('작업자 배정','primary','queueAssignTop',selectedCount===0)}${btn('Wave 생성','','queueWaveTop',selectedCount===0)}${btn('예외만 보기','','queueException')}</div>`;
|
||||
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT06();
|
||||
}
|
||||
function bindT06(){
|
||||
$$('[data-queue-filter]',workspace).forEach(button=>button.onclick=()=>{state.queueFilter=button.dataset.queueFilter;state.selectedQueueRows.clear();state.queueActionResult='';state.queueDataState='ready';renderT06(screen('WMS-WORK-001'));});
|
||||
const reset=$('#queueReset');if(reset)reset.onclick=()=>{state.queueFilter='all';state.selectedQueueRows.clear();renderT06(screen('WMS-WORK-001'));};$('#queueException').onclick=()=>{state.queueFilter='exception';state.selectedQueueRows.clear();state.queueDataState='ready';renderT06(screen('WMS-WORK-001'));};$('#queueSearch').onclick=executeQueueSearch;const inlineSearch=$('#queueSearchInline');if(inlineSearch)inlineSearch.onclick=executeQueueSearch;const form=$('#queueSearchForm');if(form)form.onsubmit=e=>{e.preventDefault();executeQueueSearch();};
|
||||
const clearSearch=$('#queueClearSearch');if(clearSearch)clearSearch.onclick=()=>{state.queueFilter='all';state.queueSearch={worker:'',keyword:''};state.queueDataState='ready';state.selectedQueueRows.clear();renderT06(screen('WMS-WORK-001'));};
|
||||
$$('[data-queue-row]',workspace).forEach(box=>box.onchange=()=>{box.checked?state.selectedQueueRows.add(box.dataset.queueRow):state.selectedQueueRows.delete(box.dataset.queueRow);renderT06(screen('WMS-WORK-001'));});
|
||||
const selectAll=$('#queueSelectAll');if(selectAll)selectAll.onchange=()=>{queueVisibleRows().forEach(row=>selectAll.checked?state.selectedQueueRows.add(row.no):state.selectedQueueRows.delete(row.no));renderT06(screen('WMS-WORK-001'));};
|
||||
const assign=()=>{if(!state.selectedQueueRows.size)return;for(const row of queueRows){if(state.selectedQueueRows.has(row.no))row.worker='홍길동';}state.queueActionResult=`${state.selectedQueueRows.size}건을 홍길동 작업자에게 배정했습니다.`;state.selectedQueueRows.clear();renderT06(screen('WMS-WORK-001'));};
|
||||
const wave=()=>{if(!state.selectedQueueRows.size)return;const waveNo='W260811-NEW';let changed=0;for(const row of queueRows){if(state.selectedQueueRows.has(row.no)&&row.wave==='-'){row.wave=waveNo;changed++;}}state.queueActionResult=changed?`${changed}건에 Wave ${waveNo}를 생성했습니다.`:'선택 작업에는 새 Wave를 생성할 대상이 없습니다.';state.selectedQueueRows.clear();renderT06(screen('WMS-WORK-001'));};
|
||||
for(const id of ['queueAssign','queueAssignTop']){const button=$('#'+id);if(button)button.onclick=assign;}for(const id of ['queueWave','queueWaveTop']){const button=$('#'+id);if(button)button.onclick=wave;}const clear=$('#queueResultClear');if(clear)clear.onclick=()=>{state.queueActionResult='';renderT06(screen('WMS-WORK-001'));};
|
||||
}
|
||||
|
||||
function reconcileVisibleRows(){
|
||||
const keyword=state.reconcileSearch.keyword.trim().toLocaleLowerCase('ko-KR');return reconcileRows.filter(row=>(!state.mismatchOnly||row.diff!==0)&&(!keyword||`${row.no} ${row.reason} ${row.status}`.toLocaleLowerCase('ko-KR').includes(keyword)));
|
||||
}
|
||||
function executeReconcileSearch(){
|
||||
state.reconcileSearch={date:($('#reconcileDate')?.value??state.reconcileSearch.date).trim(),keyword:($('#reconcileKeyword')?.value??state.reconcileSearch.keyword).trim()};state.selectedReconcileRows.clear();state.reconcileActionResult='';state.reconcileDataState='loading';renderT07(screen('COMMON-REC-001'));setTimeout(()=>{state.reconcileDataState='ready';renderT07(screen('COMMON-REC-001'));announce(`대사 결과 ${reconcileVisibleRows().length}건을 표시했습니다.`);},220);
|
||||
}
|
||||
function renderT07(s){
|
||||
const visible=reconcileVisibleRows();const selectedCount=state.selectedReconcileRows.size;const allSelected=visible.length>0&&visible.every(row=>state.selectedReconcileRows.has(row.no));
|
||||
const rows=visible.map(row=>`<tr class="${state.selectedReconcileRows.has(row.no)?'selected-row':''}"><td class="center"><input type="checkbox" data-reconcile-row="${esc(row.no)}" aria-label="${esc(row.no)} 선택" ${state.selectedReconcileRows.has(row.no)?'checked':''}></td><td>${esc(row.no)}</td><td class="num">${row.expected}</td><td class="num">${row.actual}</td><td class="num ${row.diff!==0?'error':''}">${row.diff}</td><td>${esc(row.reason)}</td><td>${statusChip(row.status)}</td></tr>`).join('');
|
||||
const table=grid(['선택','주문번호','OMS','WMS','차이','원인','처리상태'],rows,{caption:'OMS WMS 재고 출고 대사'}).replace('<th scope="col">선택</th>',`<th scope="col" class="center"><input id="reconcileSelectAll" type="checkbox" aria-label="현재 표시 대사 전체 선택" ${allSelected?'checked':''}></th>`);
|
||||
const result=state.reconcileActionResult?`<div class="action-result" role="status"><strong>최근 처리</strong><span>${esc(state.reconcileActionResult)}</span><button id="reconcileResultClear" type="button" class="text-action">닫기</button></div>`:'';
|
||||
const search=`<form class="search-panel" id="reconcileSearch"><div class="search-fields"><label class="field-inline"><span>기준일</span><input id="reconcileDate" class="control" type="date" value="${esc(state.reconcileSearch.date)}"></label><label class="field-inline"><span>통합검색</span><input id="reconcileKeyword" class="control wide" value="${esc(state.reconcileSearch.keyword)}" placeholder="주문번호/원인/상태"></label><label class="field-inline checkbox-inline"><input id="mismatchOnly" type="checkbox" ${state.mismatchOnly?'checked':''}><span>불일치만 표시</span></label></div><div class="search-actions">${btn('조회','primary','reconcileSearchInline')}</div></form>`;
|
||||
let resultContent='';if(state.reconcileDataState==='loading')resultContent=dataState('loading','대사 결과 조회 중...','Expected/Actual/Difference Projection을 다시 조회하고 있습니다.');else if(!visible.length)resultContent=dataState('empty','조건에 맞는 대사 건이 없습니다.','불일치 필터 또는 검색어를 초기화해 보세요.','reconcileReset');else resultContent=`${selectedCount?`<div class="bulk-bar"><strong>${selectedCount}건 선택</strong>${btn('원인 재조회','','reconcileReason')}${btn('재처리','primary','reconcileRun')}${btn('처리사유 등록','','reconcileReasonNote')}</div>`:''}${result}${table}`;
|
||||
const body=`${search}<div class="reconcile-summary"><span>전체 <strong>12,482</strong></span><span>정상 <strong>12,439</strong></span><span class="mismatch">불일치 <strong>43</strong></span><span>표시 <strong>${visible.length}</strong></span><span>선택 <strong>${selectedCount}</strong></span></div>${resultContent}`;
|
||||
const commands=`<div class="command-group">${btn('조회 F3','','reconcileSearchButton')}</div><span class="command-separator"></span><div class="command-group">${btn('재처리','primary','reconcileRunTop',selectedCount===0)}${btn('엑셀 ▼')}</div>`;
|
||||
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT07();
|
||||
}
|
||||
function bindT07(){
|
||||
const mismatch=$('#mismatchOnly');if(mismatch)mismatch.onchange=e=>{state.mismatchOnly=e.target.checked;state.selectedReconcileRows.clear();state.reconcileDataState='ready';renderT07(screen('COMMON-REC-001'));};const form=$('#reconcileSearch');if(form)form.onsubmit=e=>{e.preventDefault();executeReconcileSearch();};const reconcileInline=$('#reconcileSearchInline');if(reconcileInline)reconcileInline.onclick=executeReconcileSearch;$('#reconcileSearchButton').onclick=executeReconcileSearch;
|
||||
const reset=$('#reconcileReset');if(reset)reset.onclick=()=>{state.mismatchOnly=false;state.reconcileSearch={date:'2026-08-11',keyword:''};state.selectedReconcileRows.clear();state.reconcileDataState='ready';renderT07(screen('COMMON-REC-001'));};
|
||||
$$('[data-reconcile-row]',workspace).forEach(box=>box.onchange=()=>{box.checked?state.selectedReconcileRows.add(box.dataset.reconcileRow):state.selectedReconcileRows.delete(box.dataset.reconcileRow);renderT07(screen('COMMON-REC-001'));});
|
||||
const all=$('#reconcileSelectAll');if(all)all.onchange=()=>{reconcileVisibleRows().forEach(row=>all.checked?state.selectedReconcileRows.add(row.no):state.selectedReconcileRows.delete(row.no));renderT07(screen('COMMON-REC-001'));};
|
||||
const rerun=()=>{if(!state.selectedReconcileRows.size)return;const count=state.selectedReconcileRows.size;state.reconcileActionResult=`${count}건의 재처리 요청을 등록했습니다. 원천 OMS/WMS 값은 이 화면에서 직접 덮어쓰지 않습니다.`;state.selectedReconcileRows.clear();renderT07(screen('COMMON-REC-001'));};
|
||||
for(const id of ['reconcileRun','reconcileRunTop']){const button=$('#'+id);if(button)button.onclick=rerun;}const reason=$('#reconcileReason');if(reason)reason.onclick=()=>{state.reconcileActionResult=`${state.selectedReconcileRows.size}건의 원인 Projection을 다시 조회했습니다.`;renderT07(screen('COMMON-REC-001'));};const note=$('#reconcileReasonNote');if(note)note.onclick=()=>{state.reconcileActionResult=`${state.selectedReconcileRows.size}건에 처리사유가 필요합니다. 실제 제품에서는 사유 입력 Drawer를 사용합니다.`;renderT07(screen('COMMON-REC-001'));};const clear=$('#reconcileResultClear');if(clear)clear.onclick=()=>{state.reconcileActionResult='';renderT07(screen('COMMON-REC-001'));};
|
||||
}
|
||||
|
||||
function applyImportFile(file){
|
||||
if(!file)return false;const name=String(file.name||'').trim();const size=Number(file.size||0);const validExt=/\.xlsx?$/i.test(name);const max=20*1024*1024;
|
||||
if(!validExt){toast('Excel .xlsx 또는 .xls 파일을 선택하세요.','error');return false}if(size>max){toast('파일 크기는 20MB 이하여야 합니다.','error');return false}
|
||||
state.importFileName=name;state.importFileSize=size;state.importFileType=file.type||'';state.importJobStatus='idle';state.importProgress=0;state.importProcessed=0;renderT08(screen('OMS-ORD-003'));announce(`${name} 파일을 선택했습니다.`);return true;
|
||||
}
|
||||
function renderT08(s){
|
||||
const step=state.importStep;const steps=['파일','매핑','검증','미리보기','반영','결과'];
|
||||
let content='';
|
||||
if(step===1)content=`<input id="importFileInput" class="sr-only" type="file" accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"><div class="drop-zone" id="dropZone" tabindex="0" role="button" aria-controls="importFileInput"><div><strong>${state.importFileName?esc(state.importFileName):'Excel 파일을 선택하세요'}</strong><p>${state.importFileName?`${state.importFileSize?`${(state.importFileSize/1024/1024).toFixed(2)}MB · `:''}파일 선택 완료 · 다음 단계에서 필드 매핑을 확인합니다.`:'파일 선택은 필수 접근 경로이며 Drag & Drop은 편의 기능입니다. .xlsx/.xls · 최대 20MB'}</p>${btn(state.importFileName?'파일 다시 선택':'파일 선택','primary','fileSelect')} ${btn('업로드 양식 다운로드')}</div></div>`;
|
||||
if(step===2)content=`<section class="import-stage-focus" data-import-stage-heading tabindex="-1" aria-label="필드 매핑"><h2>필드 매핑</h2><div class="mapping-table"><div class="mapping-head"><strong>Excel Column</strong><strong>System Field</strong><strong>매핑 근거</strong></div>${[['주문번호','주문번호','정확 일치'],['상품코드','품목코드','Alias 일치'],['수량','주문수량','Alias 일치'],['구매자','주문자명','Alias 일치'],['업체코드','거래처코드','사용자 확인 필요']].map(row=>`<div class="mapping-row"><span>${esc(row[0])}</span><span>→ ${esc(row[1])}</span><span>${esc(row[2])}</span></div>`).join('')}</div></section>`;
|
||||
if(step===3){const validationErrors=[['8','품목코드','ABC99','존재하지 않는 품목'],['12','수량','-1','수량은 0보다 커야 합니다.']];content=`<section class="import-stage-focus" data-import-stage-heading tabindex="-1" aria-label="검증 결과"><h2>검증 결과</h2><div class="validation-cards"><div class="validation-card"><span>전체</span><b>18,230</b></div><div class="validation-card"><span>정상</span><b>17,894</b></div><div class="validation-card error"><span>오류</span><b>336</b></div></div><div class="import-error-nav" role="group" aria-label="검증 오류 탐색"><span>샘플 오류 <b>${state.importErrorIndex+1} / ${validationErrors.length}</b></span>${btn('첫 오류','','importFirstError')}${btn('다음 오류','','importNextError')}${btn('오류 336건 다운로드')}</div>${grid(['행','필드','값','오류'],validationErrors.map((row,index)=>`<tr tabindex="-1" data-import-error-row="${index}" aria-current="${index===state.importErrorIndex?'true':'false'}"><td>${row[0]}</td><td>${esc(row[1])}</td><td>${esc(row[2])}</td><td class="error">${esc(row[3])}</td></tr>`).join(''),{caption:'Excel 검증 오류'})}</section>`;}
|
||||
if(step===4)content=`<section class="commit-panel import-stage-focus" data-import-stage-heading tabindex="-1"><div class="pane-header"><strong>반영 미리보기</strong><span>오류 336건 제외</span></div><div class="commit-body"><p><strong>17,894건</strong>을 반영할 수 있습니다.</p><dl><div><dt>신규</dt><dd>17,201</dd></div><div><dt>수정</dt><dd>693</dd></div><div><dt>제외 오류</dt><dd>336</dd></div></dl><p class="warning-copy">실제 제품은 Staging 데이터를 Domain 검증한 뒤 Commit합니다.</p></div></section>`;
|
||||
if(step===5){const running=state.importJobStatus==='running';const completed=state.importJobStatus==='completed';content=`<section class="commit-panel import-stage-focus" data-import-stage-heading tabindex="-1"><div class="pane-header"><strong>Background 반영 작업</strong><span>${completed?'완료':running?'처리 중':'대기'}</span></div><div class="commit-body"><div class="job-progress"><div class="job-progress-track"><span style="width:${state.importProgress}%"></span></div><strong>${state.importProgress}%</strong><p>${state.importProcessed.toLocaleString('ko-KR')} / 17,894 처리</p></div><p>다른 화면으로 이동해도 작업은 계속됩니다. 홈의 진행 작업에서 다시 들어올 수 있습니다.</p>${!running&&!completed?btn('17,894건 반영','primary','importCommit'):''}${completed?btn('결과 보기','primary','importResult'):''}</div></section>`;}
|
||||
if(step===6)content=`<section class="commit-panel import-stage-focus" data-import-stage-heading tabindex="-1"><div class="pane-header"><strong>반영 결과</strong><span>완료</span></div><div class="commit-body"><div class="validation-cards"><div class="validation-card"><span>성공</span><b>17,894</b></div><div class="validation-card error"><span>오류</span><b>336</b></div></div><p>오류 데이터는 Domain DB에 반영하지 않았습니다.</p><div class="inline-actions">${btn('오류 336건 다운로드')}${btn('새 업로드','primary','importReset')}</div></div></section>`;
|
||||
const nav=`<div class="import-steps">${steps.map((label,index)=>`<div class="import-step ${index+1===step?'active':''} ${index+1<step?'done':''}"><b>${index+1}</b><span>${label}</span></div>`).join('')}</div>`;
|
||||
const buttons=step<5?`<div class="wizard-actions">${step>1?btn('이전','','importBack'):''}${step<4?btn('다음','primary','importNext',step===1&&!state.importFileName):step===4?btn('반영 단계','primary','importNext'):''}</div>`:'';
|
||||
workspace.innerHTML=pageShell(s,`${nav}${content}${buttons}`,`<div class="command-group">${btn('양식 다운로드')}</div><span class="command-separator"></span><div class="command-group">${btn('최근 업로드 결과')}</div>`);bindPageTools(s);bindT08();if(state.importFocusStep!==step){state.importFocusStep=step;setTimeout(()=>{const target=step===1?$('#dropZone',workspace):$('[data-import-stage-heading]',workspace);target?.focus({preventScroll:true});},0);}
|
||||
}
|
||||
function startImportJob(){
|
||||
if(importTimer)clearInterval(importTimer);state.importStep=5;state.importJobStatus='running';state.importProgress=0;state.importProcessed=0;renderT08(screen('OMS-ORD-003'));
|
||||
importTimer=setInterval(()=>{const increment=state.importProgress<60?11:state.importProgress<90?7:3;state.importProgress=Math.min(100,state.importProgress+increment);state.importProcessed=Math.floor(17894*state.importProgress/100);if(state.activeId==='OMS-ORD-003'&&state.importStep===5)renderT08(screen('OMS-ORD-003'));if(state.importProgress>=100){clearInterval(importTimer);importTimer=null;state.importJobStatus='completed';state.importProcessed=17894;if(state.activeId==='OMS-ORD-003'&&state.importStep===5)renderT08(screen('OMS-ORD-003'));toast('Excel 주문 Import가 완료되었습니다.');}},180);
|
||||
}
|
||||
function bindT08(){
|
||||
const trigger=$('#fileSelect');const input=$('#importFileInput');if(trigger&&input)trigger.onclick=()=>input.click();if(input)input.onchange=()=>{const file=input.files?.[0];if(file)applyImportFile(file);};
|
||||
const drop=$('#dropZone');if(drop){drop.ondragover=e=>{e.preventDefault();drop.classList.add('dragging')};drop.ondragleave=()=>drop.classList.remove('dragging');drop.ondrop=e=>{e.preventDefault();drop.classList.remove('dragging');applyImportFile(e.dataTransfer?.files?.[0]);};drop.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();input?.click();}};}
|
||||
const next=$('#importNext');if(next)next.onclick=()=>{if(state.importStep===1&&!state.importFileName)return;state.importStep=Math.min(5,state.importStep+1);renderT08(screen('OMS-ORD-003'));};
|
||||
const back=$('#importBack');if(back)back.onclick=()=>{state.importStep=Math.max(1,state.importStep-1);renderT08(screen('OMS-ORD-003'));};
|
||||
const commit=$('#importCommit');if(commit)commit.onclick=startImportJob;const result=$('#importResult');if(result)result.onclick=()=>{state.importStep=6;renderT08(screen('OMS-ORD-003'));};
|
||||
const firstError=$('#importFirstError');if(firstError)firstError.onclick=()=>{state.importErrorIndex=0;renderT08(screen('OMS-ORD-003'));setTimeout(()=>$('[data-import-error-row="0"]',workspace)?.focus(),0);};
|
||||
const nextError=$('#importNextError');if(nextError)nextError.onclick=()=>{state.importErrorIndex=(state.importErrorIndex+1)%2;renderT08(screen('OMS-ORD-003'));setTimeout(()=>$(`[data-import-error-row="${state.importErrorIndex}"]`,workspace)?.focus(),0);};
|
||||
const reset=$('#importReset');if(reset)reset.onclick=()=>{state.importStep=1;state.importFileName='';state.importFileSize=0;state.importFileType='';state.importJobStatus='idle';state.importProgress=0;state.importProcessed=0;state.importErrorIndex=0;state.importFocusStep=0;renderT08(screen('OMS-ORD-003'));};
|
||||
}
|
||||
|
||||
function renderT09(s){
|
||||
const remain=Math.max(0,state.pickingRequired-state.pickingQty);const completed=remain===0;
|
||||
const network=state.networkStatus==='online'?['●','온라인 · 동기화 완료','online']:state.networkStatus==='unstable'?['▲','연결 불안정 · 서버 확인 필요','unstable']:['○','오프라인 · 작업 반영 중지','offline'];
|
||||
const scanTruth=state.networkStatus!=='online'?'입력 차단 · 온라인 확인 필요':completed?'상품 완료':'스캔 가능';
|
||||
const confirmed=state.lastConfirmedScanValue?`${esc(state.lastConfirmedScanValue)} · ${new Date(state.lastConfirmedScanAt).toLocaleTimeString('ko-KR')}`:'아직 확인된 스캔 없음';
|
||||
const errorTitle=state.scanError?.startsWith('NETWORK:')?'네트워크를 확인하세요.':'다른 상품입니다.';const errorCopy=(state.scanError||'').replace(/^NETWORK:/,'');
|
||||
workspace.innerHTML=`<div class="mobile-stage"><main class="wms-phone" data-screen-id="${esc(s.id)}"><header class="wms-header"><div><h1>출고 피킹</h1><strong>23 / 138</strong></div><span class="network ${network[2]}">${network[0]} ${network[1]}</span></header><section class="wms-content"><span class="wms-label">LOCATION</span><div class="scan-location">A-03-02</div><p>위치 확인 완료 · 상품을 스캔하세요.</p><div class="scan-truth ${state.networkStatus==='online'?'ready':'blocked'}" role="status"><div><span>스캔 상태</span><b>${esc(scanTruth)}</b></div><div><span>최근 서버 확인</span><b>${confirmed}</b></div></div><div id="scanBox" class="scan-box ${state.scanError?'scan-error':''}" tabindex="0" role="textbox" aria-label="바코드 스캔 입력" aria-describedby="scanHint"><strong>${state.scanBuffer?esc(state.scanBuffer):'SCAN READY'}</strong><small id="scanHint">스캐너 입력 후 Enter · 기대 바코드 8801234567890</small></div><section class="wms-item"><strong>ABC 운동화</strong><p>BLACK / 270</p></section><div class="qty-grid"><div><span>필요</span><b>${state.pickingRequired}</b></div><div><span>피킹</span><b>${state.pickingQty}</b></div><div><span>남음</span><b>${remain}</b></div></div>${completed?'<div class="success-panel"><strong>상품 완료</strong><span>다음 품목으로 자동 진행할 수 있습니다.</span></div>':''}${state.scanError?`<div class="error-panel" role="alert"><strong>${esc(errorTitle)}</strong><span>${esc(errorCopy)}</span></div>`:''}</section><footer class="wms-actions">${btn('+1 피킹','primary','pickOne',completed||state.networkStatus!=='online')}${btn('수량입력','','pickQty',state.networkStatus!=='online')}${btn('재고부족 신고','danger full','stockIssue')}</footer></main></div>`;
|
||||
bindT09();
|
||||
}
|
||||
function applyScan(value){
|
||||
const normalized=value.trim();state.scanBuffer='';if(!normalized)return;const now=Date.now();
|
||||
if(normalized===state.lastScanValue&&now-state.lastScanAt<700){toast('중복 스캔을 감지해 무시했습니다. 수량은 변경되지 않았습니다.','error');setTimeout(()=>$('#scanBox')?.focus(),0);return;}
|
||||
state.lastScanValue=normalized;state.lastScanAt=now;
|
||||
if(state.networkStatus!=='online'){state.scanError=`NETWORK:${state.networkStatus==='unstable'?'연결이 불안정하여 서버 검증을 완료하지 못했습니다. 수량은 반영하지 않았습니다.':'오프라인에서는 서버 검증이 필요한 피킹 수량을 반영하지 않습니다.'}`;renderT09(screen('WMS-PICK-001'));setTimeout(()=>$('#scanBox')?.focus(),0);return;}
|
||||
if(normalized!=='8801234567890'){state.scanError=`스캔 ${normalized} · 필요 ABC001 / 8801234567890`;renderT09(screen('WMS-PICK-001'));setTimeout(()=>$('#scanBox')?.focus(),0);return;}
|
||||
state.scanError='';state.lastConfirmedScanValue=normalized;state.lastConfirmedScanAt=Date.now();if(state.pickingQty<state.pickingRequired)state.pickingQty++;renderT09(screen('WMS-PICK-001'));toast(state.pickingQty===state.pickingRequired?'상품 피킹 완료.':'정상 상품을 1개 피킹했습니다.');setTimeout(()=>$('#scanBox')?.focus(),0);
|
||||
}
|
||||
function bindT09(){
|
||||
const scan=$('#scanBox');if(scan){scan.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyScan(state.scanBuffer);return;}if(e.key==='Backspace'){state.scanBuffer=state.scanBuffer.slice(0,-1);}else if(e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){state.scanBuffer+=e.key;}const strong=$('strong',scan);if(strong)strong.textContent=state.scanBuffer||'SCAN READY';};}
|
||||
$('#pickOne').onclick=()=>{if(state.pickingQty<state.pickingRequired)state.pickingQty++;state.scanError='';renderT09(screen('WMS-PICK-001'));if(state.pickingQty===state.pickingRequired)toast('상품 피킹 완료. 다음 상품으로 진행합니다.');};
|
||||
$('#pickQty').onclick=()=>{$('#quantityInput').value=String(state.pickingQty);$('#quantityInput').max=String(state.pickingRequired);quantityDialog.showModal();setTimeout(()=>$('#quantityInput').focus(),0);};
|
||||
$('#stockIssue').onclick=()=>{exceptionDialog.showModal();setTimeout(()=>$('input[name="exceptionReason"]',exceptionDialog)?.focus(),0);};
|
||||
}
|
||||
|
||||
function renderActive(){ const s=screen(state.activeId);app.dataset.mode=s.template==='T09'?'wms-mobile':'desktop';if(s.id==='HOME')return renderHome();({T01:renderT01,T02:renderT02,T03:renderT03,T04:renderT04,T05:renderT05,T06:renderT06,T07:renderT07,T08:renderT08,T09:renderT09}[s.template]||renderHome)(s); }
|
||||
function renderAll(){renderNavigation();renderTabs();renderActive();}
|
||||
|
||||
function openSearch(){closeSystemPanel(false);state.searchIndex=0;searchInput.value='';searchInput.setAttribute('aria-expanded','true');renderSearchResults();if(!searchDialog.open)searchDialog.showModal();setTimeout(()=>searchInput.focus(),0);}
|
||||
function searchMatches(){const q=searchInput.value.trim().toLowerCase();return screens.filter(x=>x.id!=='HOME').filter(x=>!q||[x.id,x.title,x.module,x.group,...x.keywords].join(' ').toLowerCase().includes(q));}
|
||||
function renderSearchResults(){
|
||||
const list=searchMatches();if(state.searchIndex>=list.length)state.searchIndex=Math.max(0,list.length-1);searchInput.setAttribute('aria-activedescendant',list[state.searchIndex]?`menu-result-${list[state.searchIndex].id}`:'');
|
||||
searchResults.innerHTML=list.length?list.map((item,index)=>`<button id="menu-result-${esc(item.id)}" type="button" class="search-result ${index===state.searchIndex?'active':''}" data-screen="${esc(item.id)}" role="option" aria-selected="${index===state.searchIndex}"><span class="code">${esc(item.id)}</span><span class="copy"><b>${esc(item.title)}</b><small>${esc(item.group)} · ${esc(item.template)}</small></span><span class="module">${esc(labelModule(item.module))}</span></button>`).join(''):'<div class="search-empty">검색 결과가 없습니다.</div>';
|
||||
$$('.search-result',searchResults).forEach((button,index)=>{button.onmousemove=()=>{state.searchIndex=index;$$('.search-result',searchResults).forEach((x,i)=>x.classList.toggle('active',i===index));};button.onclick=()=>{searchDialog.close();openScreen(button.dataset.screen);};});
|
||||
}
|
||||
|
||||
searchInput.oninput=()=>{state.searchIndex=0;renderSearchResults();};
|
||||
searchInput.onkeydown=e=>{const list=searchMatches();if(e.key==='ArrowDown'){e.preventDefault();state.searchIndex=Math.min(list.length-1,state.searchIndex+1);renderSearchResults();$('.search-result.active',searchResults)?.scrollIntoView({block:'nearest'});}else if(e.key==='ArrowUp'){e.preventDefault();state.searchIndex=Math.max(0,state.searchIndex-1);renderSearchResults();$('.search-result.active',searchResults)?.scrollIntoView({block:'nearest'});}else if(e.key==='Enter'&&list[state.searchIndex]){e.preventDefault();searchDialog.close();openScreen(list[state.searchIndex].id);}};
|
||||
|
||||
$('#homeButton').onclick=()=>openScreen('HOME');$('#sideHomeButton').onclick=()=>openScreen('HOME');$('#globalSearchButton').onclick=openSearch;$('#sideSearchButton').onclick=openSearch;$('#searchCloseButton').onclick=()=>searchDialog.close();
|
||||
searchDialog.addEventListener('close',()=>searchInput.setAttribute('aria-expanded','false'));
|
||||
$('#lookupCloseButton').onclick=()=>lookupDialog.close();$('#lookupSearchForm').onsubmit=e=>{e.preventDefault();state.lookupIndex=0;renderLookupResults();};lookupSearchInput.oninput=()=>{state.lookupIndex=0;renderLookupResults();};
|
||||
lookupSearchInput.onkeydown=e=>{const list=lookupItems();if(e.key==='ArrowDown'){e.preventDefault();state.lookupIndex=Math.min(Math.max(0,list.length-1),state.lookupIndex+1);renderLookupResults();$('.lookup-result.active',lookupResults)?.scrollIntoView({block:'nearest'});}else if(e.key==='ArrowUp'){e.preventDefault();state.lookupIndex=Math.max(0,state.lookupIndex-1);renderLookupResults();$('.lookup-result.active',lookupResults)?.scrollIntoView({block:'nearest'});}else if(e.key==='Enter'&&list[state.lookupIndex]){e.preventDefault();selectLookup();}};$('#lookupSelectButton').onclick=selectLookup;lookupDialog.addEventListener('close',()=>{if(document.activeElement===document.body)lookupRestoreFocus?.focus();});
|
||||
$('#collapseButton').onclick=()=>{state.collapsed=!state.collapsed;persist();renderNavigation();};
|
||||
$('#densityButton').onclick=()=>{state.density=state.density==='compact'?'comfortable':'compact';persist();renderNavigation();toast(`화면 밀도를 ${state.density==='compact'?'Compact':'Comfortable'}로 변경했습니다.`);};
|
||||
$('#themeButton').onclick=()=>{state.theme=state.theme==='dark'?'light':'dark';persist();renderNavigation();toast(`KBX Business ${state.theme==='dark'?'Dark':'Light'} 테마를 적용했습니다.`);};
|
||||
$('#moduleSelect').onchange=e=>{state.module=e.target.value;renderNavigation();if(state.activeId==='HOME')renderHome();};
|
||||
$('#drawerClose').onclick=closeDrawer;drawerBackdrop.onclick=closeDrawer;$('#systemPanelClose').onclick=()=>closeSystemPanel();$$('[data-panel]').forEach(button=>button.onclick=()=>showSystemPanel(button.dataset.panel));
|
||||
drawer.onkeydown=e=>{if(e.key==='Escape'){e.preventDefault();closeDrawer();return;}if(e.key!=='Tab')return;const focusables=$$('button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),a[href]',drawer);if(!focusables.length)return;const first=focusables[0],last=focusables[focusables.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}};
|
||||
|
||||
function clearUnsavedPending(){state.pendingCloseId=null;state.pendingNavigationId=null;state.pendingNavigationOptions=null;state.pendingMasterId=null;state.pendingUnsavedMode=null;}
|
||||
function continuePendingNavigation(message){
|
||||
const target=state.pendingNavigationId;const options=state.pendingNavigationOptions||{};clearUnsavedPending();unsavedDialog.close();
|
||||
if(target){openScreen(target,{...options,force:true});toast(message);}
|
||||
}
|
||||
function trySaveDirtyScreen(id){
|
||||
if(id==='ERP-MST-ITEM-001'){if(!validateMasterDraft())return false;return saveMaster();}
|
||||
if(id==='OMS-ORD-002'){if(!validateTransaction())return false;markDirty(id,false);toast('주문을 저장했습니다.');return true;}
|
||||
if(id==='ERP-PRICE-001'){if(fastErrorCount()>0)return false;markDirty(id,false);toast('오류 없는 변경 행을 저장했습니다.');return true;}
|
||||
markDirty(id,false);return true;
|
||||
}
|
||||
$('#unsavedStay').onclick=()=>{clearUnsavedPending();};
|
||||
$('#unsavedDiscard').onclick=()=>{
|
||||
if(state.pendingUnsavedMode==='master-select'){const target=state.pendingMasterId;markDirty('ERP-MST-ITEM-001',false);clearUnsavedPending();unsavedDialog.close();if(target)performMasterTarget(target);toast('저장하지 않은 품목 변경을 버리고 전환했습니다.');return;}
|
||||
if(state.pendingUnsavedMode==='navigate'){markDirty(state.activeId,false);continuePendingNavigation('저장하지 않은 변경을 버리고 이동했습니다.');return;}
|
||||
const id=state.pendingCloseId;clearUnsavedPending();unsavedDialog.close();if(id){markDirty(id,false);closeTabNow(id);toast('저장하지 않은 변경을 버리고 화면을 닫았습니다.');}
|
||||
};
|
||||
$('#unsavedSave').onclick=()=>{
|
||||
if(state.pendingUnsavedMode==='master-select'){const target=state.pendingMasterId;if(!validateMasterDraft()){clearUnsavedPending();unsavedDialog.close();renderT02(screen('ERP-MST-ITEM-001'));setTimeout(()=>$('[data-master-error]',workspace)?.focus(),0);return;}saveMaster();clearUnsavedPending();unsavedDialog.close();if(target)performMasterTarget(target);return;}
|
||||
if(state.pendingUnsavedMode==='navigate'){const activeId=state.activeId;if(!trySaveDirtyScreen(activeId)){const mode=state.pendingUnsavedMode;clearUnsavedPending();unsavedDialog.close();if(activeId==='ERP-MST-ITEM-001')renderT02(screen(activeId));if(activeId==='OMS-ORD-002')renderT03(screen(activeId));if(activeId==='ERP-PRICE-001')renderT04(screen(activeId));toast('입력 오류가 있어 저장 후 이동할 수 없습니다.','error');return;}continuePendingNavigation('변경사항을 저장하고 이동했습니다.');return;}
|
||||
const id=state.pendingCloseId;if(id&&!trySaveDirtyScreen(id)){clearUnsavedPending();unsavedDialog.close();toast('입력 오류가 있어 저장 후 닫을 수 없습니다.','error');return;}clearUnsavedPending();unsavedDialog.close();if(id){closeTabNow(id);toast('변경사항을 저장하고 화면을 닫았습니다.');}
|
||||
};
|
||||
$('#quantityApply').onclick=()=>{const value=Number($('#quantityInput').value);if(!Number.isInteger(value)||value<0||value>state.pickingRequired){$('#quantityHelp').textContent=`0 ~ ${state.pickingRequired} 사이 정수를 입력하세요.`;$('#quantityInput').focus();return;}state.pickingQty=value;state.scanError='';quantityDialog.close();renderT09(screen('WMS-PICK-001'));toast('피킹 수량을 변경했습니다.');};
|
||||
$('#exceptionApply').onclick=()=>{const reason=$('input[name="exceptionReason"]:checked',exceptionDialog)?.value||'기타';exceptionDialog.close();toast(`재고부족 사유 '${reason}'를 등록했습니다.`,'error');};
|
||||
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.defaultPrevented)return;
|
||||
const modalOpen=[searchDialog,lookupDialog,unsavedDialog,quantityDialog,exceptionDialog].some(dialog=>dialog.open);
|
||||
if(modalOpen)return;
|
||||
if(e.key==='Escape'&&!systemPanel.hidden){e.preventDefault();closeSystemPanel();return;}
|
||||
if(e.key==='Escape'&&drawer.classList.contains('open')){e.preventDefault();closeDrawer();return;}
|
||||
if(drawer.classList.contains('open'))return;
|
||||
if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='k'){e.preventDefault();openSearch();return;}
|
||||
if(e.key==='F2'&&['T02','T03'].includes(screen(state.activeId).template)){const root=document.activeElement?.closest?.('.lookup')||$('.lookup',workspace);if(root){e.preventDefault();openLookup(root);return;}}
|
||||
if(e.key==='F3'&&state.activeId!=='HOME'){e.preventDefault();const searchButton=state.activeId==='OMS-ORD-001'?$('#commandSearch'):state.activeId==='ERP-MST-ITEM-001'?$('#masterSearchTop'):state.activeId==='ERP-INV-001'?$('#inventorySearchButton'):state.activeId==='WMS-WORK-001'?$('#queueSearch'):state.activeId==='COMMON-REC-001'?$('#reconcileSearchButton'):null;if(searchButton)searchButton.click();else toast(`${screen(state.activeId).title} 조회를 실행했습니다.`);}
|
||||
if(e.key==='F8'&&['T02','T03','T04'].includes(screen(state.activeId).template)){e.preventDefault();const saveButton=state.activeId==='ERP-MST-ITEM-001'?$('#masterSave'):state.activeId==='OMS-ORD-002'?$('#saveTransaction'):state.activeId==='ERP-PRICE-001'?$('#fastSave'):null;if(saveButton&&!saveButton.disabled)saveButton.click();}
|
||||
});
|
||||
window.addEventListener('hashchange',()=>{const found=screens.find(x=>x.path===location.hash);if(!found||found.id===state.activeId)return;const opened=openScreen(found.id,{updateHash:false});if(!opened){state.pendingNavigationOptions={...(state.pendingNavigationOptions||{}),updateHash:true};history.replaceState(null,'',screen(state.activeId).path);}});
|
||||
window.addEventListener('beforeunload',event=>{if(state.tabs.some(tab=>tab.dirty)){event.preventDefault();event.returnValue='';}});
|
||||
|
||||
unsavedDialog.addEventListener('close',()=>{if(state.pendingUnsavedMode)clearUnsavedPending();});
|
||||
restore();restoreWorkspaceSession();
|
||||
const params=new URLSearchParams(location.search);const demoState=params.get('state');if(['ready','loading','empty','error'].includes(demoState))state.orderDataState=demoState;const network=params.get('network');if(['online','unstable','offline'].includes(network))state.networkStatus=network;const theme=params.get('theme');if(['light','dark'].includes(theme))state.theme=theme;
|
||||
const byHash=screens.find(x=>x.path===location.hash);if(byHash)state.activeId=byHash.id;
|
||||
if(state.activeId!=='HOME'&&!state.tabs.some(t=>t.id===state.activeId))state.tabs.push({id:state.activeId,title:screen(state.activeId).title,pinned:false,dirty:false,resumed:false});persistWorkspaceSession();
|
||||
renderAll();
|
||||
})();
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"viewport": "1440x900",
|
||||
"method": "Headless Chromium interaction harness using the same index/css/js with resources inlined because loopback/file navigation is blocked by the execution sandbox.",
|
||||
"runtimeErrors": 0,
|
||||
"checks": {
|
||||
"homeRendered": true,
|
||||
"ctrlKSearchOpenedT01": true,
|
||||
"t01QuickFilterChangedRows": true,
|
||||
"t01SelectionEnabledBulkAction": true,
|
||||
"drawerEscapeRecovery": true,
|
||||
"dirtyTabCloseGuard": true,
|
||||
"t05MasterDetailChanged": true,
|
||||
"t06ExceptionFilter": true,
|
||||
"t07MismatchToggle": true,
|
||||
"t08FileGateAndStep2": true,
|
||||
"t09WrongBarcodeError": true,
|
||||
"t09CorrectBarcodeIncrement": true,
|
||||
"t09QuantityDialog": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"version": "v39",
|
||||
"passed": 31,
|
||||
"total": 31,
|
||||
"results": [
|
||||
{
|
||||
"name": "home stock intent opens T01",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "home stock intent applies filter",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "stock filter narrows visible rows",
|
||||
"ok": true,
|
||||
"detail": "1"
|
||||
},
|
||||
{
|
||||
"name": "T01 Space selects focused row",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T01 Enter opens detail drawer",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "drawer makes background inert",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "drawer Esc restores background",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "dirty navigation opens guard",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "dirty navigation keeps T03 until decision",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "discard continues pending navigation",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T02 F2 opens lookup dialog",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "lookup Enter selects warehouse",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "lookup selection advances focus",
|
||||
"ok": true,
|
||||
"detail": "barcode"
|
||||
},
|
||||
{
|
||||
"name": "help opens actual drawer content",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "suggestion form submits and closes",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T08 opens after save",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T08 reaches preview step",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T08 background job progresses",
|
||||
"ok": true,
|
||||
"detail": "22"
|
||||
},
|
||||
{
|
||||
"name": "T08 can leave while job runs",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T08 return restores progress",
|
||||
"ok": true,
|
||||
"detail": "(22, 55)"
|
||||
},
|
||||
{
|
||||
"name": "T08 job completes",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "WMS valid scan increments once",
|
||||
"ok": true,
|
||||
"detail": "5"
|
||||
},
|
||||
{
|
||||
"name": "WMS duplicate scan does not increment",
|
||||
"ok": true,
|
||||
"detail": "(5, 5)"
|
||||
},
|
||||
{
|
||||
"name": "runtime console/page errors",
|
||||
"ok": true,
|
||||
"detail": "[]"
|
||||
},
|
||||
{
|
||||
"name": "1280x720 no document horizontal overflow",
|
||||
"ok": true,
|
||||
"detail": "[1280, 1280]"
|
||||
},
|
||||
{
|
||||
"name": "1280x720 grid remains in local scroll container",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "1280 runtime errors",
|
||||
"ok": true,
|
||||
"detail": "[]"
|
||||
},
|
||||
{
|
||||
"name": "narrow/zoom-pressure shell avoids document overflow",
|
||||
"ok": true,
|
||||
"detail": "[720, 720]"
|
||||
},
|
||||
{
|
||||
"name": "narrow shell collapses side nav",
|
||||
"ok": true,
|
||||
"detail": "56px"
|
||||
},
|
||||
{
|
||||
"name": "narrow runtime errors",
|
||||
"ok": true,
|
||||
"detail": "[]"
|
||||
},
|
||||
{
|
||||
"name": "WMS 390x844 uses full mobile viewport",
|
||||
"ok": true,
|
||||
"detail": "mode=wms-mobile, header=none, width=390px, scroll=[390, 390], errors=[]"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"version": "v40",
|
||||
"passed": 49,
|
||||
"total": 49,
|
||||
"results": [
|
||||
{
|
||||
"name": "home renders",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "home actionable attention 4",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T01 channel+keyword filters actual rows",
|
||||
"pass": true,
|
||||
"detail": "rows=1"
|
||||
},
|
||||
{
|
||||
"name": "T01 query values preserved after loading render",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T01 selection enables ship",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T01 reset clears actual criteria",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T02 F8 uses validation not dirty bypass",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T02 lookup dialog opens",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T02 lookup selected warehouse",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T02 new master save adds list row",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T02 save clears dirty indicator",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T02 master selection dirty guard",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T02 discard then select target",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T03 F8 blocks invalid line",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T03 valid save clears dirty",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T03 explicit state transition",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T03 confirmed disables confirm",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T06 selection enables assign",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T06 assignment changes row state",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T06 action result visible",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T07 reprocess resolves selected mismatch",
|
||||
"pass": true,
|
||||
"detail": "2->1"
|
||||
},
|
||||
{
|
||||
"name": "T07 result feedback persists",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "browser hash navigation dirty guard opens",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "browser hash rolled back while pending",
|
||||
"pass": true,
|
||||
"detail": "#items"
|
||||
},
|
||||
{
|
||||
"name": "discard completes pending route",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T08 background job running",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T08 can leave job",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T09 mobile hides desktop shell",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T09 validated scan increments",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "runtime console/page errors zero",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "Ctrl+K opens menu search",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "menu search Enter opens target",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "side nav collapse state",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "density toggle applies comfortable",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "utility drawer opens",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "drawer makes shell inert",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "drawer Esc restores shell",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T04 invalid price disables save",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T04 corrected price restores save",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T04 save clears dirty before navigation",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T05 master detail context changes",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T08 job progresses while away",
|
||||
"pass": true,
|
||||
"detail": "11->33"
|
||||
},
|
||||
{
|
||||
"name": "T08 job completes asynchronously",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T08 result step reachable",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T09 duplicate scan does not double increment",
|
||||
"pass": true,
|
||||
"detail": "5->5"
|
||||
},
|
||||
{
|
||||
"name": "T09 wrong barcode recovery visible",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "T09 390 full viewport width",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "1280x720 document horizontal overflow none",
|
||||
"pass": true,
|
||||
"detail": "0"
|
||||
},
|
||||
{
|
||||
"name": "supplement runtime errors zero",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
}
|
||||
],
|
||||
"runtimeErrors": [],
|
||||
"note": "Production DOM/CSS/JS tested in inline-asset browser harness because container browser navigation is administratively blocked."
|
||||
}
|
||||
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"version": "v43",
|
||||
"scope": "inline-asset browser QA for FE Reference Lab; production CSP remains statically validated",
|
||||
"passed": 16,
|
||||
"total": 16,
|
||||
"checks": [
|
||||
{
|
||||
"name": "initial-light",
|
||||
"pass": true,
|
||||
"detail": "light"
|
||||
},
|
||||
{
|
||||
"name": "toggle-dark",
|
||||
"pass": true,
|
||||
"detail": "dark"
|
||||
},
|
||||
{
|
||||
"name": "dark-text-recomputed",
|
||||
"pass": true,
|
||||
"detail": "rgb(229, 231, 235)"
|
||||
},
|
||||
{
|
||||
"name": "theme-aria",
|
||||
"pass": true,
|
||||
"detail": "밝은 테마로 변경"
|
||||
},
|
||||
{
|
||||
"name": "T01-dark-render",
|
||||
"pass": true,
|
||||
"detail": "OMS-ORD-001:T01"
|
||||
},
|
||||
{
|
||||
"name": "T02-dark-render",
|
||||
"pass": true,
|
||||
"detail": "ERP-MST-ITEM-001:T02"
|
||||
},
|
||||
{
|
||||
"name": "T03-dark-render",
|
||||
"pass": true,
|
||||
"detail": "OMS-ORD-002:T03"
|
||||
},
|
||||
{
|
||||
"name": "T04-dark-render",
|
||||
"pass": true,
|
||||
"detail": "ERP-PRICE-001:T04"
|
||||
},
|
||||
{
|
||||
"name": "T05-dark-render",
|
||||
"pass": true,
|
||||
"detail": "ERP-INV-001:T05"
|
||||
},
|
||||
{
|
||||
"name": "T06-dark-render",
|
||||
"pass": true,
|
||||
"detail": "WMS-WORK-001:T06"
|
||||
},
|
||||
{
|
||||
"name": "T07-dark-render",
|
||||
"pass": true,
|
||||
"detail": "COMMON-REC-001:T07"
|
||||
},
|
||||
{
|
||||
"name": "T08-dark-render",
|
||||
"pass": true,
|
||||
"detail": "OMS-ORD-003:T08"
|
||||
},
|
||||
{
|
||||
"name": "T09-dark-render",
|
||||
"pass": true,
|
||||
"detail": "WMS-PICK-001:wms-mobile:wms-mobile"
|
||||
},
|
||||
{
|
||||
"name": "1280-no-document-overflow",
|
||||
"pass": true,
|
||||
"detail": "False"
|
||||
},
|
||||
{
|
||||
"name": "toggle-light-back",
|
||||
"pass": true,
|
||||
"detail": "light"
|
||||
},
|
||||
{
|
||||
"name": "runtime-errors-zero",
|
||||
"pass": true,
|
||||
"detail": ""
|
||||
}
|
||||
],
|
||||
"pageErrors": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 182 KiB |
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<title>KBX 업무 플랫폼</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>KBX 업무 화면을 사용하려면 JavaScript가 필요합니다.</noscript>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@kbx/web",
|
||||
"version": "0.45.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"dev:demo": "vite --mode demo --host 127.0.0.1",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"build:demo": "vue-tsc --noEmit && vite build --mode demo",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kbx/contracts": "workspace:*",
|
||||
"@kbx/ui": "workspace:*",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@tanstack/vue-query": "^5.85.5",
|
||||
"ag-grid-community": "^34.1.2",
|
||||
"ag-grid-vue3": "^34.1.2",
|
||||
"axios": "^1.11.0",
|
||||
"pinia": "^3.0.3",
|
||||
"primevue": "^4.3.7",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1",
|
||||
"zod": "^4.0.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^7.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^8.0.0",
|
||||
"vue-tsc": "^3.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { kbxPermissionCatalog } from '@kbx/contracts'
|
||||
import KbxAppFrame from './shell/KbxAppFrame.vue'
|
||||
|
||||
const demoMode = import.meta.env.VITE_KBX_DEMO_MODE === 'true'
|
||||
const permissions = computed(() => demoMode ? kbxPermissionCatalog.map(permission => permission.id) : [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxAppFrame
|
||||
product-name="KBX"
|
||||
:granted-permissions="permissions"
|
||||
:preference-scope="demoMode ? 'demo-tenant:qa-user' : 'anonymous:secure-default'"
|
||||
app-version="v60-fe-status-canonical-contract-hardening"
|
||||
:user-role="demoMode ? 'QA' : 'anonymous'"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
import '@kbx/ui/tokens.css'
|
||||
import type { App } from 'vue'
|
||||
import { kbxLookupRegistryKey } from '@kbx/ui'
|
||||
import { lookupRegistry } from '../lookups/lookupRegistry'
|
||||
import { installScreenRegistry } from '../registry/screens'
|
||||
|
||||
export function installKbx(app: App) {
|
||||
app.provide(kbxLookupRegistryKey, lookupRegistry)
|
||||
installScreenRegistry()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import * as signalR from '@microsoft/signalr'
|
||||
export interface KbxExperimentChangedEvent { experimentId:string; screenId:string; changedAt:string }
|
||||
export function createExperimentRolloutConnection(url='/hubs/kbx-experiments'){
|
||||
const hub=new signalR.HubConnectionBuilder().withUrl(url).withAutomaticReconnect().build()
|
||||
return {start:()=>hub.state===signalR.HubConnectionState.Disconnected?hub.start():Promise.resolve(),stop:()=>hub.stop(),onChanged:(callback:(event:KbxExperimentChangedEvent)=>void)=>hub.on('ExperimentChanged',callback)}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { KbxApiOperationId, KbxExperimentAssignmentsResponse, KbxExperimentId } from '@kbx/contracts'
|
||||
import { kbxApi } from '../http/generated/kbxApiClient'
|
||||
import { kbxTelemetry } from '../telemetry/kbxTelemetryClient'
|
||||
import { createExperimentRolloutConnection } from './createExperimentRolloutConnection'
|
||||
|
||||
const screenCache=new Map<string,Promise<KbxExperimentAssignmentsResponse>>()
|
||||
const listeners=new Map<string,Set<()=>void>>()
|
||||
let realtimeStarted=false
|
||||
const realtime=createExperimentRolloutConnection()
|
||||
realtime.onChanged(event=>{screenCache.delete(event.screenId);for(const refresh of listeners.get(event.screenId)??[])void refresh()})
|
||||
async function ensureRealtime(){if(realtimeStarted)return;realtimeStarted=true;try{await realtime.start()}catch{realtimeStarted=false}}
|
||||
function assignments(screenId:string,force=false){if(force)screenCache.delete(screenId);if(!screenCache.has(screenId))screenCache.set(screenId,kbxApi.request<KbxExperimentAssignmentsResponse>('common.experiments.assignments' as KbxApiOperationId,{query:{screenId}}));return screenCache.get(screenId)!}
|
||||
export function useKbxExperiment(screenId:string,screenVersion:string,experimentId:KbxExperimentId,surface:string){
|
||||
const variant=ref('control'),enrolled=ref(false);let lastExposure=''
|
||||
const refresh=async()=>{
|
||||
try{const result=await assignments(screenId,true);const assignment=result.assignments.find(x=>x.experimentId===experimentId);if(!assignment||!assignment.enrolled){enrolled.value=false;variant.value='control';lastExposure='';kbxTelemetry.clearExperiment(screenId);return}enrolled.value=true;variant.value=assignment.variant;kbxTelemetry.bindExperiment(screenId,assignment.experimentId,assignment.variant);const exposure=`${assignment.experimentId}:${assignment.variant}`;if(lastExposure!==exposure){lastExposure=exposure;kbxTelemetry.track('experiment.exposed',{screenId,screenVersion,attributes:{surface}})}}catch{/* experiment infrastructure must not block business UI */}
|
||||
}
|
||||
onMounted(async()=>{const set=listeners.get(screenId)??new Set<()=>void>();set.add(refresh);listeners.set(screenId,set);await ensureRealtime();await refresh()})
|
||||
onUnmounted(()=>{listeners.get(screenId)?.delete(refresh);kbxTelemetry.clearExperiment(screenId)})
|
||||
return {variant,enrolled,is:(value:string)=>variant.value===value,refresh}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { KbxHelpContent } from '@kbx/contracts'
|
||||
|
||||
export const helpRegistry: Record<string, KbxHelpContent> = {
|
||||
'OMS-ORD-001': {
|
||||
key: 'OMS-ORD-001', title: '주문관리', purpose: '주문을 조회하고 예외 및 출고대상을 일괄 처리합니다.',
|
||||
steps: ['조회조건을 입력합니다.', 'F3 또는 조회를 실행합니다.', '처리할 주문을 선택한 뒤 업무 명령을 실행합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
},
|
||||
'OMS-ORD-002': {
|
||||
key: 'OMS-ORD-002', title: '주문등록', purpose: '거래처 주문을 Header/Detail 방식으로 등록합니다.',
|
||||
steps: ['거래처와 출고창고를 선택합니다.', '품목코드 또는 F2로 상품을 입력합니다.', '수량·단가를 확인하고 F8로 저장합니다.'],
|
||||
shortcuts: [{ key: 'F2', description: '코드 조회' }, { key: 'F8', description: '저장' }],
|
||||
},
|
||||
'ERP-MST-ITEM-001': {
|
||||
key: 'ERP-MST-ITEM-001', title: '품목관리', purpose: '품목 기준정보와 물류 속성을 관리합니다.',
|
||||
steps: ['왼쪽 목록에서 품목을 선택하거나 신규를 누릅니다.', '기준정보와 물류정보를 입력합니다.', 'F8로 저장합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }, { key: 'F8', description: '저장' }, { key: 'F2', description: '창고 조회' }],
|
||||
cautions: ['사용된 품목은 삭제보다 사용중지를 우선합니다.'],
|
||||
},
|
||||
'ERP-INV-001': {
|
||||
key: 'ERP-INV-001', title: '재고현황', purpose: '품목별 현재고에서 창고·로케이션 근거와 재고이력까지 같은 조회 맥락에서 확인합니다.',
|
||||
steps: ['조회조건을 입력하고 F3을 누릅니다.', '품목을 선택하면 오른쪽 위치재고와 아래 재고이력이 함께 바뀝니다.', '현재고 또는 가용수량을 눌러 산정 근거를 확인합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
cautions: ['화면 수량은 조회 Projection이며 실제 변경·확정 시 서버 재고 정책이 다시 검증합니다.'],
|
||||
},
|
||||
'ERP-PRICE-001': {
|
||||
key: 'ERP-PRICE-001', title: '품목 단가 일괄등록', purpose: '품목별 적용일과 단가를 Excel 방식으로 빠르게 입력하고 일괄 저장합니다.',
|
||||
steps: ['품목코드를 입력하거나 F2로 품목을 선택합니다.', '적용일과 단가를 입력하고 필요하면 Excel 영역을 붙여넣습니다.', '오류가 없으면 F8로 입력 건을 한 번에 저장합니다.'],
|
||||
shortcuts: [{ key: 'F2', description: '품목 조회' }, { key: 'F8', description: '저장' }],
|
||||
cautions: ['같은 품목과 적용일을 요청 안에서 중복 입력할 수 없습니다.', '서버가 품목 존재·권한·단가 범위를 다시 검증하고 저장 이력을 남깁니다.'],
|
||||
},
|
||||
'WMS-PICK-001': {
|
||||
key: 'WMS-PICK-001', title: '출고 피킹', purpose: '위치와 상품을 스캔하여 출고 피킹을 수행합니다.',
|
||||
cautions: ['서버 성공 응답 전에 같은 상품을 다시 스캔하지 마세요. 네트워크 오류는 동일 Idempotency Key로 자동 재시도합니다.'],
|
||||
},
|
||||
'OMS-ORD-003': {
|
||||
key: 'OMS-ORD-003', title: '주문 Excel 업로드', purpose: '주문 데이터를 공통 매핑·검증 절차로 대량 등록하거나 수정합니다.',
|
||||
steps: ['업로드 양식 또는 기존 Excel 파일을 선택합니다.', '자동 매핑 결과를 확인하고 필요한 열만 수정합니다.', '검증 결과에서 오류행을 확인한 뒤 정상 데이터만 반영합니다.'],
|
||||
cautions: ['검증되지 않은 데이터는 반영할 수 없습니다.', '기존 주문은 수정 가능한 업무상태에서만 업데이트됩니다.'],
|
||||
},
|
||||
'COMMON-OPS-001': {
|
||||
key: 'COMMON-OPS-001', title: '업무 예외 센터', purpose: '정상 업무가 아니라 사람이 판단하거나 복구해야 할 예외만 모아 처리합니다.',
|
||||
steps: ['미처리 예외를 조회합니다.', '중요도와 경과시간을 기준으로 우선순위를 확인합니다.', '원 업무 보기 또는 등록된 안전한 재처리 기능으로 해결합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
cautions: ['예외 센터는 원천 업무 데이터의 Source of Truth가 아닙니다.', '원천 조건이 해결되지 않은 예외를 단순 숨김 처리하지 않습니다.'],
|
||||
},
|
||||
'COMMON-REC-001': {
|
||||
key: 'COMMON-REC-001', title: '업무 데이터 대사', purpose: 'OMS·WMS·ERP 사이의 기대값과 실제값 차이를 시스템이 계산해 보여줍니다.',
|
||||
steps: ['대사유형과 결과를 선택합니다.', '불일치 건의 기대값·실제값·원인을 확인합니다.', '조사가 필요한 건만 예외 센터에 등록합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
cautions: ['대사 화면에서 원천 데이터를 직접 수정하지 않습니다.'],
|
||||
},
|
||||
'OMS-CLM-001': {
|
||||
key:'OMS-CLM-001', title:'반품·클레임 관리', purpose:'반품·교환·취소 요청을 기존 주문 맥락에서 조회하고 처리합니다.',
|
||||
steps:['F3으로 미처리 요청을 조회합니다.','주문번호와 사유를 확인합니다.','허용된 상태전이만 실행합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['완료된 클레임은 원 주문·물류 이력과 함께 추적합니다.'],
|
||||
},
|
||||
'ERP-PUR-001': {
|
||||
key:'ERP-PUR-001', title:'구매등록', purpose:'거래처 구매를 Header/Detail 방식으로 입력하고 입고 흐름과 연결합니다.',
|
||||
steps:['거래처와 입고창고를 선택합니다.','품목·수량·단가·납기일을 입력합니다.','F8 저장 후 구매확정합니다.'], shortcuts:[{key:'F2',description:'코드 조회'},{key:'F8',description:'저장'}], cautions:['확정 후에는 입고 실적과 연결되므로 직접 수정 범위를 제한합니다.'],
|
||||
},
|
||||
'ERP-INV-MOVE-001': {
|
||||
key:'ERP-INV-MOVE-001', title:'재고이동', purpose:'창고 간 재고이동을 등록하고 이동중·입고완료 상태를 추적합니다.',
|
||||
steps:['출발·도착창고를 선택합니다.','품목과 이동수량을 입력합니다.','이동확정 후 WMS/입고 흐름에서 완료합니다.'], shortcuts:[{key:'F2',description:'코드 조회'},{key:'F8',description:'저장'}], cautions:['출발창고와 도착창고는 같을 수 없고 가용재고보다 많이 이동할 수 없습니다.'],
|
||||
},
|
||||
'WMS-REC-001': { key:'WMS-REC-001', title:'입고 검수', purpose:'입고예정/ASN과 실물을 스캔해 수량·품목을 검수합니다.', cautions:['예정과 다른 상품·수량은 자동 확정하지 않고 예외로 남깁니다.'] },
|
||||
'WMS-PUT-001': { key:'WMS-PUT-001', title:'입고 적치', purpose:'검수 완료 상품을 추천 로케이션에 적치합니다.', cautions:['추천 위치와 다른 위치는 정상 흐름에서 자동 승인하지 않습니다.'] },
|
||||
'WMS-COUNT-001': { key:'WMS-COUNT-001', title:'재고실사', purpose:'위치·상품을 스캔해 실물수량을 기록하고 장부수량 차이를 예외로 보냅니다.', cautions:['실사 차이는 현장에서 장부수량을 직접 덮어쓰지 않습니다.'] },
|
||||
|
||||
'WMS-WORK-001': { key:'WMS-WORK-001', title:'물류 작업', purpose:'입고·적치·피킹·검수·실사 작업과 예외를 작업자 관점에서 조회하고 시작합니다.', steps:['F3으로 내 작업을 조회합니다.','SLA·진행률·예외를 확인합니다.','작업을 선택해 작업 시작을 실행합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['현장 실행은 각 PDA 작업화면의 서버 검증과 Idempotency 규칙을 따릅니다.'] },
|
||||
|
||||
'COMMON-EXP-001': { key:'COMMON-EXP-001', title:'UX 실험·점진배포', purpose:'되돌릴 수 있는 UX 변경을 제한된 사용자에게 점진 배포하고 기존 UX 지표와 Guardrail을 비교합니다.', steps:['Draft 상태에서 변경 범위와 기준 지표를 검토합니다.','소규모 Rollout으로 시작하고 표본·P95·오류율을 확인합니다.','Guardrail 위반 시 즉시 롤백하고 원인을 기록합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['권한·민감정보·Domain Rule·Validation·Idempotency·WMS Scan 규칙은 실험 대상이 아닙니다.','운영 Threshold는 통계적 유의성을 의미하지 않습니다.'] },
|
||||
|
||||
'COMMON-UX-001': { key:'COMMON-UX-001', title:'UX 품질 지표', purpose:'Task 시간·의미 조작수·Manual Intervention Rate·검증/Import 실패율·예외 해결시간·AI 제안 수용률을 확인합니다.', steps:['기간을 선택하고 F3으로 조회합니다.','표본 수와 P50/P95를 함께 확인합니다.','지표 변화는 위험도·업무오류·현장 VOC와 같이 판단합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['DOM 전체 클릭이나 고객·주문 원문 데이터를 수집하지 않습니다.','Manual Intervention Rate를 낮추기 위해 위험 업무를 무리하게 자동화하지 않습니다.'] },
|
||||
|
||||
'COMMON-DATA-001': { key:'COMMON-DATA-001', title:'외부 데이터 상태', purpose:'KRX·OPENDART·KIS 외부 데이터의 출처·수신시각·신선도·캐시 상태를 확인합니다.', steps:['F3으로 현재 상태를 조회합니다.','Stale/Expired 데이터셋의 최근 수신시각과 정책을 확인합니다.','허용된 데이터셋만 재수집합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['외부 데이터는 Domain 업무원장이 아닙니다.','KRX 승인 서비스는 서비스별 명시적 신선도 정책이 없으면 범용 TTL을 추정하지 않습니다.'] },
|
||||
|
||||
'COMMON-DS-001': { key:'COMMON-DS-001', title:'KBX 컴포넌트 카탈로그', purpose:'공통 컴포넌트의 상태·밀도·키보드·접근성 계약을 독립적으로 재현합니다.', steps:['밀도를 선택합니다.','컴포넌트의 기본·오류·비활성 상태를 비교합니다.','Visual/Keyboard Regression 기준으로 사용합니다.'], cautions:['업무 사용자를 위한 메뉴가 아니라 KBX 개발·검증 화면입니다.'] },
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { AxiosError, type AxiosAdapter, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||
import { kbxHttp } from '../http/kbxHttpClient'
|
||||
|
||||
const now = () => new Date().toISOString()
|
||||
const clone = <T>(value: T): T => structuredClone(value)
|
||||
const uid = () => crypto.randomUUID()
|
||||
|
||||
const customers = [
|
||||
{ id:'11111111-1111-4111-8111-111111111111', code:'10001', displayName:'대한상사', secondaryText:'서울 · 정상', status:'active', metadata:{ businessNumber:'123-45-67890', representative:'김대표' } },
|
||||
{ id:'22222222-2222-4222-8222-222222222222', code:'10002', displayName:'한빛유통', secondaryText:'경기 · 정상', status:'active', metadata:{ businessNumber:'234-56-78901', representative:'이대표' } },
|
||||
{ id:'33333333-3333-4333-8333-333333333333', code:'10003', displayName:'새봄물류', secondaryText:'인천 · 정상', status:'active', metadata:{ businessNumber:'345-67-89012', representative:'박대표' } },
|
||||
]
|
||||
const warehouses = [
|
||||
{ id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', code:'WH01', displayName:'서울물류센터', secondaryText:'수도권', status:'active' },
|
||||
{ id:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', code:'WH02', displayName:'인천물류센터', secondaryText:'수도권', status:'active' },
|
||||
{ id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc', code:'WH03', displayName:'부산물류센터', secondaryText:'영남권', status:'active' },
|
||||
]
|
||||
|
||||
const items = [
|
||||
{ id:'10000000-0000-4000-8000-000000000001', code:'ABC001', name:'운동화 BLACK 270', displayName:'운동화 BLACK 270', categoryName:'신발', specification:'BLACK / 270', unit:'EA', barcode:'8801234567001', defaultWarehouseId:warehouses[0].id, defaultWarehouseName:warehouses[0].displayName, lotManaged:false, expiryManaged:false, active:true, version:3 },
|
||||
{ id:'10000000-0000-4000-8000-000000000002', code:'ABC002', name:'운동화 WHITE 265', displayName:'운동화 WHITE 265', categoryName:'신발', specification:'WHITE / 265', unit:'EA', barcode:'8801234567002', defaultWarehouseId:warehouses[0].id, defaultWarehouseName:warehouses[0].displayName, lotManaged:false, expiryManaged:false, active:true, version:2 },
|
||||
{ id:'10000000-0000-4000-8000-000000000003', code:'DEF001', name:'기능성 양말', displayName:'기능성 양말', categoryName:'의류', specification:'FREE / BLACK', unit:'EA', barcode:'8801234567003', defaultWarehouseId:warehouses[1].id, defaultWarehouseName:warehouses[1].displayName, lotManaged:false, expiryManaged:false, active:true, version:5 },
|
||||
{ id:'10000000-0000-4000-8000-000000000004', code:'FOOD01', name:'에너지바 40g', displayName:'에너지바 40g', categoryName:'식품', specification:'40g', unit:'EA', barcode:'8801234567004', defaultWarehouseId:warehouses[1].id, defaultWarehouseName:warehouses[1].displayName, lotManaged:true, expiryManaged:true, active:true, version:1 },
|
||||
]
|
||||
|
||||
function demoOrderLifecycleFromShipment(value:string){
|
||||
return ({NEW:'DRAFT',READY:'CONFIRMED',CONFIRMED:'CONFIRMED',HOLD:'HOLD',SHIPPED:'SHIPPED',ERROR:'ERROR'} as Record<string,string>)[value] ?? value
|
||||
}
|
||||
|
||||
const orderDetails = new Map<string, any>([
|
||||
['20000000-0000-4000-8000-000000000001', {
|
||||
orderId:'20000000-0000-4000-8000-000000000001', orderNo:'ORD-260811-0001', version:4, status:'CONFIRMED', orderDate:'2026-08-11', customerId:customers[0].id, warehouseId:warehouses[0].id,
|
||||
receiverName:'홍길동', phone:'010-1234-5678', postalCode:'06236', address1:'서울특별시 강남구 테헤란로 1', address2:'101호',
|
||||
lines:[{id:'line-1',clientId:'line-1',itemId:items[0].id,itemCode:'ABC001',itemName:items[0].name,orderQty:2,unitPrice:89000,amount:178000,remark:''}],
|
||||
}],
|
||||
['20000000-0000-4000-8000-000000000002', {
|
||||
orderId:'20000000-0000-4000-8000-000000000002', orderNo:'ORD-260811-0002', version:2, status:'DRAFT', orderDate:'2026-08-11', customerId:customers[1].id, warehouseId:warehouses[0].id,
|
||||
receiverName:'김하나', phone:'010-2345-6789', postalCode:'04524', address1:'서울특별시 중구 세종대로 10', address2:'',
|
||||
lines:[{id:'line-2',clientId:'line-2',itemId:items[2].id,itemCode:'DEF001',itemName:items[2].name,orderQty:5,unitPrice:12000,amount:60000,remark:'선물포장'}],
|
||||
}],
|
||||
['20000000-0000-4000-8000-000000000003', {
|
||||
orderId:'20000000-0000-4000-8000-000000000003', orderNo:'ORD-260811-0003', version:6, status:'CONFIRMED', orderDate:'2026-08-11', customerId:customers[2].id, warehouseId:warehouses[1].id,
|
||||
receiverName:'박현장', phone:'010-3456-7890', postalCode:'22382', address1:'인천광역시 중구 공항동로 1', address2:'물류동',
|
||||
lines:[{id:'line-3',clientId:'line-3',itemId:items[1].id,itemCode:'ABC002',itemName:items[1].name,orderQty:1,unitPrice:92000,amount:92000,remark:''}],
|
||||
}],
|
||||
])
|
||||
|
||||
let orderRows = [
|
||||
{ id:'20000000-0000-4000-8000-000000000001', orderNo:'ORD-260811-0001', channelName:'쿠팡', orderedAt:'2026-08-11T09:15:00+09:00', customerName:'홍길동', itemSummary:'운동화 BLACK 270', totalQty:2, amount:178000, allocationStatus:'부족', shipmentStatus:'READY', exceptionCount:1 },
|
||||
{ id:'20000000-0000-4000-8000-000000000002', orderNo:'ORD-260811-0002', channelName:'스마트스토어', orderedAt:'2026-08-11T09:38:00+09:00', customerName:'김하나', itemSummary:'기능성 양말', totalQty:5, amount:60000, allocationStatus:'정상', shipmentStatus:'NEW', exceptionCount:0 },
|
||||
{ id:'20000000-0000-4000-8000-000000000003', orderNo:'ORD-260811-0003', channelName:'자사몰', orderedAt:'2026-08-11T10:02:00+09:00', customerName:'박현장', itemSummary:'운동화 WHITE 265', totalQty:1, amount:92000, allocationStatus:'정상', shipmentStatus:'READY', exceptionCount:0 },
|
||||
{ id:'20000000-0000-4000-8000-000000000004', orderNo:'ORD-260811-0004', channelName:'쿠팡', orderedAt:'2026-08-11T10:17:00+09:00', customerName:'이소라', itemSummary:'에너지바 40g', totalQty:12, amount:36000, allocationStatus:'정상', shipmentStatus:'HOLD', exceptionCount:1 },
|
||||
{ id:'20000000-0000-4000-8000-000000000005', orderNo:'ORD-260811-0005', channelName:'스마트스토어', orderedAt:'2026-08-11T11:05:00+09:00', customerName:'정우진', itemSummary:'기능성 양말 외 1종', totalQty:4, amount:84000, allocationStatus:'정상', shipmentStatus:'SHIPPED', exceptionCount:0 },
|
||||
]
|
||||
|
||||
const auditByEntity = new Map<string, any[]>()
|
||||
function audit(id:string, action:string, changes:any[] = []) {
|
||||
const list = auditByEntity.get(id) ?? []
|
||||
list.unshift({ id:uid(), occurredAt:now(), actor:{type:'user',displayName:'QA 사용자'}, action, changes })
|
||||
auditByEntity.set(id, list)
|
||||
}
|
||||
for (const id of orderDetails.keys()) audit(id, '주문 조회용 데모 데이터 생성')
|
||||
for (const item of items) audit(item.id, '품목 기준정보 생성')
|
||||
|
||||
let notifications:any[] = [
|
||||
{ id:'noti-1', severity:'warning', title:'재고부족 주문 7건', message:'출고 전 확인이 필요합니다.', createdAt:now(), screenId:'OMS-ORD-001', action:{id:'open',label:'주문 보기',route:'/oms/orders?exceptionOnly=true'} },
|
||||
{ id:'noti-2', severity:'error', title:'OMS ↔ WMS 대사 불일치 3건', message:'원인 확인 후 재처리하세요.', createdAt:now(), screenId:'COMMON-REC-001', action:{id:'open',label:'대사 보기',route:'/operations/reconcile'} },
|
||||
]
|
||||
let operations:any[] = [
|
||||
{ id:'job-1', type:'excel-import', title:'주문 Excel Import', sourceScreenId:'OMS-ORD-003', status:'running', requestedAt:now(), startedAt:now(), processed:13421, total:17894, succeeded:13218, failed:203, resultMessage:'75% 처리 중' },
|
||||
]
|
||||
|
||||
const workItems:any[] = [
|
||||
{ id:'op-1', sourceModule:'OMS', sourceType:'order', sourceId:orderRows[0].id, referenceNo:'ORD-260811-0001', sourceScreenId:'OMS-ORD-001', code:'STOCK_SHORTAGE', title:'주문 재고 부족', detail:'ABC001 가용재고 부족', severity:'warning', status:'open', ownerId:null, ownerName:null, occurredAt:'2026-08-11T09:15:00+09:00', dueAt:null, ageMinutes:75, version:1, context:{ itemCode:'ABC001' }, retryActionKey:null, allowManualResolution:true },
|
||||
{ id:'op-2', sourceModule:'WMS', sourceType:'integration', sourceId:'sync-2', referenceNo:'SYNC-260811-0002', sourceScreenId:'COMMON-OPS-001', code:'SHIPMENT_SYNC', title:'출고 연계 지연', detail:'WMS 전송 재시도 대기', severity:'critical', status:'open', ownerId:null, ownerName:null, occurredAt:'2026-08-11T08:10:00+09:00', dueAt:null, ageMinutes:140, version:2, context:{ integration:'WMS' }, retryActionKey:'shipment-sync', allowManualResolution:false },
|
||||
{ id:'op-3', sourceModule:'ERP', sourceType:'inventory', sourceId:'inventory-3', referenceNo:'INV-260811-0003', sourceScreenId:'ERP-INV-001', code:'INVENTORY_REVIEW', title:'재고 확인 필요', detail:'실사 차이 검토가 필요합니다.', severity:'info', status:'claimed', ownerId:'demo-user', ownerName:'QA 사용자', occurredAt:'2026-08-11T10:30:00+09:00', dueAt:null, ageMinutes:32, version:1, context:{}, retryActionKey:null, allowManualResolution:true },
|
||||
]
|
||||
function demoWorkItemActions(item:any){
|
||||
const actions:any[]=[{id:'navigate',label:'원 업무 보기',kind:'navigate'}]
|
||||
if(item.status==='open')actions.push({id:'claim',label:'내가 처리',kind:'claim',permission:'common.operations.claim'})
|
||||
if(['open','claimed'].includes(item.status)&&item.retryActionKey)actions.push({id:'retry',label:'재처리',kind:'retry',permission:'common.operations.retry'})
|
||||
if(['open','claimed'].includes(item.status)&&item.allowManualResolution)actions.push({id:'resolve',label:'해결 처리',kind:'resolve',permission:'common.operations.resolve'})
|
||||
return actions
|
||||
}
|
||||
const reconcileItems:any[] = [
|
||||
{ id:'rec-1', reconcileType:'OMS_WMS_OUTBOUND_QTY', referenceNo:'ORD-260811-0001', sourceLabel:'OMS 주문수량', targetLabel:'WMS 피킹수량', expectedValue:'2', actualValue:'1', differenceValue:'-1', reasonCode:'PICK_SHORT', reasonText:'피킹부족', status:'mismatch', occurredAt:'2026-08-11T10:42:00+09:00', sourceId:orderRows[0].id, targetId:'pick-line-1', version:3 },
|
||||
{ id:'rec-2', reconcileType:'OMS_WMS_OUTBOUND_QTY', referenceNo:'ORD-260811-0003', sourceLabel:'OMS 주문수량', targetLabel:'WMS 피킹수량', expectedValue:'1', actualValue:'1', differenceValue:'0', reasonCode:null, reasonText:null, status:'matched', occurredAt:'2026-08-11T10:38:00+09:00', sourceId:orderRows[2].id, targetId:'pick-line-3', version:1 },
|
||||
{ id:'rec-3', reconcileType:'OMS_WMS_OUTBOUND_QTY', referenceNo:'ORD-260811-0004', sourceLabel:'OMS 주문수량', targetLabel:'WMS 피킹수량', expectedValue:'12', actualValue:'10', differenceValue:'-2', reasonCode:'SYNC_PENDING', reasonText:'연계 결과 안정화 대기', status:'pending', occurredAt:'2026-08-11T10:47:00+09:00', sourceId:orderRows[3].id, targetId:'pick-line-4', version:2 },
|
||||
]
|
||||
|
||||
|
||||
let claimRows:any[] = [
|
||||
{id:'50000000-0000-4000-8000-000000000001',claimNo:'CLM-260811-01',orderNo:'ORD-260811-0001',channelName:'쿠팡',type:'RETURN',reason:'사이즈 교환',requestedQty:1,status:'REQUESTED',ownerName:'QA 사용자',requestedAt:'2026-08-11T13:20:00+09:00',version:1},
|
||||
{id:'50000000-0000-4000-8000-000000000002',claimNo:'CLM-260811-02',orderNo:'ORD-260811-0003',channelName:'자사몰',type:'EXCHANGE',reason:'색상 교환',requestedQty:1,status:'APPROVED',ownerName:'QA 사용자',requestedAt:'2026-08-11T13:35:00+09:00',version:2},
|
||||
{id:'50000000-0000-4000-8000-000000000003',claimNo:'CLM-260811-03',orderNo:'ORD-260811-0004',channelName:'쿠팡',type:'CANCEL',reason:'고객 요청',requestedQty:2,status:'IN_PROGRESS',ownerName:'QA 사용자',requestedAt:'2026-08-11T14:10:00+09:00',version:3},
|
||||
{id:'50000000-0000-4000-8000-000000000004',claimNo:'CLM-260811-04',orderNo:'ORD-260811-0002',channelName:'스마트스토어',type:'RETURN',reason:'검수 필요',requestedQty:1,status:'HOLD',ownerName:'QA 사용자',requestedAt:'2026-08-11T14:30:00+09:00',version:2},
|
||||
]
|
||||
|
||||
const pickingTask:any = {
|
||||
taskId:'30000000-0000-4000-8000-000000000001', taskNo:'PICK-260811-001', status:'ready', stage:'ready', version:1,
|
||||
currentLine:{ lineId:'pick-line-1', lineNo:23, locationCode:'A-03-02', itemId:items[0].id, itemCode:items[0].code, itemName:items[0].name, itemOption:items[0].specification, barcode:items[0].barcode, requiredQty:6, pickedQty:4, remainingQty:2 },
|
||||
completedLines:22, totalLines:138, completedQty:71, totalQty:427, message:'작업을 시작하세요.',
|
||||
}
|
||||
|
||||
const imports = new Map<string, any>()
|
||||
|
||||
function ok(config: AxiosRequestConfig, data: unknown, status = 200): AxiosResponse {
|
||||
return { data, status, statusText: status === 204 ? 'No Content' : 'OK', headers:{}, config:config as any, request:{} }
|
||||
}
|
||||
function fail(config:AxiosRequestConfig,data:unknown,status:number):never {
|
||||
const response=ok(config,data,status)
|
||||
throw new AxiosError(typeof data==='object'&&data&&'title' in data?String((data as any).title):`Demo HTTP ${status}`,AxiosError.ERR_BAD_REQUEST,config as any,{},response)
|
||||
}
|
||||
function text(v:unknown){ return String(v ?? '').trim().toLowerCase() }
|
||||
function body(config:AxiosRequestConfig):any {
|
||||
if (typeof config.data === 'string') { try { return JSON.parse(config.data) } catch { return config.data } }
|
||||
return config.data ?? {}
|
||||
}
|
||||
function params(config:AxiosRequestConfig):Record<string,any>{ return (config.params ?? {}) as Record<string,any> }
|
||||
function path(config:AxiosRequestConfig){ return String(config.url ?? '').split('?')[0] }
|
||||
function findLookup(list:any[], codeOrId:string){ return list.find(x=>x.id===codeOrId||x.code.toLowerCase()===codeOrId.toLowerCase()) ?? null }
|
||||
function lookupResponse(list:any[], q:any){
|
||||
const keyword=text(q.query)
|
||||
const filtered=keyword?list.filter(x=>`${x.code} ${x.displayName} ${x.secondaryText??''}`.toLowerCase().includes(keyword)):list
|
||||
return { items:clone(filtered.slice(0,Number(q.pageSize??20))), totalCount:filtered.length }
|
||||
}
|
||||
|
||||
const demoAdapter:AxiosAdapter = async config => {
|
||||
await new Promise(resolve => setTimeout(resolve, 90))
|
||||
const method=String(config.method??'get').toUpperCase(); const url=path(config); const q=params(config); const data=body(config)
|
||||
|
||||
if(method==='GET'&&url==='/api/kbx/runtime/notice') return ok(config,null)
|
||||
if(method==='GET'&&url==='/api/kbx/runtime/operations') return ok(config,clone(operations))
|
||||
if(method==='GET'&&url==='/api/kbx/runtime/notifications') return ok(config,clone(notifications))
|
||||
if(method==='POST'&&/^\/api\/kbx\/runtime\/notifications\/[^/]+\/read$/.test(url)){const id=url.split('/').at(-2)!;const n=notifications.find(x=>x.id===id);if(n)n.readAt=now();return ok(config,undefined,204)}
|
||||
if(method==='POST'&&url==='/api/kbx/ux/events') return ok(config,undefined,204)
|
||||
if(method==='POST'&&url==='/api/common/suggestions') return ok(config,{id:uid(),receivedAt:now()})
|
||||
if(method==='POST'&&url==='/api/common/ai/ask') return ok(config,{answer:'현재 화면의 예외와 선택 상태를 기준으로 확인할 항목을 정리했습니다.',actions:[]})
|
||||
|
||||
if(method==='GET'&&url==='/api/oms/orders'){
|
||||
let rows=[...orderRows]; const keyword=text(q.keyword)
|
||||
if(q.status) rows=rows.filter(x=>x.shipmentStatus===q.status)
|
||||
if(String(q.exceptionOnly)==='true'||q.exceptionOnly===true) rows=rows.filter(x=>x.exceptionCount>0)
|
||||
if(keyword) rows=rows.filter(x=>`${x.orderNo} ${x.customerName} ${x.itemSummary} ${x.channelName}`.toLowerCase().includes(keyword))
|
||||
const counters={all:orderRows.length,new:orderRows.filter(x=>x.shipmentStatus==='NEW').length,readyToShip:orderRows.filter(x=>x.shipmentStatus==='READY').length,exceptions:orderRows.filter(x=>x.exceptionCount>0).length}
|
||||
const pageSize=Number(q.pageSize??100); const page=Math.max(Number(q.page??1),1); const slice=rows.slice((page-1)*pageSize,page*pageSize)
|
||||
return ok(config,{items:clone(slice),totalCount:rows.length,totalQty:rows.reduce((s,x)=>s+x.totalQty,0),totalAmount:rows.reduce((s,x)=>s+x.amount,0),counters})
|
||||
}
|
||||
if(method==='GET'&&/^\/api\/oms\/orders\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;const existing=orderDetails.get(id);if(existing)return ok(config,clone(existing));const row=orderRows.find(x=>x.id===id);if(row)return ok(config,{orderId:id,orderNo:row.orderNo,version:1,status:demoOrderLifecycleFromShipment(row.shipmentStatus),orderDate:'2026-08-11',customerId:customers[0].id,warehouseId:warehouses[0].id,receiverName:row.customerName,phone:'010-0000-0000',address1:'서울특별시',lines:[]});return ok(config,null)}
|
||||
if(method==='GET'&&/^\/api\/oms\/orders\/[^/]+\/audit$/.test(url)){const id=url.split('/').at(-2)!;return ok(config,clone(auditByEntity.get(id)??[]))}
|
||||
if(method==='POST'&&url==='/api/oms/orders/ship'){
|
||||
const ids:Array<string>=data.mode==='ids'?(data.ids??[]):orderRows.filter(row=>row.shipmentStatus==='READY').map(row=>row.id)
|
||||
let accepted=0; for(const id of ids){const row=orderRows.find(x=>x.id===id);if(row&&row.shipmentStatus==='READY'){row.shipmentStatus='CONFIRMED';accepted++}}
|
||||
return ok(config,{requested:ids.length,accepted,rejected:ids.length-accepted})
|
||||
}
|
||||
if(method==='POST'&&url==='/api/oms/orders/register'){
|
||||
const id=uid();const orderNo=`ORD-260811-${String(orderRows.length+1).padStart(4,'0')}`;const detail={...data,orderId:id,orderNo,version:1,status:'DRAFT',lines:(data.lines??[]).map((line:any,index:number)=>{const item=items.find(x=>x.id===line.itemId);return{id:`${id}-${index+1}`,clientId:line.clientId,itemId:line.itemId,itemCode:item?.code??'',itemName:item?.name??'',orderQty:Number(line.orderQty),unitPrice:Number(line.unitPrice),amount:Number(line.orderQty)*Number(line.unitPrice),remark:line.remark??''}})};orderDetails.set(id,detail);orderRows.unshift({id,orderNo,channelName:'직접주문',orderedAt:now(),customerName:data.receiverName,itemSummary:detail.lines[0]?.itemName??'상품 없음',totalQty:detail.lines.reduce((s:number,x:any)=>s+x.orderQty,0),amount:detail.lines.reduce((s:number,x:any)=>s+x.amount,0),allocationStatus:'미확인',shipmentStatus:'NEW',exceptionCount:0});audit(id,'주문 등록');return ok(config,{orderId:id,orderNo,version:1,status:'DRAFT'})
|
||||
}
|
||||
if(method==='PUT'&&/^\/api\/oms\/orders\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;const existing=orderDetails.get(id);const version=Number(existing?.version??0)+1;const next={...(existing??{}),...data,orderId:id,orderNo:existing?.orderNo??`ORD-${id.slice(0,8)}`,version,status:existing?.status??'DRAFT',lines:(data.lines??existing?.lines??[]).map((line:any,index:number)=>{const item=items.find(x=>x.id===line.itemId);return{...line,id:line.id??`${id}-${index+1}`,itemCode:item?.code??line.itemCode??'',itemName:item?.name??line.itemName??'',amount:Number(line.orderQty)*Number(line.unitPrice)}})};orderDetails.set(id,next);audit(id,'주문 수정');return ok(config,{orderId:id,orderNo:next.orderNo,version,status:next.status})}
|
||||
if(method==='POST'&&/^\/api\/oms\/orders\/[^/]+\/confirm$/.test(url)){const id=url.split('/').at(-2)!;const existing=orderDetails.get(id);if(!existing)return fail(config,{type:'not-found',code:'ORDER_NOT_FOUND',title:'주문을 찾을 수 없습니다.'},404);if(existing.status!=='DRAFT')return fail(config,{type:'business-rule',code:'ORDER_NOT_CONFIRMABLE',title:'주문을 확정할 수 없습니다.',detail:'작성 상태의 주문만 확정할 수 있습니다.'},409);existing.version=Number(existing.version)+1;existing.status='CONFIRMED';audit(id,'주문 확정');return ok(config,{orderId:id,orderNo:existing.orderNo,version:existing.version,status:'CONFIRMED'})}
|
||||
|
||||
if(url.startsWith('/api/lookups/customers')){
|
||||
if(method==='GET'&&url==='/api/lookups/customers')return ok(config,lookupResponse(customers,q))
|
||||
const key=decodeURIComponent(url.split('/').at(-1)!);return ok(config,clone(findLookup(customers,key)))
|
||||
}
|
||||
if(url.startsWith('/api/lookups/warehouses')){
|
||||
if(method==='GET'&&url==='/api/lookups/warehouses')return ok(config,lookupResponse(warehouses,q))
|
||||
const key=decodeURIComponent(url.split('/').at(-1)!);return ok(config,clone(findLookup(warehouses,key)))
|
||||
}
|
||||
if(url.startsWith('/api/lookups/items')){
|
||||
const lookupItems=items.map(x=>({id:x.id,code:x.code,displayName:x.name,secondaryText:`${x.specification} · ${x.unit}`,status:x.active?'active':'disabled',metadata:{availableQty:x.code==='ABC001'?8:120}}))
|
||||
if(method==='GET'&&url==='/api/lookups/items')return ok(config,lookupResponse(lookupItems,q))
|
||||
const key=decodeURIComponent(url.split('/').at(-1)!);return ok(config,clone(findLookup(lookupItems,key)))
|
||||
}
|
||||
|
||||
if(method==='GET'&&url==='/api/erp/items'){const keyword=text(q.keyword);const found=keyword?items.filter(x=>`${x.code} ${x.name}`.toLowerCase().includes(keyword)):items;return ok(config,{items:clone(found),totalCount:found.length})}
|
||||
if(method==='GET'&&/^\/api\/erp\/items\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;return ok(config,clone(items.find(x=>x.id===id)??null))}
|
||||
if(method==='GET'&&/^\/api\/erp\/items\/[^/]+\/audit$/.test(url)){const id=url.split('/').at(-2)!;return ok(config,clone(auditByEntity.get(id)??[]))}
|
||||
if(method==='POST'&&url==='/api/erp/items'){const id=uid();const created={id,version:1,active:true,defaultWarehouseName:warehouses.find(x=>x.id===data.defaultWarehouseId)?.displayName??'',...data};items.push(created);audit(id,'품목 등록');return ok(config,{id,version:1,status:'사용'})}
|
||||
if(method==='PUT'&&/^\/api\/erp\/items\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;const target=items.find(x=>x.id===id);if(target){Object.assign(target,data,{version:Number(target.version)+1,defaultWarehouseName:warehouses.find(x=>x.id===data.defaultWarehouseId)?.displayName??target.defaultWarehouseName});audit(id,'품목 수정');return ok(config,{id,version:target.version,status:target.active?'사용':'사용중지'})}return ok(config,{id,version:1,status:'사용'})}
|
||||
if(method==='POST'&&/^\/api\/erp\/items\/[^/]+\/deactivate$/.test(url)){const id=url.split('/').at(-2)!;const target=items.find(x=>x.id===id);if(target){target.active=false;target.version=Number(target.version)+1;audit(id,'품목 사용중지');return ok(config,{id,version:target.version,status:'사용중지'})}return ok(config,{id,version:1,status:'사용중지'})}
|
||||
|
||||
if(method==='GET'&&url==='/api/erp/inventory'){
|
||||
const inv=items.map((x,index)=>({itemId:x.id,itemCode:x.code,itemName:x.name,specification:x.specification,unit:x.unit,totalQty:[1280,721,312,96][index]??0,availableQty:[830,650,290,80][index]??0,allocatedQty:[430,61,12,8][index]??0,holdQty:[20,10,10,8][index]??0}))
|
||||
const keyword=text(q.keyword);const found=keyword?inv.filter(x=>`${x.itemCode} ${x.itemName}`.toLowerCase().includes(keyword)):inv;return ok(config,{items:found,totalCount:found.length})
|
||||
}
|
||||
if(method==='GET'&&/^\/api\/erp\/inventory\/[^/]+\/locations$/.test(url)){return ok(config,{items:[{warehouseCode:'WH01',warehouseName:'서울물류센터',locationCode:'A-01-01',onHandQty:300,availableQty:260},{warehouseCode:'WH01',warehouseName:'서울물류센터',locationCode:'A-01-02',onHandQty:530,availableQty:500}]})}
|
||||
if(method==='GET'&&/^\/api\/erp\/inventory\/[^/]+\/history$/.test(url)){return ok(config,{items:[{occurredAt:'2026-08-11T10:21:00+09:00',businessType:'출고',inQty:0,outQty:2,balanceQty:830,referenceNo:'ORD-260811-0001'},{occurredAt:'2026-08-11T08:40:00+09:00',businessType:'입고',inQty:100,outQty:0,balanceQty:832,referenceNo:'GR-260811-001'}]})}
|
||||
if(method==='POST'&&url==='/api/erp/item-prices/bulk'){return ok(config,{requested:data.rows?.length??0,saved:data.rows?.length??0,created:data.rows?.length??0,updated:0})}
|
||||
|
||||
if(method==='GET'&&url==='/api/operations/work-items'){
|
||||
let rows=[...workItems];const keyword=text(q.keyword)
|
||||
if(q.module)rows=rows.filter(x=>x.sourceModule===q.module)
|
||||
if(q.severity)rows=rows.filter(x=>x.severity===q.severity)
|
||||
if(q.status)rows=rows.filter(x=>x.status===q.status)
|
||||
if(q.code)rows=rows.filter(x=>x.code===q.code)
|
||||
if(q.owner==='mine')rows=rows.filter(x=>x.ownerId==='demo-user')
|
||||
if(q.owner==='unassigned')rows=rows.filter(x=>!x.ownerId)
|
||||
if(keyword)rows=rows.filter(x=>`${x.title} ${x.detail??''} ${x.referenceNo} ${x.sourceId}`.toLowerCase().includes(keyword))
|
||||
const counters=Object.values(workItems.filter(x=>['open','claimed'].includes(x.status)).reduce((acc:any,item:any)=>{const current=acc[item.code]??{key:item.code,label:item.title,count:0,severity:item.severity};current.count++;if(item.severity==='critical')current.severity='critical';else if(item.severity==='warning'&¤t.severity!=='critical')current.severity='warning';acc[item.code]=current;return acc},{}))
|
||||
return ok(config,{items:clone(rows.map(x=>({...x,actions:demoWorkItemActions(x)}))),totalCount:rows.length,counters:clone(counters)})
|
||||
}
|
||||
if(method==='POST'&&url==='/api/operations/work-items/claim'){let claimedCount=0;for(const id of data.ids??[]){const item=workItems.find(x=>x.id===id);if(item&&item.status==='open'){item.status='claimed';item.ownerId='demo-user';item.ownerName='QA 사용자';item.version++;claimedCount++}}return ok(config,{claimedCount,skippedCount:Math.max((data.ids??[]).length-claimedCount,0)})}
|
||||
if(method==='POST'&&url==='/api/operations/work-items/resolve'){let resolvedCount=0;for(const id of data.ids??[]){const item=workItems.find(x=>x.id===id);if(item&&['open','claimed'].includes(item.status)){item.status='resolved';item.version++;resolvedCount++}}return ok(config,{resolvedCount,rejectedCount:Math.max((data.ids??[]).length-resolvedCount,0)})}
|
||||
if(method==='POST'&&/^\/api\/operations\/work-items\/[^/]+\/retry$/.test(url))return ok(config,{accepted:true})
|
||||
if(method==='GET'&&url==='/api/reconcile/items'){
|
||||
const keyword=text(q.keyword);let rows=[...reconcileItems]
|
||||
if(q.reconcileType)rows=rows.filter(x=>x.reconcileType===q.reconcileType)
|
||||
if(q.status)rows=rows.filter(x=>x.status===q.status)
|
||||
if(keyword)rows=rows.filter(x=>`${x.referenceNo} ${x.reasonText??''}`.toLowerCase().includes(keyword))
|
||||
const summaryBase=reconcileItems.filter(x=>(!q.reconcileType||x.reconcileType===q.reconcileType)&&(!keyword||`${x.referenceNo} ${x.reasonText??''}`.toLowerCase().includes(keyword)))
|
||||
const summary={totalCount:summaryBase.length,matchedCount:summaryBase.filter(x=>x.status==='matched').length,mismatchCount:summaryBase.filter(x=>x.status==='mismatch').length,pendingCount:summaryBase.filter(x=>x.status==='pending').length,resolvedCount:summaryBase.filter(x=>x.status==='resolved').length}
|
||||
return ok(config,{items:clone(rows),summary})
|
||||
}
|
||||
if(method==='POST'&&url==='/api/reconcile/items/create-exceptions'){
|
||||
const requested=[...new Set(data.ids??[])].slice(0,500);let createdOrUpdatedCount=0
|
||||
for(const id of requested){const row=reconcileItems.find(x=>x.id===id);if(!row||!['mismatch','pending'].includes(row.status))continue;createdOrUpdatedCount++;const workId=`reconcile-${row.id}`;if(!workItems.some(x=>x.id===workId))workItems.push({id:workId,sourceModule:'COMMON',sourceType:'reconcile',sourceId:row.id,referenceNo:row.referenceNo,sourceScreenId:'COMMON-REC-001',code:'RECONCILE_MISMATCH',title:`대사 불일치 · ${row.referenceNo}`,detail:row.reasonText??'대사 근거 확인 필요',severity:'warning',status:'open',ownerId:null,ownerName:null,occurredAt:row.occurredAt,dueAt:null,ageMinutes:0,version:row.version,context:{reconcileType:row.reconcileType,expectedValue:row.expectedValue,actualValue:row.actualValue,differenceValue:row.differenceValue},retryActionKey:null,allowManualResolution:true})}
|
||||
return ok(config,{createdOrUpdatedCount,skippedCount:requested.length-createdOrUpdatedCount})
|
||||
}
|
||||
|
||||
if(method==='GET'&&url==='/api/oms/claims'){
|
||||
let rows=[...claimRows];const keyword=text(q.keyword)
|
||||
if(q.type)rows=rows.filter(x=>x.type===q.type)
|
||||
if(q.status)rows=rows.filter(x=>x.status===q.status)
|
||||
if(keyword)rows=rows.filter(x=>`${x.claimNo} ${x.orderNo} ${x.reason} ${x.channelName}`.toLowerCase().includes(keyword))
|
||||
return ok(config,{items:clone(rows),totalCount:rows.length})
|
||||
}
|
||||
if(method==='POST'&&/^\/api\/oms\/claims\/[^/]+\/(approve|hold|start|complete)$/.test(url)){
|
||||
const transition=url.split('/').at(-1)!;const id=url.split('/').at(-2)!;const row=claimRows.find(x=>x.id===id)
|
||||
const policy:any={approve:{from:'REQUESTED',to:'APPROVED'},hold:{from:'REQUESTED',to:'HOLD'},start:{from:'APPROVED',to:'IN_PROGRESS'},complete:{from:'IN_PROGRESS',to:'COMPLETED'}}
|
||||
const rule=policy[transition]
|
||||
if(row&&rule&&row.status===rule.from){row.status=rule.to;row.version++;return ok(config,{id:row.id,status:row.status,version:row.version})}
|
||||
return fail(config,{type:'business-rule',code:'CLAIM_TRANSITION_NOT_ALLOWED',title:'현재 상태에서는 요청한 클레임 전이를 실행할 수 없습니다.',detail:`${rule?.from??'허용 상태'} 상태를 확인하세요.`},409)
|
||||
}
|
||||
|
||||
if(method==='GET'&&/^\/api\/wms\/picking\/tasks\/[^/]+$/.test(url))return ok(config,clone(pickingTask))
|
||||
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/start$/.test(url)){pickingTask.status='picking';pickingTask.stage='await-location';pickingTask.message='A-03-02 위치를 스캔하세요.';pickingTask.version++;return ok(config,clone(pickingTask))}
|
||||
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/scan$/.test(url)){
|
||||
const raw=String(data.normalizedValue??data.rawValue??data.barcode??'')
|
||||
if(pickingTask.stage==='await-location'){
|
||||
const accepted=raw===pickingTask.currentLine?.locationCode
|
||||
if(accepted){pickingTask.stage='await-item';pickingTask.message='상품을 스캔하세요.';pickingTask.version++}
|
||||
return ok(config,{accepted,duplicate:false,task:clone(pickingTask),feedback:accepted?'success':'error',message:accepted?'위치를 확인했습니다.':`잘못된 위치입니다. ${pickingTask.currentLine?.locationCode}로 이동하세요.`})
|
||||
}
|
||||
const expected=pickingTask.currentLine?.barcode
|
||||
if(raw===expected&&pickingTask.currentLine){pickingTask.currentLine.pickedQty++;pickingTask.currentLine.remainingQty=Math.max(pickingTask.currentLine.requiredQty-pickingTask.currentLine.pickedQty,0);pickingTask.completedQty++;pickingTask.version++;pickingTask.message=pickingTask.currentLine.remainingQty===0?'현재 상품 피킹을 완료했습니다.':'수량을 반영했습니다.';return ok(config,{accepted:true,duplicate:false,task:clone(pickingTask),feedback:'success',message:pickingTask.message})}
|
||||
return ok(config,{accepted:false,duplicate:false,task:clone(pickingTask),feedback:'error',message:'다른 상품입니다.'})
|
||||
}
|
||||
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/quantity$/.test(url)){if(pickingTask.currentLine){const before=pickingTask.currentLine.pickedQty;pickingTask.currentLine.pickedQty=Number(data.pickedQty??0);pickingTask.currentLine.remainingQty=Math.max(pickingTask.currentLine.requiredQty-pickingTask.currentLine.pickedQty,0);pickingTask.completedQty+=pickingTask.currentLine.pickedQty-before;pickingTask.version++;pickingTask.message='피킹 수량을 변경했습니다.'}return ok(config,{accepted:true,duplicate:false,task:clone(pickingTask),feedback:'success',message:pickingTask.message})}
|
||||
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/exceptions$/.test(url)){pickingTask.status='exception';pickingTask.version++;return ok(config,clone(pickingTask))}
|
||||
|
||||
if(method==='POST'&&url==='/api/imports/sessions'){
|
||||
const id=uid();const mapping=[{sourceColumn:'주문번호',targetField:'orderNo',source:'exact',confidence:1,reason:'정확 일치'},{sourceColumn:'주문일자',targetField:'orderDate',source:'alias',confidence:1,reason:'Alias 일치'},{sourceColumn:'업체코드',targetField:'customerCode',source:'alias',confidence:1,reason:'Alias 일치'},{sourceColumn:'출고창고',targetField:'warehouseCode',source:'alias',confidence:1,reason:'Alias 일치'},{sourceColumn:'받는분',targetField:'receiverName',source:'alias',confidence:1,reason:'Alias 일치'},{sourceColumn:'상품코드',targetField:'itemCode',source:'alias',confidence:1,reason:'Alias 일치'},{sourceColumn:'수량',targetField:'orderQty',source:'alias',confidence:1,reason:'Alias 일치'}];const session={id,importType:'oms.orders.v1',screenId:'OMS-ORD-003',fileName:'demo-orders.xlsx',status:'mapping-required',totalRows:128,validRows:0,invalidRows:0,warningRows:0,createdRows:0,updatedRows:0,progressPercent:10,mapping,sourceColumns:mapping.map(x=>x.sourceColumn),createdAt:now()};imports.set(id,session);operations=[{id:`import:${id}`,type:'excel-import',title:'주문 Excel Import',sourceScreenId:'OMS-ORD-003',status:'running',requestedAt:now(),startedAt:now(),processed:0,total:128,succeeded:0,failed:0},...operations];return ok(config,clone(session))
|
||||
}
|
||||
if(method==='GET'&&/^\/api\/imports\/sessions\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;return ok(config,clone(imports.get(id)??null))}
|
||||
if(method==='PUT'&&/^\/api\/imports\/sessions\/[^/]+\/mapping$/.test(url)){const id=url.split('/').at(-2)!;const s=imports.get(id);if(s){s.mapping=data.mappings??[];s.status='uploaded';s.progressPercent=20}return ok(config,clone(s))}
|
||||
if(method==='POST'&&/^\/api\/imports\/sessions\/[^/]+\/validate$/.test(url)){const id=url.split('/').at(-2)!;const s=imports.get(id);if(s){Object.assign(s,{status:'validated',validRows:125,invalidRows:3,warningRows:2,progressPercent:60,errors:[{rowNumber:8,field:'itemCode',sourceColumn:'상품코드',code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다.',severity:'error'}]})}return ok(config,clone(s))}
|
||||
if(method==='POST'&&/^\/api\/imports\/sessions\/[^/]+\/commit$/.test(url)){const id=url.split('/').at(-2)!;const s=imports.get(id);if(s){Object.assign(s,{status:'partially-completed',createdRows:120,updatedRows:5,progressPercent:100,completedAt:now()});const op=operations.find(x=>x.id===`import:${id}`);if(op)Object.assign(op,{status:'partially-completed',processed:128,succeeded:125,failed:3,completedAt:now(),resultMessage:'125건 반영 / 3건 오류'})}return ok(config,clone(s))}
|
||||
if(method==='POST'&&/^\/api\/imports\/sessions\/[^/]+\/saved-mappings$/.test(url))return ok(config,undefined,204)
|
||||
if(method==='GET'&&/^\/api\/imports\/oms.orders.v1\/template$/.test(url))return ok(config,new Blob(['KBX demo workbook'],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}))
|
||||
if(method==='GET'&&/^\/api\/imports\/sessions\/[^/]+\/errors.xlsx$/.test(url))return ok(config,new Blob(['KBX demo errors'],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}))
|
||||
|
||||
if(method==='GET'&&url==='/api/kbx/external-data/status')return ok(config,{items:[{datasetId:'krx.daily',name:'KRX 일별 시세',provider:'KRX OPEN API',status:'healthy',lastSucceededAt:now(),freshness:{observedAt:now(),source:'KRX'}},{datasetId:'dart.corp',name:'DART 기업정보',provider:'OPENDART',status:'healthy',lastSucceededAt:now(),freshness:{observedAt:now(),source:'DART'}}]})
|
||||
if(method==='POST'&&/^\/api\/kbx\/external-data\/[^/]+\/refresh$/.test(url))return ok(config,{accepted:true})
|
||||
if(method==='GET'&&url==='/api/kbx/ux/metrics')return ok(config,{summary:{taskCompletionRate:0.96,manualInterventionRate:0.0318,validationFailureRate:0.012},screens:[]})
|
||||
if(method==='GET'&&url==='/api/kbx/experiments')return ok(config,{items:[]})
|
||||
if(method==='GET'&&url==='/api/kbx/experiments/assignments')return ok(config,{assignments:[]})
|
||||
|
||||
// Frontend demo mode never fabricates a successful write for an unknown operation.
|
||||
// Returning a visible unsupported response makes missing FE/API coverage explicit during QA.
|
||||
console.warn(`[KBX demo API] unsupported ${method} ${url}`)
|
||||
return ok(config,{ type:'system', code:'DEMO_OPERATION_UNSUPPORTED', title:'이 화면의 Demo API가 아직 준비되지 않았습니다.', detail:`${method} ${url}` },501)
|
||||
}
|
||||
|
||||
export function installFrontendDemoApi() {
|
||||
kbxHttp.defaults.adapter = demoAdapter
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { kbxApiCatalog, type KbxApiOperationDefinition, type KbxApiOperationId, type KbxApiRequestOptions } from '@kbx/contracts'
|
||||
import { kbxHttp } from '../kbxHttpClient'
|
||||
|
||||
function buildPath(template: string, values: Record<string, string | number> = {}) {
|
||||
return template.replace(/\{([^}:]+)(?::[^}]+)?\}/g, (_, name: string) => {
|
||||
const key = Object.keys(values).find(x => x.toLowerCase() === name.toLowerCase())
|
||||
if (!key) throw new Error(`API path parameter '${name}' is required.`)
|
||||
return encodeURIComponent(String(values[key]))
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestKbxApi<TResponse = unknown, TBody = unknown>(
|
||||
operationId: KbxApiOperationId,
|
||||
options: KbxApiRequestOptions<TBody> = {},
|
||||
): Promise<TResponse> {
|
||||
const operation: KbxApiOperationDefinition = kbxApiCatalog[operationId]
|
||||
if (operation.idempotency === 'required' && !options.idempotencyKey) {
|
||||
throw new Error(`Operation '${operationId}' requires a stable idempotency key.`)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { ...(options.headers ?? {}) }
|
||||
if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey
|
||||
|
||||
const response = await kbxHttp.request<TResponse>({
|
||||
method: operation.method,
|
||||
url: buildPath(operation.path, options.path),
|
||||
params: options.query,
|
||||
data: options.body,
|
||||
headers,
|
||||
responseType: options.responseType ?? operation.responseType ?? 'json',
|
||||
validateStatus: status => operation.successStatuses.some(value => value === status),
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const kbxApi = {
|
||||
request: requestKbxApi,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import axios, { AxiosError, type AxiosRequestConfig } from 'axios'
|
||||
import { isKbxProblem, kbxUnexpectedProblem, type KbxProblem, type KbxSystemProblem } from '@kbx/contracts'
|
||||
|
||||
export const kbxHttp = axios.create({
|
||||
timeout: 30_000,
|
||||
headers: { 'X-KBX-Client': 'web' },
|
||||
})
|
||||
|
||||
kbxHttp.interceptors.request.use(config => {
|
||||
config.headers['X-Correlation-Id'] ??= crypto.randomUUID()
|
||||
return config
|
||||
})
|
||||
|
||||
kbxHttp.interceptors.response.use(
|
||||
response => response,
|
||||
(error: AxiosError) => {
|
||||
const correlationId = String(error.config?.headers?.['X-Correlation-Id'] ?? crypto.randomUUID())
|
||||
|
||||
if (!error.response) {
|
||||
const problem: KbxSystemProblem = {
|
||||
type: 'system',
|
||||
code: 'NETWORK_UNAVAILABLE',
|
||||
title: '서버와 연결할 수 없습니다.',
|
||||
detail: '입력한 내용은 유지됩니다. 네트워크 상태를 확인한 후 다시 시도하세요.',
|
||||
correlationId,
|
||||
retryable: true,
|
||||
}
|
||||
return Promise.reject(problem)
|
||||
}
|
||||
|
||||
const data = error.response.data
|
||||
if (isKbxProblem(data)) {
|
||||
const problem: KbxProblem = {
|
||||
...data,
|
||||
correlationId: data.correlationId ?? correlationId,
|
||||
}
|
||||
return Promise.reject(problem)
|
||||
}
|
||||
|
||||
if (error.response.status === 403) {
|
||||
return Promise.reject({
|
||||
type: 'permission', code: 'PERMISSION_DENIED', title: '이 작업을 수행할 권한이 없습니다.', correlationId,
|
||||
} satisfies KbxProblem)
|
||||
}
|
||||
if (error.response.status === 404) {
|
||||
return Promise.reject({
|
||||
type: 'not-found', code: 'RESOURCE_NOT_FOUND', title: '요청한 데이터를 찾을 수 없습니다.', correlationId,
|
||||
} satisfies KbxProblem)
|
||||
}
|
||||
|
||||
return Promise.reject(kbxUnexpectedProblem(correlationId, `HTTP ${error.response.status}`, error.response.status >= 500))
|
||||
},
|
||||
)
|
||||
|
||||
export function withIdempotency(config: AxiosRequestConfig = {}, key = crypto.randomUUID()): AxiosRequestConfig {
|
||||
return {
|
||||
...config,
|
||||
headers: {
|
||||
...(config.headers ?? {}),
|
||||
'Idempotency-Key': key,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ref } from 'vue'
|
||||
import type { KbxConflictSnapshot, KbxProblem, KbxSystemProblem } from '@kbx/contracts'
|
||||
|
||||
export function useKbxProblemHandler() {
|
||||
const conflict = ref<KbxConflictSnapshot | null>(null)
|
||||
const systemProblem = ref<KbxSystemProblem | null>(null)
|
||||
|
||||
function handle(problem: KbxProblem) {
|
||||
if (problem.type === 'conflict') {
|
||||
conflict.value = {
|
||||
code: problem.code,
|
||||
title: problem.title,
|
||||
currentVersion: problem.currentVersion,
|
||||
correlationId: problem.correlationId,
|
||||
}
|
||||
return
|
||||
}
|
||||
if (problem.type === 'system') {
|
||||
systemProblem.value = problem
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
conflict.value = null
|
||||
systemProblem.value = null
|
||||
}
|
||||
|
||||
return { conflict, systemProblem, handle, clear }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as signalR from '@microsoft/signalr'
|
||||
import type { KbxImportProgressEvent } from '@kbx/contracts'
|
||||
import type { KbxImportProgressConnection } from '@kbx/ui'
|
||||
|
||||
export function createImportProgressConnection(url = '/hubs/import-progress'): KbxImportProgressConnection {
|
||||
const hub = new signalR.HubConnectionBuilder()
|
||||
.withUrl(url)
|
||||
.withAutomaticReconnect()
|
||||
.build()
|
||||
|
||||
return {
|
||||
start: () => hub.state === signalR.HubConnectionState.Disconnected ? hub.start() : Promise.resolve(),
|
||||
stop: () => hub.stop(),
|
||||
subscribe: sessionId => hub.invoke('Subscribe', sessionId),
|
||||
unsubscribe: sessionId => hub.state === signalR.HubConnectionState.Connected
|
||||
? hub.invoke('Unsubscribe', sessionId)
|
||||
: Promise.resolve(),
|
||||
onProgress(callback) {
|
||||
hub.on('ImportProgress', (event: KbxImportProgressEvent) => callback(event))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { isKbxProblem, type KbxLookupProvider } from '@kbx/contracts'
|
||||
import { kbxApi } from '../http/generated/kbxApiClient'
|
||||
|
||||
const lookupOperations = {
|
||||
customer: { search: 'lookup.customers.search', byId: 'lookup.customers.resolveById', byCode: 'lookup.customers.resolveByCode' },
|
||||
item: { search: 'lookup.items.search', byId: 'lookup.items.resolveById', byCode: 'lookup.items.resolveByCode' },
|
||||
warehouse: { search: 'lookup.warehouses.search', byId: 'lookup.warehouses.resolveById', byCode: 'lookup.warehouses.resolveByCode' },
|
||||
} as const
|
||||
|
||||
type LookupEntity = keyof typeof lookupOperations
|
||||
|
||||
function provider(entity: LookupEntity): KbxLookupProvider<string> {
|
||||
const ops = lookupOperations[entity]
|
||||
return {
|
||||
search(request) {
|
||||
return kbxApi.request(ops.search, { query: request })
|
||||
},
|
||||
async resolveById(id) {
|
||||
try { return await kbxApi.request(ops.byId, { path: { id } }) }
|
||||
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
|
||||
},
|
||||
async resolveByCode(code) {
|
||||
try { return await kbxApi.request(ops.byCode, { path: { code } }) }
|
||||
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
|
||||
},
|
||||
} as KbxLookupProvider<string>
|
||||
}
|
||||
|
||||
export const lookupRegistry = {
|
||||
customer: provider('customer'),
|
||||
item: provider('item'),
|
||||
warehouse: provider('warehouse'),
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Aura from '@primeuix/themes/aura'
|
||||
import App from './App.vue'
|
||||
import { appRoutes } from './router/appRoutes'
|
||||
import { installKbx } from './app/installKbx'
|
||||
import { installFrontendDemoApi } from './http/demo/installFrontendDemoApi'
|
||||
import './styles/app.css'
|
||||
|
||||
if (import.meta.env.VITE_KBX_DEMO_MODE === 'true') installFrontendDemoApi()
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: appRoutes,
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
})
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, refetchOnWindowFocus: false },
|
||||
mutations: { retry: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(VueQueryPlugin, { queryClient })
|
||||
app.use(PrimeVue, { theme: { preset: Aura, options: { darkModeSelector: '[data-kbx-theme=\"dark\"]' } }, ripple: false })
|
||||
installKbx(app)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
KbxAuditTrail, KbxBulkActionBar, KbxButton, KbxCommandBar, KbxDateField, KbxDateRange,
|
||||
KbxInput, KbxFormGrid, KbxFormSection, KbxFormSpan, KbxMoneyField, KbxNumberField, KbxPageHeader, KbxProposalPanel, KbxQuantityField,
|
||||
KbxSelect, KbxStatus, KbxToast, KbxDataState, KbxTabs, KbxDataGrid, KbxSearchPanel, kbxComponentCatalog, kbxTemplateManifest,
|
||||
} from '@kbx/ui'
|
||||
import type { KbxAiProposal, KbxAuditEntry, KbxCommandDefinition, KbxDensity, KbxGridColumn, KbxSearchField, KbxValidationError } from '@kbx/contracts'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
|
||||
const density=ref<KbxDensity>((new URLSearchParams(location.search).get('density') as KbxDensity) || 'compact')
|
||||
const text=ref('대한상사')
|
||||
const number=ref<number|null>(1200)
|
||||
const money=ref<number|null>(1250000)
|
||||
const qty=ref<number|null>(10)
|
||||
const date=ref('2026-08-08')
|
||||
const from=ref('2026-08-01')
|
||||
const to=ref('2026-08-08')
|
||||
const status=ref('READY')
|
||||
const tab=ref('basic')
|
||||
|
||||
const search=ref<Record<string,unknown>>({keyword:'',status:null,mismatchOnly:false})
|
||||
const rememberSearch=ref(true)
|
||||
const searchFields:KbxSearchField[]=[
|
||||
{key:'keyword',label:'통합검색',type:'text',width:'lg',placeholder:'주문번호/주문자'},
|
||||
{key:'status',label:'상태',type:'select',options:[{value:'READY',label:'출고대기'},{value:'ERROR',label:'오류'}]},
|
||||
{key:'mismatchOnly',label:'오류만 보기',type:'checkbox',primary:false,defaultValue:false},
|
||||
]
|
||||
type DemoRow={id:string;orderNo:string;customerName:string;orderQty:number;amount:number;status:string}
|
||||
const gridRows=ref<DemoRow[]>([
|
||||
{id:'1',orderNo:'TEST-ORD-001',customerName:'대한상사',orderQty:10,amount:128000,status:'READY'},
|
||||
{id:'2',orderNo:'TEST-ORD-002',customerName:'서울유통',orderQty:8,amount:96000,status:'ERROR'},
|
||||
{id:'3',orderNo:'TEST-ORD-003',customerName:'부산물류',orderQty:4,amount:52000,status:'NEW'},
|
||||
])
|
||||
const gridColumns:KbxGridColumn<DemoRow>[]=[
|
||||
{field:'orderNo',header:'주문번호',type:'link',width:150,pinned:'left'},
|
||||
{field:'customerName',header:'거래처',width:160},
|
||||
{field:'orderQty',header:'수량',type:'quantity',width:100,editable:true},
|
||||
{field:'amount',header:'금액',type:'money',width:130},
|
||||
{field:'status',header:'상태',type:'status',width:120,statusMap:{definitions:kbxStatusCatalog.orderShipment}},
|
||||
]
|
||||
const gridErrors:KbxValidationError[]=[{rowKey:'2',field:'orderQty',code:'INSUFFICIENT_STOCK',message:'출고 가능 수량은 6개입니다.'}]
|
||||
|
||||
const fastRows=ref<DemoRow[]>([
|
||||
{id:'F1',orderNo:'TEST-FAST-001',customerName:'대한상사',orderQty:1,amount:12000,status:'NEW'},
|
||||
{id:'F2',orderNo:'TEST-FAST-002',customerName:'대한상사',orderQty:2,amount:24000,status:'NEW'},
|
||||
{id:'F3',orderNo:'TEST-FAST-003',customerName:'대한상사',orderQty:3,amount:36000,status:'NEW'},
|
||||
])
|
||||
let fastSeq=4
|
||||
function addFastRow(){fastRows.value.push({id:`F${fastSeq}`,orderNo:`TEST-FAST-${String(fastSeq++).padStart(3,'0')}`,customerName:'',orderQty:1,amount:0,status:'NEW'})}
|
||||
function duplicateFastRows(rows:DemoRow[]){for(const row of rows)fastRows.value.push({...row,id:`F${fastSeq}`,orderNo:`TEST-FAST-${String(fastSeq++).padStart(3,'0')}`})}
|
||||
|
||||
const commands:KbxCommandDefinition[]=[
|
||||
{id:'search',label:'조회',group:'query',shortcut:'F3'},
|
||||
{id:'save',label:'저장',group:'edit',shortcut:'F8',variant:'primary'},
|
||||
{id:'ship',label:'출고지시',group:'workflow',requiresSelection:true},
|
||||
]
|
||||
const proposal:KbxAiProposal={id:'catalog-proposal',type:'warehouse-change',title:'출고창고 변경 제안',explanation:'가용재고가 있는 창고를 제안한 예시입니다.',capability:'draft',confidence:.87,proposedChanges:[{field:'warehouseId',label:'출고창고',before:'서울센터',after:'인천센터'}],evidence:[{label:'재고 Read Model',sourceType:'domain'}]}
|
||||
const audit:KbxAuditEntry[]=[{id:'1',occurredAt:'2026-08-08 15:32',actor:{type:'user',displayName:'홍길동'},action:'수량 변경',changes:[{field:'qty',label:'출고수량',before:10,after:8}],reason:'고객 요청'}]
|
||||
const groupCounts=computed(()=>Object.fromEntries([...new Set(kbxComponentCatalog.map(x=>x.group))].map(g=>[g,kbxComponentCatalog.filter(x=>x.group===g).length])))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="catalog" :data-kbx-density="density">
|
||||
<KbxPageHeader title="KBX 컴포넌트 카탈로그" breadcrumb="COMMON > KBX" description="Default / Changed / Warning / AI Suggested / Readonly / Disabled / Error / Loading / Empty / Keyboard 상태를 독립적으로 재현합니다." />
|
||||
<nav class="density" aria-label="밀도 선택"><strong>Density</strong><button v-for="d in ['compact','comfortable','touch']" :key="d" type="button" :aria-pressed="density===d" @click="density=d as KbxDensity">{{d}}</button></nav>
|
||||
|
||||
<section class="catalog__summary" aria-label="카탈로그 요약"><span v-for="(count,group) in groupCounts" :key="group"><strong>{{group}}</strong> {{count}}</span></section>
|
||||
|
||||
<section class="catalog__block"><h2>입력</h2><div class="catalog__grid"><div><h3>KbxInput 상태</h3><KbxInput v-model="text" label="거래처명" required/><KbxInput model-value="변경한 값" label="Changed" state="changed" help-text="저장 전 변경값입니다."/><KbxInput model-value="확인 필요" label="Warning" state="warning" warning="업무 규칙을 다시 확인하세요."/><KbxInput model-value="인천센터" label="AI Suggested" state="ai-suggested" help-text="AI 추천값이며 아직 반영되지 않았습니다."/><KbxInput model-value="" label="오류" error="거래처명을 입력하세요."/><KbxInput model-value="읽기전용" label="Readonly" readonly/></div><div><h3>Number / Money / Quantity</h3><KbxNumberField v-model="number" label="일반 숫자" state="changed"/><KbxMoneyField v-model="money" label="단가"/><KbxQuantityField v-model="qty" label="출고수량" :available-quantity="8"/></div><div><h3>Date / Range / Select</h3><KbxDateField v-model="date" label="주문일"/><KbxDateRange v-model:from="from" v-model:to="to" label="주문기간"/><KbxSelect v-model="status" label="상태" :options="[{value:'READY',label:'준비'},{value:'DONE',label:'완료'}]"/><KbxTabs v-model="tab" :items="[{key:'basic',label:'기본'},{key:'audit',label:'변경이력',badge:3}]"/></div></div></section>
|
||||
|
||||
<section class="catalog__block"><h2>Form Composition</h2><p class="catalog__hint">T02/T03 Desktop Form은 KbxFormSection + KbxFormGrid의 2-column을 기본으로 하고 주소·비고처럼 관계상 넓게 보여야 하는 항목만 명시적으로 full span 합니다.</p><KbxFormSection title="기본정보" description="Label 폭과 행/열 간격은 Design Token에서 통제합니다."><KbxFormGrid><KbxInput v-model="text" label="품목명"/><KbxDateField v-model="date" label="적용일"/><KbxFormSpan span="full"><KbxInput model-value="서울특별시 강남구 ..." label="주소" readonly/></KbxFormSpan></KbxFormGrid></KbxFormSection></section>
|
||||
|
||||
<section class="catalog__block"><h2>Search / Grid Runtime</h2><KbxSearchPanel v-model="search" v-model:remember-checked="rememberSearch" :fields="searchFields" remember saved-search/><div class="catalog__grid-demo"><KbxDataGrid :rows="gridRows" :columns="gridColumns" row-key="id" selection="multiple" editable :errors="gridErrors" :changed-cells="[{rowKey:'1',field:'orderQty'}]" :total-count="82415" allow-all-filtered-selection personalization exportable :summary="[{key:'count',label:'조회',kind:'count'},{key:'amount',label:'금액 합계',kind:'sum',field:'amount'}]"/></div></section>
|
||||
|
||||
<section class="catalog__block"><h2>Fast Entry Grid</h2><p class="catalog__hint">행 추가/복제, 선택 행 기준 Fill Down, Excel 다중 셀 붙여넣기 정규화, 오류 탐색을 동일 Grid 계약으로 재현합니다.</p><div class="catalog__grid-demo"><KbxDataGrid :rows="fastRows" :columns="gridColumns" row-key="id" selection="multiple" editable :editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}" @row-add-requested="addFastRow" @row-duplicate-requested="duplicateFastRows"/></div></section>
|
||||
|
||||
<section class="catalog__block"><h2>Loading / Empty / Error</h2><div class="catalog__grid"><KbxDataState state="loading"/><KbxDataState state="empty" title="조회된 주문이 없습니다." detail="조회조건을 변경해 보세요."/><KbxDataState state="error" detail="네트워크 연결을 확인한 후 다시 시도하세요." action-label="다시 조회"/></div></section>
|
||||
|
||||
<section class="catalog__block"><h2>Command / 상태</h2><KbxCommandBar :commands="commands" :selection-count="0"/><KbxBulkActionBar :selection-count="17" :actions="[{id:'ship',label:'출고지시',group:'workflow'},{id:'hold',label:'보류',group:'workflow'}]"/><div class="status-row"><KbxStatus label="작성" semantic="draft"/><KbxStatus label="출고대기" semantic="ready"/><KbxStatus label="진행" semantic="processing"/><KbxStatus label="완료" semantic="completed"/><KbxStatus label="재고부족" semantic="warning"/><KbxStatus label="오류" semantic="error"/><KbxStatus label="정의되지 않음 · NEW_STATE" semantic="warning" unknown raw-value="NEW_STATE"/></div><KbxToast message="저장했습니다."/><KbxToast message="일부 항목을 확인하세요." kind="warning"/></section>
|
||||
|
||||
<section class="catalog__block"><h2>AI / Audit</h2><div class="catalog__grid"><KbxProposalPanel :proposal="proposal"/><KbxAuditTrail :entries="audit"/></div></section>
|
||||
|
||||
<section class="catalog__block"><h2>T01~T09 Template Contract</h2><p class="catalog__hint">Screen Type을 선택하면 필수 Surface·Core Component·Keyboard·완료 점검까지 함께 결정됩니다. 화면별 임의 Layout은 예외입니다.</p><table><thead><tr><th>Type</th><th>Component</th><th>필수 Surface</th><th>Core Components</th><th>완료 점검</th><th>Reference</th></tr></thead><tbody><tr v-for="template in kbxTemplateManifest" :key="template.code"><td><strong>{{template.code}}</strong><br><small>{{template.type}}</small></td><td>{{template.component}}<br><small>{{template.keyboard.join(' · ')}}</small></td><td>{{template.requiredSurfaces.join(' · ')}}</td><td>{{template.coreComponents.join(' · ')}}</td><td>{{template.completionChecks.join(' · ')}}</td><td>{{template.referenceScreens.join(', ')}}</td></tr></tbody></table></section>
|
||||
|
||||
<section class="catalog__block"><h2>검증 계약</h2><table><thead><tr><th>Component</th><th>Group</th><th>States</th><th>Keyboard</th><th>Focus</th></tr></thead><tbody><tr v-for="entry in kbxComponentCatalog" :key="entry.component"><td>{{entry.component}}</td><td>{{entry.group}}</td><td>{{entry.scenarios.map(x=>x.label).join(', ')}}</td><td>{{entry.accessibility.keyboard?'✓':'-'}}</td><td>{{entry.accessibility.focusVisible?'✓':'-'}}</td></tr></tbody></table></section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.catalog{display:grid;gap:var(--kbx-space-4);padding:var(--kbx-space-4);background:var(--kbx-color-surface);color:var(--kbx-color-text)}.density,.catalog__summary,.status-row{display:flex;align-items:center;gap:var(--kbx-space-2);flex-wrap:wrap}.density button{height:var(--kbx-control-height);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface);border-radius:var(--kbx-radius-sm);padding:0 10px}.density button[aria-pressed="true"]{border-color:var(--kbx-color-primary);color:var(--kbx-color-primary);font-weight:600}.catalog__summary span{padding:6px 8px;background:var(--kbx-color-surface-muted);border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm)}.catalog__block{display:grid;gap:var(--kbx-space-3);padding:var(--kbx-space-3);border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm)}h2,h3{margin:0}.catalog__grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:var(--kbx-space-4)}.catalog__grid>div{display:grid;gap:var(--kbx-space-2)}.catalog__grid-demo{height:360px}.catalog__hint{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}table{width:100%;border-collapse:collapse;font-size:var(--kbx-font-sm)}th,td{padding:7px 8px;border-bottom:1px solid var(--kbx-color-border);text-align:left}th{background:var(--kbx-color-surface-muted);font-weight:600}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
|
||||
export const designSystemCatalogScreen = defineKbxScreen({
|
||||
id:'COMMON-DS-001',
|
||||
version:'1.0.0',
|
||||
module:'COMMON',
|
||||
type:'list', templateCode:'T01',
|
||||
title:'KBX 컴포넌트 카탈로그',
|
||||
description:'공통 컴포넌트의 상태·밀도·키보드·접근성 계약을 재현합니다.',
|
||||
permissions:['kbx.design.read'],
|
||||
helpKey:'COMMON-DS-001',
|
||||
telemetry:{enabled:true},
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxListPage, KbxDataGrid, KbxDialog, KbxInput, KbxNumberField, KbxSelect, KbxButton } from '@kbx/ui'
|
||||
import type { KbxExperimentOverviewRow, KbxGridColumn } from '@kbx/contracts'
|
||||
import { experimentsScreen } from './experiments.definition'
|
||||
import { experimentApi } from './experimentApi'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
const selected=ref<KbxExperimentOverviewRow[]>([])
|
||||
const query=useQuery({queryKey:['kbx','experiments'],queryFn:experimentApi.overview})
|
||||
const rows=computed(()=>query.data.value?.items??[])
|
||||
const columns:KbxGridColumn<KbxExperimentOverviewRow>[]=[
|
||||
{field:'experimentId',header:'실험',width:280},{field:'screenId',header:'화면',width:140},{field:'state',header:'상태',type:'status',width:120,statusMap:{definitions:kbxStatusCatalog.experiment}},{field:'rolloutPercent',header:'배포 %',type:'integer',width:90},{field:'decision',header:'판정',width:150},{field:'updatedAt',header:'변경일시',type:'datetime',width:170},
|
||||
]
|
||||
const dialogOpen=ref(false),mode=ref<'rollout'|'rollback'>('rollout'),reason=ref(''),rolloutPercent=ref<number|null>(0),rolloutState=ref<string|null>('running'),error=ref('')
|
||||
function openManage(next:'rollout'|'rollback'){const item=selected.value[0];if(!item)return;mode.value=next;reason.value='';error.value='';rolloutPercent.value=item.rolloutPercent;rolloutState.value=item.state==='paused'?'paused':'running';dialogOpen.value=true}
|
||||
async function apply(){const item=selected.value[0];if(!item)return;if(!reason.value.trim()){error.value='변경 사유를 입력하세요.';return}if(mode.value==='rollout'){const percent=rolloutPercent.value??0;if(percent<0||percent>100){error.value='배포 비율은 0~100 사이여야 합니다.';return}await experimentApi.rollout(item.experimentId,{rolloutPercent:percent,state:(rolloutState.value==='paused'?'paused':'running'),reason:reason.value.trim()})}else await experimentApi.rollback(item.experimentId,{reason:reason.value.trim()});dialogOpen.value=false;selected.value=[];await query.refetch()}
|
||||
async function command(id:string){if(id==='search')await query.refetch();if(id==='rollout')openManage('rollout');if(id==='rollback')openManage('rollback')}
|
||||
</script>
|
||||
<template><KbxListPage :screen="experimentsScreen" :selection-count="selected.length" @command="command">
|
||||
<template #quick-filter><div class="notice"><strong>안전 범위</strong> — Layout·정보강조·기본필터·Navigation·Copy만 실험합니다. 권한·민감정보·Domain Rule·Validation·WMS Scan Rule은 실험하지 않습니다.</div></template>
|
||||
<template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="experimentId" selection="single" :loading="query.isFetching.value" @selection-changed="value=>selected=value" /></template>
|
||||
<template #summary>판정은 운영 Threshold 기반입니다. 통계적 유의성을 자동 주장하지 않으며, Guardrail 위반은 즉시 롤백 대상으로 봅니다.</template>
|
||||
</KbxListPage>
|
||||
<KbxDialog :open="dialogOpen" :title="mode==='rollback'?'UX 변경 즉시 롤백':'점진배포 설정'" size="md" @update:open="dialogOpen=$event">
|
||||
<div class="dialog-fields">
|
||||
<template v-if="mode==='rollout'">
|
||||
<KbxNumberField v-model="rolloutPercent" label="배포 비율" suffix="%" :min="0" :max="100" />
|
||||
<KbxSelect v-model="rolloutState" label="상태" :options="[{value:'running',label:'실행'},{value:'paused',label:'일시중지'}]" />
|
||||
</template>
|
||||
<KbxInput v-model="reason" label="변경 사유" required :maxlength="500" :error="error" placeholder="지표·장애·VOC 등 변경 근거를 입력하세요." @enter="apply" />
|
||||
<p v-if="mode==='rollback'">롤백하면 Kill Switch가 활성화되고 모든 사용자는 즉시 Control UX로 돌아갑니다. 기존 Assignment와 Audit 이력은 유지됩니다.</p>
|
||||
</div>
|
||||
<template #footer><KbxButton label="취소" @click="dialogOpen=false"/><KbxButton :label="mode==='rollback'?'즉시 롤백':'적용'" :variant="mode==='rollback'?'danger':'primary'" @click="apply"/></template>
|
||||
</KbxDialog>
|
||||
</template>
|
||||
<style scoped>.notice{padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.dialog-fields{display:grid;gap:var(--kbx-space-3)}.dialog-fields p{color:var(--kbx-color-text-muted)}</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { KbxExperimentOverviewResponse, KbxExperimentRolloutRequest, KbxExperimentRollbackRequest } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
export const experimentApi={
|
||||
overview:()=>kbxApi.request<KbxExperimentOverviewResponse>('common.experiments.overview'),
|
||||
rollout:(experimentId:string,body:KbxExperimentRolloutRequest)=>kbxApi.request('common.experiments.rollout',{path:{experimentId},body}),
|
||||
rollback:(experimentId:string,body:KbxExperimentRollbackRequest)=>kbxApi.request('common.experiments.rollback',{path:{experimentId},body}),
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const experimentsScreen=defineKbxScreen({
|
||||
id:'COMMON-EXP-001',version:'1.1.0',module:'COMMON',type:'list', templateCode:'T01',title:'UX 실험·점진배포',
|
||||
description:'안전한 UX 변경만 점진 배포하고 가드레일 지표와 즉시 롤백 상태를 확인합니다.',
|
||||
permissions:['common.experiment.read'],helpKey:'COMMON-EXP-001',telemetry:{enabled:true},
|
||||
commands:[{id:'search',label:'조회',group:'query',shortcut:'F3'},{id:'rollout',label:'배포 설정',group:'workflow',permission:'common.experiment.manage',requiresSelection:true,minSelection:1,maxSelection:1},{id:'rollback',label:'즉시 롤백',group:'workflow',variant:'danger',permission:'common.experiment.manage',requiresSelection:true,minSelection:1,maxSelection:1}],
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxListPage, KbxDataGrid } from '@kbx/ui'
|
||||
import type { KbxExternalDataStatusRow, KbxGridColumn } from '@kbx/contracts'
|
||||
import { externalDataScreen } from './external-data.definition'
|
||||
import { externalDataApi } from './externalDataApi'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
const selected=ref<KbxExternalDataStatusRow[]>([])
|
||||
const query=useQuery({queryKey:['kbx','external-data','status'],queryFn:externalDataApi.status})
|
||||
const rows=computed(()=>query.data.value?.items??[])
|
||||
const columns:KbxGridColumn<KbxExternalDataStatusRow>[]=[
|
||||
{field:'sourceLabel',header:'출처',width:110,pinned:'left'},
|
||||
{field:'datasetId',header:'데이터셋',width:320},
|
||||
{field:'state',header:'신선도',type:'status',width:130,statusMap:{definitions:kbxStatusCatalog.externalData}},
|
||||
{field:'cacheEntries',header:'캐시',type:'integer',width:90},
|
||||
{field:'staleEntries',header:'Stale',type:'integer',width:90},
|
||||
{field:'unavailableEntries',header:'사용불가',type:'integer',width:100},
|
||||
{field:'lastReceivedAt',header:'최근 수신',type:'datetime',width:170},
|
||||
{field:'oldestFreshUntil',header:'최초 만료',type:'datetime',width:170},
|
||||
]
|
||||
async function command(id:string){if(id==='search')await query.refetch();if(id==='refresh'){const row=selected.value[0];if(!row)return;await externalDataApi.refresh(row.datasetId);selected.value=[];await query.refetch()}}
|
||||
</script>
|
||||
<template><KbxListPage :screen="externalDataScreen" :selection-count="selected.length" @command="command">
|
||||
<template #quick-filter><div class="notice"><strong>외부 데이터는 업무 원장이 아닙니다.</strong> 화면에는 정규화된 Projection과 출처·신선도를 함께 표시하고, 만료된 데이터를 최신값처럼 사용하지 않습니다.</div></template>
|
||||
<template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="datasetId" selection="single" :loading="query.isFetching.value" @selection-changed="value=>selected=value" /></template>
|
||||
<template #summary>KRX의 승인 서비스별 TTL은 별도 정책 없이는 재수집할 수 없습니다. OPENDART/KIS의 캐시 시간은 KBX 운영정책이며 공급자 공식 호출한도와 구분합니다.</template>
|
||||
</KbxListPage></template>
|
||||
<style scoped>.notice{padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const externalDataScreen=defineKbxScreen({
|
||||
id:'COMMON-DATA-001',version:'1.1.0',module:'COMMON',type:'list', templateCode:'T01',title:'외부 데이터 상태',
|
||||
description:'KRX·OPENDART·KIS 외부 데이터의 출처·수신시각·신선도·캐시 상태를 확인합니다.',
|
||||
permissions:['common.external-data.read'],helpKey:'COMMON-DATA-001',telemetry:{enabled:true},
|
||||
commands:[
|
||||
{id:'search',label:'조회',group:'query',shortcut:'F3'},
|
||||
{id:'refresh',label:'재수집',group:'workflow',permission:'common.external-data.refresh',requiresSelection:true,minSelection:1,maxSelection:1},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { KbxExternalDataStatusResponse } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
export const externalDataApi={
|
||||
status:()=>kbxApi.request<KbxExternalDataStatusResponse>('common.externalData.status'),
|
||||
refresh:(datasetId:string)=>kbxApi.request('common.externalData.refresh',{path:{datasetId}}),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<template><div aria-hidden="true" /></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { KbxRouteNotFound } from '@kbx/ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useKbxWorkspaceStore } from '../../../shell/workspaceStore'
|
||||
const router=useRouter();const store=useKbxWorkspaceStore()
|
||||
function home(){void router.replace('/home')}
|
||||
</script>
|
||||
<template><KbxRouteNotFound @home="home" @menu-search="store.menuSearchOpen=true"/></template>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
KbxButton,
|
||||
KbxDataGrid,
|
||||
KbxExceptionCenter,
|
||||
KbxSearchPanel,
|
||||
KbxWorkQueuePage,
|
||||
useKbxPageShortcuts,
|
||||
type KbxWorkItem,
|
||||
type KbxWorkItemAction,
|
||||
} from '@kbx/ui'
|
||||
import { operationsApi, type OperationsFilter } from './operationsApi'
|
||||
import { kbxTelemetry } from '../../../telemetry/kbxTelemetryClient'
|
||||
import { operationsColumns, operationsQueueScreen, operationsSearchFields } from './operations.definition'
|
||||
|
||||
const searchModel = ref<Record<string, unknown>>({ status: 'open' })
|
||||
const applied = ref<OperationsFilter>({ status: 'open', page: 1, pageSize: 200 })
|
||||
const selectedRows = ref<KbxWorkItem[]>([])
|
||||
const detail = ref<KbxWorkItem | null>(null)
|
||||
const quickCode = ref<string | null>(null)
|
||||
const problem=ref<KbxProblem|null>(null)
|
||||
const actionReceipt=ref<{kind:'claim'|'resolve';requested:number;succeeded:number;skipped:number}|null>(null)
|
||||
const actionReceiptEl=ref<HTMLElement|null>(null)
|
||||
let retryAction:(()=>Promise<void>)|null=null
|
||||
const query = useQuery({
|
||||
queryKey: computed(() => ['operations', 'work-items', applied.value]),
|
||||
queryFn: () => operationsApi.search(applied.value),
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => query.data.value?.items ?? [])
|
||||
const criticalVisible = computed(() => visibleItems.value.filter(item => item.severity === 'critical' && item.status !== 'resolved').length)
|
||||
const unassignedVisible = computed(() => visibleItems.value.filter(item => !item.ownerId && item.status !== 'resolved').length)
|
||||
const agedVisible = computed(() => visibleItems.value.filter(item => item.ageMinutes >= 60 && item.status !== 'resolved').length)
|
||||
const nextUrgent = computed(() => visibleItems.value.find(item => item.severity === 'critical' && item.status !== 'resolved') ?? null)
|
||||
const pageContext=computed(()=>({
|
||||
label:'예외 업무 Queue',
|
||||
hint:'정상 건은 제외하고 해결이 필요한 업무만 표시합니다.',
|
||||
metrics:[
|
||||
{key:'total',label:'대상',value:query.data.value?.totalCount??0},
|
||||
{key:'critical',label:'표시 긴급',value:criticalVisible.value,tone:'danger' as const,emphasis:criticalVisible.value>0},
|
||||
{key:'unassigned',label:'표시 미지정',value:unassignedVisible.value,tone:'warning' as const},
|
||||
{key:'aged',label:'표시 60분+',value:agedVisible.value,tone:'warning' as const},
|
||||
{key:'selected',label:'선택',value:selectedRows.value.length},
|
||||
],
|
||||
}))
|
||||
|
||||
useKbxPageShortcuts([{ key: 'F3', execute: () => search() }])
|
||||
|
||||
async function search() {
|
||||
problem.value=null; retryAction=null
|
||||
selectedRows.value = []
|
||||
actionReceipt.value = null
|
||||
applied.value = {
|
||||
module: asString(searchModel.value.module),
|
||||
severity: asString(searchModel.value.severity),
|
||||
status: asString(searchModel.value.status),
|
||||
owner: asString(searchModel.value.owner),
|
||||
keyword: asString(searchModel.value.keyword),
|
||||
code: quickCode.value,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
}
|
||||
await query.refetch()
|
||||
}
|
||||
|
||||
function asString(value: unknown) { return value == null || value === '' ? null : String(value) }
|
||||
|
||||
async function selectQuickFilter(code: string | null) {
|
||||
quickCode.value = code
|
||||
await search()
|
||||
}
|
||||
|
||||
async function showMine() {
|
||||
searchModel.value = { ...searchModel.value, owner: 'mine', status: searchModel.value.status || 'open' }
|
||||
await search()
|
||||
}
|
||||
|
||||
function openNextUrgent() {
|
||||
if (nextUrgent.value) detail.value = nextUrgent.value
|
||||
}
|
||||
|
||||
async function runProblemAware(action:()=>Promise<void>){
|
||||
problem.value=null; retryAction=null
|
||||
try{await action()}catch(error){if(isKbxProblem(error)){problem.value=error;retryAction=()=>runProblemAware(action);return}throw error}
|
||||
}
|
||||
async function executeCommand(id: string) {
|
||||
const ids = selectedRows.value.map(x => x.id)
|
||||
if (id === 'search') return search()
|
||||
if (id === 'claim' && ids.length) return runProblemAware(async()=>{
|
||||
const result=await operationsApi.claim(ids)
|
||||
actionReceipt.value={kind:'claim',requested:ids.length,succeeded:result.claimedCount,skipped:result.skippedCount}
|
||||
selectedRows.value=[]
|
||||
await query.refetch()
|
||||
await nextTick(); actionReceiptEl.value?.focus({preventScroll:true})
|
||||
})
|
||||
}
|
||||
|
||||
async function executeAction(action: KbxWorkItemAction) {
|
||||
if (!detail.value) return
|
||||
const item=detail.value
|
||||
if(action.kind==='navigate'){window.dispatchEvent(new CustomEvent('kbx:navigate-source',{detail:item}));detail.value=null;return}
|
||||
await runProblemAware(async()=>{
|
||||
if (action.kind === 'claim') {
|
||||
const result=await operationsApi.claim([item.id])
|
||||
actionReceipt.value={kind:'claim',requested:1,succeeded:result.claimedCount,skipped:result.skippedCount}
|
||||
}
|
||||
if (action.kind === 'resolve') {
|
||||
const result=await operationsApi.resolve([item.id])
|
||||
actionReceipt.value={kind:'resolve',requested:1,succeeded:result.resolvedCount,skipped:result.rejectedCount}
|
||||
if(result.resolvedCount>0){
|
||||
kbxTelemetry.track('exception.resolved',{screenId:operationsQueueScreen.id,screenVersion:operationsQueueScreen.version,durationMs:Math.max(0,item.ageMinutes*60000),attributes:{exceptionType:item.code,resolutionType:'manual'}})
|
||||
}
|
||||
}
|
||||
if (action.kind === 'retry') await operationsApi.retry(item.id)
|
||||
detail.value = null
|
||||
await query.refetch()
|
||||
if(actionReceipt.value){await nextTick();actionReceiptEl.value?.focus({preventScroll:true})}
|
||||
})
|
||||
}
|
||||
async function retryProblem(){const retry=retryAction;problem.value=null;retryAction=null;if(retry)await retry()}
|
||||
function dismissProblem(){problem.value=null;retryAction=null}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxWorkQueuePage
|
||||
:screen="operationsQueueScreen"
|
||||
:selection-count="selectedRows.length"
|
||||
:context="pageContext"
|
||||
:content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':query.data.value?.totalCount===0?'empty':'ready'"
|
||||
:refreshing="query.isFetching.value&&Boolean(query.data.value)"
|
||||
:problem="problem"
|
||||
queue-title="예외 업무 Queue"
|
||||
queue-description="긴급도·담당자·경과시간을 먼저 확인하고 상세 Drawer에서 원인과 다음 행동을 처리합니다."
|
||||
:queue-count="query.data.value?.totalCount ?? 0"
|
||||
empty-title="처리할 예외 업무가 없습니다."
|
||||
empty-detail="현재 조건에는 사람이 확인해야 할 예외가 없습니다. 조건을 바꾸거나 전체 미처리 업무를 조회하세요."
|
||||
@command="executeCommand"
|
||||
@retry-problem="retryProblem"
|
||||
@dismiss-problem="dismissProblem"
|
||||
>
|
||||
<template #search>
|
||||
<KbxSearchPanel v-model="searchModel" :fields="operationsSearchFields" @search="search" />
|
||||
</template>
|
||||
|
||||
<template #queue-summary>
|
||||
<span class="queue-hint">정상 건은 표시하지 않습니다. 중요도와 경과시간이 높은 예외부터 처리합니다.</span>
|
||||
</template>
|
||||
|
||||
<template #queue-actions>
|
||||
<KbxButton label="내 업무" variant="secondary" @click="showMine" />
|
||||
<KbxButton label="다음 긴급건" variant="secondary" :disabled="!nextUrgent" @click="openNextUrgent" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<section v-if="actionReceipt" ref="actionReceiptEl" class="bulk-receipt" :data-result="actionReceipt.skipped>0?'partial':'success'" role="status" aria-live="polite" tabindex="-1" data-kbx-surface="authoritative-action-receipt">
|
||||
<div>
|
||||
<strong>{{ actionReceipt.kind==='claim'
|
||||
? (actionReceipt.skipped>0 ? '담당 지정이 일부 처리되었습니다.' : '담당 지정을 완료했습니다.')
|
||||
: (actionReceipt.skipped>0 ? '해결 처리가 완료되지 않았습니다.' : '해결 처리를 완료했습니다.') }}</strong>
|
||||
<span v-if="actionReceipt.kind==='claim'">요청 {{ actionReceipt.requested.toLocaleString('ko-KR') }}건 · 내 업무 전환 {{ actionReceipt.succeeded.toLocaleString('ko-KR') }}건 · 건너뜀 {{ actionReceipt.skipped.toLocaleString('ko-KR') }}건</span>
|
||||
<span v-else>요청 {{ actionReceipt.requested.toLocaleString('ko-KR') }}건 · 해결 {{ actionReceipt.succeeded.toLocaleString('ko-KR') }}건 · 거절 {{ actionReceipt.skipped.toLocaleString('ko-KR') }}건</span>
|
||||
<small v-if="actionReceipt.skipped>0">HTTP 성공 여부가 아니라 서버 업무 결과를 기준으로 표시합니다. 최신 상태를 다시 조회해 다음 행동을 결정하세요.</small>
|
||||
</div>
|
||||
<div class="bulk-receipt__actions">
|
||||
<KbxButton v-if="actionReceipt.kind==='claim' && actionReceipt.succeeded>0" label="내 업무 보기" variant="secondary" @click="showMine" />
|
||||
<KbxButton v-if="actionReceipt.skipped>0" label="최신 상태 조회" variant="secondary" @click="search" />
|
||||
<KbxButton label="결과 닫기" variant="ghost" @click="actionReceipt=null" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<KbxExceptionCenter
|
||||
:counters="query.data.value?.counters ?? []"
|
||||
:active-key="quickCode"
|
||||
:selected-item="detail"
|
||||
@filter="selectQuickFilter"
|
||||
@close-detail="detail = null"
|
||||
@action="executeAction"
|
||||
>
|
||||
<KbxDataGrid
|
||||
:rows="query.data.value?.items ?? []"
|
||||
:columns="operationsColumns"
|
||||
row-key="id"
|
||||
selection="multiple"
|
||||
:loading="query.isFetching.value"
|
||||
@selection-changed="selectedRows = $event"
|
||||
@row-double-clicked="detail = $event"
|
||||
/>
|
||||
</KbxExceptionCenter>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
전체 {{ query.data.value?.totalCount ?? 0 }}건 · 선택 {{ selectedRows.length }}건
|
||||
</template>
|
||||
|
||||
</KbxWorkQueuePage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulk-receipt{display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);margin-bottom:var(--kbx-space-3);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-success-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-success-border);background:var(--kbx-color-success-surface);font-size:var(--kbx-font-sm)}
|
||||
.bulk-receipt[data-result="partial"]{border-color:var(--kbx-color-warning-border);border-left-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}
|
||||
.bulk-receipt>div:first-child{display:grid;gap:var(--kbx-space-1)}.bulk-receipt span,.bulk-receipt small{color:var(--kbx-color-text-muted)}.bulk-receipt__actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:var(--kbx-space-2)}
|
||||
@media(max-width:60rem){.bulk-receipt{align-items:stretch;flex-direction:column}.bulk-receipt__actions{justify-content:flex-start}}
|
||||
@media(forced-colors:active){.bulk-receipt,.bulk-receipt[data-result="partial"]{border-color:CanvasText;border-left-color:Highlight}}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField, type KbxWorkItem } from '@kbx/ui'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
|
||||
export const operationsQueueScreen = defineKbxScreen({
|
||||
id: 'COMMON-OPS-001',
|
||||
version: '1.3.0',
|
||||
module: 'COMMON',
|
||||
type: 'queue', templateCode:'T06',
|
||||
title: '업무 예외 센터',
|
||||
description: '정상 건이 아니라 사람이 판단하거나 복구해야 할 업무만 모아 처리합니다.',
|
||||
permissions: ['common.operations.read'],
|
||||
helpKey: 'COMMON-OPS-001',
|
||||
telemetry: { enabled: true },
|
||||
commands: [
|
||||
{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' },
|
||||
{ id: 'claim', label: '내가 처리', group: 'workflow', requiresSelection: true, minSelection: 1, permission: 'common.operations.claim' },
|
||||
{ id: 'excel', label: '엑셀', group: 'output' },
|
||||
],
|
||||
})
|
||||
|
||||
export const operationsSearchFields: KbxSearchField[] = [
|
||||
{ key: 'module', label: '모듈', type: 'select', primary: true, options: [
|
||||
{ value: 'OMS', label: 'OMS' }, { value: 'WMS', label: 'WMS' }, { value: 'ERP', label: 'ERP' },
|
||||
] },
|
||||
{ key: 'severity', label: '심각도', type: 'select', primary: true, options: [
|
||||
{ value: 'critical', label: '긴급' }, { value: 'warning', label: '주의' }, { value: 'info', label: '정보' },
|
||||
] },
|
||||
{ key: 'status', label: '상태', type: 'select', primary: true, options: [
|
||||
{ value: 'open', label: '미처리' }, { value: 'claimed', label: '처리중' }, { value: 'resolved', label: '해결' },
|
||||
] },
|
||||
{ key: 'owner', label: '담당', type: 'select', primary: false, options: [
|
||||
{ value: 'mine', label: '내 업무' }, { value: 'unassigned', label: '미지정' },
|
||||
] },
|
||||
{ key: 'keyword', label: '검색', type: 'text', primary: true, width: 'lg', placeholder: '주문번호, 작업번호, 오류명' },
|
||||
]
|
||||
|
||||
export const operationsColumns: KbxGridColumn<KbxWorkItem>[] = [
|
||||
{ field: 'severity', header: '중요도', type: 'status', width: 92, pinned: 'left', statusMap:{definitions:kbxStatusCatalog.workSeverity} },
|
||||
{ field: 'sourceModule', header: '모듈', width: 76 },
|
||||
{ field: 'title', header: '확인할 업무', width: 250, pinned: 'left' },
|
||||
{ field: 'referenceNo', header: '대상번호', type: 'code', width: 150 },
|
||||
{ field: 'detail', header: '원인/안내', width: 300 },
|
||||
{ field: 'ownerName', header: '담당자', width: 100 },
|
||||
{ field: 'occurredAt', header: '발생시각', type: 'datetime', width: 160 },
|
||||
{ field: 'ageMinutes', header: '경과(분)', type: 'integer', width: 90 },
|
||||
{ field: 'status', header: '처리상태', type: 'status', width: 110, statusMap:{definitions:kbxStatusCatalog.workItem} },
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { KbxWorkQueueResult } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface ClaimWorkItemsResponse {
|
||||
claimedCount: number
|
||||
skippedCount: number
|
||||
}
|
||||
|
||||
export interface ResolveWorkItemsResponse {
|
||||
resolvedCount: number
|
||||
rejectedCount: number
|
||||
}
|
||||
|
||||
export interface OperationsFilter {
|
||||
module?: string | null
|
||||
severity?: string | null
|
||||
status?: string | null
|
||||
owner?: string | null
|
||||
code?: string | null
|
||||
keyword?: string | null
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export const operationsApi = {
|
||||
search(filter: OperationsFilter) {
|
||||
return kbxApi.request<KbxWorkQueueResult>('common.operations.search', { query: filter })
|
||||
},
|
||||
claim(ids: string[]) {
|
||||
return kbxApi.request<ClaimWorkItemsResponse, { ids: string[] }>('common.operations.claim', { body: { ids } })
|
||||
},
|
||||
resolve(ids: string[], reason = '사용자 확인 완료') {
|
||||
return kbxApi.request<ResolveWorkItemsResponse, { ids: string[]; reason: string }>('common.operations.resolve', { body: { ids, reason } })
|
||||
},
|
||||
retry(id: string) {
|
||||
return kbxApi.request<unknown>('common.operations.retry', { path: { id } })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const operationsRoutes = [
|
||||
{ path: '/operations/exceptions', name: 'common-operations-exceptions', component: () => import('./OperationsQueuePage.vue') },
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, ref } from 'vue'
|
||||
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
KbxButton,
|
||||
KbxDataGrid,
|
||||
KbxDrawer,
|
||||
KbxPermissionHostKey,
|
||||
KbxQuickFilterBar,
|
||||
KbxReconcilePage,
|
||||
KbxSearchPanel,
|
||||
useKbxPageShortcuts,
|
||||
type KbxReconcileItem,
|
||||
} from '@kbx/ui'
|
||||
import { reconcileApi, type ReconcileFilter } from './reconcileApi'
|
||||
import { reconcileColumns, reconcileScreen, reconcileSearchFields } from './reconcile.definition'
|
||||
|
||||
const searchModel = ref<Record<string, unknown>>({ status: 'mismatch' })
|
||||
const applied = ref<ReconcileFilter>({ status: 'mismatch', page: 1, pageSize: 200 })
|
||||
const selectedRows = ref<KbxReconcileItem[]>([])
|
||||
const detail = ref<KbxReconcileItem | null>(null)
|
||||
const problem=ref<KbxProblem|null>(null)
|
||||
const exceptionReceipt=ref<{requested:number;created:number;skipped:number}|null>(null)
|
||||
const exceptionReceiptEl=ref<HTMLElement|null>(null)
|
||||
const router=useRouter()
|
||||
let retryAction:(()=>Promise<void>)|null=null
|
||||
const permissionHost=inject(KbxPermissionHostKey,null)
|
||||
const canCreateException=computed(()=>permissionHost?.has('common.operations.create')??true)
|
||||
const query = useQuery({ queryKey: computed(() => ['reconcile', applied.value]), queryFn: () => reconcileApi.search(applied.value) })
|
||||
const pageContext=computed(()=>({label:'대사 결과',hint:'원천값을 직접 수정하지 않고 불일치는 해결 업무로 전환합니다.',metrics:[{key:'mismatch',label:'불일치',value:query.data.value?.summary.mismatchCount??0,tone:'danger' as const,emphasis:true},{key:'selected',label:'선택',value:selectedRows.value.length}]}))
|
||||
const quickFilters=computed(()=>{
|
||||
const summary=query.data.value?.summary
|
||||
const active=String(searchModel.value.status??'')
|
||||
return [
|
||||
{key:'all',label:'전체',count:summary?.totalCount??0,active:active===''},
|
||||
{key:'mismatch',label:'불일치',count:summary?.mismatchCount??0,active:active==='mismatch',tone:'danger' as const},
|
||||
{key:'pending',label:'확인중',count:summary?.pendingCount??0,active:active==='pending',tone:'warning' as const},
|
||||
{key:'resolved',label:'해결',count:summary?.resolvedCount??0,active:active==='resolved'},
|
||||
]
|
||||
})
|
||||
|
||||
useKbxPageShortcuts([{ key: 'F3', execute: () => search() }])
|
||||
|
||||
function stringOrNull(value: unknown) { return value == null || value === '' ? null : String(value) }
|
||||
async function search() {
|
||||
problem.value=null; retryAction=null
|
||||
selectedRows.value = []
|
||||
detail.value = null
|
||||
exceptionReceipt.value = null
|
||||
applied.value = {
|
||||
reconcileType: stringOrNull(searchModel.value.reconcileType),
|
||||
status: stringOrNull(searchModel.value.status),
|
||||
keyword: stringOrNull(searchModel.value.keyword),
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
}
|
||||
await query.refetch()
|
||||
}
|
||||
async function selectResultFilter(key:string){
|
||||
searchModel.value={...searchModel.value,status:key==='all'?null:key}
|
||||
await search()
|
||||
}
|
||||
async function runProblemAware(action:()=>Promise<void>){problem.value=null;retryAction=null;try{await action()}catch(error){if(isKbxProblem(error)){problem.value=error;retryAction=()=>runProblemAware(action);return}throw error}}
|
||||
async function createExceptions(ids:string[]){
|
||||
if(!ids.length)return
|
||||
return runProblemAware(async()=>{
|
||||
const result=await reconcileApi.createExceptions(ids)
|
||||
exceptionReceipt.value={requested:ids.length,created:result.createdOrUpdatedCount,skipped:result.skippedCount}
|
||||
selectedRows.value=[]
|
||||
detail.value=null
|
||||
await query.refetch()
|
||||
await nextTick();exceptionReceiptEl.value?.focus({preventScroll:true})
|
||||
})
|
||||
}
|
||||
async function command(id: string) {
|
||||
if (id === 'search') return search()
|
||||
if (id === 'createException' && selectedRows.value.length) return createExceptions(selectedRows.value.map(x=>x.id))
|
||||
}
|
||||
async function createDetailException(){if(detail.value&&canCreateException.value)await createExceptions([detail.value.id])}
|
||||
async function retryProblem(){const retry=retryAction;problem.value=null;retryAction=null;if(retry)await retry()}
|
||||
function dismissProblem(){problem.value=null;retryAction=null}
|
||||
async function openExceptionCenter(){await router.push('/operations/exceptions')}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxReconcilePage
|
||||
:screen="reconcileScreen"
|
||||
:summary="query.data.value?.summary"
|
||||
:selection-count="selectedRows.length"
|
||||
:context="pageContext"
|
||||
:content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':query.data.value?.summary.totalCount===0?'empty':'ready'"
|
||||
:refreshing="query.isFetching.value&&Boolean(query.data.value)"
|
||||
:problem="problem"
|
||||
comparison-title="OMS ↔ WMS 대사"
|
||||
comparison-description="Expected · Actual · Difference · Reason · Resolution 순서로 불일치 근거를 확인합니다. 행을 두 번 클릭하면 근거를 유지한 채 상세를 확인합니다."
|
||||
:comparison-count="query.data.value?.summary.totalCount ?? 0"
|
||||
@command="command"
|
||||
@retry-problem="retryProblem"
|
||||
@dismiss-problem="dismissProblem"
|
||||
>
|
||||
<template #search><KbxSearchPanel v-model="searchModel" :fields="reconcileSearchFields" @search="search" /></template>
|
||||
<template #filters><KbxQuickFilterBar :items="quickFilters" aria-label="대사 결과 빠른 필터" @select="selectResultFilter" /></template>
|
||||
<template #comparison-actions><KbxButton label="선택건 예외 등록" variant="secondary" :disabled="selectedRows.length===0 || !canCreateException" :title="!canCreateException?'예외 등록 권한이 없습니다.':undefined" @click="command('createException')" /></template>
|
||||
<template #content>
|
||||
<section v-if="exceptionReceipt" ref="exceptionReceiptEl" class="reconcile-receipt" :data-result="exceptionReceipt.skipped>0?'partial':'success'" role="status" aria-live="polite" tabindex="-1" data-kbx-surface="reconcile-authoritative-receipt">
|
||||
<div>
|
||||
<strong>{{ exceptionReceipt.skipped>0 ? '예외 업무 등록이 일부 처리되었습니다.' : '예외 업무 등록을 완료했습니다.' }}</strong>
|
||||
<span>요청 {{ exceptionReceipt.requested.toLocaleString('ko-KR') }}건 · 생성/갱신 {{ exceptionReceipt.created.toLocaleString('ko-KR') }}건 · 건너뜀 {{ exceptionReceipt.skipped.toLocaleString('ko-KR') }}건</span>
|
||||
<small v-if="exceptionReceipt.skipped>0">건너뜀은 실행 시점에 더 이상 불일치/확인중 상태가 아니었던 건입니다. 서버의 최신 대사 상태를 기준으로 다음 행동을 결정하세요.</small>
|
||||
</div>
|
||||
<div class="reconcile-receipt__actions">
|
||||
<KbxButton v-if="exceptionReceipt.created>0" label="예외 센터 보기" variant="secondary" @click="openExceptionCenter" />
|
||||
<KbxButton v-if="exceptionReceipt.skipped>0" label="최신 대사 조회" variant="secondary" @click="search" />
|
||||
<KbxButton label="결과 닫기" variant="ghost" @click="exceptionReceipt=null" />
|
||||
</div>
|
||||
</section>
|
||||
<KbxDataGrid
|
||||
:rows="query.data.value?.items ?? []"
|
||||
:columns="reconcileColumns"
|
||||
row-key="id"
|
||||
selection="multiple"
|
||||
:loading="query.isFetching.value"
|
||||
@selection-changed="selectedRows = $event"
|
||||
@row-double-clicked="detail = $event"
|
||||
/>
|
||||
</template>
|
||||
<template #detail>
|
||||
<KbxDrawer :open="Boolean(detail)" :title="detail ? `대사 근거 · ${detail.referenceNo}` : '대사 근거'" @update:open="value=>{if(!value)detail=null}">
|
||||
<article v-if="detail" class="reconcile-detail" :data-status="detail.status">
|
||||
<header>
|
||||
<span>{{ detail.reconcileType }}</span>
|
||||
<strong>{{ detail.status==='mismatch'?'불일치':detail.status==='pending'?'확인중':detail.status==='resolved'?'해결':'정상' }}</strong>
|
||||
</header>
|
||||
<section class="reconcile-detail__comparison" aria-label="대사 비교 근거">
|
||||
<div><span>{{ detail.sourceLabel }}</span><small>Expected</small><strong>{{ detail.expectedValue }}</strong></div>
|
||||
<div class="difference"><span>Difference</span><small>차이</small><strong>{{ detail.differenceValue ?? '-' }}</strong></div>
|
||||
<div><span>{{ detail.targetLabel }}</span><small>Actual</small><strong>{{ detail.actualValue }}</strong></div>
|
||||
</section>
|
||||
<dl>
|
||||
<div><dt>참조번호</dt><dd>{{ detail.referenceNo }}</dd></div>
|
||||
<div><dt>원인</dt><dd>{{ detail.reasonText || '원인 미확인' }}</dd></div>
|
||||
<div><dt>확인시각</dt><dd>{{ detail.occurredAt }}</dd></div>
|
||||
<div><dt>처리상태</dt><dd>{{ detail.status }}</dd></div>
|
||||
</dl>
|
||||
<p class="reconcile-detail__policy">원천값을 화면에서 직접 덮어쓰지 않습니다. 불일치는 예외 업무로 등록한 뒤 담당자가 근거와 업무 규칙을 확인합니다.</p>
|
||||
</article>
|
||||
<template #footer>
|
||||
<div class="reconcile-detail__actions">
|
||||
<KbxButton label="닫기" variant="secondary" @click="detail=null" />
|
||||
<KbxButton v-if="detail && ['mismatch','pending'].includes(detail.status)" label="이 건 예외 등록" variant="primary" :disabled="!canCreateException" :title="!canCreateException?'예외 등록 권한이 없습니다.':undefined" @click="createDetailException" />
|
||||
</div>
|
||||
</template>
|
||||
</KbxDrawer>
|
||||
</template>
|
||||
<template #footer>불일치 건은 원천 데이터를 직접 수정하지 않고 예외 업무로 전환해 담당자가 원인을 확인합니다.</template>
|
||||
</KbxReconcilePage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reconcile-receipt{display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);margin-bottom:var(--kbx-space-3);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-success-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-success-border);background:var(--kbx-color-success-surface);font-size:var(--kbx-font-sm)}.reconcile-receipt[data-result="partial"]{border-color:var(--kbx-color-warning-border);border-left-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.reconcile-receipt>div:first-child{display:grid;gap:var(--kbx-space-1)}.reconcile-receipt span,.reconcile-receipt small{color:var(--kbx-color-text-muted)}.reconcile-receipt__actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:var(--kbx-space-2)}
|
||||
.reconcile-detail{display:grid;gap:var(--kbx-space-4)}.reconcile-detail>header{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)}.reconcile-detail[data-status="mismatch"]>header{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}.reconcile-detail__comparison{display:grid;grid-template-columns:minmax(0,1fr) minmax(var(--kbx-summary-item-min-width),.7fr) minmax(0,1fr);border:var(--kbx-border-width) solid var(--kbx-color-border)}.reconcile-detail__comparison>div{display:grid;gap:var(--kbx-space-1);padding:var(--kbx-space-3);border-right:var(--kbx-border-width) solid var(--kbx-color-border)}.reconcile-detail__comparison>div:last-child{border-right:0}.reconcile-detail__comparison span,.reconcile-detail__comparison small{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.reconcile-detail__comparison strong{font-size:var(--kbx-font-xl)}.reconcile-detail__comparison .difference strong{color:var(--kbx-color-danger)}.reconcile-detail dl{display:grid;gap:var(--kbx-space-2);margin:0}.reconcile-detail dl div{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2)}.reconcile-detail dt{color:var(--kbx-color-text-muted)}.reconcile-detail dd{margin:0}.reconcile-detail__policy{margin:0;padding:var(--kbx-space-3);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}.reconcile-detail__actions{display:flex;justify-content:flex-end;gap:var(--kbx-space-2)}
|
||||
@media(max-width:60rem){.reconcile-receipt{align-items:stretch;flex-direction:column}.reconcile-receipt__actions{justify-content:flex-start}}
|
||||
@media(max-width:40rem){.reconcile-detail__comparison{grid-template-columns:1fr}.reconcile-detail__comparison>div{border-right:0;border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.reconcile-detail__comparison>div:last-child{border-bottom:0}}
|
||||
@media(forced-colors:active){.reconcile-receipt,.reconcile-receipt[data-result="partial"]{border-color:CanvasText;border-left-color:Highlight}}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxReconcileItem, type KbxSearchField } from '@kbx/ui'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
|
||||
export const reconcileScreen = defineKbxScreen({
|
||||
id: 'COMMON-REC-001',
|
||||
version: '1.3.0',
|
||||
module: 'COMMON',
|
||||
type: 'reconcile', templateCode:'T07',
|
||||
title: '업무 데이터 대사',
|
||||
description: '원천·대상 시스템의 기대값과 실제값을 비교하고 불일치 원인을 추적합니다.',
|
||||
permissions: ['common.reconcile.read'],
|
||||
helpKey: 'COMMON-REC-001',
|
||||
telemetry: { enabled: true },
|
||||
commands: [
|
||||
{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' },
|
||||
{ id: 'createException', label: '예외 등록', group: 'workflow', requiresSelection: true, minSelection: 1, permission: 'common.operations.create' },
|
||||
{ id: 'excel', label: '엑셀', group: 'output' },
|
||||
],
|
||||
})
|
||||
|
||||
export const reconcileSearchFields: KbxSearchField[] = [
|
||||
{ key: 'reconcileType', label: '대사유형', type: 'select', primary: true, options: [
|
||||
{ value: 'OMS_WMS_OUTBOUND_QTY', label: 'OMS↔WMS 출고수량' },
|
||||
{ value: 'ORDER_PICK_QTY', label: '주문↔피킹수량' },
|
||||
{ value: 'INVENTORY_SNAPSHOT', label: '재고 스냅샷' },
|
||||
] },
|
||||
{ key: 'status', label: '결과', type: 'select', primary: true, options: [
|
||||
{ value: 'mismatch', label: '불일치' }, { value: 'pending', label: '확인중' }, { value: 'matched', label: '정상' }, { value: 'resolved', label: '해결' },
|
||||
] },
|
||||
{ key: 'keyword', label: '검색', type: 'text', primary: true, width: 'lg', placeholder: '주문번호, 품목, 참조번호' },
|
||||
]
|
||||
|
||||
export const reconcileColumns: KbxGridColumn<KbxReconcileItem>[] = [
|
||||
{ field: 'referenceNo', header: '참조번호', type: 'code', width: 160, pinned: 'left' },
|
||||
{ field: 'sourceLabel', header: '기준', width: 130 },
|
||||
{ field: 'expectedValue', header: '기대값', width: 130 },
|
||||
{ field: 'targetLabel', header: '비교대상', width: 130 },
|
||||
{ field: 'actualValue', header: '실제값', width: 130 },
|
||||
{ field: 'differenceValue', header: '차이', width: 110 },
|
||||
{ field: 'reasonText', header: '원인', width: 280 },
|
||||
{ field: 'status', header: '상태', type: 'status', width: 110, statusMap:{definitions:kbxStatusCatalog.reconcile} },
|
||||
{ field: 'occurredAt', header: '확인시각', type: 'datetime', width: 160 },
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { KbxReconcileResult } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface CreateReconcileExceptionsResponse {
|
||||
createdOrUpdatedCount: number
|
||||
skippedCount: number
|
||||
}
|
||||
|
||||
export interface ReconcileFilter {
|
||||
reconcileType?: string | null
|
||||
status?: string | null
|
||||
keyword?: string | null
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export const reconcileApi = {
|
||||
search(filter: ReconcileFilter) {
|
||||
return kbxApi.request<KbxReconcileResult>('common.reconcile.search', { query: filter })
|
||||
},
|
||||
createExceptions(ids: string[]) {
|
||||
return kbxApi.request<CreateReconcileExceptionsResponse, { ids: string[] }>('common.reconcile.createExceptions', { body: { ids } })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const reconcileRoutes = [
|
||||
{ path: '/operations/reconcile', name: 'common-reconcile', component: () => import('./ReconcilePage.vue') },
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxListPage, KbxSearchPanel, KbxDataGrid } from '@kbx/ui'
|
||||
import type { KbxGridColumn, KbxSearchField, KbxUxMetricRow } from '@kbx/contracts'
|
||||
import { uxMetricsScreen } from './ux-metrics.definition'
|
||||
import { uxMetricsApi } from './uxMetricsApi'
|
||||
const today=new Date();const fromDate=new Date(today);fromDate.setDate(today.getDate()-6)
|
||||
const iso=(d:Date)=>d.toISOString().slice(0,10)
|
||||
const search=ref({from:iso(fromDate),to:iso(today),screenId:''})
|
||||
const fields:KbxSearchField[]=[{key:'from',label:'시작일',type:'date',primary:true},{key:'to',label:'종료일',type:'date',primary:true},{key:'screenId',label:'화면코드',type:'text',primary:true,width:'md'}]
|
||||
const query=useQuery({queryKey:['kbx','ux-metrics',search],queryFn:()=>uxMetricsApi.get({...search.value,screenId:search.value.screenId||undefined}),enabled:false})
|
||||
const columns:KbxGridColumn<KbxUxMetricRow>[]=[
|
||||
{field:'label',header:'지표',width:240},{field:'value',header:'현재값',type:'decimal',width:140},{field:'unit',header:'단위',width:100},{field:'sampleCount',header:'표본',type:'integer',width:110},{field:'p50',header:'P50',type:'decimal',width:110},{field:'p95',header:'P95',type:'decimal',width:110},
|
||||
]
|
||||
async function command(id:string){if(id==='search')await query.refetch()}
|
||||
</script>
|
||||
<template>
|
||||
<KbxListPage :screen="uxMetricsScreen" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':!query.data.value?'idle':query.data.value.metrics.length===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" @command="command">
|
||||
<template #search><KbxSearchPanel v-model="search" :fields="fields" @search="query.refetch()" /></template>
|
||||
<template #quick-filter>
|
||||
<div class="notice"><strong>해석 기준</strong> — DOM 전체 클릭이나 검색어 원문을 수집하지 않습니다. 낮은 수치 자체보다 업무 위험·오류 감소와 함께 판단합니다.</div>
|
||||
</template>
|
||||
<template #content><KbxDataGrid :rows="query.data.value?.metrics ?? []" :columns="columns" row-key="metricKey" :loading="query.isFetching.value" /></template>
|
||||
<template #summary>{{ search.from }} ~ {{ search.to }} · Manual Intervention Rate를 자동화 개선의 핵심 추세로 봅니다.</template>
|
||||
</KbxListPage>
|
||||
</template>
|
||||
<style scoped>.notice{padding:8px 12px;border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:13px}</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const uxMetricsScreen=defineKbxScreen({
|
||||
id:'COMMON-UX-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'UX 품질 지표',
|
||||
description:'KBX 의미 이벤트와 업무 결과를 이용해 수작업 개입·업무시간·오류율을 검토합니다.',
|
||||
permissions:['common.ux.read'],helpKey:'COMMON-UX-001',telemetry:{enabled:true},
|
||||
commands:[{id:'search',label:'조회',group:'query',shortcut:'F3'}],
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { KbxUxMetricsResponse } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
export const uxMetricsApi={
|
||||
get:(query:{from:string;to:string;screenId?:string})=>kbxApi.request<KbxUxMetricsResponse>('common.uxTelemetry.metrics',{query}),
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import type { KbxLookupItem, KbxValidationError } from '@kbx/contracts'
|
||||
import {
|
||||
KbxButton,
|
||||
KbxDataGrid,
|
||||
KbxDateField,
|
||||
KbxFormGrid,
|
||||
KbxFormSection,
|
||||
KbxLookup,
|
||||
KbxLookupDialog,
|
||||
KbxTransactionPage,
|
||||
KbxUnsavedChangesDialog,
|
||||
KbxWorkflowBar,
|
||||
useKbxDirtyState,
|
||||
useKbxPageShortcuts,
|
||||
} from '@kbx/ui'
|
||||
import { useKbxWorkspaceBinding } from '../../../shell/useKbxWorkspaceBinding'
|
||||
import { lookupRegistry } from '../../../lookups/lookupRegistry'
|
||||
import { inventoryMoveColumns, inventoryMoveScreen, inventoryMoveWorkflow, type InventoryMoveLine } from './inventory-move.definition'
|
||||
|
||||
function today(){const value=new Date();return `${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`}
|
||||
function newLine():InventoryMoveLine{return{clientId:crypto.randomUUID(),itemId:null,itemCode:'',itemName:'',availableQty:0,moveQty:1,lotNo:'',remark:''}}
|
||||
|
||||
const status=ref('DRAFT')
|
||||
const header=reactive({moveDate:today(),fromWarehouseId:null as string|null,toWarehouseId:null as string|null})
|
||||
const lines=ref<InventoryMoveLine[]>([newLine()])
|
||||
const errors=ref<KbxValidationError[]>([])
|
||||
const pageNotice=ref('')
|
||||
const detailGrid=ref<any>(null)
|
||||
const itemLookupOpen=ref(false)
|
||||
const activeLine=ref<InventoryMoveLine|null>(null)
|
||||
const activeLookupField=ref<keyof InventoryMoveLine & string>('itemCode')
|
||||
const pendingReset=ref(false)
|
||||
const {dirty,touch,reset:resetDirty}=useKbxDirtyState()
|
||||
useKbxWorkspaceBinding(dirty,async()=>false)
|
||||
|
||||
const readonly=computed(()=>status.value!=='DRAFT')
|
||||
const totalQty=computed(()=>lines.value.reduce((sum,line)=>sum+Number(line.moveQty||0),0))
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'items',label:'품목',value:`${lines.value.length.toLocaleString('ko-KR')}종`},
|
||||
{key:'qty',label:'총 이동수량',value:totalQty.value,emphasis:true},
|
||||
])
|
||||
const pageContext=computed(()=>({
|
||||
label:'신규 재고이동',
|
||||
hint:dirty.value?'저장하지 않은 입력이 있습니다.':'작성 상태 · 서버 반영 전',
|
||||
metrics:[
|
||||
{key:'status',label:'상태',value:'작성'},
|
||||
{key:'lines',label:'품목',value:lines.value.length},
|
||||
{key:'errors',label:'오류',value:errors.value.length,tone:'danger' as const,emphasis:errors.value.length>0},
|
||||
],
|
||||
}))
|
||||
|
||||
function fieldError(field:string){return errors.value.find(error=>!error.rowKey&&error.field===field)?.message}
|
||||
function clearError(rowKey:string|undefined,field:string){errors.value=errors.value.filter(error=>!(error.rowKey===rowKey&&error.field===field))}
|
||||
function addLine(){if(readonly.value)return;lines.value.push(newLine());touch()}
|
||||
async function addLineAndFocus(){addLine();const line=lines.value.at(-1);if(!line)return;await nextTick();detailGrid.value?.focusCell?.(line.clientId,'itemCode')}
|
||||
function duplicateLines(selected:InventoryMoveLine[]){if(readonly.value||!selected.length)return;lines.value.push(...selected.map(line=>({...line,clientId:crypto.randomUUID()})));touch()}
|
||||
|
||||
function validateDraft(){
|
||||
const next:KbxValidationError[]=[]
|
||||
if(!header.fromWarehouseId)next.push({field:'fromWarehouseId',code:'FROM_WAREHOUSE_REQUIRED',message:'출발창고를 선택하세요.'})
|
||||
if(!header.toWarehouseId)next.push({field:'toWarehouseId',code:'TO_WAREHOUSE_REQUIRED',message:'도착창고를 선택하세요.'})
|
||||
if(header.fromWarehouseId&&header.fromWarehouseId===header.toWarehouseId)next.push({field:'toWarehouseId',code:'WAREHOUSE_MUST_DIFFER',message:'도착창고는 출발창고와 달라야 합니다.'})
|
||||
if(!lines.value.length)next.push({code:'MOVE_LINE_REQUIRED',message:'이동 품목을 1건 이상 입력하세요.'})
|
||||
for(const line of lines.value){
|
||||
if(!line.itemId)next.push({rowKey:line.clientId,field:'itemCode',code:'ITEM_REQUIRED',message:'품목을 선택하세요.'})
|
||||
if(Number(line.moveQty)<=0)next.push({rowKey:line.clientId,field:'moveQty',code:'MOVE_QTY_POSITIVE',message:'이동수량은 0보다 커야 합니다.'})
|
||||
if(Number(line.availableQty)>0&&Number(line.moveQty)>Number(line.availableQty))next.push({rowKey:line.clientId,field:'moveQty',code:'INSUFFICIENT_STOCK',message:`가용재고는 ${Number(line.availableQty).toLocaleString('ko-KR')}개입니다.`})
|
||||
}
|
||||
errors.value=next
|
||||
return next.length===0
|
||||
}
|
||||
|
||||
async function onCellChanged(event:{row:InventoryMoveLine;field:keyof InventoryMoveLine & string;newValue:unknown}){
|
||||
clearError(event.row.clientId,event.field)
|
||||
if(event.field==='itemCode'){
|
||||
const code=String(event.newValue??'').trim()
|
||||
if(!code)Object.assign(event.row,{itemId:null,itemName:'',availableQty:0})
|
||||
else{
|
||||
const item=await lookupRegistry.item.resolveByCode(code)
|
||||
if(item)Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName,availableQty:Number(item.metadata?.availableQty??0)})
|
||||
else{
|
||||
Object.assign(event.row,{itemId:null,itemName:'',availableQty:0})
|
||||
errors.value.push({rowKey:event.row.clientId,field:'itemCode',code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'})
|
||||
}
|
||||
}
|
||||
}
|
||||
touch()
|
||||
}
|
||||
function openGridLookup(event:{row:InventoryMoveLine;field:keyof InventoryMoveLine & string;entity:string}){if(readonly.value||event.entity!=='item')return;activeLine.value=event.row;activeLookupField.value=event.field;itemLookupOpen.value=true}
|
||||
async function selectGridItem(item:KbxLookupItem<string>){if(!activeLine.value)return;Object.assign(activeLine.value,{itemId:item.id,itemCode:item.code,itemName:item.displayName,availableQty:Number(item.metadata?.availableQty??0)});clearError(activeLine.value.clientId,'itemCode');touch();itemLookupOpen.value=false;await nextTick();detailGrid.value?.focusNextEditableCell?.(activeLine.value.clientId,activeLookupField.value)}
|
||||
|
||||
function resetDraft(){status.value='DRAFT';Object.assign(header,{moveDate:today(),fromWarehouseId:null,toWarehouseId:null});lines.value=[newLine()];errors.value=[];pageNotice.value='';pendingReset.value=false;resetDirty()}
|
||||
function requestNew(){if(dirty.value){pendingReset.value=true;return}resetDraft()}
|
||||
function saveDraft(){pageNotice.value='';if(!validateDraft()){pageNotice.value='저장할 수 없습니다. 입력 오류를 확인하세요.';nextTick(()=>detailGrid.value?.focusError?.(1));return false}pageNotice.value='저장 Command가 아직 서버에 연결되지 않아 입력값을 반영하지 않았습니다. 화면 초안은 유지됩니다.';return false}
|
||||
function executeWorkflow(id:string){if(dirty.value){pageNotice.value='상태 변경 전에 먼저 저장해야 합니다.';return}pageNotice.value=`${inventoryMoveWorkflow.transitions.find(item=>item.id===id)?.label??'상태 변경'} Command가 서버에 연결되지 않아 상태를 변경하지 않았습니다.`}
|
||||
function command(id:string){if(id==='new')return requestNew();if(id==='save')return saveDraft();if(['confirm','ship','receive'].includes(id))return executeWorkflow(id);if(id==='excel')pageNotice.value='엑셀 기능은 공통 Import/Export 계약이 연결된 뒤 활성화합니다.'}
|
||||
function focusNextError(){detailGrid.value?.focusError?.(1)}
|
||||
useKbxPageShortcuts([{key:'F8',execute:saveDraft}])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxTransactionPage
|
||||
:screen="inventoryMoveScreen"
|
||||
:status="status"
|
||||
:dirty="dirty"
|
||||
:errors="errors"
|
||||
:workflow="inventoryMoveWorkflow"
|
||||
:summary-items="summaryItems"
|
||||
:context="pageContext"
|
||||
header-title="이동 정보"
|
||||
header-description="출발/도착 창고와 이동일을 먼저 확인합니다. 동일 창고 이동은 입력 단계에서 차단합니다."
|
||||
detail-title="이동 품목"
|
||||
:detail-count="lines.length"
|
||||
detail-count-unit="종"
|
||||
detail-description="가용재고와 이동수량을 비교하며 입력하고, F2로 품목을 조회합니다."
|
||||
breadcrumb="ERP > 재고"
|
||||
@command="command"
|
||||
@transition="executeWorkflow"
|
||||
>
|
||||
<template #notice><div v-if="pageNotice" class="page-notice" role="status">{{pageNotice}}</div></template>
|
||||
<template #header>
|
||||
<KbxWorkflowBar :workflow="inventoryMoveWorkflow" :current="status" @transition="executeWorkflow" />
|
||||
<KbxFormSection title="이동정보">
|
||||
<KbxFormGrid>
|
||||
<KbxDateField v-model="header.moveDate" label="이동일" required :readonly="readonly" @update:model-value="touch" />
|
||||
<KbxLookup v-model="header.fromWarehouseId" entity="warehouse" label="출발창고" required :readonly="readonly" :error="fieldError('fromWarehouseId')" @selected="()=>{clearError(undefined,'fromWarehouseId');touch()}" />
|
||||
<KbxLookup v-model="header.toWarehouseId" entity="warehouse" label="도착창고" required :readonly="readonly" :error="fieldError('toWarehouseId')" @selected="()=>{clearError(undefined,'toWarehouseId');touch()}" />
|
||||
</KbxFormGrid>
|
||||
</KbxFormSection>
|
||||
</template>
|
||||
|
||||
<template #detail-actions>
|
||||
<KbxButton v-if="errors.length" label="첫/다음 오류" variant="ghost" @click="focusNextError" />
|
||||
<KbxButton v-if="!readonly" label="행 추가" variant="secondary" @click="addLineAndFocus" />
|
||||
</template>
|
||||
<template #detail>
|
||||
<KbxDataGrid
|
||||
ref="detailGrid"
|
||||
:rows="lines"
|
||||
:columns="inventoryMoveColumns"
|
||||
row-key="clientId"
|
||||
aria-label="재고이동 품목"
|
||||
selection="multiple"
|
||||
:errors="errors"
|
||||
:editable="!readonly"
|
||||
:editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}"
|
||||
@row-add-requested="addLine"
|
||||
@row-duplicate-requested="duplicateLines"
|
||||
@cell-changed="onCellChanged"
|
||||
@lookup-requested="openGridLookup"
|
||||
/>
|
||||
</template>
|
||||
</KbxTransactionPage>
|
||||
|
||||
<KbxLookupDialog v-model:visible="itemLookupOpen" entity="item" title="품목" @select="selectGridItem" />
|
||||
<KbxUnsavedChangesDialog :open="pendingReset" :can-save="false" @stay="pendingReset=false" @discard="resetDraft" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-notice{padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
export interface InventoryMoveLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; availableQty:number; moveQty:number; lotNo:string; remark:string }
|
||||
export const inventoryMoveScreen=defineKbxScreen({ id:'ERP-INV-MOVE-001',version:'1.0.0',module:'ERP',type:'transaction', templateCode:'T03',title:'재고이동',helpKey:'ERP-INV-MOVE-001',permissions:['erp.inventory.move.read'],telemetry:{enabled:true}, description:'창고 간 재고이동을 등록하고 출고·입고 상태를 추적합니다.', commands:[
|
||||
{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.inventory.move.write'},{id:'confirm',label:'이동확정',group:'workflow',variant:'primary',permission:'erp.inventory.move.confirm'},{id:'excel',label:'엑셀',group:'output'}] })
|
||||
export const inventoryMoveColumns:KbxGridColumn<InventoryMoveLine>[]=[
|
||||
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'},{field:'itemName',header:'품목명',width:200},{field:'availableQty',header:'가용재고',type:'quantity',width:100},{field:'moveQty',header:'이동수량',type:'quantity',width:100,editable:true},{field:'lotNo',header:'LOT',type:'code',width:120,editable:true},{field:'remark',header:'비고',width:220,editable:true}]
|
||||
export const inventoryMoveWorkflow:KbxWorkflowDefinition={id:'erp.inventory-move',version:'1.0.0',states:[{value:'DRAFT',label:'작성',semantic:'draft'},{value:'CONFIRMED',label:'확정',semantic:'pending'},{value:'IN_TRANSIT',label:'이동중',semantic:'processing'},{value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true},{value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}],transitions:[{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'이동확정',permission:'erp.inventory.move.confirm',confirm:true},{id:'ship',from:['CONFIRMED'],to:'IN_TRANSIT',label:'출고',permission:'erp.inventory.move.ship'},{id:'receive',from:['IN_TRANSIT'],to:'RECEIVED',label:'입고완료',permission:'erp.inventory.move.receive',confirm:true}]}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const erpInventoryMoveRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/erp/inventory-moves/new', name:'erp-inventory-move-new', component:()=>import('./InventoryMovePage.vue'), meta:{ screenId:'ERP-INV-MOVE-001' } },
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxDataGrid, KbxDrawer, KbxMasterDetailPage, KbxSearchPanel } from '@kbx/ui'
|
||||
import { inventoryApi } from './inventoryApi'
|
||||
import { inventoryHistoryColumns, inventoryItemColumns, inventoryLocationColumns, inventoryScreen, inventorySearchFields, type InventoryItemRow } from './inventory.definition'
|
||||
|
||||
const appliedKeyword=ref('')
|
||||
const searchModel=ref<Record<string, unknown>>({ keyword: '' })
|
||||
const selectedItemId=ref<string|null>(null)
|
||||
const selectedItem=ref<InventoryItemRow|null>(null)
|
||||
const breakdownOpen=ref(false)
|
||||
const itemsQuery=useQuery({queryKey:computed(()=>['erp','inventory',appliedKeyword.value]),queryFn:()=>inventoryApi.search(appliedKeyword.value),enabled:false})
|
||||
const locationsQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'locations']),queryFn:()=>inventoryApi.locations(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
|
||||
const historyQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'history']),queryFn:()=>inventoryApi.history(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
|
||||
|
||||
async function search(){
|
||||
const previous=selectedItemId.value; appliedKeyword.value=String(searchModel.value.keyword??'').trim(); await itemsQuery.refetch()
|
||||
const items=itemsQuery.data.value?.items??[]; const next=items.find(x=>x.itemId===previous)??items[0]??null; activate(next)
|
||||
}
|
||||
function activate(row:InventoryItemRow|null){ selectedItem.value=row; selectedItemId.value=row?.itemId??null }
|
||||
function select(rows:InventoryItemRow[]){ activate(rows[0]??null) }
|
||||
function drill(row:InventoryItemRow){ activate(row); breakdownOpen.value=true }
|
||||
function command(id:string){if(id==='search')return search()}
|
||||
const contextText=computed(()=>selectedItem.value?`${selectedItem.value.itemCode} · ${selectedItem.value.itemName}${selectedItem.value.specification?` · ${selectedItem.value.specification}`:''}`:'품목을 선택하면 로케이션과 재고이력을 함께 조회합니다.')
|
||||
const pageContext=computed(()=>({label:contextText.value,hint:selectedItem.value?'선택 품목 기준으로 위치와 이력을 동기화합니다.':'Master 품목을 선택하세요.',metrics:selectedItem.value?[{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true}]:[]}))
|
||||
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'items',label:'조회 품목',value:itemsQuery.data.value?.totalCount??0},
|
||||
{key:'selected',label:'선택 품목',value:selectedItem.value?.itemCode??'-'},
|
||||
])
|
||||
const drawerSummary=computed(()=>selectedItem.value?[
|
||||
{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},
|
||||
{key:'allocated',label:'할당',value:selectedItem.value.allocatedQty},
|
||||
{key:'hold',label:'보류',value:selectedItem.value.holdQty},
|
||||
{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true},
|
||||
]:[])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxMasterDetailPage :screen="inventoryScreen" breadcrumb="ERP > 재고" master-title="품목" detail-title="창고/로케이션" bottom-title="재고이력" master-description="품목을 선택하면 우측 위치와 하단 이력이 같은 Context로 갱신됩니다." :master-count="itemsQuery.data.value?.totalCount ?? 0" master-count-unit="개" detail-description="선택 품목의 창고·로케이션별 현재 재고입니다." :detail-count="locationsQuery.data.value?.items?.length ?? 0" detail-count-unit="개" bottom-description="선택 품목의 입출고·조정 근거입니다." :bottom-count="historyQuery.data.value?.items?.length ?? 0" bottom-count-unit="건" :context="pageContext" :summary-items="summaryItems" :content-state="itemsQuery.error.value?'error':itemsQuery.isFetching.value&&!itemsQuery.data.value?'loading':!itemsQuery.data.value?'idle':itemsQuery.data.value.totalCount===0?'empty':'ready'" :refreshing="itemsQuery.isFetching.value&&Boolean(itemsQuery.data.value)" @command="command">
|
||||
<template #search><KbxSearchPanel v-model="searchModel" :fields="inventorySearchFields" @search="search" /></template>
|
||||
<template #master>
|
||||
<KbxDataGrid :rows="itemsQuery.data.value?.items??[]" :columns="inventoryItemColumns" row-key="itemId" selection="single" :active-row-key="selectedItemId" :loading="itemsQuery.isFetching.value" :error-text="itemsQuery.error.value?'재고를 조회하지 못했습니다.':''" @selection-changed="select" @row-double-clicked="drill" @drill-down-requested="drill" @retry="search" />
|
||||
</template>
|
||||
<template #detail>
|
||||
<KbxDataGrid :rows="locationsQuery.data.value?.items??[]" :columns="inventoryLocationColumns" row-key="key" :loading="locationsQuery.isFetching.value" :error-text="locationsQuery.error.value?'재고 위치를 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고 위치가 없습니다." @retry="()=>locationsQuery.refetch()" />
|
||||
</template>
|
||||
<template #bottom>
|
||||
<KbxDataGrid :rows="historyQuery.data.value?.items??[]" :columns="inventoryHistoryColumns" row-key="entryId" :loading="historyQuery.isFetching.value" :error-text="historyQuery.error.value?'재고이력을 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고이력이 없습니다." @retry="()=>historyQuery.refetch()" />
|
||||
</template>
|
||||
</KbxMasterDetailPage>
|
||||
|
||||
<KbxDrawer v-model:open="breakdownOpen" :title="selectedItem?`${selectedItem.itemCode} 재고 산정 근거`:'재고 산정 근거'">
|
||||
<div v-if="selectedItem" class="inventory-breakdown">
|
||||
<p>{{selectedItem.itemName}} {{selectedItem.specification}}</p>
|
||||
<KbxSummaryBar :items="drawerSummary" />
|
||||
<dl><dt>현재고</dt><dd>물리적으로 기록된 총 재고</dd><dt>할당</dt><dd>주문·작업에 예약된 수량</dd><dt>보류</dt><dd>검사·품질·업무 사유로 출고할 수 없는 수량</dd><dt>가용</dt><dd>현재 정책상 신규 업무에 사용할 수 있는 수량</dd></dl>
|
||||
<p class="inventory-breakdown__note">수량 산정의 최종 기준은 서버 재고 정책이며, 화면 값은 조회 Projection입니다.</p>
|
||||
</div>
|
||||
</KbxDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inventory-breakdown{display:flex;flex-direction:column;gap:var(--kbx-space-3);font-size:var(--kbx-font-sm)}
|
||||
.inventory-breakdown p{margin:0}.inventory-breakdown dl{display:grid;grid-template-columns:80px 1fr;gap:var(--kbx-space-2);margin:0}.inventory-breakdown dt{font-weight:600}.inventory-breakdown dd{margin:0;color:var(--kbx-color-text-muted)}.inventory-breakdown__note{padding:var(--kbx-space-2);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border)}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/ui'
|
||||
|
||||
export interface InventoryItemRow {
|
||||
itemId: string; itemCode: string; itemName: string; specification: string
|
||||
totalQty: number; availableQty: number; allocatedQty: number; holdQty: number
|
||||
}
|
||||
export interface InventoryLocationRow {
|
||||
key: string; warehouseName: string; locationCode: string
|
||||
onHandQty: number; allocatedQty: number; availableQty: number; holdQty: number
|
||||
}
|
||||
export interface InventoryHistoryRow {
|
||||
entryId: string; occurredAt: string; businessType: string; referenceNo: string
|
||||
warehouseName: string; locationCode: string; inboundQty: number; outboundQty: number; balanceQty: number; actor: string
|
||||
}
|
||||
|
||||
export const inventoryScreen = defineKbxScreen({
|
||||
id: 'ERP-INV-001', version: '1.1.0', module: 'ERP', type: 'master-detail', templateCode:'T05', title: '재고현황',
|
||||
description: '품목 현재고에서 창고·로케이션 근거와 재고이력까지 조회 Context를 유지해 탐색합니다.',
|
||||
helpKey: 'ERP-INV-001', permissions: ['erp.inventory.read'], telemetry: { enabled: true },
|
||||
commands: [{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' }, { id: 'excel', label: '엑셀', group: 'output' }],
|
||||
})
|
||||
|
||||
export const inventoryItemColumns: KbxGridColumn<InventoryItemRow>[] = [
|
||||
{ field: 'itemCode', header: '품목코드', type: 'code', width: 120, pinned: 'left' },
|
||||
{ field: 'itemName', header: '품목명', width: 190 },
|
||||
{ field: 'specification', header: '규격', width: 140 },
|
||||
{ field: 'totalQty', header: '현재고', type: 'quantity', width: 100, drilldown:true },
|
||||
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100, drilldown:true },
|
||||
]
|
||||
export const inventoryLocationColumns: KbxGridColumn<InventoryLocationRow>[] = [
|
||||
{ field: 'warehouseName', header: '창고', width: 150 },
|
||||
{ field: 'locationCode', header: '로케이션', type: 'code', width: 120 },
|
||||
{ field: 'onHandQty', header: '현재고', type: 'quantity', width: 100 },
|
||||
{ field: 'allocatedQty', header: '할당', type: 'quantity', width: 90 },
|
||||
{ field: 'holdQty', header: '보류', type: 'quantity', width: 90 },
|
||||
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100 },
|
||||
]
|
||||
export const inventoryHistoryColumns: KbxGridColumn<InventoryHistoryRow>[] = [
|
||||
{ field:'occurredAt', header:'일시', type:'datetime', width:165 },
|
||||
{ field:'businessType', header:'업무', width:110 },
|
||||
{ field:'referenceNo', header:'참조번호', type:'code', width:150 },
|
||||
{ field:'warehouseName', header:'창고', width:130 },
|
||||
{ field:'locationCode', header:'로케이션', type:'code', width:110 },
|
||||
{ field:'inboundQty', header:'입고', type:'quantity', width:90 },
|
||||
{ field:'outboundQty', header:'출고', type:'quantity', width:90 },
|
||||
{ field:'balanceQty', header:'잔량', type:'quantity', width:100 },
|
||||
{ field:'actor', header:'처리자', width:110 },
|
||||
]
|
||||
export const inventorySearchFields: KbxSearchField[] = [{ key: 'keyword', label: '품목', type: 'text', width: 'lg', placeholder: '품목코드/품목명' }]
|
||||
@@ -0,0 +1,8 @@
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
import type { InventoryHistoryRow, InventoryItemRow, InventoryLocationRow } from './inventory.definition'
|
||||
export interface InventorySearchResponse { items: InventoryItemRow[]; totalCount: number }
|
||||
export const inventoryApi = {
|
||||
search(keyword = '') { return kbxApi.request<InventorySearchResponse>('erp.inventory.search', { query: { keyword, page: 1, pageSize: 200 } }) },
|
||||
locations(itemId: string) { return kbxApi.request<{ items: InventoryLocationRow[] }>('erp.inventory.locations', { path: { itemId } }) },
|
||||
history(itemId: string) { return kbxApi.request<{ items: InventoryHistoryRow[] }>('erp.inventory.history', { path: { itemId }, query:{ pageSize:100 } }) },
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { KbxLookupItem } from '@kbx/contracts'
|
||||
import { KbxDataGrid, KbxFastEntryPage, KbxLookupDialog, useKbxPageShortcuts } from '@kbx/ui'
|
||||
import { itemPriceColumns, itemPriceScreen, type ItemPriceRow } from './item-price.definition'
|
||||
import { useItemPriceFastEntry } from './useItemPriceFastEntry'
|
||||
import { useKbxWorkspaceBinding } from '../../../shell/useKbxWorkspaceBinding'
|
||||
|
||||
const vm=useItemPriceFastEntry()
|
||||
useKbxWorkspaceBinding(vm.dirty, async()=>await vm.save())
|
||||
const lookupOpen=ref(false); const activeRow=ref<ItemPriceRow|null>(null)
|
||||
function openLookup(event:{row:ItemPriceRow;entity:string}){ if(event.entity!=='item')return;activeRow.value=event.row;lookupOpen.value=true }
|
||||
function selectItem(item:KbxLookupItem<string>){ if(activeRow.value)vm.applyItemLookup(activeRow.value,item) }
|
||||
const pageContext=computed(()=>({label:'품목 단가 입력',hint:'저장 전 오류를 모두 해소한 뒤 F8로 일괄 저장합니다.',metrics:[{key:'rows',label:'입력',value:vm.enteredCount.value},{key:'errors',label:'오류',value:vm.errors.value.length,tone:vm.errors.value.length?'danger' as const:'default' as const,emphasis:vm.errors.value.length>0}]}))
|
||||
const summary=computed(()=>[
|
||||
{key:'rows',label:'입력',value:`${vm.enteredCount.value.toLocaleString('ko-KR')}건`},
|
||||
{key:'errors',label:'오류',value:`${vm.errors.value.length.toLocaleString('ko-KR')}건`},
|
||||
...(vm.lastResult.value?[{key:'saved',label:'최근 저장',value:`${vm.lastResult.value.saved.toLocaleString('ko-KR')}건`,emphasis:true}]:[]),
|
||||
])
|
||||
useKbxPageShortcuts([{key:'F8',execute:()=>vm.save()}])
|
||||
async function command(id:string){ if(id==='save')await vm.save(); if(id==='new')vm.clear() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxFastEntryPage :screen="itemPriceScreen" :selection-count="0" :dirty="vm.dirty.value" :context="pageContext" :errors="vm.errors.value" :summary-items="summary" :problem="vm.problem.value" grid-title="품목 단가 입력" grid-description="품목·단가를 연속 입력하고 오류 셀을 해소한 뒤 F8로 저장합니다." :row-count="vm.enteredCount.value" row-count-unit="건" breadcrumb="ERP > 기준정보" @command="command" @retry-problem="vm.retryProblem" @dismiss-problem="vm.dismissProblem">
|
||||
<template #guide>
|
||||
<span><kbd>Enter</kbd> 다음 셀</span><span><kbd>F2</kbd> 품목 조회</span><span><kbd>Ctrl+V</kbd> Excel 붙여넣기</span><span>행 선택 후 아래 채우기/복제</span>
|
||||
</template>
|
||||
<template #notice>
|
||||
<div v-if="vm.lastResult.value" class="price-save-result" role="status">저장 {{vm.lastResult.value.saved.toLocaleString('ko-KR')}}건 · 신규 {{vm.lastResult.value.created.toLocaleString('ko-KR')}} · 수정 {{vm.lastResult.value.updated.toLocaleString('ko-KR')}}</div>
|
||||
</template>
|
||||
<template #grid-actions><span class="grid-action-status">오류 {{vm.errors.value.length.toLocaleString('ko-KR')}}건</span></template>
|
||||
<template #content>
|
||||
<KbxDataGrid
|
||||
:rows="vm.rows.value" :columns="itemPriceColumns" row-key="clientId" selection="multiple"
|
||||
:errors="vm.errors.value" editable clipboard
|
||||
:editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}"
|
||||
@row-add-requested="vm.addRow" @row-duplicate-requested="vm.duplicateRows" @cell-changed="vm.onCellChanged" @lookup-requested="openLookup"
|
||||
/>
|
||||
</template>
|
||||
</KbxFastEntryPage>
|
||||
<KbxLookupDialog v-model:visible="lookupOpen" entity="item" title="품목" @select="selectItem" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid-action-status{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted);white-space:nowrap}.price-save-result{min-height:var(--kbx-control-sm);display:flex;align-items:center;padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-success-border);background:var(--kbx-color-success-surface);color:var(--kbx-color-success-text);font-size:var(--kbx-font-sm)}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineKbxScreen, type KbxGridColumn } from '@kbx/ui'
|
||||
|
||||
export interface ItemPriceRow {
|
||||
clientId: string
|
||||
itemId: string | null
|
||||
itemCode: string
|
||||
itemName: string
|
||||
effectiveDate: string
|
||||
unitPrice: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export const itemPriceScreen = defineKbxScreen({
|
||||
id: 'ERP-PRICE-001',
|
||||
version: '1.0.0',
|
||||
module: 'ERP',
|
||||
type: 'fast-entry', templateCode:'T04',
|
||||
title: '품목 단가 일괄등록',
|
||||
description: '품목별 적용일과 표준단가를 Excel처럼 연속 입력하고 한 번에 저장합니다.',
|
||||
helpKey: 'ERP-PRICE-001',
|
||||
permissions: ['erp.item.price.read', 'erp.item.read'],
|
||||
commands: [
|
||||
{ id: 'new', label: '신규', group: 'edit' },
|
||||
{ id: 'save', label: '저장', group: 'edit', variant: 'primary', shortcut: 'F8', permission: 'erp.item.price.write' },
|
||||
{ id: 'excel', label: '엑셀', group: 'output' },
|
||||
],
|
||||
telemetry: { enabled: true },
|
||||
})
|
||||
|
||||
export const itemPriceColumns: KbxGridColumn<ItemPriceRow>[] = [
|
||||
{ field:'itemCode', header:'품목코드', type:'lookup', width:140, pinned:'left', editable:true, lookup:{entity:'item'} },
|
||||
{ field:'itemName', header:'품목명', width:220 },
|
||||
{ field:'effectiveDate', header:'적용일', type:'date', width:120, editable:true },
|
||||
{ field:'unitPrice', header:'단가', type:'money', width:130, editable:true },
|
||||
{ field:'remark', header:'비고', width:260, editable:true },
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface SaveItemPriceRow {
|
||||
clientId: string
|
||||
itemId: string
|
||||
effectiveDate: string
|
||||
unitPrice: number
|
||||
remark?: string
|
||||
}
|
||||
export interface SaveItemPricesRequest { rows: SaveItemPriceRow[] }
|
||||
export interface SaveItemPricesResponse { requested: number; saved: number; created: number; updated: number }
|
||||
export interface ItemLookupResult { id: string; code: string; displayName: string; status?: string }
|
||||
|
||||
export function toKbxProblem(error: unknown): KbxProblem | null { return isKbxProblem(error) ? error : null }
|
||||
|
||||
export const itemPriceApi = {
|
||||
async resolveItemByCode(code: string) {
|
||||
try { return await kbxApi.request<ItemLookupResult>('lookup.items.resolveByCode', { path:{ code } }) }
|
||||
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
|
||||
},
|
||||
save(request: SaveItemPricesRequest, idempotencyKey: string) {
|
||||
return kbxApi.request<SaveItemPricesResponse, SaveItemPricesRequest>('erp.itemPrices.bulkSave', { body:request, idempotencyKey })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import type { KbxLookupItem, KbxProblem, KbxValidationError } from '@kbx/contracts'
|
||||
import { useKbxDirtyState, useKbxValidation } from '@kbx/ui'
|
||||
import { itemPriceApi, toKbxProblem } from './itemPriceApi'
|
||||
import type { ItemPriceRow } from './item-price.definition'
|
||||
|
||||
function localDate() {
|
||||
const d=new Date(); const m=String(d.getMonth()+1).padStart(2,'0'); const day=String(d.getDate()).padStart(2,'0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
function newRow():ItemPriceRow { return { clientId:crypto.randomUUID(), itemId:null, itemCode:'', itemName:'', effectiveDate:localDate(), unitPrice:0, remark:'' } }
|
||||
|
||||
export function useItemPriceFastEntry() {
|
||||
const rows=ref<ItemPriceRow[]>(Array.from({length:8},()=>newRow()))
|
||||
const validation=useKbxValidation()
|
||||
const { dirty, touch, markSaved, reset:resetDirty }=useKbxDirtyState()
|
||||
const mutation=useMutation({ mutationFn:({request,key}:{request:{rows:Array<{clientId:string;itemId:string;effectiveDate:string;unitPrice:number;remark?:string}>};key:string})=>itemPriceApi.save(request,key) })
|
||||
const lastResult=ref<{requested:number;saved:number;created:number;updated:number}|null>(null)
|
||||
const problem=ref<KbxProblem|null>(null)
|
||||
let failedAction:'save'|null=null
|
||||
let idempotencyKey=crypto.randomUUID()
|
||||
|
||||
const enteredRows=computed(()=>rows.value.filter(x=>x.itemId||x.itemCode.trim()||Number(x.unitPrice)!==0||x.remark.trim()))
|
||||
function invalidateCommandKey(){ idempotencyKey=crypto.randomUUID() }
|
||||
function addRow(){ rows.value.push(newRow()); validation.clear(); touch(); invalidateCommandKey() }
|
||||
function duplicateRows(source:ItemPriceRow[]){ rows.value.push(...source.map(x=>({...x,clientId:crypto.randomUUID()}))); touch(); invalidateCommandKey() }
|
||||
function clear(){ rows.value=Array.from({length:8},()=>newRow()); validation.clear(); lastResult.value=null; problem.value=null; failedAction=null; resetDirty(); invalidateCommandKey() }
|
||||
|
||||
function localErrors():KbxValidationError[] {
|
||||
const errors:KbxValidationError[]=[]
|
||||
const active=enteredRows.value
|
||||
if(!active.length) return [{code:'PRICE_ROWS_REQUIRED',message:'저장할 단가를 한 건 이상 입력하세요.'}]
|
||||
const duplicate=new Map<string,string>()
|
||||
for(const row of active){
|
||||
if(!row.itemId) errors.push({rowKey:row.clientId,field:'itemCode',code:'ITEM_REQUIRED',message:'품목을 선택하세요.'})
|
||||
if(!/^\d{4}-\d{2}-\d{2}$/.test(row.effectiveDate)) errors.push({rowKey:row.clientId,field:'effectiveDate',code:'EFFECTIVE_DATE_REQUIRED',message:'적용일을 YYYY-MM-DD 형식으로 입력하세요.'})
|
||||
if(!Number.isFinite(Number(row.unitPrice))||Number(row.unitPrice)<0) errors.push({rowKey:row.clientId,field:'unitPrice',code:'PRICE_NONNEGATIVE',message:'단가는 0 이상이어야 합니다.'})
|
||||
if(row.itemId){ const key=`${row.itemId}|${row.effectiveDate}`; if(duplicate.has(key)){ errors.push({rowKey:row.clientId,field:'effectiveDate',code:'DUPLICATE_ITEM_DATE',message:'같은 품목과 적용일이 중복되었습니다.'}); const first=duplicate.get(key)!; if(!errors.some(x=>x.rowKey===first&&x.code==='DUPLICATE_ITEM_DATE')) errors.push({rowKey:first,field:'effectiveDate',code:'DUPLICATE_ITEM_DATE',message:'같은 품목과 적용일이 중복되었습니다.'}) } else duplicate.set(key,row.clientId) }
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
async function onCellChanged(event:{row:ItemPriceRow;field:keyof ItemPriceRow;newValue:unknown}){
|
||||
if(event.field==='itemCode'){
|
||||
const code=String(event.newValue??'').trim()
|
||||
if(!code) Object.assign(event.row,{itemId:null,itemName:''})
|
||||
else {
|
||||
const item=await itemPriceApi.resolveItemByCode(code)
|
||||
if(!item){ Object.assign(event.row,{itemId:null,itemName:''}); validation.setRowFieldError(event.row.clientId,'itemCode',{code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'}) }
|
||||
else { Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName}); validation.setRowFieldError(event.row.clientId,'itemCode') }
|
||||
}
|
||||
}
|
||||
lastResult.value=null; touch(); invalidateCommandKey()
|
||||
}
|
||||
function applyItemLookup(row:ItemPriceRow,item:KbxLookupItem<string>){ Object.assign(row,{itemId:item.id,itemCode:item.code,itemName:item.displayName}); validation.setRowFieldError(row.clientId,'itemCode'); touch(); invalidateCommandKey() }
|
||||
|
||||
async function save(){
|
||||
validation.clear(); problem.value=null; failedAction=null; const errors=localErrors(); if(errors.length){validation.setErrors(errors);return false}
|
||||
const active=enteredRows.value
|
||||
try{
|
||||
const result=await mutation.mutateAsync({key:idempotencyKey,request:{rows:active.map(x=>({clientId:x.clientId,itemId:x.itemId!,effectiveDate:x.effectiveDate,unitPrice:Number(x.unitPrice),remark:x.remark||undefined}))}})
|
||||
lastResult.value=result; markSaved(); idempotencyKey=crypto.randomUUID(); return true
|
||||
}catch(error){ const issue=toKbxProblem(error); if(issue?.type==='validation'){validation.applyProblem(issue);return false} if(issue){problem.value=issue;failedAction='save';return false} throw error }
|
||||
}
|
||||
async function retryProblem(){const action=failedAction;problem.value=null;failedAction=null;if(action==='save')return save();return false}
|
||||
function dismissProblem(){problem.value=null;failedAction=null}
|
||||
return {rows,errors:validation.errors,dirty,saving:computed(()=>mutation.isPending.value),lastResult,problem,enteredCount:computed(()=>enteredRows.value.length),addRow,duplicateRows,onCellChanged,applyItemLookup,save,clear,touch,retryProblem,dismissProblem}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
isKbxProblem,
|
||||
kbxFieldReadonly,
|
||||
resolveKbxRecordStatePolicy,
|
||||
type KbxAuditEntry,
|
||||
type KbxConflictSnapshot,
|
||||
type KbxProblem,
|
||||
type KbxSearchField,
|
||||
} from '@kbx/contracts'
|
||||
import {
|
||||
KbxBarcodeField,
|
||||
KbxCheckbox,
|
||||
KbxDataGrid,
|
||||
KbxFormGrid,
|
||||
KbxFormSection,
|
||||
KbxInput,
|
||||
KbxLookup,
|
||||
KbxMasterPage,
|
||||
KbxRecordNavigator,
|
||||
KbxSearchPanel,
|
||||
KbxUnsavedChangesDialog,
|
||||
useKbxDirtyState,
|
||||
useKbxPageShortcuts,
|
||||
} from '@kbx/ui'
|
||||
import { itemApi } from './itemApi'
|
||||
import {
|
||||
itemMasterColumns,
|
||||
itemMasterScreen,
|
||||
itemStatePolicies,
|
||||
itemWorkflow,
|
||||
type ItemMasterRow,
|
||||
} from './item.definition'
|
||||
|
||||
const searchModel = ref({ keyword:'' })
|
||||
const searchFields:KbxSearchField[] = [
|
||||
{ key:'keyword', label:'검색', type:'text', width:'lg', placeholder:'품목코드/품목명' },
|
||||
]
|
||||
|
||||
const selected = ref<ItemMasterRow[]>([])
|
||||
const form = reactive<ItemMasterRow>(emptyItem())
|
||||
const auditEntries = ref<KbxAuditEntry[]>([])
|
||||
const conflict = ref<KbxConflictSnapshot | null>(null)
|
||||
const problem = ref<KbxProblem | null>(null)
|
||||
const failedCommand = ref<'load'|'save'|'deactivate'|null>(null)
|
||||
const errors = ref<any[]>([])
|
||||
const masterGrid = ref<any>(null)
|
||||
const pendingTransition = ref<{ kind:'row'; row:ItemMasterRow } | { kind:'new' } | null>(null)
|
||||
const savingTransition = ref(false)
|
||||
const { dirty, touch, markSaved, reset:resetDirty } = useKbxDirtyState()
|
||||
|
||||
const status = computed(() => !form.id ? '신규' : form.active ? '사용' : '사용중지')
|
||||
const policy = computed(() => resolveKbxRecordStatePolicy(itemStatePolicies, status.value))
|
||||
const query = useQuery({
|
||||
queryKey:['erp','items'],
|
||||
queryFn:() => itemApi.search(searchModel.value.keyword),
|
||||
enabled:false,
|
||||
})
|
||||
const rows = computed(() => query.data.value?.items ?? [])
|
||||
const currentRowIndex = computed(() => form.id ? rows.value.findIndex(row => row.id === form.id) : -1)
|
||||
const currentRowPosition = computed(() => currentRowIndex.value >= 0 ? currentRowIndex.value + 1 : 0)
|
||||
function navigateRecord(offset:number) {
|
||||
const index=currentRowIndex.value
|
||||
if(index<0)return
|
||||
const target=rows.value[index+offset]
|
||||
if(target) requestChoose([target])
|
||||
}
|
||||
|
||||
function emptyItem():ItemMasterRow {
|
||||
return {
|
||||
id:'', code:'', name:'', categoryName:'', specification:'', unit:'EA', barcode:'',
|
||||
defaultWarehouseId:null, defaultWarehouseName:'', lotManaged:false, expiryManaged:false,
|
||||
active:true, version:0,
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
selected.value = []
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
await query.refetch()
|
||||
}
|
||||
|
||||
async function loadRow(row:ItemMasterRow) {
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
try {
|
||||
const latest = await itemApi.get(row.id)
|
||||
const audit = await itemApi.audit(latest.id)
|
||||
selected.value = [latest]
|
||||
Object.assign(form, latest)
|
||||
auditEntries.value = audit
|
||||
errors.value = []
|
||||
conflict.value = null
|
||||
resetDirty()
|
||||
await nextTick()
|
||||
masterGrid.value?.selectRowByKey?.(latest.id)
|
||||
} catch (error) {
|
||||
if (isKbxProblem(error)) {
|
||||
problem.value = error
|
||||
failedCommand.value = 'load'
|
||||
await nextTick()
|
||||
masterGrid.value?.selectRowByKey?.(form.id || null)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function requestChoose(rows:ItemMasterRow[]) {
|
||||
const row = rows[0]
|
||||
if (!row || row.id === form.id) return
|
||||
if (dirty.value) {
|
||||
pendingTransition.value = { kind:'row', row }
|
||||
await nextTick()
|
||||
masterGrid.value?.selectRowByKey?.(form.id || null)
|
||||
return
|
||||
}
|
||||
await loadRow(row)
|
||||
}
|
||||
|
||||
function state(field:string) {
|
||||
return kbxFieldReadonly(policy.value, field)
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
Object.assign(form, emptyItem())
|
||||
selected.value = []
|
||||
auditEntries.value = []
|
||||
conflict.value = null
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
errors.value = []
|
||||
resetDirty()
|
||||
masterGrid.value?.clearSelection?.()
|
||||
}
|
||||
|
||||
function requestNew() {
|
||||
if (dirty.value) {
|
||||
pendingTransition.value = { kind:'new' }
|
||||
return
|
||||
}
|
||||
createNew()
|
||||
}
|
||||
|
||||
function copy() {
|
||||
const source = { ...form }
|
||||
Object.assign(form, { ...source, id:'', code:'', barcode:'', version:0, active:true })
|
||||
selected.value = []
|
||||
auditEntries.value = []
|
||||
conflict.value = null
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
errors.value = []
|
||||
resetDirty()
|
||||
masterGrid.value?.clearSelection?.()
|
||||
touch()
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
id:form.id || undefined,
|
||||
version:form.version || undefined,
|
||||
code:form.code.trim(),
|
||||
name:form.name.trim(),
|
||||
categoryName:form.categoryName,
|
||||
specification:form.specification,
|
||||
unit:form.unit,
|
||||
barcode:form.barcode,
|
||||
defaultWarehouseId:form.defaultWarehouseId,
|
||||
lotManaged:form.lotManaged,
|
||||
expiryManaged:form.expiryManaged,
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
errors.value = []
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
if (!form.code.trim() || !form.name.trim()) {
|
||||
errors.value = [
|
||||
...(!form.code.trim() ? [{ field:'code', code:'REQUIRED', message:'품목코드를 입력하세요.' }] : []),
|
||||
...(!form.name.trim() ? [{ field:'name', code:'REQUIRED', message:'품목명을 입력하세요.' }] : []),
|
||||
]
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = form.id ? await itemApi.update(form.id, payload()) : await itemApi.create(payload())
|
||||
form.id = result.id
|
||||
form.version = result.version
|
||||
form.active = true
|
||||
markSaved()
|
||||
auditEntries.value = await itemApi.audit(form.id)
|
||||
await query.refetch()
|
||||
await nextTick()
|
||||
masterGrid.value?.selectRowByKey?.(form.id)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isKbxProblem(error) && error.type === 'validation') {
|
||||
errors.value = error.errors
|
||||
return false
|
||||
}
|
||||
if (isKbxProblem(error) && error.type === 'conflict' && form.id) {
|
||||
const latest = await itemApi.get(form.id)
|
||||
conflict.value = {
|
||||
code:error.code,
|
||||
title:error.title,
|
||||
detail:'저장하지 않은 입력은 유지됩니다. 최신 값과 비교한 뒤 다시 읽을 수 있습니다.',
|
||||
entityId:form.id,
|
||||
requestedVersion:form.version,
|
||||
currentVersion:error.currentVersion ?? latest.version,
|
||||
changes:[['code','품목코드'],['name','품목명'],['unit','단위'],['barcode','바코드']].map(([field,label]) => ({
|
||||
field,
|
||||
label,
|
||||
mine:(form as any)[field],
|
||||
latest:(latest as any)[field],
|
||||
})),
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (isKbxProblem(error)) {
|
||||
problem.value = error
|
||||
failedCommand.value = 'save'
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPendingTransition() {
|
||||
const pending = pendingTransition.value
|
||||
pendingTransition.value = null
|
||||
if (!pending) return
|
||||
if (pending.kind === 'new') createNew()
|
||||
else await loadRow(pending.row)
|
||||
}
|
||||
|
||||
async function saveThenTransition() {
|
||||
if (!pendingTransition.value) return
|
||||
savingTransition.value = true
|
||||
try {
|
||||
if (await save()) await applyPendingTransition()
|
||||
} finally {
|
||||
savingTransition.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!form.id) return
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
try {
|
||||
const result = await itemApi.deactivate(form.id, form.version)
|
||||
form.version = result.version
|
||||
form.active = false
|
||||
markSaved()
|
||||
auditEntries.value = await itemApi.audit(form.id)
|
||||
await query.refetch()
|
||||
} catch (error) {
|
||||
if (isKbxProblem(error) && error.type === 'conflict') {
|
||||
const latest = await itemApi.get(form.id)
|
||||
conflict.value = {
|
||||
code:error.code,
|
||||
title:error.title,
|
||||
currentVersion:error.currentVersion ?? latest.version,
|
||||
requestedVersion:form.version,
|
||||
entityId:form.id,
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isKbxProblem(error)) {
|
||||
problem.value = error
|
||||
failedCommand.value = 'deactivate'
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadConflict() {
|
||||
if (!form.id) return
|
||||
const latest = await itemApi.get(form.id)
|
||||
Object.assign(form, latest)
|
||||
auditEntries.value = await itemApi.audit(form.id)
|
||||
conflict.value = null
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
resetDirty()
|
||||
await nextTick()
|
||||
masterGrid.value?.selectRowByKey?.(form.id)
|
||||
}
|
||||
|
||||
async function retryProblem() {
|
||||
const action = failedCommand.value
|
||||
const row = selected.value[0]
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
if (action === 'load' && row) return loadRow(row)
|
||||
if (action === 'save') return save()
|
||||
if (action === 'deactivate') return deactivate()
|
||||
}
|
||||
|
||||
function dismissProblem() {
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
}
|
||||
|
||||
async function command(id:string) {
|
||||
if (id === 'search') return search()
|
||||
if (id === 'new') return requestNew()
|
||||
if (id === 'copy') return copy()
|
||||
if (id === 'save') return save()
|
||||
if (id === 'deactivate') return deactivate()
|
||||
}
|
||||
|
||||
useKbxPageShortcuts([
|
||||
{ key:'F3', execute:search },
|
||||
{ key:'F8', execute:save },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxMasterPage
|
||||
:screen="itemMasterScreen"
|
||||
:status="status"
|
||||
:dirty="dirty"
|
||||
:version="form.version || undefined"
|
||||
:errors="errors"
|
||||
:workflow="itemWorkflow"
|
||||
:conflict="conflict"
|
||||
:problem="problem"
|
||||
:audit-entries="auditEntries"
|
||||
list-title="품목 목록"
|
||||
:list-count="query.data.value?.totalCount ?? rows.length"
|
||||
list-count-unit="개"
|
||||
detail-title="품목 상세"
|
||||
:detail-description="form.id ? `${form.code} · ${form.name}` : '신규 품목을 등록합니다.'"
|
||||
:content-state="query.error.value ? 'error' : query.isFetching.value && !query.data.value ? 'loading' : !query.data.value ?'idle' : query.data.value.items.length === 0 ? 'empty' : 'ready'"
|
||||
:refreshing="query.isFetching.value && Boolean(query.data.value)"
|
||||
breadcrumb="ERP > 기준정보"
|
||||
@command="command"
|
||||
@transition="command"
|
||||
@reload-conflict="reloadConflict"
|
||||
@dismiss-conflict="conflict=null"
|
||||
@retry-problem="retryProblem"
|
||||
@dismiss-problem="dismissProblem"
|
||||
>
|
||||
<template #detail-actions>
|
||||
<KbxRecordNavigator
|
||||
:current="currentRowPosition"
|
||||
:total="rows.length"
|
||||
item-label="품목"
|
||||
:previous-disabled="dirty || currentRowPosition<=1"
|
||||
:next-disabled="dirty || currentRowPosition>=rows.length"
|
||||
@previous="navigateRecord(-1)"
|
||||
@next="navigateRecord(1)"
|
||||
/>
|
||||
</template>
|
||||
<template #list>
|
||||
<KbxSearchPanel v-model="searchModel" :fields="searchFields" @search="search" />
|
||||
<KbxDataGrid
|
||||
ref="masterGrid"
|
||||
:rows="rows"
|
||||
:columns="itemMasterColumns"
|
||||
row-key="id"
|
||||
aria-label="품목 목록"
|
||||
selection="single"
|
||||
:active-row-key="form.id || undefined"
|
||||
:loading="query.isFetching.value"
|
||||
@selection-changed="requestChoose"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #detail>
|
||||
<KbxFormSection title="기본정보" description="품목 식별과 분류에 필요한 핵심 기준정보입니다.">
|
||||
<KbxFormGrid>
|
||||
<KbxInput v-model="form.code" label="품목코드" required :readonly="Boolean(form.id) || state('code')" :error="errors.find(x => x.field === 'code')?.message" @update:model-value="touch" />
|
||||
<KbxInput v-model="form.name" label="품목명" required :readonly="state('name')" :error="errors.find(x => x.field === 'name')?.message" @update:model-value="touch" />
|
||||
<KbxInput v-model="form.categoryName" label="품목그룹" :readonly="state('categoryName')" @update:model-value="touch" />
|
||||
<KbxInput v-model="form.specification" label="규격" :readonly="state('specification')" @update:model-value="touch" />
|
||||
<KbxInput v-model="form.unit" label="단위" :readonly="state('unit')" @update:model-value="touch" />
|
||||
</KbxFormGrid>
|
||||
</KbxFormSection>
|
||||
|
||||
<KbxFormSection title="물류정보" description="창고·바코드·LOT 등 현장 처리 기준을 정의합니다.">
|
||||
<KbxFormGrid>
|
||||
<KbxLookup v-model="form.defaultWarehouseId" entity="warehouse" label="기본창고" :readonly="state('defaultWarehouseId')" @selected="touch" />
|
||||
<KbxBarcodeField v-model="form.barcode" label="바코드" :readonly="state('barcode')" @update:model-value="touch" />
|
||||
<KbxCheckbox v-model="form.lotManaged" label="LOT 관리" :disabled="state('lotManaged')" @update:model-value="touch" />
|
||||
<KbxCheckbox v-model="form.expiryManaged" label="유통기한 관리" :disabled="state('expiryManaged')" @update:model-value="touch" />
|
||||
</KbxFormGrid>
|
||||
</KbxFormSection>
|
||||
</template>
|
||||
</KbxMasterPage>
|
||||
|
||||
<KbxUnsavedChangesDialog
|
||||
:open="Boolean(pendingTransition)"
|
||||
:saving="savingTransition"
|
||||
@stay="pendingTransition=null"
|
||||
@discard="applyPendingTransition"
|
||||
@save="saveThenTransition"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxRecordStatePolicy, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
|
||||
export interface ItemMasterRow { id:string; code:string; name:string; categoryName:string; specification:string; unit:string; barcode:string; defaultWarehouseId:string|null; defaultWarehouseName:string; lotManaged:boolean; expiryManaged:boolean; active:boolean; version:number }
|
||||
|
||||
export const itemWorkflow:KbxWorkflowDefinition={id:'erp.item.lifecycle',version:'1.0.0',states:[{value:'신규',label:'신규',semantic:'draft'},{value:'사용',label:'사용',semantic:'completed'},{value:'사용중지',label:'사용중지',semantic:'disabled',terminal:true}],transitions:[{id:'deactivate',from:['사용'],to:'사용중지',label:'사용중지',permission:'erp.item.write',confirm:true}]}
|
||||
export const itemStatePolicies:KbxRecordStatePolicy[]=[{status:'신규',editability:'editable'},{status:'사용',editability:'editable'},{status:'사용중지',editability:'readonly',message:'사용중지된 품목은 조회·복사·이력확인만 가능합니다.'}]
|
||||
|
||||
export const itemMasterScreen = defineKbxScreen({
|
||||
id: 'ERP-MST-ITEM-001', version: '1.1.0', module: 'ERP', type: 'master', templateCode:'T02', title: '품목관리',
|
||||
description: '품목 기준정보와 물류 속성을 동일한 문법으로 관리합니다.', helpKey:'ERP-MST-ITEM-001', permissions:['erp.item.read'], telemetry:{enabled:true},
|
||||
commands:[
|
||||
{id:'search',label:'조회',group:'query',shortcut:'F3'},
|
||||
{id:'new',label:'신규',group:'edit',permission:'erp.item.create'},
|
||||
{id:'save',label:'저장',group:'edit',variant:'primary',shortcut:'F8',permissionByStatus:{'신규':'erp.item.create','사용':'erp.item.write'},allowedStatuses:['신규','사용'],requiresDirty:true},
|
||||
{id:'copy',label:'복사',group:'edit',permission:'erp.item.create'},
|
||||
{id:'deactivate',label:'사용중지',group:'workflow',permission:'erp.item.write',allowedStatuses:['사용'],confirm:{title:'품목을 사용중지하시겠습니까?',detail:'사용중지 후 신규 업무에서 이 품목을 선택할 수 없습니다. 기존 이력은 유지됩니다.',level:'high',confirmLabel:'사용중지'}},
|
||||
{id:'excel',label:'엑셀',group:'output'},
|
||||
],
|
||||
})
|
||||
export const itemMasterColumns:KbxGridColumn<ItemMasterRow>[]=[{field:'code',header:'품목코드',type:'code',width:120,pinned:'left'},{field:'name',header:'품목명',width:200},{field:'specification',header:'규격',width:160},{field:'unit',header:'단위',width:70},{field:'active',header:'사용',type:'boolean',width:70}]
|
||||
@@ -0,0 +1,14 @@
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
import type { KbxAuditEntry } from '@kbx/contracts'
|
||||
import type { ItemMasterRow } from './item.definition'
|
||||
export interface ItemSearchResponse { items: ItemMasterRow[]; totalCount: number }
|
||||
export interface ItemSaveRequest { id?:string; version?:number; code:string; name:string; categoryName?:string; specification?:string; unit:string; barcode?:string; defaultWarehouseId?:string|null; lotManaged:boolean; expiryManaged:boolean }
|
||||
export interface ItemSaveResponse { id:string; version:number; status:string }
|
||||
export const itemApi = {
|
||||
search(keyword = '') { return kbxApi.request<ItemSearchResponse>('erp.items.search', { query: { keyword, page: 1, pageSize: 200 } }) },
|
||||
get(id: string) { return kbxApi.request<ItemMasterRow>('erp.items.get', { path: { id } }) },
|
||||
create(body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.create', { body }) },
|
||||
update(id:string,body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.update', { path:{id}, body }) },
|
||||
deactivate(id:string,version:number) { return kbxApi.request<ItemSaveResponse>('erp.items.deactivate', { path:{id}, body:{version} }) },
|
||||
audit(id:string) { return kbxApi.request<KbxAuditEntry[]>('erp.items.audit', { path:{id} }) },
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import type { KbxLookupItem, KbxValidationError } from '@kbx/contracts'
|
||||
import {
|
||||
KbxButton,
|
||||
KbxDataGrid,
|
||||
KbxDateField,
|
||||
KbxFormGrid,
|
||||
KbxFormSection,
|
||||
KbxInput,
|
||||
KbxLookup,
|
||||
KbxLookupDialog,
|
||||
KbxTransactionPage,
|
||||
KbxUnsavedChangesDialog,
|
||||
KbxWorkflowBar,
|
||||
useKbxDirtyState,
|
||||
useKbxPageShortcuts,
|
||||
} from '@kbx/ui'
|
||||
import { useKbxWorkspaceBinding } from '../../../shell/useKbxWorkspaceBinding'
|
||||
import { lookupRegistry } from '../../../lookups/lookupRegistry'
|
||||
import { purchaseColumns, purchaseScreen, purchaseWorkflow, type PurchaseLine } from './purchase.definition'
|
||||
|
||||
function today() {
|
||||
const value = new Date()
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
function newLine():PurchaseLine {
|
||||
return { clientId:crypto.randomUUID(), itemId:null, itemCode:'', itemName:'', quantity:1, unitPrice:0, amount:0, dueDate:'' }
|
||||
}
|
||||
|
||||
const status=ref('DRAFT')
|
||||
const header=reactive({ purchaseDate:today(), supplierId:null as string|null, warehouseId:null as string|null, buyer:'', remark:'' })
|
||||
const lines=ref<PurchaseLine[]>([newLine()])
|
||||
const errors=ref<KbxValidationError[]>([])
|
||||
const pageNotice=ref('')
|
||||
const detailGrid=ref<any>(null)
|
||||
const itemLookupOpen=ref(false)
|
||||
const activeLine=ref<PurchaseLine|null>(null)
|
||||
const activeLookupField=ref<keyof PurchaseLine & string>('itemCode')
|
||||
const pendingReset=ref(false)
|
||||
const { dirty, touch, reset:resetDirty }=useKbxDirtyState()
|
||||
useKbxWorkspaceBinding(dirty, async()=>false)
|
||||
|
||||
const readonly=computed(()=>status.value!=='DRAFT')
|
||||
const totalQty=computed(()=>lines.value.reduce((sum,line)=>sum+Number(line.quantity||0),0))
|
||||
const total=computed(()=>lines.value.reduce((sum,line)=>sum+Number(line.amount||0),0))
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'items',label:'품목',value:`${lines.value.length.toLocaleString('ko-KR')}종`},
|
||||
{key:'qty',label:'총수량',value:totalQty.value},
|
||||
{key:'amount',label:'총 구매금액',value:`₩${total.value.toLocaleString('ko-KR')}`,emphasis:true},
|
||||
])
|
||||
const pageContext=computed(()=>({
|
||||
label:'신규 구매',
|
||||
hint:dirty.value?'저장하지 않은 입력이 있습니다.':'작성 상태 · 서버 반영 전',
|
||||
metrics:[
|
||||
{key:'status',label:'상태',value:'작성'},
|
||||
{key:'lines',label:'품목',value:lines.value.length},
|
||||
{key:'errors',label:'오류',value:errors.value.length,tone:'danger' as const,emphasis:errors.value.length>0},
|
||||
],
|
||||
}))
|
||||
|
||||
function fieldError(field:string){return errors.value.find(error=>!error.rowKey&&error.field===field)?.message}
|
||||
function clearError(rowKey:string|undefined,field:string){errors.value=errors.value.filter(error=>!(error.rowKey===rowKey&&error.field===field))}
|
||||
function recalc(line:PurchaseLine){line.amount=Number(line.quantity||0)*Number(line.unitPrice||0)}
|
||||
function addLine(){if(readonly.value)return;lines.value.push(newLine());touch()}
|
||||
async function addLineAndFocus(){addLine();const line=lines.value.at(-1);if(!line)return;await nextTick();detailGrid.value?.focusCell?.(line.clientId,'itemCode')}
|
||||
function duplicateLines(selected:PurchaseLine[]){if(readonly.value||!selected.length)return;lines.value.push(...selected.map(line=>({...line,clientId:crypto.randomUUID()})));touch()}
|
||||
|
||||
function validateDraft(){
|
||||
const next:KbxValidationError[]=[]
|
||||
if(!header.supplierId)next.push({field:'supplierId',code:'SUPPLIER_REQUIRED',message:'거래처를 선택하세요.'})
|
||||
if(!header.warehouseId)next.push({field:'warehouseId',code:'WAREHOUSE_REQUIRED',message:'입고창고를 선택하세요.'})
|
||||
if(!lines.value.length)next.push({code:'PURCHASE_LINE_REQUIRED',message:'구매 품목을 1건 이상 입력하세요.'})
|
||||
for(const line of lines.value){
|
||||
if(!line.itemId)next.push({rowKey:line.clientId,field:'itemCode',code:'ITEM_REQUIRED',message:'품목을 선택하세요.'})
|
||||
if(Number(line.quantity)<=0)next.push({rowKey:line.clientId,field:'quantity',code:'QUANTITY_POSITIVE',message:'수량은 0보다 커야 합니다.'})
|
||||
if(Number(line.unitPrice)<0)next.push({rowKey:line.clientId,field:'unitPrice',code:'UNIT_PRICE_NONNEGATIVE',message:'단가는 0 이상이어야 합니다.'})
|
||||
}
|
||||
errors.value=next
|
||||
return next.length===0
|
||||
}
|
||||
|
||||
async function onCellChanged(event:{row:PurchaseLine;field:keyof PurchaseLine & string;newValue:unknown}){
|
||||
clearError(event.row.clientId,event.field)
|
||||
if(event.field==='itemCode'){
|
||||
const code=String(event.newValue??'').trim()
|
||||
if(!code)Object.assign(event.row,{itemId:null,itemName:''})
|
||||
else{
|
||||
const item=await lookupRegistry.item.resolveByCode(code)
|
||||
if(item)Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName})
|
||||
else{
|
||||
Object.assign(event.row,{itemId:null,itemName:''})
|
||||
errors.value.push({rowKey:event.row.clientId,field:'itemCode',code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'})
|
||||
}
|
||||
}
|
||||
}
|
||||
if(event.field==='quantity'||event.field==='unitPrice')recalc(event.row)
|
||||
touch()
|
||||
}
|
||||
|
||||
function openGridLookup(event:{row:PurchaseLine;field:keyof PurchaseLine & string;entity:string}){
|
||||
if(readonly.value||event.entity!=='item')return
|
||||
activeLine.value=event.row
|
||||
activeLookupField.value=event.field
|
||||
itemLookupOpen.value=true
|
||||
}
|
||||
async function selectGridItem(item:KbxLookupItem<string>){
|
||||
if(!activeLine.value)return
|
||||
Object.assign(activeLine.value,{itemId:item.id,itemCode:item.code,itemName:item.displayName})
|
||||
clearError(activeLine.value.clientId,'itemCode')
|
||||
touch();itemLookupOpen.value=false
|
||||
await nextTick();detailGrid.value?.focusNextEditableCell?.(activeLine.value.clientId,activeLookupField.value)
|
||||
}
|
||||
|
||||
function resetDraft(){
|
||||
status.value='DRAFT'
|
||||
Object.assign(header,{purchaseDate:today(),supplierId:null,warehouseId:null,buyer:'',remark:''})
|
||||
lines.value=[newLine()]
|
||||
errors.value=[];pageNotice.value='';pendingReset.value=false;resetDirty()
|
||||
}
|
||||
function requestNew(){if(dirty.value){pendingReset.value=true;return}resetDraft()}
|
||||
function saveDraft(){
|
||||
pageNotice.value=''
|
||||
if(!validateDraft()){pageNotice.value='저장할 수 없습니다. 입력 오류를 확인하세요.';nextTick(()=>detailGrid.value?.focusError?.(1));return false}
|
||||
pageNotice.value='저장 Command가 아직 서버에 연결되지 않아 입력값을 반영하지 않았습니다. 화면 초안은 유지됩니다.'
|
||||
return false
|
||||
}
|
||||
function executeWorkflow(id:string){
|
||||
if(id==='confirm'){
|
||||
if(dirty.value){pageNotice.value='구매확정 전에 먼저 저장해야 합니다.';return}
|
||||
pageNotice.value='구매확정 Command가 서버에 연결되지 않아 상태를 변경하지 않았습니다.'
|
||||
return
|
||||
}
|
||||
pageNotice.value='현재 상태 변경은 서버 Domain Command가 연결된 뒤 실행할 수 있습니다.'
|
||||
}
|
||||
function command(id:string){
|
||||
if(id==='new')return requestNew()
|
||||
if(id==='save')return saveDraft()
|
||||
if(id==='confirm'||id==='cancel')return executeWorkflow(id)
|
||||
if(id==='excel')pageNotice.value='엑셀 메뉴는 공통 KbxExcelMenu 연결 후 활성화합니다. 임의 브라우저 Import는 수행하지 않습니다.'
|
||||
}
|
||||
function focusNextError(){detailGrid.value?.focusError?.(1)}
|
||||
|
||||
useKbxPageShortcuts([{key:'F8',execute:saveDraft}])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxTransactionPage
|
||||
:screen="purchaseScreen"
|
||||
:status="status"
|
||||
:dirty="dirty"
|
||||
:errors="errors"
|
||||
:workflow="purchaseWorkflow"
|
||||
:summary-items="summaryItems"
|
||||
:context="pageContext"
|
||||
header-title="구매 정보"
|
||||
header-description="거래처·입고창고·담당자 기준을 먼저 확정합니다."
|
||||
detail-title="구매 품목"
|
||||
:detail-count="lines.length"
|
||||
detail-count-unit="종"
|
||||
detail-description="수량·단가·납기일을 한 Grid에서 연속 입력합니다. F2로 품목을 조회할 수 있습니다."
|
||||
breadcrumb="ERP > 구매"
|
||||
@command="command"
|
||||
@transition="executeWorkflow"
|
||||
>
|
||||
<template #notice><div v-if="pageNotice" class="page-notice" role="status">{{pageNotice}}</div></template>
|
||||
<template #header>
|
||||
<KbxWorkflowBar :workflow="purchaseWorkflow" :current="status" @transition="executeWorkflow" />
|
||||
<KbxFormSection title="구매정보">
|
||||
<KbxFormGrid>
|
||||
<KbxDateField v-model="header.purchaseDate" label="구매일" required :readonly="readonly" @update:model-value="touch" />
|
||||
<KbxLookup v-model="header.supplierId" entity="customer" label="거래처" required :readonly="readonly" :error="fieldError('supplierId')" @selected="()=>{clearError(undefined,'supplierId');touch()}" />
|
||||
<KbxLookup v-model="header.warehouseId" entity="warehouse" label="입고창고" required :readonly="readonly" :error="fieldError('warehouseId')" @selected="()=>{clearError(undefined,'warehouseId');touch()}" />
|
||||
<KbxInput v-model="header.buyer" label="담당자" :readonly="readonly" @update:model-value="touch" />
|
||||
</KbxFormGrid>
|
||||
</KbxFormSection>
|
||||
</template>
|
||||
|
||||
<template #detail-actions>
|
||||
<KbxButton v-if="errors.length" label="첫/다음 오류" variant="ghost" @click="focusNextError" />
|
||||
<KbxButton v-if="!readonly" label="행 추가" variant="secondary" @click="addLineAndFocus" />
|
||||
</template>
|
||||
<template #detail>
|
||||
<KbxDataGrid
|
||||
ref="detailGrid"
|
||||
:rows="lines"
|
||||
:columns="purchaseColumns"
|
||||
row-key="clientId"
|
||||
aria-label="구매 품목"
|
||||
selection="multiple"
|
||||
:errors="errors"
|
||||
:editable="!readonly"
|
||||
:editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}"
|
||||
@row-add-requested="addLine"
|
||||
@row-duplicate-requested="duplicateLines"
|
||||
@cell-changed="onCellChanged"
|
||||
@lookup-requested="openGridLookup"
|
||||
/>
|
||||
</template>
|
||||
</KbxTransactionPage>
|
||||
|
||||
<KbxLookupDialog v-model:visible="itemLookupOpen" entity="item" title="품목" @select="selectGridItem" />
|
||||
<KbxUnsavedChangesDialog :open="pendingReset" :can-save="false" @stay="pendingReset=false" @discard="resetDraft" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-notice{padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
export interface PurchaseLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; quantity:number; unitPrice:number; amount:number; dueDate:string }
|
||||
export const purchaseScreen = defineKbxScreen({
|
||||
id:'ERP-PUR-001', version:'1.0.0', module:'ERP', type:'transaction', templateCode:'T03', title:'구매등록', helpKey:'ERP-PUR-001', permissions:['erp.purchase.read'], telemetry:{enabled:true},
|
||||
description:'거래처 구매를 Header/Detail 방식으로 입력하고 확정합니다.',
|
||||
commands:[{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.purchase.write'},{id:'confirm',label:'구매확정',group:'workflow',variant:'primary',permission:'erp.purchase.confirm'},{id:'excel',label:'엑셀',group:'output'}]
|
||||
})
|
||||
export const purchaseColumns: KbxGridColumn<PurchaseLine>[] = [
|
||||
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'}, {field:'itemName',header:'품목명',width:210},
|
||||
{field:'quantity',header:'수량',type:'quantity',width:95,editable:true}, {field:'unitPrice',header:'단가',type:'money',width:120,editable:true}, {field:'amount',header:'금액',type:'money',width:130}, {field:'dueDate',header:'납기일',type:'date',width:110,editable:true}
|
||||
]
|
||||
export const purchaseWorkflow: KbxWorkflowDefinition = { id:'erp.purchase', version:'1.0.0', states:[
|
||||
{value:'DRAFT',label:'작성',semantic:'draft'}, {value:'CONFIRMED',label:'확정',semantic:'processing'}, {value:'PARTIALLY_RECEIVED',label:'부분입고',semantic:'processing'}, {value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true}, {value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}
|
||||
], transitions:[
|
||||
{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'구매확정',permission:'erp.purchase.confirm',confirm:true}, {id:'cancel',from:['DRAFT','CONFIRMED'],to:'CANCELLED',label:'구매취소',permission:'erp.purchase.cancel',reasonRequired:true}
|
||||
] }
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const erpPurchaseRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/erp/purchases/new', name:'erp-purchase-new', component:()=>import('./PurchasePage.vue'), meta:{ screenId:'ERP-PUR-001' } },
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { isKbxProblem } from '@kbx/contracts'
|
||||
import { KbxDataGrid, KbxListPage, KbxSearchPanel, KbxWorkflowBar } from '@kbx/ui'
|
||||
import { claimColumns, claimScreen, claimSearchFields, claimWorkflow, type ClaimRow } from './claims.definition'
|
||||
import { claimsApi } from './claimsApi'
|
||||
|
||||
type ClaimActionReceipt={actionId:string;requested:number;succeeded:number;failed:number;failures:{claimNo:string;message:string}[]}
|
||||
const actionLabel:Record<string,string>={approve:'승인',hold:'보류',start:'처리 시작',complete:'처리 완료'}
|
||||
const allowedTransitions=new Set(Object.keys(actionLabel))
|
||||
const search=ref<Record<string,unknown>>({status:'REQUESTED'})
|
||||
const selected=ref<ClaimRow[]>([])
|
||||
const actionReceipt=ref<ClaimActionReceipt|null>(null)
|
||||
const receiptEl=ref<HTMLElement|null>(null)
|
||||
const q=useQuery({queryKey:['oms','claims',search],queryFn:()=>claimsApi.search(search.value),enabled:false})
|
||||
|
||||
async function run(){selected.value=[];actionReceipt.value=null;await q.refetch()}
|
||||
function failureMessage(cause:unknown){return isKbxProblem(cause)?cause.detail||cause.title:cause instanceof Error?cause.message:'업무 전이를 완료하지 못했습니다.'}
|
||||
async function executeTransition(actionId:string){
|
||||
if(!allowedTransitions.has(actionId))return
|
||||
const targets=[...selected.value]
|
||||
if(!targets.length)return
|
||||
if((actionId==='start'||actionId==='complete')&&targets.length!==1)return
|
||||
let succeeded=0
|
||||
const failures:ClaimActionReceipt['failures']=[]
|
||||
for(const row of targets){
|
||||
try{await claimsApi.transition(row.id,actionId);succeeded++}
|
||||
catch(cause){failures.push({claimNo:row.claimNo,message:failureMessage(cause)})}
|
||||
}
|
||||
actionReceipt.value={actionId,requested:targets.length,succeeded,failed:failures.length,failures}
|
||||
await q.refetch()
|
||||
selected.value=[]
|
||||
await nextTick();receiptEl.value?.focus()
|
||||
}
|
||||
async function command(id:string){if(id==='search')return run();return executeTransition(id)}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxListPage
|
||||
:screen="claimScreen"
|
||||
:selection-count="selected.length"
|
||||
:content-state="q.error.value?'error':q.isFetching.value&&!q.data.value?'loading':!q.data.value?'idle':q.data.value.totalCount===0?'empty':'ready'"
|
||||
:refreshing="q.isFetching.value&&Boolean(q.data.value)"
|
||||
@command="command"
|
||||
>
|
||||
<template #search><KbxSearchPanel v-model="search" :fields="claimSearchFields" @search="run" /></template>
|
||||
<template #notice>
|
||||
<section
|
||||
v-if="actionReceipt"
|
||||
ref="receiptEl"
|
||||
class="claim-action-receipt"
|
||||
data-kbx-surface="claim-action-receipt"
|
||||
tabindex="-1"
|
||||
role="status"
|
||||
:data-tone="actionReceipt.failed?'warning':'success'"
|
||||
>
|
||||
<strong>{{actionLabel[actionReceipt.actionId]}} 결과</strong>
|
||||
<span>요청 {{actionReceipt.requested}}건 · 성공 {{actionReceipt.succeeded}}건 · 실패 {{actionReceipt.failed}}건</span>
|
||||
<small v-if="actionReceipt.failures.length">{{actionReceipt.failures.map(item=>`${item.claimNo}: ${item.message}`).join(' · ')}}</small>
|
||||
</section>
|
||||
</template>
|
||||
<template #content>
|
||||
<KbxWorkflowBar v-if="selected.length===1" :workflow="claimWorkflow" :current="selected[0].status" @transition="command" />
|
||||
<KbxDataGrid
|
||||
:rows="q.data.value?.items ?? []"
|
||||
:columns="claimColumns"
|
||||
row-key="id"
|
||||
selection="multiple"
|
||||
:loading="q.isFetching.value"
|
||||
personalization
|
||||
exportable
|
||||
@selection-changed="selected=$event"
|
||||
/>
|
||||
</template>
|
||||
<template #summary>클레임 {{q.data.value?.totalCount ?? 0}}건 · 선택 {{selected.length}}건</template>
|
||||
</KbxListPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.claim-action-receipt{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:center;gap:var(--kbx-space-2);min-height:var(--kbx-control-height);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-success-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-success);background:var(--kbx-color-success-surface);font-size:var(--kbx-font-sm)}
|
||||
.claim-action-receipt[data-tone="warning"]{border-color:var(--kbx-color-warning-border);border-left-color:var(--kbx-color-warning);background:var(--kbx-color-warning-surface)}
|
||||
.claim-action-receipt small{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--kbx-color-text-muted)}
|
||||
.claim-action-receipt:focus-visible{outline:calc(var(--kbx-border-width) * 2) solid var(--kbx-color-focus);outline-offset:var(--kbx-space-1)}
|
||||
</style>
|
||||