V13-FE-006: consolidate approved UI and contract hardening
@@ -1,6 +1,6 @@
|
||||
# KBX Design Philosophy — Reference Index
|
||||
|
||||
이 디렉터리의 4개 문서는 K-ArtSell Aegis 프론트엔드가 채택하는 **디자인 철학 소스**다. `docs/Design/kbx-foundation-v36/`은 이 문서를 구현한 참조 코드(도메인은 OMS/WMS/ERP로 다르지만 UX 계약은 동일)이며, 이식 대상이 아니라 구현 참고용이다.
|
||||
이 디렉터리의 4개 문서는 K-ArtSell Aegis 프론트엔드가 채택하는 **디자인 철학 소스**다. `docs/Design/kbx-foundation-v60-status-canonical-contract-hardening/`은 이 문서를 구현한 최신 참조 코드(도메인은 OMS/WMS/ERP로 다르지만 UX 계약은 동일)이며, 이식 대상이 아니라 구현 참고용이다.
|
||||
|
||||
## 문서 역할
|
||||
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
# KBX Foundation v50 — FE Operational Truth · Retryability · Home Overflow · Theme Hardening
|
||||
|
||||
## v50 핵심
|
||||
|
||||
- T01~T09 주요 Template에 `errorRetryable` 계약을 전파해 영구 오류에서 무의미한 `[다시 조회]` 제거
|
||||
- `KbxTemplateStateBoundary 1.3.0`: transient error와 non-retryable error를 UI 계약으로 분리
|
||||
- `WMS-WORK-001 1.2.0`: `WMS_WORK_QUERY_NOT_CONNECTED`를 `retryable:false`로 수정, API 미연결에 무한 Retry 착시 제거
|
||||
- Home Attention: 기본 8개 유지, 총 건수 >24이면 `전체`가 아니라 `우선 24개 보기`로 정확히 표현
|
||||
- 24개 이후 남은 Attention은 `작업 센터 전체 / 알림 센터 전체`로 직접 연결
|
||||
- `KbxHomePage 1.10.0`, `KbxQueuePage 1.9.0`, 기타 T01~T09 Template 공개 Retry 계약 Minor Upgrade
|
||||
- Reference HTML/JavaScript도 28개 fixture로 8→24 bounded disclosure와 Center navigation 재현
|
||||
- KBX Business Light/Dark + Forced Colors semantic theme 유지, Theme는 Retry/Permission/Domain truth를 결정하지 않음
|
||||
- Browser Reference QA **16/16 PASS**, 전체 `validate:kbx` PASS, Design Debt **116 <= 131**
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v50.md`
|
||||
검증: `docs/validation-report-v50.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v49 — FE Workbench · WMS Truthful Boundary · Theme Hardening
|
||||
|
||||
## v49 핵심
|
||||
|
||||
- Home `확인 필요`가 상위 8개만 숨긴 채 끝나지 않도록 `8개 ↔ 전체(최대 24개)` 접근성 Disclosure 추가
|
||||
- Home 우선업무 출처를 미저장/실패/중요알림/진행/알림으로 요약해 AX 판단량 감소
|
||||
- `KbxQueuePage 1.8.0`: T06 Empty/Error/Retry copy와 Empty recovery Action 계약 추가
|
||||
- `WMS-WORK-001 1.1.0`: Demo에서는 실제 필터 가능한 Queue, 일반 Runtime에서는 미연결 API를 `0건`으로 가장하지 않고 Integration Problem으로 fail-explicit
|
||||
- Receiving/Putaway/Counting `1.1.0`: client-only 성공 상태를 Production에서 차단하고 Demo Mode에서만 FE scanner state machine 검증
|
||||
- `KbxWmsMobilePage 1.7.0`: Production execution 차단 시 빈 sticky action footer를 숨기는 `actionsVisible` 계약
|
||||
- WMS 네트워크 표시를 `useKbxNetworkState()`로 통일하고 hardcoded online 제거
|
||||
- Reference Home도 동일한 8→전체 Priority disclosure와 KBX Business Light/Dark semantic theme 적용
|
||||
- Browser Reference QA **10/10 PASS**: Attention disclosure, keyboard collapse, Dark theme, 1280×720 overflow 0, runtime error 0
|
||||
- WMS에서 Server validation / Idempotency / Audit 이전의 성공표시는 금지한다는 경계를 FE Gate로 고정
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v49.md`
|
||||
검증: `docs/validation-report-v49.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v48 — FE Template Completion · Real Search/File · Theme-safe Recovery Hardening
|
||||
|
||||
## v48 핵심
|
||||
|
||||
- 실제 Vue T04/T06/T07/T08에 `KbxProblemFeedback` 기반 Actionable Problem Recovery 확장
|
||||
- T04~T08 Template 계층의 `problem / retry / dismiss` 계약 통일
|
||||
- `KbxProblemFeedback 1.1.0`: 상위 Validation Problem의 bounded summary 추가, Field/Cell Validation 역할은 유지
|
||||
- Reference T05/T06/T07: Toast/표시 위주의 조회를 실제 검색 → Loading → 결과/Empty → 복구 상태 전이로 교체
|
||||
- Chromium QA에서 발견한 Search Panel Mouse no-op 결함 수정: T05/T06/T07 `[조회]` click과 Enter/F3 동일 실행
|
||||
- Reference T08: 샘플 파일명 주입 제거, 실제 Browser File input/Drop + `.xlsx/.xls` + 20MB guard
|
||||
- T08 6단계(`파일→매핑→검증→미리보기→반영→결과`)와 CSS 6-column 정합화
|
||||
- KBX Business Light/Dark semantic theme, Home/Workspace, Permission/Domain server-authority 경계 유지
|
||||
- Browser Reference QA **30/30 PASS**: T05~T08 completion loops, Dark theme, 1280px overflow 0, runtime error 0
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v48.md`
|
||||
검증: `docs/validation-report-v48.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v47 — Actionable Problem Recovery · Theme-safe FE Hardening
|
||||
|
||||
## v47 핵심
|
||||
|
||||
- 실제 T01/T02/T03에서 Command 실패를 조회 Error와 분리: `business-rule / permission / not-found / integration / system` 인라인 Problem Surface
|
||||
- `KbxProblemFeedback 1.0.0`: 문제 유형 텍스트, 원인, 참조번호, retryable 재시도, 닫기
|
||||
- Server `problem.actions`는 기본 미노출 + 명시적 allow-list만 렌더링하여 임의 Action ID → FE Command 실행 차단
|
||||
- `OMS-ORD-001` Bulk 출고, `OMS-ORD-002` 저장/확정, `ERP-MST-ITEM-001` 목록 로드/저장/사용중지 실패 시 현재 화면·선택·입력 Context 유지
|
||||
- `KbxListPage 1.8.0`, `KbxMasterPage 1.7.0`, `KbxTransactionPage 1.7.0`
|
||||
- Reference HTML/JavaScript: 예외 주문 출고지시 → `업무 규칙` 인라인 오류 → 문제 주문만 선택 복구
|
||||
- Browser Reference QA **12/12 PASS**: Dark Theme, 문제표시/복구/닫기, 1280px overflow 0, runtime error 0
|
||||
- 전체 Problem 계약에서도 Validation은 Field/Cell, Conflict는 최신값 비교, Command Problem은 인라인 복구로 역할 분리
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v47.md`
|
||||
검증: `docs/validation-report-v47.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v46 — Safe Workspace Resume · T09 Recovery · Theme-preserving FE Hardening
|
||||
|
||||
## v46 핵심
|
||||
|
||||
- 열린 Workspace의 **clean 업무 위치만 sessionStorage**에 복원, Query/Hash/Dirty Form·Grid 값은 저장 금지
|
||||
- 복원 전 Permission + Screen Registry + Safe Route 재검증, `resumed` 상태는 실제 업무 진입 시 제거
|
||||
- Home `바로 시작`에서 `복원` 업무를 구분하고 미저장 편집은 안전상 복원 제외 사실만 표시
|
||||
- Home에서 active tab이 없어도 첫 Workspace Tab을 keyboard roving entry로 제공
|
||||
- Navigation Preference의 최초 localStorage read까지 fail-soft로 통일
|
||||
- `KbxWorkspaceTabs 1.3.0`, `KbxHomePage 1.8.0`, `KbxWmsMobilePage 1.6.0`
|
||||
- T09 WMS Picking: TanStack Query Loading/Error/Ready → 공통 Mobile State Boundary, 오류 `[작업 다시 불러오기]` 실제 refetch 연결
|
||||
- Reference HTML/CSS/JavaScript Browser QA **20/20 PASS**: clean resume, dirty exclusion, storage-blocked Shell, Dark Theme, 1280px overflow
|
||||
- Theme/Permission/Domain 경계는 유지: Theme/Resume는 FE 표현·생산성 상태이며 업무 truth가 아님
|
||||
- 전체 `validate:kbx` PASS: 153 tokens / 20 screens / 84 components / 69 catalog entries / 306 TS·Vue units / Design Debt **116 <= 131**
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v46.md`
|
||||
검증: `docs/validation-report-v46.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v45 — Safe Preference · Home Context · Theme Hardening
|
||||
|
||||
## v45 핵심
|
||||
|
||||
- 실제 Vue `OMS-ORD-001`에 `기본조건 저장 / 마지막 조회조건 기억`을 연결하되 자유검색어는 localStorage 저장에서 제외
|
||||
- Screen Preference key를 `Tenant/User scope + ScreenId + ScreenVersion`으로 격리하고 size/type 검증 + fail-soft 적용
|
||||
- Home의 실패/진행 작업 클릭 시 Runtime Center 전체가 아니라 해당 Operation/Notification으로 Focus
|
||||
- `KbxSearchPanel 1.4.0`, `KbxOperationCenter 1.1.0`, `KbxNotificationCenter 1.1.0`
|
||||
- HTML/JavaScript Reference Lab에도 동일한 안전한 조회조건 기억과 Light/Dark Theme parity 적용
|
||||
- Browser QA 14/14: 저장/복원, 자유검색어 비저장, Dark Theme, 1280 Home overflow, runtime error 0
|
||||
- Domain Rule·Permission·Concurrency·Audit은 여전히 Server authority이며 Preference는 표현/생산성 상태에 한정
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v45.md`
|
||||
검증: `docs/validation-report-v45.md`
|
||||
|
||||
|
||||
## v44 핵심
|
||||
|
||||
- 실제 App artifact version `v44-fe-theme-state`로 정합화
|
||||
- Header 작업/알림 trigger ↔ runtime panel `aria-expanded/aria-controls` 연결
|
||||
- runtime panel: 명시 제목/닫기, Esc 닫기, open 시 panel focus, close 시 trigger focus restore
|
||||
- route 전환/메뉴검색/Utility Rail 진입 시 non-modal runtime panel을 정리해 surface stacking 방지
|
||||
- Windows Forced Colors에서 Light/Dark palette 대신 OS system color로 fail-safe
|
||||
- Forced Colors에서도 현재 화면/선택/Focus를 색상 하나가 아니라 outline/텍스트 상태로 유지
|
||||
- T01 Empty → `[조회조건 초기화]` → 즉시 재조회 공통 Recovery contract
|
||||
- FE Reference Lab 작업/알림을 실제 Vue와 동일한 non-modal System Panel로 정렬
|
||||
- FE Reference Lab에도 동일 high-contrast bridge 적용
|
||||
- Browser matrix **28/28 PASS**: T01~T09 Light/Dark, T01 Empty recovery, 1280px, Forced Colors, runtime error 0
|
||||
- v44 전용 static QA gate를 전체 `validate:kbx`에 편입
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v44.md`
|
||||
검증: `docs/validation-report-v44.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v43 — Semantic Theme · Home Navigation Hierarchy · Light/Dark FE Hardening
|
||||
|
||||
이번 iteration은 v42의 first-class Vue runtime 위에 **KBX Business Light / Dark를 실제 업무 Semantic Theme으로 통합**한다. Theme를 장식용 팔레트로 만들지 않고 `surface / text / border / primary / success / warning / danger / focus / AI` 의미 계약을 Application Shell, Home, PrimeVue, AG Grid, 공통 Component, FE Reference Lab까지 공유한다.
|
||||
|
||||
## v43 핵심
|
||||
|
||||
- Design Token source 1.9.0: `KBX Business Light / Dark` semantic override 계약
|
||||
- Theme runtime API + `light|dark` allow-list/fail-safe preference persistence
|
||||
- Global Header에 명시적 Light/Dark 전환 + 접근성 label + narrow-header reflow
|
||||
- PrimeVue dark selector와 KBX theme root 연결
|
||||
- AG Grid Quartz foreground/background/accent/border/header/hover/selection을 KBX semantic token으로 연결
|
||||
- Home: `확인 필요 → 바로 시작 → 모듈 업무`의 시각 우선순위를 warning/danger/info semantic으로 강화하되 Dashboard card 증식 금지
|
||||
- Side Navigation 현재 화면 primary inset marker
|
||||
- Network/Dialog/Toast/Reference surface 하드코딩 감소
|
||||
- FE Reference Lab Light/Dark parity + 실제 Dark inheritance 결함 발견/수정
|
||||
- Theme Browser QA **16/16 PASS**: T01~T09 Dark render, T09 WMS mobile mode, 1280px overflow 0, runtime error 0
|
||||
- 전체 `validate:kbx` PASS / 305 TS·Vue script units / Design Debt **116 (ratchet 131 대비 감소)**
|
||||
|
||||
### v43에서 완료로 계산하지 않는 항목
|
||||
|
||||
첨부 표준은 Semantic Color/Density/Accessibility를 요구하지만 Light/Dark 자체를 명시적으로 강제하지 않는다. Dark는 사용자 요청에 따른 구현 확장이다. 또한 현재 실행환경은 package registry 접근이 차단되어 **실제 Vite + PrimeVue + AG Grid live-runtime Theme E2E를 통과했다고 주장하지 않는다.** Browser 증적은 실제 FE Reference HTML/CSS/JS asset 기준이며 production CSP는 별도 정적 Gate로 검증한다. Server-side full-query XLSX 역시 기존 잔여 P1이다.
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v43.md`
|
||||
검증: `docs/validation-report-v43.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v42 — First-class Vue Runtime · Secure Demo Transport · Home Context · Excel Menu · Zoom Hardening
|
||||
|
||||
이번 iteration은 v41의 Golden Screen parity를 넘어 **실제 FE 제품을 독립적으로 기동·빌드·검증할 수 있는 Runtime 경계**를 우선 완성한다. BE 결합을 더 늘리지 않고 `apps/web`을 Vite 8 application으로 명시하고, Demo Mode도 별도 Mock UI가 아니라 실제 Axios/kbxApi 경계의 transport adapter로 구성했다.
|
||||
|
||||
## v42 핵심
|
||||
|
||||
- `apps/web` first-class Vite 8 package / `index.html` / `main.ts` / `App.vue` / typecheck-build scripts
|
||||
- Vue + Pinia + Router + TanStack Query + PrimeVue + `installKbx()` 실제 application bootstrap
|
||||
- `--mode demo`에서만 fixture HTTP adapter 사용, 일반 runtime은 real transport + permission fail-closed
|
||||
- Demo unknown operation은 성공 위장이 아니라 `DEMO_OPERATION_UNSUPPORTED` 명시 실패
|
||||
- Home/Notification의 OMS 예외 Context를 `useOrderSearch()`가 allow-list로 hydrate하고 자동 조회
|
||||
- `KbxCommandBar` ↔ `KbxExcelMenu` 실제 통합, Excel 5개 표준 진입점 + keyboard/focus contract
|
||||
- 일부 row만 로드된 경우 local CSV를 전체 Export로 오인하지 않도록 다운로드 차단
|
||||
- 56rem/40rem Shell pressure hardening: 200% Zoom에서 Workspace 우선 보존
|
||||
- v42 runtime-readiness Gate를 전체 `validate:kbx`에 편입
|
||||
- 304 TS/Vue syntax + 전체 Governance PASS / Design Debt **122 (ratchet 131 대비 감소)**
|
||||
|
||||
### v42에서 완료로 계산하지 않는 항목
|
||||
|
||||
현재 실행환경은 npm registry 접근이 차단되어 pnpm 의존성 설치가 불가능하다. 따라서 **실제 Vite build/server와 Vue/PrimeVue/AG Grid Playwright를 통과했다고 주장하지 않는다.** 또한 T01의 전체 검색조건 XLSX/Background Export API는 아직 미연결이며, 부분 row CSV는 전체 Export로 오인될 수 있어 의도적으로 차단한다.
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v42.md`
|
||||
검증: `docs/validation-report-v42.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v41 — Actual Vue Golden Screen Parity · Controlled Grid Selection · Drawer/Dirty/Focus Hardening
|
||||
|
||||
이번 iteration은 v40의 FE Reference Lab을 더 확장하지 않고, **실제 Vue/PrimeVue/AG Grid Golden Screen이 동일한 업무 UX 계약을 지키는지**를 우선 검수한다. 대상은 `OMS-ORD-001`, `OMS-ORD-002`, `ERP-MST-ITEM-001`, `KbxDataGrid`, `KbxLookup`, `KbxUnsavedChangesDialog`, Application Shell navigation이다.
|
||||
|
||||
## v41 핵심
|
||||
|
||||
- OMS-ORD-001 주문번호 Link의 no-op 제거: 실제 Order Detail Drawer 연결
|
||||
- 행 Double Click도 Drawer 우선으로 통일하여 List 조회 Context 보존
|
||||
- Drawer async request sequence guard로 늦게 도착한 응답의 stale overwrite 방지
|
||||
- KbxDataGrid: AG Grid 33+ multiRow selection contract, checkbox/header checkbox, 외부 Selection State ↔ 내부 Grid selection 동기화
|
||||
- Grid error navigation이 Header/Form 오류를 Cell 오류로 잘못 세지 않도록 grid-scoped error 분리
|
||||
- KbxDataGrid에 `focusCell`, `focusNextEditableCell`, `selectRowByKey`, `clearSelection` 제어 API 추가
|
||||
- KbxLookup: 직접 코드 Enter 성공도 다음 Control로 Focus 이동, 선택/취소 Focus 정책 분리, 접근성 label 고유화
|
||||
- ERP-MST-ITEM-001: Dirty 상태에서 다른 Master 선택/New 전환 시 3-way Guard로 편집값 덮어쓰기 방지
|
||||
- OMS-ORD-002: F2 품목 선택 후 다음 Editable Cell Focus, New/Copy 내부 전환에도 Dirty Guard 적용
|
||||
- KbxUnsavedChangesDialog: Esc/Tab/Shift+Tab Focus Trap + 닫힌 후 Focus Restore
|
||||
- OMS 주문관리의 구현되지 않은 `보류` no-op Command 제거
|
||||
- Side Navigation 즐겨찾기 접근성 이름과 현재 화면 상태 보강
|
||||
- Component version: KbxDataGrid 1.5.0 / KbxLookup 1.4.0 / UnsavedChangesDialog 1.1.0 / SideNavigation 1.3.1
|
||||
- 전체 `validate:kbx` PASS / Design Debt **131 유지**
|
||||
|
||||
### v41에서 완료로 계산하지 않는 항목
|
||||
|
||||
현재 OMS 주문조회 `엑셀` 동작은 AG Grid CSV export 수준이다. KBX 표준의 Excel 메뉴 전체 계약(현재 조회결과 Excel, 양식, 업로드, 붙여넣기, 최근 업로드)은 아직 완료가 아니므로 **잔여 P1**로 유지한다. 또한 이 산출물에는 `node_modules`가 없어 실제 Vue runtime E2E/visual regression을 실행하지 못했다. 이번 v41 증적은 TypeScript/Vue syntax transpile + 전체 Foundation governance/static parity gate이며, 실제 앱 런타임 E2E는 다음 Gate다.
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v41.md`
|
||||
검증: `docs/validation-report-v41.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v40 — FE Completion Loop · Validation-safe Shortcut · Master/Transaction/Queue/Reconcile Hardening
|
||||
|
||||
이번 iteration은 BE 구조나 새 Metadata Framework를 늘리지 않고, 실제 HTML/CSS/JavaScript FE에서 **T01~T09의 최소 업무 완료 루프가 닫히는지**를 기준으로 고도화한다. v39의 Navigation/Lookup/Drawer/Job/WMS 기반 위에서 T01 실제 검색, T02 Master CRUD, T03 Header+Detail 저장/확정, T06 Queue Action, T07 Reconcile Resolution을 실제 상태 전이로 연결했다.
|
||||
|
||||
## v40 핵심
|
||||
|
||||
- T01 검색조건(판매채널/상태/키워드/예외) ↔ 실제 Grid 결과 결합
|
||||
- T02 Master 신규/수정/복사/검증/F2 Lookup/저장/상태/Dirty 전환
|
||||
- T03 Header+Detail 입력/계산/Validation/F8 저장/작성→확정
|
||||
- T06 선택 기반 작업자 배정/Wave 생성 실제 Row 갱신
|
||||
- T07 선택 기반 재처리 후 Expected/Actual/Difference 및 mismatch 목록 갱신
|
||||
- Global F8이 실제 화면 Save Handler를 호출하도록 수정 — Validation 우회 제거
|
||||
- Hash/Refresh/Close까지 Dirty 보호 확대
|
||||
- Browser QA **49/49 PASS**, Runtime Error 0
|
||||
- 1280×720 document overflow 0, WMS 390×844 Full View
|
||||
- Design Debt ratchet **131 유지**
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v40.md`
|
||||
검증: `docs/validation-report-v40.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v39 — FE Product Parity · Lookup · Dirty Navigation · Job/WMS Hardening
|
||||
|
||||
이번 iteration은 BE 구조를 늘리지 않고 실제 HTML/CSS/JavaScript와 핵심 Vue Component의 사용성을 고도화한다. v38의 상태전이 기반 위에 **Context-aware Home, 화면 이동 Dirty Guard, 실제 F2 Lookup, Utility Drawer, T08 Background Job UX, T09 Mobile Full View/중복 Scan Guard**를 추가했다.
|
||||
|
||||
## v39 핵심
|
||||
|
||||
- Home 예외 → 실제 대상 필터/Context 적용 후 이동
|
||||
- Dirty 상태에서 Tab Close뿐 아니라 Side Nav/Home/다른 Tab 이동도 3-way Guard
|
||||
- T02/T03 F2 Lookup: Search/Arrow/Enter/Esc/Focus Next
|
||||
- 실제 `KbxLookup.vue`: 선택 후 다음 Control, 취소 시 Trigger Focus 복원
|
||||
- Help/AI/Suggestion/Job/Notification 실제 Drawer + inert/Focus Trap
|
||||
- T08 6단계 + Background Progress + 화면 이탈/복귀
|
||||
- T09 중복 Barcode Guard + Network Failure No-Commit UX
|
||||
- 767px 이하 WMS Mobile Full View
|
||||
- 1280×720 및 720px zoom-pressure document overflow 없음
|
||||
- Browser QA 31/31 PASS · Runtime Error 0
|
||||
- Design Debt ratchet 131 유지
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v39.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v38 — FE QA Hardening · Interaction-complete Reference Lab
|
||||
|
||||
이 패키지는 기존 v36 Foundation 거버넌스를 유지하면서 v37 FE Reference Lab을 **실제 상태 전이·복구·키보드·보안 실패 격리** 기준으로 고도화한 v38 작업본이다. 이번 iteration은 BE 구조를 확장하지 않고 실제 HTML/CSS/JavaScript FE 완성도에 집중한다.
|
||||
|
||||
## v38 핵심
|
||||
|
||||
- Home: 예외 우선 → 이어서 처리 → 모듈별 업무 → Ctrl+K 검색
|
||||
- T01: Quick Filter/전체선택/Bulk/Drawer/Data State/선택 기반 Command disable
|
||||
- T02: Master→Detail 실제 전환 + Dirty 보호
|
||||
- T03: Header/Detail edit + Enter 흐름 + Dirty/Save
|
||||
- T04: Fast Entry input + TSV paste + Add/Duplicate/Fill Down + error gate
|
||||
- T05: 품목→Location→재고이력 동시 문맥 갱신
|
||||
- T06/T07: Queue/불일치 필터 실제 적용
|
||||
- T08: 파일 gate + 이전/다음 + Mapping/Validation/Commit/Result
|
||||
- T09: keyboard-wedge Scan + wrong barcode recovery + quantity/exception dialog
|
||||
- CSP + dynamic HTML escape + localStorage fail-soft + prompt/eval/document.write 금지
|
||||
- Headless Chromium interaction evidence 및 1440×900/390px 화면 증적
|
||||
- Design Debt ratchet **131 유지**
|
||||
|
||||
## v38 검증
|
||||
|
||||
```bash
|
||||
node scripts/validate-fe-reference.mjs
|
||||
node scripts/validate-kbx.mjs
|
||||
```
|
||||
|
||||
상세: `docs/frontend/KBX-FE-QA-Hardening-v38.md`
|
||||
|
||||
---
|
||||
|
||||
# KBX Foundation v36 — Recipe Verification · Generated Test Plan · Deterministic Home Workbench
|
||||
|
||||
KBX v36는 v35의 Screen Recipe / Secure Scaffolder 기반을 실제 검증계약까지 닫는 Foundation iteration이다. 목표는 화면·컴포넌트 수를 더 늘리는 것이 아니라, T01~T09를 선택한 순간부터 표준 UX, 보안, 복구, 테스트 증적이 함께 따라오도록 만드는 것이다.
|
||||
|
||||
## v36 핵심
|
||||
|
||||
- T01~T09 `testProfile` 추가
|
||||
- 필수 Scenario Kind
|
||||
- 필수 Behavioral Tag
|
||||
- 필수 Evidence
|
||||
- 필수 Recovery/Interaction Check
|
||||
- `generated/screen-recipe-verification-manifest.json` 추가
|
||||
- T01~T09 Recipe Verification **9/9 자동 폐쇄성**
|
||||
- 각 Recipe는 실제 해당 Template을 사용하는 Screen의 Canonical E2E Scenario를 최소 1개 요구
|
||||
- T02 Master / T06 Queue / T07 Reconcile Canonical E2E Recovery Scenario 추가
|
||||
- Scaffolder가 신규 Vertical Slice에 `*.test-plan.ts` 자동 생성
|
||||
- 생성 Test Plan은 Canonical Scenario / Required Check / Required Evidence를 타입 계약으로 보존
|
||||
- 기존 Master / Transaction / Fast Entry의 명시적 `--write-permission` fail-closed 정책 유지
|
||||
- Home Attention Queue를 동일 우선순위 내 최신 발생 순으로 정렬
|
||||
- Home은 표시 상한과 별개로 권한 필터 후 실제 전체 건수 및 overflow를 정확히 표시
|
||||
- `buildKbxHomeAttentionQueue()` public API 추가, 기존 `buildKbxHomeAttention()` 호환 유지
|
||||
- Screen Recipe `testProfile` / `canonicalScenarioIds` 변경을 Release Impact에서 추적
|
||||
- Design Debt ratchet **131 유지**
|
||||
|
||||
## 검증
|
||||
|
||||
```bash
|
||||
node scripts/validate-kbx.mjs
|
||||
```
|
||||
|
||||
최종 검증 기준:
|
||||
|
||||
- 299 TS/Vue script units
|
||||
- 20 Screen Definitions
|
||||
- 84 Component Definitions
|
||||
- 66 Component Catalog entries
|
||||
- 153 Design Tokens
|
||||
- 9 Screen Recipes
|
||||
- 30 Canonical Test Scenarios
|
||||
- Recipe Verification 9/9
|
||||
- 16 Navigation Entries
|
||||
- Design Debt 131 <= 131
|
||||
- Release Impact major / major
|
||||
|
||||
## 문서
|
||||
|
||||
- `docs/screen-recipe-verification-home-attention-v36.md`
|
||||
- `docs/kbx-v36-standard-traceability.md`
|
||||
- `docs/validation-report-v36.md`
|
||||
- `docs/release/migration-guide.md`
|
||||
|
||||
## FE Reference Lab (v37)
|
||||
|
||||
Backend 없이 실제 브라우저에서 Application Shell, 홈 네비게이션과 T01~T09를 검증하려면 `apps/web/fe-reference/`를 사용한다.
|
||||
|
||||
```bash
|
||||
cd apps/web/fe-reference
|
||||
python3 -m http.server 4173
|
||||
```
|
||||
|
||||
FE 정적 계약 검증:
|
||||
|
||||
```bash
|
||||
pnpm validate:fe-reference
|
||||
```
|
||||
|
||||
상세 설계 기록: `docs/frontend/KBX-FE-Completion-v37.md`
|
||||
@@ -1,129 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
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)
|
||||
let retryAction:(()=>Promise<void>)|null=null
|
||||
const pageContext=computed(()=>({label:'예외 업무 Queue',hint:'정상 건은 제외하고 해결이 필요한 업무만 표시합니다.',metrics:[{key:'total',label:'대상',value:query.data.value?.totalCount??0},{key:'selected',label:'선택',value:selectedRows.value.length}]}))
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: computed(() => ['operations', 'work-items', applied.value]),
|
||||
queryFn: () => operationsApi.search(applied.value),
|
||||
})
|
||||
|
||||
useKbxPageShortcuts([{ key: 'F3', execute: () => search() }])
|
||||
|
||||
async function search() {
|
||||
problem.value=null; retryAction=null
|
||||
selectedRows.value = []
|
||||
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 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()=>{await operationsApi.claim(ids);selectedRows.value=[];await query.refetch()})
|
||||
}
|
||||
|
||||
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') await operationsApi.claim([item.id])
|
||||
if (action.kind === 'resolve') { await operationsApi.resolve([item.id]); 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()
|
||||
})
|
||||
}
|
||||
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"
|
||||
@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 #content>
|
||||
<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>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxDataGrid, 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 problem=ref<KbxProblem|null>(null)
|
||||
let retryAction:(()=>Promise<void>)|null=null
|
||||
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}]}))
|
||||
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 = []
|
||||
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 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 command(id: string) {
|
||||
if (id === 'search') return search()
|
||||
if (id === 'createException' && selectedRows.value.length) {
|
||||
const ids=selectedRows.value.map(x=>x.id)
|
||||
return runProblemAware(async()=>{await reconcileApi.createExceptions(ids);selectedRows.value=[];await query.refetch()})
|
||||
}
|
||||
}
|
||||
async function retryProblem(){const retry=retryAction;problem.value=null;retryAction=null;if(retry)await retry()}
|
||||
function dismissProblem(){problem.value=null;retryAction=null}
|
||||
</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 #content>
|
||||
<KbxDataGrid
|
||||
:rows="query.data.value?.items ?? []"
|
||||
:columns="reconcileColumns"
|
||||
row-key="id"
|
||||
selection="multiple"
|
||||
:loading="query.isFetching.value"
|
||||
@selection-changed="selectedRows = $event"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>불일치 건은 원천 데이터를 직접 수정하지 않고 예외 업무로 전환해 담당자가 원인을 확인합니다.</template>
|
||||
</KbxReconcilePage>
|
||||
</template>
|
||||
@@ -1,9 +0,0 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { KbxDataGrid, KbxDateField, KbxFormGrid, KbxFormSection, KbxLookup, KbxTransactionPage, KbxWorkflowBar } from '@kbx/ui'
|
||||
import { inventoryMoveColumns, inventoryMoveScreen, inventoryMoveWorkflow, type InventoryMoveLine } from './inventory-move.definition'
|
||||
const status=ref('DRAFT'); const header=reactive({moveDate:new Date().toISOString().slice(0,10),fromWarehouseId:null as string|null,toWarehouseId:null as string|null}); const lines=ref<InventoryMoveLine[]>([])
|
||||
function command(id:string){ const t=inventoryMoveWorkflow.transitions.find(x=>x.id===id && x.from.includes(status.value)); if(t) status.value=t.to }
|
||||
</script>
|
||||
<template><KbxTransactionPage :screen="inventoryMoveScreen" :status="status" :summary-items="[{key:'items',label:'품목',value:`${lines.length}종`}]" header-title="이동 정보" header-description="출발/도착 창고와 이동일을 먼저 확인합니다." detail-title="이동 품목" :detail-count="lines.length" detail-description="가용재고와 이동수량을 비교하며 입력합니다." @command="command"><template #header><KbxWorkflowBar :workflow="inventoryMoveWorkflow" :current="status" @transition="command"/><KbxFormSection title="이동정보"><KbxFormGrid><KbxDateField v-model="header.moveDate" label="이동일" required/><KbxLookup v-model="header.fromWarehouseId" entity="warehouse" label="출발창고" required/><KbxLookup v-model="header.toWarehouseId" entity="warehouse" label="도착창고" required/></KbxFormGrid></KbxFormSection></template><template #detail><KbxDataGrid :rows="lines" :columns="inventoryMoveColumns" row-key="clientId" editable clipboard/></template></KbxTransactionPage></template>
|
||||
@@ -1,19 +0,0 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { KbxDataGrid, KbxDateField, KbxFormGrid, KbxFormSection, KbxLookup, KbxInput, KbxTransactionPage, KbxWorkflowBar } from '@kbx/ui'
|
||||
import { purchaseColumns, purchaseScreen, purchaseWorkflow, type PurchaseLine } from './purchase.definition'
|
||||
const status=ref('DRAFT'); const lines=ref<PurchaseLine[]>([{clientId:crypto.randomUUID(),itemId:null,itemCode:'',itemName:'',quantity:1,unitPrice:0,amount:0,dueDate:''}])
|
||||
const header=reactive({ purchaseDate:new Date().toISOString().slice(0,10), supplierId:null as string|null, warehouseId:null as string|null, buyer:'', remark:'' })
|
||||
const total=computed(()=>lines.value.reduce((s,x)=>s+x.quantity*x.unitPrice,0))
|
||||
function command(id:string){ if(id==='new'){ status.value='DRAFT'; lines.value=[] } if(id==='confirm' && status.value==='DRAFT') status.value='CONFIRMED' }
|
||||
</script>
|
||||
<template>
|
||||
<KbxTransactionPage :screen="purchaseScreen" :status="status" :summary-items="[{key:'amount',label:'총 구매금액',value:`₩${total.toLocaleString('ko-KR')}`,emphasis:true}]" header-title="구매 정보" header-description="거래처·입고창고·담당자 기준을 먼저 확정합니다." detail-title="구매 품목" :detail-count="lines.length" detail-description="수량·단가·납기일을 한 Grid에서 연속 입력합니다." @command="command">
|
||||
<template #header>
|
||||
<KbxWorkflowBar :workflow="purchaseWorkflow" :current="status" @transition="command" />
|
||||
<KbxFormSection title="구매정보"><KbxFormGrid><KbxDateField v-model="header.purchaseDate" label="구매일" required/><KbxLookup v-model="header.supplierId" entity="customer" label="거래처" required/><KbxLookup v-model="header.warehouseId" entity="warehouse" label="입고창고" required/><KbxInput v-model="header.buyer" label="담당자"/></KbxFormGrid></KbxFormSection>
|
||||
</template>
|
||||
<template #detail><KbxDataGrid :rows="lines" :columns="purchaseColumns" row-key="clientId" editable clipboard /></template>
|
||||
</KbxTransactionPage>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxDataGrid, KbxListPage, KbxSearchPanel, KbxWorkflowBar } from '@kbx/ui'
|
||||
import { claimColumns, claimScreen, claimSearchFields, claimWorkflow, type ClaimRow } from './claims.definition'
|
||||
import { claimsApi } from './claimsApi'
|
||||
const search = reactive<Record<string,unknown>>({ status:'REQUESTED' })
|
||||
const selected = ref<ClaimRow[]>([])
|
||||
const q = useQuery({ queryKey:['oms','claims',search], queryFn:()=>claimsApi.search(search), enabled:false })
|
||||
async function run(){ selected.value=[]; await q.refetch() }
|
||||
async function command(id:string){ if(id==='search') return run(); if(['approve','hold'].includes(id)) for(const row of selected.value) await claimsApi.transition(row.id,id); await run() }
|
||||
</script>
|
||||
<template>
|
||||
<KbxListPage :screen="claimScreen" :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 #content>
|
||||
<KbxWorkflowBar v-if="selected.length === 1" :workflow="claimWorkflow" :current="selected[0].status" @transition="command" />
|
||||
<KbxDataGrid v-model:selection="selected" :rows="q.data.value?.items ?? []" :columns="claimColumns" row-key="id" selection="multiple" :loading="q.isFetching.value" personalization exportable />
|
||||
</template>
|
||||
<template #summary>클레임 {{ q.data.value?.totalCount ?? 0 }}건 · 선택 {{ selected.length }}건</template>
|
||||
</KbxListPage>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxRecordStatePolicy, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
|
||||
export interface OrderLineForm { clientId:string; itemId:string|null; itemCode:string; itemName:string; availableQty:number|null; orderQty:number; unitPrice:number; amount:number; remark:string }
|
||||
|
||||
export const orderWorkflow:KbxWorkflowDefinition={id:'oms.order.lifecycle',version:'1.0.0',states:[
|
||||
{value:'신규',label:'신규',semantic:'draft'},{value:'작성',label:'작성',semantic:'draft'},{value:'확정',label:'확정',semantic:'pending'},{value:'할당',label:'할당',semantic:'processing'},{value:'피킹',label:'피킹',semantic:'processing'},{value:'검수',label:'검수',semantic:'processing'},{value:'출고완료',label:'출고완료',semantic:'completed',terminal:true}],
|
||||
transitions:[{id:'confirm',from:['작성'],to:'확정',label:'주문확정',permission:'oms.order.confirm',confirm:true}]}
|
||||
export const orderStatePolicies:KbxRecordStatePolicy[]=[{status:'신규',editability:'editable'},{status:'작성',editability:'editable'},{status:'확정',editability:'readonly',message:'확정된 주문은 직접 수정할 수 없습니다.'},{status:'할당',editability:'readonly'},{status:'피킹',editability:'readonly'},{status:'검수',editability:'readonly'},{status:'출고완료',editability:'readonly'}]
|
||||
|
||||
export const orderRegisterScreen = defineKbxScreen({
|
||||
id:'OMS-ORD-002',version:'1.3.0',module:'OMS',type:'transaction', templateCode:'T03',title:'주문등록',helpKey:'OMS-ORD-002',permissions:['oms.order.read','erp.item.read'],
|
||||
commands:[
|
||||
{id:'new',label:'신규',group:'edit'},
|
||||
{id:'save',label:'저장',group:'edit',variant:'primary',shortcut:'F8',permissionByStatus:{'신규':'oms.order.create','작성':'oms.order.write'},allowedStatuses:['신규','작성'],requiresDirty:true},
|
||||
{id:'confirm',label:'주문확정',group:'workflow',permission:'oms.order.confirm',allowedStatuses:['작성'],requiresClean:true,disabledReason:'변경사항을 저장한 뒤 주문을 확정하세요.',confirm:{title:'주문을 확정하시겠습니까?',detail:'확정 후 주문의 일반 입력 필드는 직접 수정할 수 없습니다.',level:'high',confirmLabel:'주문확정'}},
|
||||
{id:'copy',label:'주문복사',group:'edit'},
|
||||
{id:'excel',label:'엑셀',group:'output'},
|
||||
],telemetry:{enabled:true},
|
||||
})
|
||||
export const orderLineColumns:KbxGridColumn<OrderLineForm>[]=[
|
||||
{field:'itemCode',header:'품목코드',type:'lookup',width:130,editable:true,pinned:'left',lookup:{entity:'item'}},{field:'itemName',header:'품목명',width:220},{field:'availableQty',header:'가용재고',type:'quantity',width:100},{field:'orderQty',header:'수량',type:'quantity',width:95,editable:true},{field:'unitPrice',header:'단가',type:'money',width:120,editable:true},{field:'amount',header:'금액',type:'money',width:130},{field:'remark',header:'비고',width:220,editable:true},
|
||||
]
|
||||
@@ -1,151 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
KbxBarcodeCapture,
|
||||
KbxWmsActionButton,
|
||||
KbxWmsMobilePage,
|
||||
} from '@kbx/ui'
|
||||
import PickingExceptionSheet from './PickingExceptionSheet.vue'
|
||||
import PickingQuantitySheet from './PickingQuantitySheet.vue'
|
||||
import { useWmsPicking } from './useWmsPicking'
|
||||
import { pickingScreen } from './picking.definition'
|
||||
|
||||
const props = defineProps<{ taskId: string }>()
|
||||
const vm = useWmsPicking(props.taskId)
|
||||
|
||||
const progress = computed(() => vm.task.value
|
||||
? `${vm.task.value.completedLines} / ${vm.task.value.totalLines}`
|
||||
: undefined)
|
||||
|
||||
const current = computed(() => vm.task.value?.currentLine ?? null)
|
||||
const stageTitle = computed(() => {
|
||||
switch (vm.task.value?.stage) {
|
||||
case 'ready': return '작업을 시작하세요.'
|
||||
case 'await-location': return '위치를 스캔하세요.'
|
||||
case 'await-item': return '상품을 스캔하세요.'
|
||||
case 'processing': return '처리 중입니다.'
|
||||
case 'completed': return '피킹을 완료했습니다.'
|
||||
case 'blocked': return '관리자 확인이 필요합니다.'
|
||||
default: return '작업을 불러오는 중입니다.'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxWmsMobilePage
|
||||
:screen="pickingScreen"
|
||||
:progress="progress"
|
||||
:online="vm.online.value"
|
||||
:pending-commands="vm.pendingCommands.value"
|
||||
:syncing="vm.syncing.value"
|
||||
:content-state="vm.contentState.value"
|
||||
retry-label="작업 다시 불러오기"
|
||||
@retry="vm.reload"
|
||||
>
|
||||
<p v-if="vm.message.value" class="message" role="status">{{ vm.message.value }}</p>
|
||||
|
||||
<section v-if="vm.task.value" class="instruction">
|
||||
<p class="eyebrow">{{ stageTitle }}</p>
|
||||
|
||||
<template v-if="current">
|
||||
<div class="location">
|
||||
<span>LOCATION</span>
|
||||
<strong>{{ current.locationCode }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<span>상품</span>
|
||||
<strong>{{ current.itemName }}</strong>
|
||||
<small>{{ current.itemCode }}<template v-if="current.itemOption"> · {{ current.itemOption }}</template></small>
|
||||
<small>바코드 {{ current.barcode }}</small>
|
||||
</div>
|
||||
|
||||
<div class="qty">
|
||||
<div><span>필요</span><strong>{{ current.requiredQty }}</strong></div>
|
||||
<div><span>피킹</span><strong>{{ current.pickedQty }}</strong></div>
|
||||
<div><span>남음</span><strong>{{ current.remainingQty }}</strong></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<KbxWmsActionButton
|
||||
v-if="current && vm.task.value.stage === 'await-item' && current.pickedQty > 0 && current.remainingQty > 0"
|
||||
class="quantity-action"
|
||||
label="수량 직접 입력"
|
||||
variant="secondary"
|
||||
@click="vm.openQuantity"
|
||||
/>
|
||||
|
||||
<div v-else-if="vm.task.value.stage === 'ready'" class="ready">
|
||||
<strong>{{ vm.task.value.taskNo }}</strong>
|
||||
<span>총 {{ vm.task.value.totalLines }}개 라인 · {{ vm.task.value.totalQty }}개</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<KbxBarcodeCapture
|
||||
v-if="vm.task.value && !['ready','completed','blocked'].includes(vm.task.value.stage)"
|
||||
:enabled="vm.scanEnabled.value"
|
||||
:label="vm.scanLabel.value"
|
||||
@scan="vm.handleScan"
|
||||
/>
|
||||
|
||||
<template #actions>
|
||||
<KbxWmsActionButton
|
||||
v-if="vm.task.value?.stage === 'ready'"
|
||||
label="작업 시작"
|
||||
:busy="vm.starting.value"
|
||||
:disabled="!vm.online.value"
|
||||
@click="vm.start"
|
||||
/>
|
||||
|
||||
<KbxWmsActionButton
|
||||
v-else-if="vm.task.value && ['await-location','await-item'].includes(vm.task.value.stage)"
|
||||
label="문제 신고"
|
||||
variant="secondary"
|
||||
:disabled="!vm.online.value || vm.pendingCommands.value > 0"
|
||||
@click="vm.openException"
|
||||
/>
|
||||
|
||||
<KbxWmsActionButton
|
||||
v-else-if="vm.task.value?.stage === 'completed'"
|
||||
label="작업 목록으로"
|
||||
@click="$router.push('/wms/picking')"
|
||||
/>
|
||||
</template>
|
||||
</KbxWmsMobilePage>
|
||||
|
||||
<PickingQuantitySheet
|
||||
v-if="vm.quantityOpen.value && current"
|
||||
:current="current.pickedQty"
|
||||
:required="current.requiredQty"
|
||||
:busy="vm.settingQuantity.value"
|
||||
@close="vm.closeQuantity"
|
||||
@submit="vm.setQuantity"
|
||||
/>
|
||||
|
||||
<PickingExceptionSheet
|
||||
v-if="vm.exceptionOpen.value"
|
||||
:busy="vm.reportingException.value"
|
||||
@close="vm.closeException"
|
||||
@submit="(type, memo) => vm.reportException(type, memo)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message { margin:0 0 12px; padding:10px 12px; background:var(--kbx-color-surface-subtle); border-radius:6px; font-size:14px; }
|
||||
.instruction { text-align:center; }
|
||||
.eyebrow { margin:0 0 12px; font-size:16px; font-weight:650; }
|
||||
.location { padding:16px; background:var(--kbx-color-surface-muted); border:1px solid var(--kbx-color-border); border-radius:8px; }
|
||||
.location span, .item span { display:block; color:var(--kbx-color-text-muted); font-size:12px; font-weight:600; }
|
||||
.location strong { display:block; margin-top:3px; font-size:32px; letter-spacing:.03em; }
|
||||
.item { padding:20px 4px 8px; }
|
||||
.item strong { display:block; margin-top:4px; font-size:22px; }
|
||||
.item small { display:block; margin-top:4px; color:var(--kbx-color-text-muted); font-size:13px; }
|
||||
.qty { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-top:12px; }
|
||||
.qty div { border:1px solid var(--kbx-color-border); border-radius:8px; padding:12px 4px; }
|
||||
.qty span { display:block; font-size:13px; color:var(--kbx-color-text-muted); }
|
||||
.qty strong { display:block; margin-top:2px; font-size:28px; }
|
||||
.quantity-action { margin-top:var(--kbx-space-3); }
|
||||
.ready { min-height:260px; display:flex; flex-direction:column; justify-content:center; gap:8px; }
|
||||
.ready strong { font-size:26px; }
|
||||
.ready span { color:var(--kbx-color-text-muted); }
|
||||
</style>
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { KbxWmsScanCommand } from '@kbx/contracts'
|
||||
|
||||
const STORAGE_KEY = 'kbx:wms:safe-retry:v1'
|
||||
|
||||
export interface QueuedPickingScan {
|
||||
command: KbxWmsScanCommand
|
||||
queuedAt: string
|
||||
}
|
||||
|
||||
export function readPickingRetryQueue(): QueuedPickingScan[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as QueuedPickingScan[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function queuePickingScan(command: KbxWmsScanCommand) {
|
||||
// Safety-first queue: only unacknowledged commands are retained for replay.
|
||||
// UI stops accepting the next authoritative scan until the server confirms this one.
|
||||
const queue = readPickingRetryQueue()
|
||||
if (!queue.some(x => x.command.idempotencyKey === command.idempotencyKey)) {
|
||||
queue.push({ command, queuedAt: new Date().toISOString() })
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(queue))
|
||||
}
|
||||
}
|
||||
|
||||
export function removePickingScan(idempotencyKey: string) {
|
||||
const next = readPickingRetryQueue().filter(x => x.command.idempotencyKey !== idempotencyKey)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isKbxProblem } from '@kbx/contracts'
|
||||
import { KbxDataGrid, KbxQueuePage, KbxSearchPanel } from '@kbx/ui'
|
||||
import { routeForWmsTask, searchWmsWork } from './workApi'
|
||||
import { wmsWorkColumns, wmsWorkScreen, wmsWorkSearchFields, type WmsWorkRow } from './work.definition'
|
||||
const router=useRouter();const search=reactive({taskType:'',status:'READY',owner:'mine',keyword:''});const rows=ref<WmsWorkRow[]>([]);const selection=ref<WmsWorkRow[]>([]);const loading=ref(false);const searched=ref(false);const error=ref<unknown>(null);const activeExceptionKey=ref<string|null>(null)
|
||||
const queryProblem=computed(()=>isKbxProblem(error.value)?error.value:null)
|
||||
const contentState=computed(()=>error.value?'error':loading.value&&!searched.value?'loading':!searched.value?'idle':rows.value.length===0?'empty':'ready')
|
||||
async function executeSearch(){loading.value=true;error.value=null;selection.value=[];try{rows.value=await searchWmsWork(search);searched.value=true}catch(cause){error.value=cause;searched.value=true}finally{loading.value=false}}
|
||||
function executeCommand(id:string){if(id==='search')return executeSearch();if(id==='start'&&selection.value[0])return router.push(routeForWmsTask(selection.value[0]))}
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'total',label:'현재 결과',value:rows.value.length},
|
||||
{key:'ready',label:'대기',value:rows.value.filter(x=>x.status==='READY').length},
|
||||
{key:'blocked',label:'예외',value:rows.value.filter(x=>x.status==='BLOCKED').length,emphasis:true},
|
||||
])
|
||||
const exceptionCounters=computed(()=>[{key:'blocked',label:'확인 필요한 작업',count:rows.value.filter(x=>x.status==='BLOCKED').length,severity:'warning' as const}])
|
||||
async function filterException(key:string|null){activeExceptionKey.value=key;search.status=key==='blocked'?'BLOCKED':'READY';await executeSearch()}
|
||||
async function resetSearch(){search.taskType='';search.status='READY';search.owner='mine';search.keyword='';activeExceptionKey.value=null;await executeSearch()}
|
||||
</script>
|
||||
<template><KbxQueuePage :screen="wmsWorkScreen" :selection-count="selection.length" :content-state="contentState" :refreshing="loading&&searched&&!error" :summary-items="summaryItems" :exception-counters="exceptionCounters" :active-exception-key="activeExceptionKey" queue-title="현재 작업 Queue" queue-description="작업유형·작업자·상태를 기준으로 지금 처리할 업무만 모아서 봅니다." :queue-count="rows.length" breadcrumb="WMS > 작업관리" empty-title="조건에 맞는 물류 작업이 없습니다." empty-detail="작업유형·상태·작업자 조건을 변경하거나 기본조건으로 다시 조회하세요." empty-action-label="기본조건으로 조회" :error-title="queryProblem?.title || '물류 작업을 조회하지 못했습니다.'" :error-detail="queryProblem?.detail || '네트워크 상태를 확인한 후 다시 조회하세요.'" :error-retryable="queryProblem?.retryable !== false" retry-label="다시 조회" @command="executeCommand" @exception-filter="filterException" @empty-action="resetSearch"><template #search><KbxSearchPanel v-model="search" :fields="wmsWorkSearchFields" @search="executeSearch"/></template><template #content><KbxDataGrid :rows="rows" :columns="wmsWorkColumns" row-key="taskId" selection="single" :loading="loading" @selection-changed="selection=$event" @row-double-clicked="row=>router.push(routeForWmsTask(row))"/></template></KbxQueuePage></template>
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"foundationIteration": 50,
|
||||
"contractVersion": "1.31.0",
|
||||
"baseline": "v21",
|
||||
"declaredChangeLevel": "major",
|
||||
"releaseMode": "fe-operational-truth-home-retry-hardening",
|
||||
"notes": "v50 continues FE-first hardening without adding BE coupling or new template families. All major KBX templates now expose an explicit errorRetryable contract so permanent permission/integration/configuration failures do not render meaningless retry actions. WMS-WORK-001 marks the production API-not-connected problem non-retryable and T06 binds the problem retryability to its state boundary. Home Attention keeps the 8-item default density, truthfully labels the 24-item expansion as a bounded priority view when more items exist, and exposes direct Work Center / Notification Center routes for remaining items instead of implying that all items are on screen. Reference HTML/JavaScript mirrors the bounded disclosure and center navigation under KBX Business Light/Dark. Domain truth, permissions, idempotency and authoritative WMS completion remain server-owned."
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
const props=withDefaults(defineProps<{open:boolean;title:string;size?:'sm'|'md'|'lg';closeable?:boolean}>(),{size:'md',closeable:true})
|
||||
const emit=defineEmits<{ 'update:open':[boolean] }>()
|
||||
const widths={sm:'420px',md:'600px',lg:'840px'} as const
|
||||
</script>
|
||||
<template><Dialog :visible="open" modal :header="title" :closable="closeable" :style="{width:widths[size]}" @update:visible="emit('update:open',$event)"><slot/><template #footer><slot name="footer"/></template></Dialog></template>
|
||||
@@ -1,6 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Drawer from 'primevue/drawer'
|
||||
withDefaults(defineProps<{open:boolean;title:string;position?:'left'|'right';width?:string}>(),{position:'right',width:'560px'})
|
||||
const emit=defineEmits<{ 'update:open':[boolean] }>()
|
||||
</script>
|
||||
<template><Drawer :visible="open" :header="title" :position="position" :style="{width}" @update:visible="emit('update:open',$event)"><slot/><template #footer><slot name="footer"/></template></Drawer></template>
|
||||
@@ -1,207 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type {
|
||||
KbxImportDefinition,
|
||||
KbxImportMapping,
|
||||
KbxImportSession,
|
||||
KbxImportProgressEvent,
|
||||
} from '@kbx/contracts'
|
||||
import KbxButton from './KbxButton.vue'
|
||||
import KbxConfirm from './KbxConfirm.vue'
|
||||
import KbxJobProgress from './KbxJobProgress.vue'
|
||||
import KbxProgressSteps from './KbxProgressSteps.vue'
|
||||
import { validateKbxImportFileCandidate, validateKbxImportMappings } from '../excel/importGuard'
|
||||
|
||||
const props = defineProps<{
|
||||
definition: KbxImportDefinition
|
||||
session: KbxImportSession | null
|
||||
busy?: boolean
|
||||
progress?: KbxImportProgressEvent | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [File]
|
||||
saveMapping: [KbxImportMapping[]]
|
||||
saveNamedMapping: [string, KbxImportMapping[]]
|
||||
validate: []
|
||||
commit: []
|
||||
downloadTemplate: []
|
||||
downloadErrors: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const localMapping = ref<KbxImportMapping[] | null>(null)
|
||||
const dragActive = ref(false)
|
||||
const mappingName = ref('')
|
||||
const clientIssue = ref('')
|
||||
const commitConfirmOpen = ref(false)
|
||||
const importSteps=[{key:'file',label:'파일'},{key:'mapping',label:'매핑'},{key:'validation',label:'검증'},{key:'commit',label:'반영'}]
|
||||
const currentStepKey=computed(()=>step.value===1?'file':step.value===2?'mapping':step.value===3?'validation':'commit')
|
||||
|
||||
const step = computed(() => {
|
||||
const status = props.session?.status
|
||||
if (!status || status === 'created') return 1
|
||||
if (status === 'uploaded' || status === 'mapping-required') return 2
|
||||
if (status === 'validating' || status === 'validated') return 3
|
||||
return 4
|
||||
})
|
||||
|
||||
watch(() => props.session?.id, () => {
|
||||
localMapping.value = null
|
||||
mappingName.value = ''
|
||||
clientIssue.value = ''
|
||||
commitConfirmOpen.value = false
|
||||
})
|
||||
|
||||
const mappings = computed({
|
||||
get: () => localMapping.value ?? (props.session?.mapping ?? []),
|
||||
set: value => { localMapping.value = value },
|
||||
})
|
||||
|
||||
const importableFields = computed(() => props.definition.fields.filter(field => field.importable !== false))
|
||||
const mappingIssues = computed(() => validateKbxImportMappings(props.definition,mappings.value))
|
||||
const canValidate = computed(() => mappings.value.length > 0 && mappingIssues.value.length === 0 && !props.busy)
|
||||
const canCommit = computed(() => (props.session?.validRows ?? 0) > 0 && props.session?.status === 'validated' && !props.busy)
|
||||
const isFailed = computed(() => props.session?.status === 'failed')
|
||||
const isCancelled = computed(() => props.session?.status === 'cancelled')
|
||||
const isPartial = computed(() => props.session?.status === 'partially-completed')
|
||||
|
||||
function chooseFile() { clientIssue.value=''; fileInput.value?.click() }
|
||||
function onFiles(files: FileList | null) {
|
||||
const file = files?.item(0)
|
||||
if (!file) return
|
||||
clientIssue.value=''
|
||||
const issue=validateKbxImportFileCandidate(props.definition,file)
|
||||
if(issue){clientIssue.value=issue;return}
|
||||
emit('upload', file)
|
||||
}
|
||||
function onDrop(event: DragEvent) {
|
||||
dragActive.value = false
|
||||
onFiles(event.dataTransfer?.files ?? null)
|
||||
}
|
||||
function setTarget(index: number, targetField: string) {
|
||||
const next = mappings.value.map((item, itemIndex) => itemIndex === index
|
||||
? { ...item, targetField: targetField || null, source: 'manual' as const, confidence: undefined, reason:undefined }
|
||||
: item)
|
||||
mappings.value = next
|
||||
emit('saveMapping', next)
|
||||
}
|
||||
function requestCommit(){if(canCommit.value)commitConfirmOpen.value=true}
|
||||
function confirmCommit(){commitConfirmOpen.value=false;emit('commit')}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="kbx-import" data-kbx-component="excel-import" :aria-busy="busy || undefined">
|
||||
<header class="kbx-import__header">
|
||||
<div>
|
||||
<h2>{{ definition.title }}</h2>
|
||||
<p>파일 → 매핑 → 검증 → 반영 순서로 처리합니다. 원본 행 번호와 오류 이력은 유지됩니다.</p>
|
||||
</div>
|
||||
<KbxButton label="업로드 양식 다운로드" variant="secondary" @click="emit('downloadTemplate')" />
|
||||
</header>
|
||||
|
||||
<KbxProgressSteps data-kbx-surface="progress-steps" :steps="importSteps" :current="currentStepKey" />
|
||||
|
||||
<div v-if="step === 1" class="kbx-import__drop" data-kbx-surface="file"
|
||||
:class="{ 'is-dragging': dragActive }"
|
||||
@dragover.prevent="dragActive = true"
|
||||
@dragleave.prevent="dragActive = false"
|
||||
@drop.prevent="onDrop">
|
||||
<input ref="fileInput" hidden type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="onFiles(($event.target as HTMLInputElement).files)">
|
||||
<strong>Excel 파일을 선택하세요.</strong>
|
||||
<span>.xlsx · 최대 {{ Math.round((definition.maxFileSizeBytes ?? 10485760) / 1048576) }}MB</span>
|
||||
<KbxButton label="파일 선택" variant="primary" :loading="busy" @click="chooseFile" />
|
||||
<small>Drag & Drop은 보조 기능이며 파일 선택 버튼은 항상 제공됩니다.</small>
|
||||
<p v-if="clientIssue" class="kbx-import__client-error" role="alert">{{clientIssue}}</p>
|
||||
</div>
|
||||
|
||||
<section v-else-if="step === 2" class="kbx-import__mapping" data-kbx-surface="mapping">
|
||||
<div class="mapping-head"><span>Excel 열</span><span>시스템 필드</span><span>매핑 근거</span></div>
|
||||
<div v-for="(mapping, index) in mappings" :key="mapping.sourceColumn" class="mapping-row">
|
||||
<strong>{{ mapping.sourceColumn }}</strong>
|
||||
<select :value="mapping.targetField ?? ''" :aria-invalid="mapping.targetField ? mappings.filter(item=>item.targetField===mapping.targetField).length>1 : undefined" @change="setTarget(index, ($event.target as HTMLSelectElement).value)">
|
||||
<option value="">매핑하지 않음</option>
|
||||
<option v-for="field in importableFields" :key="field.key" :value="field.key">
|
||||
{{ field.label }}{{ field.required ? ' *' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="mapping-reason">
|
||||
{{ mapping.source === 'ai' ? `AI 추천 ${Math.round((mapping.confidence ?? 0) * 100)}%` : mapping.reason ?? mapping.source }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="mappingIssues.length" class="mapping-issues" role="alert">
|
||||
<strong>매핑을 확인하세요.</strong>
|
||||
<ul><li v-for="issue in mappingIssues" :key="`${issue.code}:${issue.fieldKey}`">{{issue.message}}</li></ul>
|
||||
</div>
|
||||
<div class="mapping-save">
|
||||
<label>다음에도 사용할 매핑 이름 <input v-model="mappingName" maxlength="80" placeholder="예: 쿠팡 주문양식"></label>
|
||||
<KbxButton label="매핑 저장" variant="secondary" :disabled="!mappingName.trim() || Boolean(mappingIssues.length)" @click="emit('saveNamedMapping', mappingName.trim(), mappings)" />
|
||||
</div>
|
||||
<div class="kbx-import__actions">
|
||||
<KbxButton label="취소" variant="secondary" @click="emit('cancel')" />
|
||||
<KbxButton label="검증 시작" variant="primary" :disabled="!canValidate" :loading="busy" @click="emit('validate')" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="step === 3" class="kbx-import__validation" data-kbx-surface="validation">
|
||||
<KbxJobProgress v-if="session?.status === 'validating'" :progress="progress ?? null" />
|
||||
<template v-else>
|
||||
<div class="summary" aria-label="검증 결과">
|
||||
<div><span>전체</span><strong>{{ session?.totalRows.toLocaleString() }}</strong></div>
|
||||
<div><span>정상</span><strong>{{ session?.validRows.toLocaleString() }}</strong></div>
|
||||
<div><span>오류</span><strong>{{ session?.invalidRows.toLocaleString() }}</strong></div>
|
||||
<div><span>경고</span><strong>{{ session?.warningRows.toLocaleString() }}</strong></div>
|
||||
</div>
|
||||
<div v-if="session?.errors?.length" class="errors">
|
||||
<div class="errors__head"><span>행</span><span>필드</span><span>사유</span></div>
|
||||
<div v-for="error in session.errors.slice(0, 100)" :key="`${error.rowNumber}-${error.field}-${error.code}`" class="errors__row" :data-severity="error.severity">
|
||||
<span>{{ error.rowNumber }}</span><span>{{ error.sourceColumn ?? error.field ?? '-' }}</span><span>{{ error.message }}</span>
|
||||
</div>
|
||||
<small v-if="session.errors.length > 100">화면에는 처음 100건만 표시합니다. 전체 오류는 오류파일로 확인하세요.</small>
|
||||
</div>
|
||||
<div class="kbx-import__actions">
|
||||
<KbxButton v-if="session?.invalidRows" label="오류파일 다운로드" variant="secondary" @click="emit('downloadErrors')" />
|
||||
<KbxButton label="정상 데이터 반영" variant="primary" :disabled="!canCommit" @click="requestCommit" />
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section v-else class="kbx-import__commit" data-kbx-surface="result" :data-result="session?.status">
|
||||
<KbxJobProgress v-if="session?.status === 'committing'" :progress="progress ?? null" />
|
||||
<div v-else-if="isFailed" class="kbx-import__terminal is-error" role="alert">
|
||||
<strong>{{session?.failure?.title ?? '반영 작업을 완료하지 못했습니다.'}}</strong>
|
||||
<p>{{session?.failure?.detail ?? '원본 업로드와 작업 이력은 유지됩니다. 원인을 확인한 뒤 다시 진행하세요.'}}</p>
|
||||
<small v-if="session?.failure?.code">오류코드 {{session.failure.code}}</small>
|
||||
<div class="kbx-import__actions"><KbxButton label="새 파일로 다시 시작" variant="secondary" @click="emit('cancel')" /></div>
|
||||
</div>
|
||||
<div v-else-if="isCancelled" class="kbx-import__terminal">
|
||||
<strong>반영 작업이 취소되었습니다.</strong><p>새 파일을 선택해 다시 시작할 수 있습니다.</p>
|
||||
<div class="kbx-import__actions"><KbxButton label="새 파일 선택" variant="secondary" @click="emit('cancel')" /></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="summary summary--result" :class="{'is-partial':isPartial}">
|
||||
<div><span>신규</span><strong>{{ session?.createdRows.toLocaleString() }}</strong></div>
|
||||
<div><span>수정</span><strong>{{ session?.updatedRows.toLocaleString() }}</strong></div>
|
||||
<div><span>오류</span><strong>{{ session?.invalidRows.toLocaleString() }}</strong></div>
|
||||
</div>
|
||||
<p v-if="isPartial" class="kbx-import__partial">정상 데이터는 반영되었고 오류 데이터는 제외되었습니다. 오류파일로 실패 건만 다시 처리할 수 있습니다.</p>
|
||||
<div v-if="isPartial && session?.invalidRows" class="kbx-import__actions"><KbxButton label="오류파일 다운로드" variant="secondary" @click="emit('downloadErrors')" /></div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<KbxConfirm
|
||||
:open="commitConfirmOpen"
|
||||
title="정상 데이터를 반영하시겠습니까?"
|
||||
:detail="`${(session?.validRows ?? 0).toLocaleString()}건을 반영합니다. 오류 ${(session?.invalidRows ?? 0).toLocaleString()}건은 제외됩니다.`"
|
||||
level="high"
|
||||
:confirm-label="`${(session?.validRows ?? 0).toLocaleString()}건 반영`"
|
||||
@update:open="commitConfirmOpen=$event"
|
||||
@confirm="confirmCommit"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kbx-import{display:grid;gap:var(--kbx-space-4);min-width:0}.kbx-import__header{display:flex;justify-content:space-between;gap:var(--kbx-space-4);align-items:flex-start}.kbx-import__header h2{margin:0 0 var(--kbx-space-1);font-size:var(--kbx-font-xl)}.kbx-import__header p{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-import__drop{min-height:var(--kbx-import-drop-min-height);border:var(--kbx-border-width) dashed var(--kbx-color-border-strong);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--kbx-space-2);background:var(--kbx-color-surface-muted)}.kbx-import__drop.is-dragging{outline:calc(var(--kbx-border-width) * 2) solid var(--kbx-color-focus);outline-offset:calc(var(--kbx-border-width) * -2)}.kbx-import__drop>span,.kbx-import__drop small{color:var(--kbx-color-text-muted)}.kbx-import__client-error{margin:0;color:var(--kbx-color-danger);font-size:var(--kbx-font-sm)}.kbx-import__mapping,.kbx-import__validation,.kbx-import__commit{border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);padding:var(--kbx-space-3)}.mapping-head,.mapping-row{display:grid;grid-template-columns:minmax(var(--kbx-import-source-column-min-width),1fr) minmax(var(--kbx-import-target-column-min-width),1.3fr) minmax(var(--kbx-import-reason-column-min-width),.8fr);gap:var(--kbx-space-3);min-height:var(--kbx-control-md);align-items:center;border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.mapping-head{font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.mapping-row select{height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface)}.mapping-row select[aria-invalid="true"]{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.mapping-reason{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.mapping-issues{margin-top:var(--kbx-space-3);padding:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface);font-size:var(--kbx-font-sm)}.mapping-issues ul{margin:var(--kbx-space-2) 0 0;padding-left:var(--kbx-space-5)}.mapping-save{display:flex;align-items:center;justify-content:flex-end;gap:var(--kbx-space-2);padding-top:var(--kbx-space-3)}.mapping-save label{display:flex;align-items:center;gap:var(--kbx-space-2);font-size:var(--kbx-font-sm)}.mapping-save input{width:var(--kbx-import-mapping-name-width);height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2)}.summary{display:grid;grid-template-columns:repeat(4,minmax(var(--kbx-summary-item-min-width),1fr));border:var(--kbx-border-width) solid var(--kbx-color-border);margin-bottom:var(--kbx-space-3)}.summary>div{padding:var(--kbx-space-3);display:flex;justify-content:space-between;border-right:var(--kbx-border-width) solid var(--kbx-color-border)}.summary>div:last-child{border-right:0}.summary strong{font-size:var(--kbx-font-xl)}.summary--result{grid-template-columns:repeat(3,minmax(var(--kbx-summary-item-min-width),1fr))}.summary--result.is-partial{border-color:var(--kbx-color-warning-border)}.errors{border:var(--kbx-border-width) solid var(--kbx-color-border)}.errors__head,.errors__row{display:grid;grid-template-columns:var(--kbx-import-error-row-width) var(--kbx-import-error-field-width) 1fr;gap:var(--kbx-space-2);min-height:var(--kbx-control-height);align-items:center;padding:0 var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);font-size:var(--kbx-font-sm)}.errors__head{font-weight:600;background:var(--kbx-color-surface-muted)}.errors__row[data-severity="warning"]{background:var(--kbx-color-warning-surface)}.errors__row[data-severity="error"]{background:var(--kbx-color-danger-surface)}.errors__row:last-of-type{border-bottom:0}.errors small{display:block;padding:var(--kbx-space-2);color:var(--kbx-color-text-muted)}.kbx-import__terminal{display:grid;gap:var(--kbx-space-2);padding:var(--kbx-space-4);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-import__terminal.is-error{background:var(--kbx-color-danger-surface);border-color:var(--kbx-color-danger-border)}.kbx-import__terminal p,.kbx-import__partial{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-import__actions{display:flex;justify-content:flex-end;gap:var(--kbx-space-2);margin-top:var(--kbx-space-3)}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { KbxStatusSemantic } from '@kbx/contracts'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
semantic: KbxStatusSemantic
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="kbx-status" :data-semantic="semantic">
|
||||
<span class="kbx-status__dot" aria-hidden="true" />
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kbx-status { display:inline-flex; align-items:center; gap:6px; min-height:24px; padding:0 8px; border:1px solid var(--kbx-color-border); border-radius:999px; background:var(--kbx-color-surface); font-size:12px; font-weight:600; white-space:nowrap; }
|
||||
.kbx-status__dot { width:6px; height:6px; border-radius:50%; background:var(--kbx-status-color, var(--kbx-color-text-muted)); }
|
||||
.kbx-status[data-semantic="completed"] { --kbx-status-color:var(--kbx-color-success); }
|
||||
.kbx-status[data-semantic="processing"], .kbx-status[data-semantic="pending"] { --kbx-status-color:var(--kbx-color-primary); }
|
||||
.kbx-status[data-semantic="warning"], .kbx-status[data-semantic="hold"] { --kbx-status-color:var(--kbx-color-warning); }
|
||||
.kbx-status[data-semantic="error"] { --kbx-status-color:var(--kbx-color-danger); }
|
||||
.kbx-status[data-semantic="cancelled"], .kbx-status[data-semantic="disabled"] { --kbx-status-color:var(--kbx-color-text-muted); color:var(--kbx-color-text-muted); }
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { KbxAsyncState, KbxProblem, KbxScreenDefinition, KbxSummaryItem, KbxTemplateContext, KbxWorkQueueCounter } from '@kbx/contracts'
|
||||
import KbxQueuePage from './KbxQueuePage.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
screen:KbxScreenDefinition
|
||||
selectionCount?:number
|
||||
can?:(permission:string)=>boolean
|
||||
breadcrumb?:string
|
||||
context?:KbxTemplateContext|null
|
||||
contentState?:KbxAsyncState
|
||||
refreshing?:boolean
|
||||
errorRetryable?:boolean
|
||||
summaryItems?:KbxSummaryItem[]
|
||||
exceptionCounters?:KbxWorkQueueCounter[]
|
||||
activeExceptionKey?:string|null
|
||||
problem?:KbxProblem|null
|
||||
problemActionAllowlist?:readonly string[]
|
||||
}>(), { context:null, contentState:'ready', refreshing:false, errorRetryable:true, summaryItems:()=>[], exceptionCounters:()=>[], activeExceptionKey:null, problem:null, problemActionAllowlist:()=>[] })
|
||||
const emit=defineEmits<{ command:[string]; exceptionFilter:[string|null]; problemAction:[string]; retryProblem:[]; dismissProblem:[] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxQueuePage :screen="screen" :selection-count="selectionCount" :can="can" :breadcrumb="breadcrumb" :context="context" :content-state="contentState" :refreshing="refreshing" :error-retryable="errorRetryable" :summary-items="summaryItems" :exception-counters="exceptionCounters" :active-exception-key="activeExceptionKey" :problem="problem" :problem-action-allowlist="problemActionAllowlist" @command="emit('command',$event)" @exception-filter="emit('exceptionFilter',$event)" @problem-action="emit('problemAction',$event)" @retry-problem="emit('retryProblem')" @dismiss-problem="emit('dismissProblem')">
|
||||
<template v-if="$slots.utility" #utility><slot name="utility" /></template>
|
||||
<template v-if="$slots.notice" #notice><slot name="notice" /></template>
|
||||
<template v-if="$slots.search" #search><slot name="search" /></template>
|
||||
<template v-if="$slots['queue-summary'] || $slots.summary" #summary><slot name="queue-summary"><slot name="summary" /></slot></template>
|
||||
<template v-if="$slots.exceptions" #exceptions><slot name="exceptions" /></template>
|
||||
<template v-if="$slots.contextual" #contextual><slot name="contextual" /></template>
|
||||
<template #content><slot name="content" /></template>
|
||||
<template v-if="$slots.footer" #footer><slot name="footer" /></template>
|
||||
<template v-if="$slots.detail" #detail><slot name="detail" /></template>
|
||||
</KbxQueuePage>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import type { KbxWorkflowDefinition, KbxWorkflowTransitionDefinition } from '@kbx/contracts'
|
||||
import KbxButton from './KbxButton.vue'
|
||||
import KbxConfirm from './KbxConfirm.vue'
|
||||
import KbxStatus from './KbxStatus.vue'
|
||||
import { KbxPermissionHostKey } from '../permission/host'
|
||||
const props=defineProps<{workflow:KbxWorkflowDefinition;current:string;can?:(permission:string)=>boolean}>();const emit=defineEmits<{transition:[string]}>();const pending=ref<KbxWorkflowTransitionDefinition|null>(null);const permissionHost=inject(KbxPermissionHostKey,null)
|
||||
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??true)}
|
||||
const currentState=computed(()=>props.workflow.states.find(x=>x.value===props.current));const actions=computed(()=>props.workflow.transitions.filter(x=>x.from.includes(props.current)&&(!x.permission||canPermission(x.permission))))
|
||||
function invoke(action:KbxWorkflowTransitionDefinition){if(action.confirm){pending.value=action;return}emit('transition',action.id)}function confirm(){if(!pending.value)return;const id=pending.value.id;pending.value=null;emit('transition',id)}
|
||||
</script>
|
||||
<template><section class="kbx-workflow-bar" aria-label="업무 상태"><div class="kbx-workflow-bar__state"><span class="label">현재 상태</span><KbxStatus v-if="currentState" :label="currentState.label" :semantic="currentState.semantic"/><strong v-else>{{current}}</strong></div><div class="kbx-workflow-bar__flow" aria-label="상태 흐름"><template v-for="(state,i) in workflow.states" :key="state.value"><span :class="['step',{current:state.value===current}]">{{state.label}}</span><span v-if="i<workflow.states.length-1" class="arrow">→</span></template></div><div class="kbx-workflow-bar__actions"><KbxButton v-for="action in actions" :key="action.id" :label="action.label" @click="invoke(action)"/></div><KbxConfirm v-if="pending" :open="Boolean(pending)" :title="`${pending.label}하시겠습니까?`" :detail="`${currentState?.label??current} 상태에서 ${workflow.states.find(x=>x.value===pending?.to)?.label??pending.to} 상태로 변경합니다.`" level="high" :confirm-label="pending.label" @update:open="value=>{if(!value)pending=null}" @confirm="confirm"/></section></template>
|
||||
<style scoped>.kbx-workflow-bar{display:grid;grid-template-columns:auto 1fr auto;gap:var(--kbx-space-3);align-items:center;min-height:var(--kbx-command-bar-height);padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);border-radius:var(--kbx-radius-sm);font-size:var(--kbx-font-sm)}.kbx-workflow-bar__state{display:flex;align-items:center;gap:var(--kbx-space-2);white-space:nowrap}.label,.kbx-workflow-bar__flow{color:var(--kbx-color-text-muted)}.kbx-workflow-bar__flow{min-width:0;overflow:auto;display:flex;align-items:center;gap:var(--kbx-space-1);white-space:nowrap}.step.current{color:var(--kbx-color-text);font-weight:600}.arrow{color:var(--kbx-color-border-strong)}.kbx-workflow-bar__actions{display:flex;gap:var(--kbx-space-2)}@media(max-width:68.75rem){.kbx-workflow-bar{grid-template-columns:1fr auto}.kbx-workflow-bar__flow{grid-column:1/-1;grid-row:2}}</style>
|
||||
@@ -1,14 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { requireKbxScenario, scenarioTitle } from './helpers/kbxScenario'
|
||||
|
||||
const queueRecovery=requireKbxScenario('scenario.common.operations.queue-recovery')
|
||||
|
||||
test.describe('COMMON-OPS-001 업무 예외 센터', () => {
|
||||
test(scenarioTitle(queueRecovery.id), async ({ page }) => {
|
||||
await page.goto('/operations/exceptions')
|
||||
await expect(page.locator('[data-screen-id="COMMON-OPS-001"]')).toBeVisible()
|
||||
await expect(page.locator('[data-kbx-surface="exception-summary"]')).toBeVisible()
|
||||
await expect(page.locator('[data-kbx-surface="queue/content"]')).toBeVisible()
|
||||
await expect(page.getByText(/정상 건은 표시하지 않습니다/)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,13 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { requireKbxScenario, scenarioTitle } from './helpers/kbxScenario'
|
||||
|
||||
const mismatchRecovery=requireKbxScenario('scenario.common.reconcile.mismatch-recovery')
|
||||
|
||||
test.describe('COMMON-REC-001 업무 데이터 대사', () => {
|
||||
test(scenarioTitle(mismatchRecovery.id), async ({ page }) => {
|
||||
await page.goto('/operations/reconcile')
|
||||
await expect(page.locator('[data-screen-id="COMMON-REC-001"]')).toBeVisible()
|
||||
await expect(page.locator('[data-kbx-surface="criteria/search"]')).toBeVisible()
|
||||
await expect(page.locator('[data-kbx-surface="comparison-grid"]')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
test('OMS-CLM-001 keeps claim processing inside familiar list/workflow grammar', async ({page}) => {
|
||||
await page.goto('/oms/claims'); await expect(page.locator('[data-screen-id="OMS-CLM-001"]')).toBeVisible(); await page.keyboard.press('F3');
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('OMS-ORD-003 주문 Excel Import Golden Flow', () => {
|
||||
test('파일 → 매핑 → 검증 → 정상건 반영', async ({ page }) => {
|
||||
await page.goto('/oms/orders/import')
|
||||
await expect(page.getByRole('heading', { name: '주문 Excel 업로드' })).toBeVisible()
|
||||
|
||||
// Host repository should supply tests/fixtures/orders-valid-and-invalid.xlsx.
|
||||
await page.locator('input[type=file]').setInputFiles('tests/fixtures/orders-valid-and-invalid.xlsx')
|
||||
await expect(page.getByText('2 매핑')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: '검증 시작' }).click()
|
||||
await expect(page.getByText('검증 결과')).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.getByText('오류')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: '정상 데이터 반영' }).click()
|
||||
await expect(page.getByText('신규')).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
})
|
||||
@@ -22,17 +22,19 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Install and test when lockfile exists
|
||||
- name: Install and execute FE runtime evidence when lockfile exists
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f pnpm-lock.yaml ]; then
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm -r --if-present run typecheck
|
||||
pnpm -r --if-present run test
|
||||
pnpm -r --if-present run build
|
||||
echo "KBX canonical scenario contract validated statically; Playwright host run belongs to product repository."
|
||||
pnpm typecheck:web
|
||||
pnpm test:unit
|
||||
pnpm build:web:demo
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
pnpm test:e2e
|
||||
else
|
||||
echo "Starter has no pnpm-lock.yaml yet; dependency build gate is intentionally deferred."
|
||||
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:
|
||||
@@ -16,6 +16,22 @@ jobs:
|
||||
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
|
||||
@@ -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로 간주하지 않는다.
|
||||
@@ -1,7 +1,29 @@
|
||||
# KBX FE Reference Lab v50
|
||||
# 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
|
||||
@@ -6,7 +6,10 @@
|
||||
<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" />
|
||||
<title>KBX FE Reference Lab v52 · Operational Navigation · Screen Anatomy · T01~T09</title>
|
||||
<!-- 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>
|
||||
@@ -103,7 +103,7 @@ button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-vis
|
||||
|
||||
/* 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(--surface-hover)}.system-panel-body{padding:8px}
|
||||
.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)}
|
||||
@@ -144,8 +144,43 @@ button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-vis
|
||||
.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}}
|
||||
@@ -15,14 +15,14 @@
|
||||
];
|
||||
|
||||
const orders = [
|
||||
{no:'ORD-260811-1024',channel:'쿠팡',time:'16:42',buyer:'김민수',item:'런닝화 외 1',qty:3,amount:128000,stock:'정상',status:'출고대기',exception:''},
|
||||
{no:'ORD-260811-1023',channel:'스마트스토어',time:'16:39',buyer:'이지은',item:'셔츠 BLACK',qty:1,amount:48000,stock:'⚠ 부족',status:'보류',exception:'재고부족'},
|
||||
{no:'ORD-260811-1022',channel:'자사몰',time:'16:31',buyer:'박서준',item:'운동화 270',qty:2,amount:196000,stock:'정상',status:'할당완료',exception:''},
|
||||
{no:'ORD-260811-1021',channel:'쿠팡',time:'16:28',buyer:'최유진',item:'양말 3PACK',qty:4,amount:36000,stock:'정상',status:'출고대기',exception:''},
|
||||
{no:'ORD-260811-1018',channel:'11번가',time:'16:12',buyer:'정하늘',item:'후드티 GRAY',qty:1,amount:69000,stock:'정상',status:'오류',exception:'송장실패'},
|
||||
{no:'ORD-260811-1014',channel:'쿠팡',time:'15:57',buyer:'한도윤',item:'운동화 BLACK',qty:2,amount:178000,stock:'정상',status:'출고대기',exception:''},
|
||||
{no:'ORD-260811-1007',channel:'자사몰',time:'15:43',buyer:'오서연',item:'스니커즈 WHITE',qty:1,amount:82000,stock:'정상',status:'신규',exception:''},
|
||||
{no:'ORD-260811-1002',channel:'스마트스토어',time:'15:31',buyer:'윤지호',item:'양말 WHITE',qty:5,amount:45000,stock:'정상',status:'출고대기',exception:'주소확인'},
|
||||
{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 = [
|
||||
@@ -60,13 +60,13 @@
|
||||
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:'작성', transactionErrors:[], transactionHeader:{orderDate:'2026-08-11',channel:'직접주문',customerCode:'10001',customerName:'대한상사',owner:'홍길동',warehouseCode:'WH01',warehouseName:'서울물류센터',receiver:'홍길동',phone:'010-1234-5678',postal:'06236',address:'서울특별시 강남구 테헤란로'},
|
||||
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, importFileName:'', importFileSize:0, importFileType:'', importJobStatus:'idle', importProgress:0, importProcessed:0,
|
||||
pickingQty:4, pickingRequired:6, scanBuffer:'', scanError:'', networkStatus:'online', pendingSync:0, lastScanValue:'', lastScanAt:0,
|
||||
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:[
|
||||
@@ -81,6 +81,33 @@
|
||||
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');
|
||||
@@ -136,7 +163,7 @@
|
||||
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:['all','신규','출고대기','할당완료','보류','오류'].includes(d.status)?d.status:'all',exception:['all','재고부족','주소확인','송장실패'].includes(d.exception)?d.exception:'all'};
|
||||
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{}
|
||||
@@ -329,30 +356,43 @@
|
||||
}
|
||||
|
||||
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 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}개 화면</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}개 · ★ ${favoriteCount} · 열림 ${openCount}</small></button>`;}).join('')}</nav>`;
|
||||
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 allAttention=homeAttentionItems();const attentionCap=24;const visibleAttention=state.homeAttentionExpanded?allAttention.slice(0,attentionCap):allAttention.slice(0,8);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>`:'';
|
||||
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">${visibleAttention.map(item=>attention(item.source,item.title,item.detail,item.id,item.critical,item.intent)).join('')}</div></section>
|
||||
<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');$$('[data-home-module]',workspace).forEach(button=>button.onclick=()=>{state.module=button.dataset.homeModule;$('#moduleSelect').value=state.module;renderNavigation();renderHome();setTimeout(()=>$('#homeExplorer')?.scrollIntoView({block:'start',behavior:'smooth'}),0);});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));$$('.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);});
|
||||
$('#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==='신규');
|
||||
else if(state.orderQuickFilter==='ready')visible=visible.filter(x=>x.status==='출고대기');
|
||||
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==='오류');
|
||||
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('부족')));
|
||||
@@ -371,12 +411,12 @@
|
||||
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:['all','신규','출고대기','할당완료','보류','오류'].includes(state.orderSearch.status)?state.orderSearch.status:'all',exception:['all','재고부족','주소확인','송장실패'].includes(state.orderSearch.exception)?state.orderSearch.exception:'all'};}
|
||||
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('신규','신규',state.orderSearch.status)}${searchOption('출고대기','출고대기',state.orderSearch.status)}${searchOption('할당완료','할당완료',state.orderSearch.status)}${searchOption('보류','보류',state.orderSearch.status)}${searchOption('오류','오류',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>`;
|
||||
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';
|
||||
@@ -386,7 +426,7 @@
|
||||
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 ${order.stock.includes('부족')?'warn':''}">${esc(order.stock)}</td><td class="center ${order.status==='오류'?'error':''}">${esc(order.status)}</td><td class="${order.exception?'error':''}">${esc(order.exception||'-')}</td></tr>`).join('');
|
||||
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','조회 중...','기존 조회조건을 유지한 채 데이터를 불러오고 있습니다.');
|
||||
@@ -428,7 +468,7 @@
|
||||
}
|
||||
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"><span class="status-chip">${esc(order.status)}</span>${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>`);
|
||||
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();}
|
||||
|
||||
@@ -460,9 +500,10 @@
|
||||
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">${esc(x.status)}</td></tr>`).join('');
|
||||
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 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"><strong>${modeLabel}</strong><span>${state.masterMode==='new'?'고유 식별자와 Barcode는 새로 입력':'목록을 바꾸기 전에 미저장 상태를 확인'}</span></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>상태 <strong>${esc(draft.status)}</strong></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 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();
|
||||
}
|
||||
@@ -475,6 +516,7 @@
|
||||
$('#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');
|
||||
}
|
||||
|
||||
@@ -491,12 +533,12 @@
|
||||
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='작성';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 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">${['작성','확정','할당','피킹','검수','출고'].map(status=>`<span class="${state.transactionStatus===status?'active':''}">${status}</span>`).join('')}<strong>현재: ${esc(state.transactionStatus)} · ${state.transactionStatus==='작성'?'다음: 저장/확정':'확정 이후 주요 값은 서버 업무 규칙으로 제한'}</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(state.transactionStatus)}</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!=='작성')}${btn('엑셀 ▼')}</div>`;
|
||||
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(){
|
||||
@@ -505,10 +547,10 @@
|
||||
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;}markDirty('OMS-ORD-002',false);toast('주문을 저장했습니다. 서버 최종 검증을 통과한 것으로 가정한 FE Reference 상태입니다.');};
|
||||
$('#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='확정';renderT03(screen('OMS-ORD-002'));toast('주문 상태를 확정으로 전환했습니다. 실제 제품에서는 Domain이 최종 검증합니다.');};
|
||||
$('#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='작성';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('주문을 복사했습니다. 수취/배송정보를 새 주문에 맞게 입력하세요.');};
|
||||
$('#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]||'';}
|
||||
@@ -516,7 +558,7 @@
|
||||
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>${grid(['#','품목코드','품목명','단가','적용일','비고'],rows,{caption:'품목단가 빠른 입력'})}<div class="summary-bar"><span>입력 ${inputCount}행</span><span>오류 <strong>${errorCount}</strong></span><span>빈 행 ${state.fastRows.length-inputCount}</span></div>`;
|
||||
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();
|
||||
}
|
||||
@@ -579,7 +621,7 @@
|
||||
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 ${row.status==='지연'?'error':''}">${esc(row.status)}</td></tr>`).join('');
|
||||
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>`:'';
|
||||
@@ -608,7 +650,7 @@
|
||||
}
|
||||
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>${esc(row.status)}</td></tr>`).join('');
|
||||
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>`;
|
||||
@@ -622,7 +664,7 @@
|
||||
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;for(const row of reconcileRows){if(state.selectedReconcileRows.has(row.no)){row.actual=row.expected;row.diff=0;row.reason='재처리 완료';row.status='해결';}}state.reconcileActionResult=`${count}건을 재처리하여 현재 Reference 데이터 기준 차이를 0으로 맞췄습니다.`;state.selectedReconcileRows.clear();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'));};
|
||||
}
|
||||
|
||||
@@ -635,14 +677,14 @@
|
||||
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=`<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>`;
|
||||
if(step===3)content=`<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>${grid(['행','필드','값','오류'],`<tr><td>8</td><td>품목코드</td><td>ABC99</td><td class="error">존재하지 않는 품목</td></tr><tr><td>12</td><td>수량</td><td>-1</td><td class="error">수량은 0보다 커야 합니다.</td></tr>`,{caption:'Excel 검증 오류'})}`;
|
||||
if(step===4)content=`<section class="commit-panel"><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"><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"><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>`;
|
||||
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();
|
||||
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'));
|
||||
@@ -654,14 +696,18 @@
|
||||
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 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;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 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>`;
|
||||
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){
|
||||
@@ -670,7 +716,7 @@
|
||||
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='';if(state.pickingQty<state.pickingRequired)state.pickingQty++;renderT09(screen('WMS-PICK-001'));toast(state.pickingQty===state.pickingRequired?'상품 피킹 완료.':'정상 상품을 1개 피킹했습니다.');setTimeout(()=>$('#scanBox')?.focus(),0);
|
||||
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';};}
|
||||
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 182 KiB |
@@ -12,7 +12,7 @@ const permissions = computed(() => demoMode ? kbxPermissionCatalog.map(permissio
|
||||
product-name="KBX"
|
||||
:granted-permissions="permissions"
|
||||
:preference-scope="demoMode ? 'demo-tenant:qa-user' : 'anonymous:secure-default'"
|
||||
app-version="v51-fe-presentation-workbench"
|
||||
app-version="v60-fe-status-canonical-contract-hardening"
|
||||
:user-role="demoMode ? 'QA' : 'anonymous'"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AxiosAdapter, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { AxiosError, type AxiosAdapter, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||
import { kbxHttp } from '../http/kbxHttpClient'
|
||||
|
||||
const now = () => new Date().toISOString()
|
||||
@@ -23,30 +23,34 @@ const items = [
|
||||
{ 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:'출고대기', orderDate:'2026-08-11', customerId:customers[0].id, warehouseId:warehouses[0].id,
|
||||
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:'신규', orderDate:'2026-08-11', customerId:customers[1].id, warehouseId:warehouses[0].id,
|
||||
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:'출고대기', orderDate:'2026-08-11', customerId:customers[2].id, warehouseId:warehouses[1].id,
|
||||
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:'출고대기', exceptionCount:1, status:'READY' },
|
||||
{ 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:'신규', exceptionCount:0, status:'NEW' },
|
||||
{ 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:'출고대기', exceptionCount:0, status:'READY' },
|
||||
{ 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:'보류', exceptionCount:1, status:'HOLD' },
|
||||
{ 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:'출고완료', exceptionCount:0, status:'SHIPPED' },
|
||||
{ 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[]>()
|
||||
@@ -66,13 +70,30 @@ 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 = [
|
||||
{ id:'op-1', module:'OMS', category:'재고', code:'STOCK_SHORTAGE', title:'주문 재고 부족', detail:'ABC001 가용재고 부족', severity:'warning', status:'open', owner:'', occurredAt:now(), screenId:'OMS-ORD-001', entityId:orderRows[0].id },
|
||||
{ id:'op-2', module:'WMS', category:'연계', code:'SHIPMENT_SYNC', title:'출고 연계 지연', detail:'WMS 전송 재시도 대기', severity:'error', status:'open', owner:'', occurredAt:now(), screenId:'COMMON-OPS-001', entityId:'sync-2' },
|
||||
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 },
|
||||
]
|
||||
const reconcileItems = [
|
||||
{ id:'rec-1', reconcileType:'OMS_WMS_SHIPMENT', referenceNo:'ORD-260811-0001', expected:2, actual:1, difference:-1, reason:'피킹부족', status:'mismatch', resolutionStatus:'미처리' },
|
||||
{ id:'rec-2', reconcileType:'OMS_WMS_SHIPMENT', referenceNo:'ORD-260811-0003', expected:1, actual:1, difference:0, reason:'', status:'matched', resolutionStatus:'정상' },
|
||||
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 = {
|
||||
@@ -86,6 +107,10 @@ 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 } }
|
||||
@@ -114,25 +139,25 @@ const demoAdapter:AxiosAdapter = async config => {
|
||||
|
||||
if(method==='GET'&&url==='/api/oms/orders'){
|
||||
let rows=[...orderRows]; const keyword=text(q.keyword)
|
||||
if(q.status) rows=rows.filter(x=>x.status===q.status)
|
||||
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.status==='NEW').length,readyToShip:orderRows.filter(x=>x.status==='READY').length,exceptions:orderRows.filter(x=>x.exceptionCount>0).length}
|
||||
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: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\/[^/]+$/.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.status==='READY').map(row=>row.id)
|
||||
let accepted=0; for(const id of ids){const row=orderRows.find(x=>x.id===id);if(row&&row.status==='READY'){row.shipmentStatus='출고지시';accepted++}}
|
||||
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:'작성',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:'작성',exceptionCount:0,status:'NEW'});audit(id,'주문 등록');return ok(config,{orderId:id,orderNo,version:1,status:'작성'})
|
||||
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??'작성',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){existing.version=Number(existing.version)+1;existing.status='확정';audit(id,'주문 확정');return ok(config,{orderId:id,orderNo:existing.orderNo,version:existing.version,status:'확정'})}return ok(config,{orderId:id,orderNo:'',version:1,status:'확정'})}
|
||||
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))
|
||||
@@ -163,15 +188,50 @@ const demoAdapter:AxiosAdapter = async config => {
|
||||
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'){return ok(config,{items:clone(workItems),totalCount:workItems.length,counters:{all:workItems.length,open:workItems.filter(x=>x.status==='open').length,critical:workItems.filter(x=>x.severity==='error').length}})}
|
||||
if(method==='POST'&&url==='/api/operations/work-items/claim'){for(const id of data.ids??[]){const item=workItems.find(x=>x.id===id);if(item)item.owner='QA 사용자'}return ok(config,{updated:(data.ids??[]).length})}
|
||||
if(method==='POST'&&url==='/api/operations/work-items/resolve'){for(const id of data.ids??[]){const item=workItems.find(x=>x.id===id);if(item)item.status='resolved'}return ok(config,{updated:(data.ids??[]).length})}
|
||||
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'){return ok(config,{items:clone(reconcileItems),totalCount:reconcileItems.length,mismatchCount:reconcileItems.filter(x=>x.difference!==0).length})}
|
||||
if(method==='POST'&&url==='/api/reconcile/items/create-exceptions')return ok(config,{created:(data.ids??[]).length})
|
||||
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')return ok(config,{items:[{id:'claim-1',claimNo:'CLM-260811-01',orderNo:'ORD-260811-0001',type:'RETURN',reason:'사이즈 교환',status:'접수',customerName:'홍길동',requestedAt:now(),version:1}],totalCount:1})
|
||||
if(method==='POST'&&/^\/api\/oms\/claims\/[^/]+\/(approve|hold|start|complete)$/.test(url)){const transition=url.split('/').at(-1)!;return ok(config,{id:url.split('/').at(-2),status:{approve:'승인',hold:'보류',start:'처리중',complete:'완료'}[transition],version:2})}
|
||||
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))}
|
||||
@@ -190,7 +250,7 @@ const demoAdapter:AxiosAdapter = async config => {
|
||||
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 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:['주문번호','상품코드','수량','구매자'],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))
|
||||
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))}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
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('대한상사')
|
||||
@@ -27,26 +28,26 @@ const searchFields:KbxSearchField[]=[
|
||||
]
|
||||
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:'출고대기'},
|
||||
{id:'2',orderNo:'TEST-ORD-002',customerName:'서울유통',orderQty:8,amount:96000,status:'재고부족'},
|
||||
{id:'3',orderNo:'TEST-ORD-003',customerName:'부산물류',orderQty:4,amount:52000,status:'신규'},
|
||||
{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:110},
|
||||
{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:'작성'},
|
||||
{id:'F2',orderNo:'TEST-FAST-002',customerName:'대한상사',orderQty:2,amount:24000,status:'작성'},
|
||||
{id:'F3',orderNo:'TEST-FAST-003',customerName:'대한상사',orderQty:3,amount:36000,status:'작성'},
|
||||
{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:'작성'})}
|
||||
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[]=[
|
||||
@@ -76,7 +77,7 @@ const groupCounts=computed(()=>Object.fromEntries([...new Set(kbxComponentCatalo
|
||||
|
||||
<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="processing"/><KbxStatus label="완료" semantic="completed"/><KbxStatus label="재고부족" semantic="warning"/><KbxStatus label="오류" semantic="error"/></div><KbxToast message="저장했습니다."/><KbxToast message="일부 항목을 확인하세요." kind="warning"/></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>
|
||||
|
||||
@@ -5,11 +5,12 @@ import { KbxListPage, KbxDataGrid, KbxDialog, KbxInput, KbxNumberField, KbxSelec
|
||||
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:110},{field:'rolloutPercent',header:'배포 %',type:'integer',width:90},{field:'decision',header:'판정',width:150},{field:'updatedAt',header:'변경일시',type:'datetime',width:170},
|
||||
{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}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const experimentsScreen=defineKbxScreen({
|
||||
id:'COMMON-EXP-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'UX 실험·점진배포',
|
||||
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}],
|
||||
@@ -5,13 +5,14 @@ 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},
|
||||
{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},
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const externalDataScreen=defineKbxScreen({
|
||||
id:'COMMON-DATA-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'외부 데이터 상태',
|
||||
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:[
|
||||
@@ -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>
|
||||
@@ -1,8 +1,9 @@
|
||||
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.0.0',
|
||||
version: '1.3.0',
|
||||
module: 'COMMON',
|
||||
type: 'queue', templateCode:'T06',
|
||||
title: '업무 예외 센터',
|
||||
@@ -34,7 +35,7 @@ export const operationsSearchFields: KbxSearchField[] = [
|
||||
]
|
||||
|
||||
export const operationsColumns: KbxGridColumn<KbxWorkItem>[] = [
|
||||
{ field: 'severity', header: '중요도', type: 'status', width: 82, pinned: 'left' },
|
||||
{ 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 },
|
||||
@@ -42,5 +43,5 @@ export const operationsColumns: KbxGridColumn<KbxWorkItem>[] = [
|
||||
{ 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: 100 },
|
||||
{ field: 'status', header: '처리상태', type: 'status', width: 110, statusMap:{definitions:kbxStatusCatalog.workItem} },
|
||||
]
|
||||
@@ -1,6 +1,16 @@
|
||||
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
|
||||
@@ -17,10 +27,10 @@ export const operationsApi = {
|
||||
return kbxApi.request<KbxWorkQueueResult>('common.operations.search', { query: filter })
|
||||
},
|
||||
claim(ids: string[]) {
|
||||
return kbxApi.request<unknown, { ids: string[] }>('common.operations.claim', { body: { ids } })
|
||||
return kbxApi.request<ClaimWorkItemsResponse, { ids: string[] }>('common.operations.claim', { body: { ids } })
|
||||
},
|
||||
resolve(ids: string[], reason = '사용자 확인 완료') {
|
||||
return kbxApi.request<unknown, { ids: string[]; reason: string }>('common.operations.resolve', { body: { ids, 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,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>
|
||||
@@ -1,8 +1,9 @@
|
||||
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.0.0',
|
||||
version: '1.3.0',
|
||||
module: 'COMMON',
|
||||
type: 'reconcile', templateCode:'T07',
|
||||
title: '업무 데이터 대사',
|
||||
@@ -37,6 +38,6 @@ export const reconcileColumns: KbxGridColumn<KbxReconcileItem>[] = [
|
||||
{ field: 'actualValue', header: '실제값', width: 130 },
|
||||
{ field: 'differenceValue', header: '차이', width: 110 },
|
||||
{ field: 'reasonText', header: '원인', width: 280 },
|
||||
{ field: 'status', header: '상태', type: 'status', width: 100 },
|
||||
{ field: 'status', header: '상태', type: 'status', width: 110, statusMap:{definitions:kbxStatusCatalog.reconcile} },
|
||||
{ field: 'occurredAt', header: '확인시각', type: 'datetime', width: 160 },
|
||||
]
|
||||