V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
- generic [ref=e3]:
|
||||
- link "본문으로 건너뛰기" [ref=e4] [cursor=pointer]:
|
||||
- /url: "#ks-main"
|
||||
- banner [ref=e5]:
|
||||
- button "K-ArtSell Aegis IMPLEMENTATION_TEMPLATE" [ref=e6] [cursor=pointer]:
|
||||
- strong [ref=e7]: K-ArtSell Aegis
|
||||
- generic [ref=e8]: IMPLEMENTATION_TEMPLATE
|
||||
- button "메뉴명 · 화면코드 · 업무명 검색 Ctrl K" [ref=e9]:
|
||||
- generic [ref=e10]: 메뉴명 · 화면코드 · 업무명 검색
|
||||
- generic [ref=e11]: Ctrl K
|
||||
- status [ref=e12]: 투자자문형 · 자동주문/KIS 제출 OFF · 자동 모델승격 OFF
|
||||
- navigation "열린 업무" [ref=e13]:
|
||||
- generic [ref=e14]:
|
||||
- button "컴포넌트 확인" [ref=e15] [cursor=pointer]
|
||||
- button "탭 고정" [ref=e17] [cursor=pointer]: 📍
|
||||
- button "탭 닫기" [ref=e18] [cursor=pointer]: ×
|
||||
- generic [ref=e19]:
|
||||
- complementary "주요 메뉴" [ref=e20]:
|
||||
- button "« 접기" [expanded] [ref=e21] [cursor=pointer]
|
||||
- generic [ref=e22]:
|
||||
- heading "Design System" [level=2] [ref=e23]
|
||||
- link "컴포넌트 확인" [ref=e24] [cursor=pointer]:
|
||||
- /url: /internal/ui-standard
|
||||
- generic [ref=e25]:
|
||||
- heading "Operations" [level=2] [ref=e26]
|
||||
- link "데이터 품질" [ref=e27] [cursor=pointer]:
|
||||
- /url: /ops/data-quality
|
||||
- link "시장 데이터 수집" [ref=e28] [cursor=pointer]:
|
||||
- /url: /ops/market-data-ingestion
|
||||
- link "수집 이력" [ref=e29] [cursor=pointer]:
|
||||
- /url: /ops/market-data-history
|
||||
- link "모델 운영" [ref=e30] [cursor=pointer]:
|
||||
- /url: /ops/model-operations
|
||||
- generic [ref=e31]:
|
||||
- heading "Portfolio" [level=2] [ref=e32]
|
||||
- link "포트폴리오 리스크" [ref=e33] [cursor=pointer]:
|
||||
- /url: /portfolio/risk
|
||||
- link "리밸런싱 제안" [ref=e34] [cursor=pointer]:
|
||||
- /url: /portfolio/rebalance
|
||||
- generic [ref=e35]:
|
||||
- heading "Research" [level=2] [ref=e36]
|
||||
- link "매도 의사결정" [ref=e37] [cursor=pointer]:
|
||||
- /url: /research/sell-decision
|
||||
- main [ref=e38]:
|
||||
- generic [ref=e39]:
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]:
|
||||
- heading "표준 UI 패턴" [level=1] [ref=e42]
|
||||
- paragraph [ref=e43]: Feature는 공급자 라이브러리를 직접 사용하지 않고, v4 어댑터·레이아웃·화면 계약을 사용한다.
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e45]: "상태: READY"
|
||||
- generic [ref=e46]: "As-of: 2026-08-02"
|
||||
- generic [ref=e47]: "Version: UI-CONTRACT-4.0"
|
||||
- button "새 화면 패킷" [ref=e49] [cursor=pointer]
|
||||
- generic [ref=e50]:
|
||||
- generic [ref=e51]:
|
||||
- strong [ref=e52]: "10"
|
||||
- generic [ref=e53]: 화면 타입
|
||||
- generic [ref=e54]:
|
||||
- strong [ref=e55]: "14"
|
||||
- generic [ref=e56]: 어댑터 포트
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58]: primevue-aggrid
|
||||
- generic [ref=e60]: PrimeVue + AG Grid Community
|
||||
- generic [ref=e61]:
|
||||
- generic [ref=e62]: 자동주문 OFF
|
||||
- generic [ref=e64]: 고정 경계
|
||||
- generic [ref=e66]:
|
||||
- generic [ref=e67]:
|
||||
- generic [ref=e68]: 검색
|
||||
- textbox "검색" [ref=e69]:
|
||||
- /placeholder: 화면 ID, 타입 또는 컴포넌트
|
||||
- generic [ref=e70]:
|
||||
- generic [ref=e71]: 상태
|
||||
- combobox "전체" [ref=e73]
|
||||
- generic [ref=e79]:
|
||||
- generic [ref=e81]:
|
||||
- main [ref=e82]:
|
||||
- generic [ref=e85]:
|
||||
- generic [ref=e86]: No Rows To Show
|
||||
- grid [ref=e87]:
|
||||
- rowgroup [ref=e88]:
|
||||
- row [ref=e89]:
|
||||
- columnheader [ref=e90]
|
||||
- columnheader "화면 ID" [ref=e91]:
|
||||
- generic [ref=e93] [cursor=pointer]
|
||||
- columnheader "화면 타입" [ref=e95]:
|
||||
- generic [ref=e97] [cursor=pointer]
|
||||
- columnheader "표준 컴포넌트" [ref=e99]:
|
||||
- generic [ref=e101] [cursor=pointer]
|
||||
- columnheader "필수 증거" [ref=e103]:
|
||||
- generic [ref=e105] [cursor=pointer]
|
||||
- columnheader "상태" [ref=e107]:
|
||||
- generic [ref=e109] [cursor=pointer]
|
||||
- rowgroup [ref=e111]:
|
||||
- row [ref=e112]:
|
||||
- gridcell [ref=e113]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e114] [cursor=pointer]
|
||||
- gridcell "T01" [ref=e115]
|
||||
- gridcell "검색·목록형 CRUD" [ref=e116]
|
||||
- gridcell "SearchListCrudPage" [ref=e117]
|
||||
- gridcell "3" [ref=e118]
|
||||
- gridcell "READY" [ref=e119]
|
||||
- row [ref=e120]:
|
||||
- gridcell [ref=e121]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e122] [cursor=pointer]
|
||||
- gridcell "T02" [ref=e123]
|
||||
- gridcell "상세 조회형" [ref=e124]
|
||||
- gridcell "DetailReadPage" [ref=e125]
|
||||
- gridcell "3" [ref=e126]
|
||||
- gridcell "READY" [ref=e127]
|
||||
- row [ref=e128]:
|
||||
- gridcell [ref=e129]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e130] [cursor=pointer]
|
||||
- gridcell "T03" [ref=e131]
|
||||
- gridcell "등록·편집 Form" [ref=e132]
|
||||
- gridcell "EditFormPage" [ref=e133]
|
||||
- gridcell "3" [ref=e134]
|
||||
- gridcell "READY" [ref=e135]
|
||||
- row [ref=e136]:
|
||||
- gridcell [ref=e137]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e138] [cursor=pointer]
|
||||
- gridcell "T04" [ref=e139]
|
||||
- gridcell "Master-Detail" [ref=e140]
|
||||
- gridcell "MasterDetailCrudPage" [ref=e141]
|
||||
- gridcell "3" [ref=e142]
|
||||
- gridcell "READY" [ref=e143]
|
||||
- row [ref=e144]:
|
||||
- gridcell [ref=e145]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e146] [cursor=pointer]
|
||||
- gridcell "T05" [ref=e147]
|
||||
- gridcell "검토·승인 Workbench" [ref=e148]
|
||||
- gridcell "ApprovalWorkbenchPage" [ref=e149]
|
||||
- gridcell "3" [ref=e150]
|
||||
- gridcell "READY" [ref=e151]
|
||||
- row [ref=e152]:
|
||||
- gridcell [ref=e153]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e154] [cursor=pointer]
|
||||
- gridcell "T06" [ref=e155]
|
||||
- gridcell "단계 Wizard" [ref=e156]
|
||||
- gridcell "StepWizardPage" [ref=e157]
|
||||
- gridcell "3" [ref=e158]
|
||||
- gridcell "READY" [ref=e159]
|
||||
- row [ref=e160]:
|
||||
- gridcell [ref=e161]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e162] [cursor=pointer]
|
||||
- gridcell "T07" [ref=e163]
|
||||
- gridcell "Dashboard·Scorecard" [ref=e164]
|
||||
- gridcell "ScorecardDashboardPage" [ref=e165]
|
||||
- gridcell "3" [ref=e166]
|
||||
- gridcell "READY" [ref=e167]
|
||||
- row [ref=e168]:
|
||||
- gridcell [ref=e169]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e170] [cursor=pointer]
|
||||
- gridcell "T08" [ref=e171]
|
||||
- gridcell "Batch·데이터 운영" [ref=e172]
|
||||
- gridcell "BatchOperationsPageV2" [ref=e173]
|
||||
- gridcell "3" [ref=e174]
|
||||
- gridcell "READY" [ref=e175]
|
||||
- row [ref=e176]:
|
||||
- gridcell [ref=e177]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e178] [cursor=pointer]
|
||||
- gridcell "T09" [ref=e179]
|
||||
- gridcell "대사·예외 처리" [ref=e180]
|
||||
- gridcell "ReconciliationExceptionPage" [ref=e181]
|
||||
- gridcell "3" [ref=e182]
|
||||
- gridcell "READY" [ref=e183]
|
||||
- row [ref=e184]:
|
||||
- gridcell [ref=e185]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e186] [cursor=pointer]
|
||||
- gridcell "T10" [ref=e187]
|
||||
- gridcell "버전 비교·거버넌스" [ref=e188]
|
||||
- gridcell "VersionGovernancePage" [ref=e189]
|
||||
- gridcell "3" [ref=e190]
|
||||
- gridcell "READY" [ref=e191]
|
||||
- row [ref=e192]:
|
||||
- gridcell [ref=e193]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e194] [cursor=pointer]
|
||||
- gridcell "T11" [ref=e195]
|
||||
- gridcell "대량 입력(Fast Grid Entry)" [ref=e196]
|
||||
- gridcell "FastEntryGridPage" [ref=e197]
|
||||
- gridcell "3" [ref=e198]
|
||||
- gridcell "READY" [ref=e199]
|
||||
- row [ref=e200]:
|
||||
- gridcell [ref=e201]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e202] [cursor=pointer]
|
||||
- gridcell "T12" [ref=e203]
|
||||
- gridcell "작업 큐(Work Queue)" [ref=e204]
|
||||
- gridcell "WorkQueuePage" [ref=e205]
|
||||
- gridcell "2" [ref=e206]
|
||||
- gridcell "READY" [ref=e207]
|
||||
- rowgroup
|
||||
- rowgroup
|
||||
- rowgroup
|
||||
- region [ref=e215]:
|
||||
- generic [ref=e216]:
|
||||
- heading "컴포넌트 동작 프리뷰" [level=2] [ref=e217]
|
||||
- paragraph [ref=e218]: 공유 UI 포트를 실제 상태로 확인하는 내부 전용 카탈로그입니다.
|
||||
- generic [ref=e219]:
|
||||
- generic [ref=e220]:
|
||||
- heading "Actions" [level=3] [ref=e221]
|
||||
- button "기본 버튼" [ref=e222] [cursor=pointer]
|
||||
- button "주의 상태" [ref=e223] [cursor=pointer]
|
||||
- generic [ref=e224]:
|
||||
- heading "Status" [level=3] [ref=e225]
|
||||
- generic [ref=e226]:
|
||||
- generic [ref=e227]: READY
|
||||
- generic [ref=e229]: REVIEW
|
||||
- generic [ref=e231]: BLOCKED
|
||||
- generic [ref=e233]:
|
||||
- heading "Inputs" [level=3] [ref=e234]
|
||||
- generic [ref=e235]:
|
||||
- generic [ref=e236]: 텍스트 필드
|
||||
- textbox "텍스트 필드" [ref=e237]: 샘플 값
|
||||
- generic [ref=e238]:
|
||||
- generic [ref=e239]: 선택 필드
|
||||
- combobox "준비" [ref=e241]
|
||||
- region [ref=e245]:
|
||||
- generic [ref=e246]:
|
||||
- heading "입력 유형 기능 테스트" [level=2] [ref=e247]
|
||||
- paragraph [ref=e248]: 서버 요청 없이 shared UI의 입력·검증·읽기전용 조합을 확인합니다.
|
||||
- generic [ref=e249]:
|
||||
- generic [ref=e250]:
|
||||
- heading "기본 입력" [level=2] [ref=e251]
|
||||
- paragraph [ref=e252]: 필수 항목과 유효성 상태를 조작할 수 있습니다.
|
||||
- button "읽기 전용" [ref=e254] [cursor=pointer]
|
||||
- group "기능 테스트 입력" [ref=e256]:
|
||||
- generic [ref=e257]:
|
||||
- generic [ref=e258]: 이름
|
||||
- textbox "이름" [ref=e259]: 테스트
|
||||
- generic [ref=e260]:
|
||||
- generic [ref=e261]: 금액
|
||||
- spinbutton "금액" [ref=e263]
|
||||
- generic [ref=e264]:
|
||||
- generic [ref=e265]: 기준일
|
||||
- generic [ref=e266]:
|
||||
- combobox "기준일" [ref=e267]
|
||||
- button "Choose Date" [ref=e268]
|
||||
- generic [ref=e271] [cursor=pointer]:
|
||||
- checkbox "검증 조건을 확인했습니다" [checked] [active] [ref=e273]
|
||||
- generic [ref=e274]: 검증 조건을 확인했습니다
|
||||
- generic [ref=e276]:
|
||||
- button "검증 실행" [ref=e277] [cursor=pointer]
|
||||
- button "초기화" [ref=e278] [cursor=pointer]
|
||||
- complementary [ref=e279]:
|
||||
- generic [ref=e280]:
|
||||
- heading "교체 계약" [level=2] [ref=e281]
|
||||
- paragraph [ref=e282]:
|
||||
- text: 기본 공급자 교체는
|
||||
- code [ref=e283]: VITE_UI_ADAPTER
|
||||
- text: 와 provider registry에서 수행한다. Feature 코드는 변경하지 않는다.
|
||||
- paragraph [ref=e284]: PrimeVue+AG Grid와 Native reference adapter가 동일 contract test를 통과해야 한다.
|
||||
- paragraph [ref=e285]: 생산 교체는 접근성, 키보드, 상태행렬, 시각회귀, 대량목록 성능을 별도 Gate로 검증한다.
|
||||
- text: "데이터 기준시각: 2026-08-02"
|
||||
- contentinfo [ref=e286]: RESEARCH_CANDIDATE_NOT_PRODUCTION
|
||||
- generic "애플리케이션 버전" [ref=e287]: v0.1.0 · UI contract 4.0
|
||||
@@ -0,0 +1,261 @@
|
||||
- generic [ref=e3]:
|
||||
- link "본문으로 건너뛰기" [ref=e4] [cursor=pointer]:
|
||||
- /url: "#ks-main"
|
||||
- banner [ref=e5]:
|
||||
- button "K-ArtSell Aegis IMPLEMENTATION_TEMPLATE" [ref=e6] [cursor=pointer]:
|
||||
- strong [ref=e7]: K-ArtSell Aegis
|
||||
- generic [ref=e8]: IMPLEMENTATION_TEMPLATE
|
||||
- button "메뉴명 · 화면코드 · 업무명 검색 Ctrl K" [ref=e9]:
|
||||
- generic [ref=e10]: 메뉴명 · 화면코드 · 업무명 검색
|
||||
- generic [ref=e11]: Ctrl K
|
||||
- status [ref=e12]: 투자자문형 · 자동주문/KIS 제출 OFF · 자동 모델승격 OFF
|
||||
- navigation "열린 업무" [ref=e13]:
|
||||
- generic [ref=e14]:
|
||||
- button "컴포넌트 확인" [ref=e15] [cursor=pointer]
|
||||
- button "탭 고정" [ref=e17] [cursor=pointer]: 📍
|
||||
- button "탭 닫기" [ref=e18] [cursor=pointer]: ×
|
||||
- generic [ref=e19]:
|
||||
- complementary "주요 메뉴" [ref=e20]:
|
||||
- button "« 접기" [expanded] [ref=e21] [cursor=pointer]
|
||||
- generic [ref=e22]:
|
||||
- heading "Design System" [level=2] [ref=e23]
|
||||
- link "컴포넌트 확인" [ref=e24] [cursor=pointer]:
|
||||
- /url: /internal/ui-standard
|
||||
- generic [ref=e25]:
|
||||
- heading "Operations" [level=2] [ref=e26]
|
||||
- link "데이터 품질" [ref=e27] [cursor=pointer]:
|
||||
- /url: /ops/data-quality
|
||||
- link "시장 데이터 수집" [ref=e28] [cursor=pointer]:
|
||||
- /url: /ops/market-data-ingestion
|
||||
- link "수집 이력" [ref=e29] [cursor=pointer]:
|
||||
- /url: /ops/market-data-history
|
||||
- link "모델 운영" [ref=e30] [cursor=pointer]:
|
||||
- /url: /ops/model-operations
|
||||
- generic [ref=e31]:
|
||||
- heading "Portfolio" [level=2] [ref=e32]
|
||||
- link "포트폴리오 리스크" [ref=e33] [cursor=pointer]:
|
||||
- /url: /portfolio/risk
|
||||
- link "리밸런싱 제안" [ref=e34] [cursor=pointer]:
|
||||
- /url: /portfolio/rebalance
|
||||
- generic [ref=e35]:
|
||||
- heading "Research" [level=2] [ref=e36]
|
||||
- link "매도 의사결정" [ref=e37] [cursor=pointer]:
|
||||
- /url: /research/sell-decision
|
||||
- main [ref=e38]:
|
||||
- generic [ref=e39]:
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]:
|
||||
- heading "표준 UI 패턴" [level=1] [ref=e42]
|
||||
- paragraph [ref=e43]: Feature는 공급자 라이브러리를 직접 사용하지 않고, v4 어댑터·레이아웃·화면 계약을 사용한다.
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e45]: "상태: READY"
|
||||
- generic [ref=e46]: "As-of: 2026-08-02"
|
||||
- generic [ref=e47]: "Version: UI-CONTRACT-4.0"
|
||||
- button "새 화면 패킷" [ref=e49] [cursor=pointer]
|
||||
- generic [ref=e50]:
|
||||
- generic [ref=e51]:
|
||||
- strong [ref=e52]: "10"
|
||||
- generic [ref=e53]: 화면 타입
|
||||
- generic [ref=e54]:
|
||||
- strong [ref=e55]: "14"
|
||||
- generic [ref=e56]: 어댑터 포트
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58]: primevue-aggrid
|
||||
- generic [ref=e60]: PrimeVue + AG Grid Community
|
||||
- generic [ref=e61]:
|
||||
- generic [ref=e62]: 자동주문 OFF
|
||||
- generic [ref=e64]: 고정 경계
|
||||
- generic [ref=e66]:
|
||||
- generic [ref=e67]:
|
||||
- generic [ref=e68]: 검색
|
||||
- textbox "검색" [ref=e69]:
|
||||
- /placeholder: 화면 ID, 타입 또는 컴포넌트
|
||||
- generic [ref=e70]:
|
||||
- generic [ref=e71]: 상태
|
||||
- combobox "전체" [ref=e73]
|
||||
- generic [ref=e79]:
|
||||
- generic [ref=e81]:
|
||||
- main [ref=e82]:
|
||||
- generic [ref=e85]:
|
||||
- generic [ref=e86]: No Rows To Show
|
||||
- grid [ref=e87]:
|
||||
- rowgroup [ref=e88]:
|
||||
- row [ref=e89]:
|
||||
- columnheader [ref=e90]
|
||||
- columnheader "화면 ID" [ref=e91]:
|
||||
- generic [ref=e93] [cursor=pointer]
|
||||
- columnheader "화면 타입" [ref=e95]:
|
||||
- generic [ref=e97] [cursor=pointer]
|
||||
- columnheader "표준 컴포넌트" [ref=e99]:
|
||||
- generic [ref=e101] [cursor=pointer]
|
||||
- columnheader "필수 증거" [ref=e103]:
|
||||
- generic [ref=e105] [cursor=pointer]
|
||||
- columnheader "상태" [ref=e107]:
|
||||
- generic [ref=e109] [cursor=pointer]
|
||||
- rowgroup [ref=e111]:
|
||||
- row [ref=e112]:
|
||||
- gridcell [ref=e113]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e114] [cursor=pointer]
|
||||
- gridcell "T01" [ref=e115]
|
||||
- gridcell "검색·목록형 CRUD" [ref=e116]
|
||||
- gridcell "SearchListCrudPage" [ref=e117]
|
||||
- gridcell "3" [ref=e118]
|
||||
- gridcell "READY" [ref=e119]
|
||||
- row [ref=e120]:
|
||||
- gridcell [ref=e121]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e122] [cursor=pointer]
|
||||
- gridcell "T02" [ref=e123]
|
||||
- gridcell "상세 조회형" [ref=e124]
|
||||
- gridcell "DetailReadPage" [ref=e125]
|
||||
- gridcell "3" [ref=e126]
|
||||
- gridcell "READY" [ref=e127]
|
||||
- row [ref=e128]:
|
||||
- gridcell [ref=e129]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e130] [cursor=pointer]
|
||||
- gridcell "T03" [ref=e131]
|
||||
- gridcell "등록·편집 Form" [ref=e132]
|
||||
- gridcell "EditFormPage" [ref=e133]
|
||||
- gridcell "3" [ref=e134]
|
||||
- gridcell "READY" [ref=e135]
|
||||
- row [ref=e136]:
|
||||
- gridcell [ref=e137]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e138] [cursor=pointer]
|
||||
- gridcell "T04" [ref=e139]
|
||||
- gridcell "Master-Detail" [ref=e140]
|
||||
- gridcell "MasterDetailCrudPage" [ref=e141]
|
||||
- gridcell "3" [ref=e142]
|
||||
- gridcell "READY" [ref=e143]
|
||||
- row [ref=e144]:
|
||||
- gridcell [ref=e145]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e146] [cursor=pointer]
|
||||
- gridcell "T05" [ref=e147]
|
||||
- gridcell "검토·승인 Workbench" [ref=e148]
|
||||
- gridcell "ApprovalWorkbenchPage" [ref=e149]
|
||||
- gridcell "3" [ref=e150]
|
||||
- gridcell "READY" [ref=e151]
|
||||
- row [ref=e152]:
|
||||
- gridcell [ref=e153]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e154] [cursor=pointer]
|
||||
- gridcell "T06" [ref=e155]
|
||||
- gridcell "단계 Wizard" [ref=e156]
|
||||
- gridcell "StepWizardPage" [ref=e157]
|
||||
- gridcell "3" [ref=e158]
|
||||
- gridcell "READY" [ref=e159]
|
||||
- row [ref=e160]:
|
||||
- gridcell [ref=e161]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e162] [cursor=pointer]
|
||||
- gridcell "T07" [ref=e163]
|
||||
- gridcell "Dashboard·Scorecard" [ref=e164]
|
||||
- gridcell "ScorecardDashboardPage" [ref=e165]
|
||||
- gridcell "3" [ref=e166]
|
||||
- gridcell "READY" [ref=e167]
|
||||
- row [ref=e168]:
|
||||
- gridcell [ref=e169]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e170] [cursor=pointer]
|
||||
- gridcell "T08" [ref=e171]
|
||||
- gridcell "Batch·데이터 운영" [ref=e172]
|
||||
- gridcell "BatchOperationsPageV2" [ref=e173]
|
||||
- gridcell "3" [ref=e174]
|
||||
- gridcell "READY" [ref=e175]
|
||||
- row [ref=e176]:
|
||||
- gridcell [ref=e177]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e178] [cursor=pointer]
|
||||
- gridcell "T09" [ref=e179]
|
||||
- gridcell "대사·예외 처리" [ref=e180]
|
||||
- gridcell "ReconciliationExceptionPage" [ref=e181]
|
||||
- gridcell "3" [ref=e182]
|
||||
- gridcell "READY" [ref=e183]
|
||||
- row [ref=e184]:
|
||||
- gridcell [ref=e185]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e186] [cursor=pointer]
|
||||
- gridcell "T10" [ref=e187]
|
||||
- gridcell "버전 비교·거버넌스" [ref=e188]
|
||||
- gridcell "VersionGovernancePage" [ref=e189]
|
||||
- gridcell "3" [ref=e190]
|
||||
- gridcell "READY" [ref=e191]
|
||||
- row [ref=e192]:
|
||||
- gridcell [ref=e193]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e194] [cursor=pointer]
|
||||
- gridcell "T11" [ref=e195]
|
||||
- gridcell "대량 입력(Fast Grid Entry)" [ref=e196]
|
||||
- gridcell "FastEntryGridPage" [ref=e197]
|
||||
- gridcell "3" [ref=e198]
|
||||
- gridcell "READY" [ref=e199]
|
||||
- row [ref=e200]:
|
||||
- gridcell [ref=e201]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e202] [cursor=pointer]
|
||||
- gridcell "T12" [ref=e203]
|
||||
- gridcell "작업 큐(Work Queue)" [ref=e204]
|
||||
- gridcell "WorkQueuePage" [ref=e205]
|
||||
- gridcell "2" [ref=e206]
|
||||
- gridcell "READY" [ref=e207]
|
||||
- rowgroup
|
||||
- rowgroup
|
||||
- rowgroup
|
||||
- region [ref=e215]:
|
||||
- generic [ref=e216]:
|
||||
- heading "컴포넌트 동작 프리뷰" [level=2] [ref=e217]
|
||||
- paragraph [ref=e218]: 공유 UI 포트를 실제 상태로 확인하는 내부 전용 카탈로그입니다.
|
||||
- generic [ref=e219]:
|
||||
- generic [ref=e220]:
|
||||
- heading "Actions" [level=3] [ref=e221]
|
||||
- button "기본 버튼" [ref=e222] [cursor=pointer]
|
||||
- button "주의 상태" [ref=e223] [cursor=pointer]
|
||||
- generic [ref=e224]:
|
||||
- heading "Status" [level=3] [ref=e225]
|
||||
- generic [ref=e226]:
|
||||
- generic [ref=e227]: READY
|
||||
- generic [ref=e229]: REVIEW
|
||||
- generic [ref=e231]: BLOCKED
|
||||
- generic [ref=e233]:
|
||||
- heading "Inputs" [level=3] [ref=e234]
|
||||
- generic [ref=e235]:
|
||||
- generic [ref=e236]: 텍스트 필드
|
||||
- textbox "텍스트 필드" [ref=e237]: 샘플 값
|
||||
- generic [ref=e238]:
|
||||
- generic [ref=e239]: 선택 필드
|
||||
- combobox "준비" [ref=e241]
|
||||
- region [ref=e245]:
|
||||
- generic [ref=e246]:
|
||||
- heading "입력 유형 기능 테스트" [level=2] [ref=e247]
|
||||
- paragraph [ref=e248]: 서버 요청 없이 shared UI의 입력·검증·읽기전용 조합을 확인합니다.
|
||||
- alert [ref=e291]:
|
||||
- strong [ref=e292]: 저장할 수 없습니다. 1개 항목을 확인하세요.
|
||||
- list [ref=e293]:
|
||||
- listitem [ref=e294]: 0보다 큰 금액을 입력하세요.
|
||||
- generic [ref=e249]:
|
||||
- generic [ref=e250]:
|
||||
- heading "기본 입력" [level=2] [ref=e251]
|
||||
- paragraph [ref=e252]: 필수 항목과 유효성 상태를 조작할 수 있습니다.
|
||||
- button "읽기 전용" [ref=e254] [cursor=pointer]
|
||||
- group "기능 테스트 입력" [ref=e256]:
|
||||
- generic [ref=e257]:
|
||||
- generic [ref=e258]: 이름
|
||||
- textbox "이름" [active] [ref=e259]: 테스트
|
||||
- generic [ref=e260]:
|
||||
- generic [ref=e261]: 금액
|
||||
- spinbutton "금액" [invalid] [ref=e263]
|
||||
- alert [ref=e295]: 0보다 커야 합니다.
|
||||
- generic [ref=e264]:
|
||||
- generic [ref=e265]: 기준일
|
||||
- generic [ref=e266]:
|
||||
- combobox "기준일" [ref=e267]
|
||||
- button "Choose Date" [ref=e268]
|
||||
- generic [ref=e271] [cursor=pointer]:
|
||||
- checkbox "검증 조건을 확인했습니다" [checked] [ref=e273]
|
||||
- generic [ref=e274]: 검증 조건을 확인했습니다
|
||||
- generic [ref=e276]:
|
||||
- button "검증 실행" [ref=e277] [cursor=pointer]
|
||||
- button "초기화" [ref=e278] [cursor=pointer]
|
||||
- complementary [ref=e279]:
|
||||
- generic [ref=e280]:
|
||||
- heading "교체 계약" [level=2] [ref=e281]
|
||||
- paragraph [ref=e282]:
|
||||
- text: 기본 공급자 교체는
|
||||
- code [ref=e283]: VITE_UI_ADAPTER
|
||||
- text: 와 provider registry에서 수행한다. Feature 코드는 변경하지 않는다.
|
||||
- paragraph [ref=e284]: PrimeVue+AG Grid와 Native reference adapter가 동일 contract test를 통과해야 한다.
|
||||
- paragraph [ref=e285]: 생산 교체는 접근성, 키보드, 상태행렬, 시각회귀, 대량목록 성능을 별도 Gate로 검증한다.
|
||||
- text: "데이터 기준시각: 2026-08-02"
|
||||
- contentinfo [ref=e286]: RESEARCH_CANDIDATE_NOT_PRODUCTION
|
||||
- generic "애플리케이션 버전" [ref=e287]: v0.1.0 · UI contract 4.0
|
||||
@@ -0,0 +1,174 @@
|
||||
- generic [ref=e3]:
|
||||
- link "본문으로 건너뛰기" [ref=e4] [cursor=pointer]:
|
||||
- /url: "#ks-main"
|
||||
- banner [ref=e5]:
|
||||
- button "K-ArtSell Aegis IMPLEMENTATION_TEMPLATE" [ref=e6] [cursor=pointer]:
|
||||
- strong [ref=e7]: K-ArtSell Aegis
|
||||
- generic [ref=e8]: IMPLEMENTATION_TEMPLATE
|
||||
- button "메뉴명 · 화면코드 · 업무명 검색 Ctrl K" [ref=e9]:
|
||||
- generic [ref=e10]: 메뉴명 · 화면코드 · 업무명 검색
|
||||
- generic [ref=e11]: Ctrl K
|
||||
- status [ref=e12]: 투자자문형 · 자동주문/KIS 제출 OFF · 자동 모델승격 OFF
|
||||
- navigation "열린 업무" [ref=e13]:
|
||||
- generic [ref=e14]:
|
||||
- button "컴포넌트 확인" [ref=e15] [cursor=pointer]
|
||||
- button "탭 고정" [ref=e17] [cursor=pointer]: 📍
|
||||
- button "탭 닫기" [ref=e18] [cursor=pointer]: ×
|
||||
- generic [ref=e19]:
|
||||
- complementary "주요 메뉴" [ref=e20]:
|
||||
- button "« 접기" [expanded] [ref=e21] [cursor=pointer]
|
||||
- generic [ref=e22]:
|
||||
- heading "Design System" [level=2] [ref=e23]
|
||||
- link "컴포넌트 확인" [ref=e24] [cursor=pointer]:
|
||||
- /url: /internal/ui-standard
|
||||
- generic [ref=e25]:
|
||||
- heading "Operations" [level=2] [ref=e26]
|
||||
- link "데이터 품질" [ref=e27] [cursor=pointer]:
|
||||
- /url: /ops/data-quality
|
||||
- link "시장 데이터 수집" [ref=e28] [cursor=pointer]:
|
||||
- /url: /ops/market-data-ingestion
|
||||
- link "수집 이력" [ref=e29] [cursor=pointer]:
|
||||
- /url: /ops/market-data-history
|
||||
- link "모델 운영" [ref=e30] [cursor=pointer]:
|
||||
- /url: /ops/model-operations
|
||||
- generic [ref=e31]:
|
||||
- heading "Portfolio" [level=2] [ref=e32]
|
||||
- link "포트폴리오 리스크" [ref=e33] [cursor=pointer]:
|
||||
- /url: /portfolio/risk
|
||||
- link "리밸런싱 제안" [ref=e34] [cursor=pointer]:
|
||||
- /url: /portfolio/rebalance
|
||||
- generic [ref=e35]:
|
||||
- heading "Research" [level=2] [ref=e36]
|
||||
- link "매도 의사결정" [ref=e37] [cursor=pointer]:
|
||||
- /url: /research/sell-decision
|
||||
- main [ref=e38]:
|
||||
- generic [ref=e39]:
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]:
|
||||
- heading "표준 UI 패턴" [level=1] [ref=e42]
|
||||
- paragraph [ref=e43]: Feature는 공급자 라이브러리를 직접 사용하지 않고, v4 어댑터·레이아웃·화면 계약을 사용한다.
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e45]: "상태: READY"
|
||||
- generic [ref=e46]: "As-of: 2026-08-02"
|
||||
- generic [ref=e47]: "Version: UI-CONTRACT-4.0"
|
||||
- button "새 화면 패킷" [ref=e49] [cursor=pointer]
|
||||
- generic [ref=e50]:
|
||||
- generic [ref=e51]:
|
||||
- strong [ref=e52]: "10"
|
||||
- generic [ref=e53]: 화면 타입
|
||||
- generic [ref=e54]:
|
||||
- strong [ref=e55]: "14"
|
||||
- generic [ref=e56]: 어댑터 포트
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58]: primevue-aggrid
|
||||
- generic [ref=e60]: PrimeVue + AG Grid Community
|
||||
- generic [ref=e61]:
|
||||
- generic [ref=e62]: 자동주문 OFF
|
||||
- generic [ref=e64]: 고정 경계
|
||||
- generic [ref=e66]:
|
||||
- generic [ref=e67]:
|
||||
- generic [ref=e68]: 검색
|
||||
- textbox "검색" [ref=e69]:
|
||||
- /placeholder: 화면 ID, 타입 또는 컴포넌트
|
||||
- text: T01
|
||||
- generic [ref=e70]:
|
||||
- generic [ref=e71]: 상태
|
||||
- combobox "전체" [ref=e73]
|
||||
- generic [ref=e79]:
|
||||
- generic [ref=e81]:
|
||||
- main [ref=e82]:
|
||||
- generic [ref=e85]:
|
||||
- generic [ref=e86]: No Rows To Show
|
||||
- grid [ref=e87]:
|
||||
- rowgroup [ref=e88]:
|
||||
- row [ref=e89]:
|
||||
- columnheader [ref=e90]
|
||||
- columnheader "화면 ID" [ref=e91]:
|
||||
- generic [ref=e93] [cursor=pointer]
|
||||
- columnheader "화면 타입" [ref=e95]:
|
||||
- generic [ref=e97] [cursor=pointer]
|
||||
- columnheader "표준 컴포넌트" [ref=e99]:
|
||||
- generic [ref=e101] [cursor=pointer]
|
||||
- columnheader "필수 증거" [ref=e103]:
|
||||
- generic [ref=e105] [cursor=pointer]
|
||||
- columnheader "상태" [ref=e107]:
|
||||
- generic [ref=e109] [cursor=pointer]
|
||||
- rowgroup [ref=e111]:
|
||||
- row [ref=e296]:
|
||||
- gridcell [ref=e297]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e298] [cursor=pointer]
|
||||
- gridcell "T01" [ref=e299]
|
||||
- gridcell "검색·목록형 CRUD" [ref=e300]
|
||||
- gridcell "SearchListCrudPage" [ref=e301]
|
||||
- gridcell "3" [ref=e302]
|
||||
- gridcell "READY" [ref=e303]
|
||||
- rowgroup
|
||||
- rowgroup
|
||||
- rowgroup
|
||||
- region [ref=e215]:
|
||||
- generic [ref=e216]:
|
||||
- heading "컴포넌트 동작 프리뷰" [level=2] [ref=e217]
|
||||
- paragraph [ref=e218]: 공유 UI 포트를 실제 상태로 확인하는 내부 전용 카탈로그입니다.
|
||||
- generic [ref=e219]:
|
||||
- generic [ref=e220]:
|
||||
- heading "Actions" [level=3] [ref=e221]
|
||||
- button "기본 버튼" [ref=e222] [cursor=pointer]
|
||||
- button "주의 상태" [ref=e223] [cursor=pointer]
|
||||
- generic [ref=e224]:
|
||||
- heading "Status" [level=3] [ref=e225]
|
||||
- generic [ref=e226]:
|
||||
- generic [ref=e227]: READY
|
||||
- generic [ref=e229]: REVIEW
|
||||
- generic [ref=e231]: BLOCKED
|
||||
- generic [ref=e233]:
|
||||
- heading "Inputs" [level=3] [ref=e234]
|
||||
- generic [ref=e235]:
|
||||
- generic [ref=e236]: 텍스트 필드
|
||||
- textbox "텍스트 필드" [ref=e237]: 샘플 값
|
||||
- generic [ref=e238]:
|
||||
- generic [ref=e239]: 선택 필드
|
||||
- combobox "준비" [ref=e241]
|
||||
- region [ref=e245]:
|
||||
- generic [ref=e246]:
|
||||
- heading "입력 유형 기능 테스트" [level=2] [ref=e247]
|
||||
- paragraph [ref=e248]: 서버 요청 없이 shared UI의 입력·검증·읽기전용 조합을 확인합니다.
|
||||
- alert [ref=e291]:
|
||||
- strong [ref=e292]: 저장할 수 없습니다. 1개 항목을 확인하세요.
|
||||
- list [ref=e293]:
|
||||
- listitem [ref=e294]: 0보다 큰 금액을 입력하세요.
|
||||
- generic [ref=e249]:
|
||||
- generic [ref=e250]:
|
||||
- heading "기본 입력" [level=2] [ref=e251]
|
||||
- paragraph [ref=e252]: 필수 항목과 유효성 상태를 조작할 수 있습니다.
|
||||
- button "편집 허용" [active] [ref=e304] [cursor=pointer]
|
||||
- group "기능 테스트 입력" [ref=e256]:
|
||||
- generic [ref=e257]:
|
||||
- generic [ref=e258]: 이름
|
||||
- textbox "이름" [disabled] [ref=e259]: 테스트
|
||||
- generic [ref=e260]:
|
||||
- generic [ref=e261]: 금액
|
||||
- spinbutton "금액" [disabled] [invalid] [ref=e263]
|
||||
- alert [ref=e295]: 0보다 커야 합니다.
|
||||
- generic [ref=e264]:
|
||||
- generic [ref=e265]: 기준일
|
||||
- generic [ref=e266]:
|
||||
- combobox "기준일" [disabled] [ref=e267]
|
||||
- button "Choose Date" [disabled] [ref=e268]
|
||||
- generic [ref=e271] [cursor=pointer]:
|
||||
- checkbox "검증 조건을 확인했습니다" [checked] [disabled] [ref=e273]
|
||||
- generic [ref=e274]: 검증 조건을 확인했습니다
|
||||
- generic [ref=e276]:
|
||||
- button "검증 실행" [disabled] [ref=e277]
|
||||
- button "초기화" [ref=e278] [cursor=pointer]
|
||||
- complementary [ref=e279]:
|
||||
- generic [ref=e280]:
|
||||
- heading "교체 계약" [level=2] [ref=e281]
|
||||
- paragraph [ref=e282]:
|
||||
- text: 기본 공급자 교체는
|
||||
- code [ref=e283]: VITE_UI_ADAPTER
|
||||
- text: 와 provider registry에서 수행한다. Feature 코드는 변경하지 않는다.
|
||||
- paragraph [ref=e284]: PrimeVue+AG Grid와 Native reference adapter가 동일 contract test를 통과해야 한다.
|
||||
- paragraph [ref=e285]: 생산 교체는 접근성, 키보드, 상태행렬, 시각회귀, 대량목록 성능을 별도 Gate로 검증한다.
|
||||
- text: "데이터 기준시각: 2026-08-02"
|
||||
- contentinfo [ref=e286]: RESEARCH_CANDIDATE_NOT_PRODUCTION
|
||||
- generic "애플리케이션 버전" [ref=e287]: v0.1.0 · UI contract 4.0
|
||||
@@ -0,0 +1,208 @@
|
||||
- generic [ref=e3]:
|
||||
- link "본문으로 건너뛰기" [ref=e4] [cursor=pointer]:
|
||||
- /url: "#ks-main"
|
||||
- banner [ref=e5]:
|
||||
- button "K-ArtSell Aegis IMPLEMENTATION_TEMPLATE" [ref=e6] [cursor=pointer]:
|
||||
- strong [ref=e7]: K-ArtSell Aegis
|
||||
- generic [ref=e8]: IMPLEMENTATION_TEMPLATE
|
||||
- button "메뉴명 · 화면코드 · 업무명 검색 Ctrl K" [ref=e9]:
|
||||
- generic [ref=e10]: 메뉴명 · 화면코드 · 업무명 검색
|
||||
- generic [ref=e11]: Ctrl K
|
||||
- status [ref=e12]: 투자자문형 · 자동주문/KIS 제출 OFF · 자동 모델승격 OFF
|
||||
- navigation "열린 업무" [ref=e13]:
|
||||
- generic [ref=e14]:
|
||||
- button "컴포넌트 확인" [ref=e15] [cursor=pointer]
|
||||
- button "탭 고정" [ref=e17] [cursor=pointer]: 📍
|
||||
- button "탭 닫기" [ref=e18] [cursor=pointer]: ×
|
||||
- generic [ref=e19]:
|
||||
- complementary "주요 메뉴" [ref=e20]:
|
||||
- button "« 접기" [expanded] [ref=e21] [cursor=pointer]
|
||||
- generic [ref=e22]:
|
||||
- heading "Design System" [level=2] [ref=e23]
|
||||
- link "컴포넌트 확인" [ref=e24] [cursor=pointer]:
|
||||
- /url: /internal/ui-standard
|
||||
- generic [ref=e25]:
|
||||
- heading "Operations" [level=2] [ref=e26]
|
||||
- link "데이터 품질" [ref=e27] [cursor=pointer]:
|
||||
- /url: /ops/data-quality
|
||||
- link "시장 데이터 수집" [ref=e28] [cursor=pointer]:
|
||||
- /url: /ops/market-data-ingestion
|
||||
- link "수집 이력" [ref=e29] [cursor=pointer]:
|
||||
- /url: /ops/market-data-history
|
||||
- link "모델 운영" [ref=e30] [cursor=pointer]:
|
||||
- /url: /ops/model-operations
|
||||
- generic [ref=e31]:
|
||||
- heading "Portfolio" [level=2] [ref=e32]
|
||||
- link "포트폴리오 리스크" [ref=e33] [cursor=pointer]:
|
||||
- /url: /portfolio/risk
|
||||
- link "리밸런싱 제안" [ref=e34] [cursor=pointer]:
|
||||
- /url: /portfolio/rebalance
|
||||
- generic [ref=e35]:
|
||||
- heading "Research" [level=2] [ref=e36]
|
||||
- link "매도 의사결정" [ref=e37] [cursor=pointer]:
|
||||
- /url: /research/sell-decision
|
||||
- main [ref=e38]:
|
||||
- generic [ref=e39]:
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]:
|
||||
- heading "표준 UI 패턴" [level=1] [ref=e42]
|
||||
- paragraph [ref=e43]: 화면 유형을 선택하고, 실제 화면 ID를 선택해 기능 화면을 엽니다.
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e45]: "상태: READY"
|
||||
- generic [ref=e46]: "As-of: 2026-08-02"
|
||||
- generic [ref=e47]: "Version: UI-CONTRACT-4.0"
|
||||
- button "선택 화면 열기" [ref=e49] [cursor=pointer]
|
||||
- generic [ref=e50]:
|
||||
- generic [ref=e51]:
|
||||
- strong [ref=e52]: "12"
|
||||
- generic [ref=e53]: 화면 타입
|
||||
- generic [ref=e54]:
|
||||
- strong [ref=e55]: "7"
|
||||
- generic [ref=e56]: 연결된 기능 화면
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58]: primevue-aggrid
|
||||
- generic [ref=e60]: PrimeVue + AG Grid Community
|
||||
- generic [ref=e61]:
|
||||
- generic [ref=e62]: 자동주문 OFF
|
||||
- generic [ref=e64]: 고정 경계
|
||||
- generic [ref=e66]:
|
||||
- generic [ref=e67]:
|
||||
- generic [ref=e68]: 화면 검색
|
||||
- textbox "화면 검색" [ref=e69]:
|
||||
- /placeholder: 화면 ID, 기능명 또는 화면 유형
|
||||
- generic [ref=e70]:
|
||||
- generic [ref=e71]: 화면 유형
|
||||
- combobox "전체 화면 유형" [ref=e73]
|
||||
- generic [ref=e79]:
|
||||
- generic [ref=e81]:
|
||||
- main [ref=e82]:
|
||||
- generic [ref=e85]:
|
||||
- generic [ref=e86]: Press SPACE to select this row
|
||||
- grid [ref=e87]:
|
||||
- rowgroup [ref=e88]:
|
||||
- row [ref=e89]:
|
||||
- columnheader [ref=e90]
|
||||
- columnheader "화면 ID" [ref=e91]:
|
||||
- generic [ref=e93] [cursor=pointer]
|
||||
- columnheader "기능 화면" [ref=e95]:
|
||||
- generic [ref=e97] [cursor=pointer]
|
||||
- columnheader "유형" [ref=e99]:
|
||||
- generic [ref=e101] [cursor=pointer]
|
||||
- columnheader "화면 유형" [ref=e103]:
|
||||
- generic [ref=e105] [cursor=pointer]
|
||||
- rowgroup [ref=e107]:
|
||||
- row [selected] [ref=e108]:
|
||||
- gridcell [ref=e109]:
|
||||
- checkbox "Press Space to toggle row selection (checked)" [checked] [ref=e110] [cursor=pointer]
|
||||
- gridcell "SCR-002" [ref=e111]
|
||||
- gridcell "매도 의사결정" [ref=e112]
|
||||
- gridcell "T03" [ref=e113]
|
||||
- gridcell "등록·편집 Form" [ref=e114]
|
||||
- row [ref=e115]:
|
||||
- gridcell [ref=e116]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e117] [cursor=pointer]
|
||||
- gridcell "SCR-013" [ref=e118]
|
||||
- gridcell "데이터 품질" [ref=e119]
|
||||
- gridcell "T08" [ref=e120]
|
||||
- gridcell "Batch·데이터 운영" [ref=e121]
|
||||
- row [ref=e122]:
|
||||
- gridcell [ref=e123]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e124] [cursor=pointer]
|
||||
- gridcell "SCR-015" [ref=e125]
|
||||
- gridcell "모델 운영" [ref=e126]
|
||||
- gridcell "T10" [ref=e127]
|
||||
- gridcell "버전 비교·거버넌스" [ref=e128]
|
||||
- row [ref=e129]:
|
||||
- gridcell [ref=e130]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e131] [cursor=pointer]
|
||||
- gridcell "SCR-016" [ref=e132]
|
||||
- gridcell "시장 데이터 수집" [active] [ref=e133]
|
||||
- gridcell "T08" [ref=e134]
|
||||
- gridcell "Batch·데이터 운영" [ref=e135]
|
||||
- row [ref=e136]:
|
||||
- gridcell [ref=e137]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e138] [cursor=pointer]
|
||||
- gridcell "SCR-017" [ref=e139]
|
||||
- gridcell "수집 이력" [ref=e140]
|
||||
- gridcell "T08" [ref=e141]
|
||||
- gridcell "Batch·데이터 운영" [ref=e142]
|
||||
- row [ref=e143]:
|
||||
- gridcell [ref=e144]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e145] [cursor=pointer]
|
||||
- gridcell "SCR-018" [ref=e146]
|
||||
- gridcell "포트폴리오 리스크" [ref=e147]
|
||||
- gridcell "T07" [ref=e148]
|
||||
- gridcell "Dashboard·Scorecard" [ref=e149]
|
||||
- row [ref=e150]:
|
||||
- gridcell [ref=e151]:
|
||||
- checkbox "Press Space to toggle row selection (unchecked)" [ref=e152] [cursor=pointer]
|
||||
- gridcell "SCR-019" [ref=e153]
|
||||
- gridcell "리밸런싱 제안" [ref=e154]
|
||||
- gridcell "T03" [ref=e155]
|
||||
- gridcell "등록·편집 Form" [ref=e156]
|
||||
- rowgroup
|
||||
- rowgroup
|
||||
- rowgroup
|
||||
- region [ref=e160]:
|
||||
- generic [ref=e161]:
|
||||
- heading "컴포넌트 동작 프리뷰" [level=2] [ref=e162]
|
||||
- paragraph [ref=e163]: 공유 UI 포트를 실제 상태로 확인하는 내부 전용 카탈로그입니다.
|
||||
- generic [ref=e164]:
|
||||
- generic [ref=e165]:
|
||||
- heading "Actions" [level=3] [ref=e166]
|
||||
- button "기본 버튼" [ref=e167] [cursor=pointer]
|
||||
- button "주의 상태" [ref=e168] [cursor=pointer]
|
||||
- generic [ref=e169]:
|
||||
- heading "Status" [level=3] [ref=e170]
|
||||
- generic [ref=e171]:
|
||||
- generic [ref=e172]: READY
|
||||
- generic [ref=e174]: REVIEW
|
||||
- generic [ref=e176]: BLOCKED
|
||||
- generic [ref=e178]:
|
||||
- heading "Inputs" [level=3] [ref=e179]
|
||||
- generic [ref=e180]:
|
||||
- generic [ref=e181]: 텍스트 필드
|
||||
- textbox "텍스트 필드" [ref=e182]: 샘플 값
|
||||
- generic [ref=e183]:
|
||||
- generic [ref=e184]: 선택 필드
|
||||
- combobox "준비" [ref=e186]
|
||||
- region [ref=e190]:
|
||||
- generic [ref=e191]:
|
||||
- heading "입력 유형 기능 테스트" [level=2] [ref=e192]
|
||||
- paragraph [ref=e193]: 서버 요청 없이 shared UI의 입력·검증·읽기전용 조합을 확인합니다.
|
||||
- generic [ref=e194]:
|
||||
- generic [ref=e195]:
|
||||
- heading "기본 입력" [level=2] [ref=e196]
|
||||
- paragraph [ref=e197]: 필수 항목과 유효성 상태를 조작할 수 있습니다.
|
||||
- button "읽기 전용" [ref=e199] [cursor=pointer]
|
||||
- group "기능 테스트 입력" [ref=e201]:
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]: 이름
|
||||
- textbox "이름" [ref=e204]
|
||||
- generic [ref=e205]:
|
||||
- generic [ref=e206]: 금액
|
||||
- spinbutton "금액" [ref=e208]
|
||||
- generic [ref=e209]:
|
||||
- generic [ref=e210]: 기준일
|
||||
- generic [ref=e211]:
|
||||
- combobox "기준일" [ref=e212]
|
||||
- button "Choose Date" [ref=e213]
|
||||
- generic [ref=e216] [cursor=pointer]:
|
||||
- checkbox "검증 조건을 확인했습니다" [ref=e218]
|
||||
- generic [ref=e219]: 검증 조건을 확인했습니다
|
||||
- generic [ref=e221]:
|
||||
- button "검증 실행" [ref=e222] [cursor=pointer]
|
||||
- button "초기화" [ref=e223] [cursor=pointer]
|
||||
- complementary [ref=e224]:
|
||||
- generic [ref=e225]:
|
||||
- heading "선택한 기능 화면" [level=2] [ref=e226]
|
||||
- paragraph [ref=e230]:
|
||||
- strong [ref=e231]: SCR-016
|
||||
- text: · 시장 데이터 수집
|
||||
- paragraph [ref=e232]: T08 · Batch·데이터 운영
|
||||
- paragraph [ref=e233]:
|
||||
- code [ref=e234]: /ops/market-data-ingestion
|
||||
- button "선택 화면 열기" [ref=e235] [cursor=pointer]
|
||||
- text: "데이터 기준시각: 2026-08-02"
|
||||
- contentinfo [ref=e228]: RESEARCH_CANDIDATE_NOT_PRODUCTION
|
||||
- generic "애플리케이션 버전" [ref=e229]: v0.1.0 · UI contract 4.0
|
||||
@@ -0,0 +1,203 @@
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e3]:
|
||||
- link "본문으로 건너뛰기" [ref=e4] [cursor=pointer]:
|
||||
- /url: "#ks-main"
|
||||
- banner [ref=e5]:
|
||||
- button "K-ArtSell Aegis IMPLEMENTATION_TEMPLATE" [ref=e6] [cursor=pointer]:
|
||||
- strong [ref=e7]: K-ArtSell Aegis
|
||||
- generic [ref=e8]: IMPLEMENTATION_TEMPLATE
|
||||
- button "메뉴명 · 화면코드 · 업무명 검색 Ctrl K" [ref=e9]:
|
||||
- generic [ref=e10]: 메뉴명 · 화면코드 · 업무명 검색
|
||||
- generic [ref=e11]: Ctrl K
|
||||
- status [ref=e12]: 투자자문형 · 자동주문/KIS 제출 OFF · 자동 모델승격 OFF
|
||||
- navigation "열린 업무" [ref=e13]:
|
||||
- generic [ref=e14]:
|
||||
- button "컴포넌트 확인" [ref=e15] [cursor=pointer]
|
||||
- button "탭 고정" [ref=e17] [cursor=pointer]: 📍
|
||||
- button "탭 닫기" [ref=e18] [cursor=pointer]: ×
|
||||
- generic [ref=e222]:
|
||||
- button "데이터 품질" [ref=e223] [cursor=pointer]
|
||||
- button "탭 고정" [ref=e225] [cursor=pointer]: 📍
|
||||
- button "탭 닫기" [ref=e226] [cursor=pointer]: ×
|
||||
- generic [ref=e19]:
|
||||
- complementary "주요 메뉴" [ref=e20]:
|
||||
- button "« 접기" [expanded] [ref=e21] [cursor=pointer]
|
||||
- generic [ref=e22]:
|
||||
- heading "Design System" [level=2] [ref=e23]
|
||||
- link "컴포넌트 확인" [ref=e24] [cursor=pointer]:
|
||||
- /url: /internal/ui-standard
|
||||
- generic [ref=e25]:
|
||||
- heading "Operations" [level=2] [ref=e26]
|
||||
- link "데이터 품질" [ref=e27] [cursor=pointer]:
|
||||
- /url: /ops/data-quality
|
||||
- link "시장 데이터 수집" [ref=e28] [cursor=pointer]:
|
||||
- /url: /ops/market-data-ingestion
|
||||
- link "수집 이력" [ref=e29] [cursor=pointer]:
|
||||
- /url: /ops/market-data-history
|
||||
- link "모델 운영" [ref=e30] [cursor=pointer]:
|
||||
- /url: /ops/model-operations
|
||||
- generic [ref=e31]:
|
||||
- heading "Portfolio" [level=2] [ref=e32]
|
||||
- link "포트폴리오 리스크" [ref=e33] [cursor=pointer]:
|
||||
- /url: /portfolio/risk
|
||||
- link "리밸런싱 제안" [ref=e34] [cursor=pointer]:
|
||||
- /url: /portfolio/rebalance
|
||||
- generic [ref=e35]:
|
||||
- heading "Research" [level=2] [ref=e36]
|
||||
- link "매도 의사결정" [ref=e37] [cursor=pointer]:
|
||||
- /url: /research/sell-decision
|
||||
- main [ref=e38]:
|
||||
- generic [ref=e227]:
|
||||
- generic [ref=e228]:
|
||||
- generic [ref=e229]:
|
||||
- heading "표준 UI 패턴" [level=1] [ref=e230]
|
||||
- paragraph [ref=e231]: 화면 유형을 선택하고, 실제 화면 ID를 선택해 기능 화면을 엽니다.
|
||||
- generic [ref=e232]:
|
||||
- generic [ref=e233]: "상태: READY"
|
||||
- generic [ref=e234]: "As-of: 2026-08-02"
|
||||
- generic [ref=e235]: "Version: UI-CONTRACT-4.0"
|
||||
- button "선택 화면 열기" [disabled] [ref=e237]
|
||||
- generic [ref=e238]:
|
||||
- generic [ref=e239]:
|
||||
- strong [ref=e240]: "12"
|
||||
- generic [ref=e241]: 화면 타입
|
||||
- generic [ref=e242]:
|
||||
- strong [ref=e243]: "7"
|
||||
- generic [ref=e244]: 연결된 기능 화면
|
||||
- generic [ref=e245]:
|
||||
- generic [ref=e246]: primevue-aggrid
|
||||
- generic [ref=e248]: PrimeVue + AG Grid Community
|
||||
- generic [ref=e249]:
|
||||
- generic [ref=e250]: 자동주문 OFF
|
||||
- generic [ref=e252]: 고정 경계
|
||||
- generic [ref=e254]:
|
||||
- generic [ref=e255]:
|
||||
- generic [ref=e256]: 화면 검색
|
||||
- textbox "화면 검색" [ref=e257]:
|
||||
- /placeholder: 화면 ID, 기능명 또는 화면 유형
|
||||
- generic [ref=e258]:
|
||||
- generic [ref=e259]: 화면 유형
|
||||
- combobox "전체 화면 유형" [ref=e261]
|
||||
- generic [ref=e265]:
|
||||
- generic [ref=e266]: 열 화면
|
||||
- combobox "화면 ID를 선택하세요" [expanded] [active] [ref=e268]
|
||||
- generic [ref=e274]:
|
||||
- generic [ref=e276]:
|
||||
- main [ref=e277]:
|
||||
- grid [ref=e282]:
|
||||
- rowgroup [ref=e283]:
|
||||
- row [ref=e284]:
|
||||
- columnheader "화면 ID" [ref=e285]:
|
||||
- generic [ref=e287] [cursor=pointer]
|
||||
- columnheader "기능 화면" [ref=e289]:
|
||||
- generic [ref=e291] [cursor=pointer]
|
||||
- columnheader "유형" [ref=e293]:
|
||||
- generic [ref=e295] [cursor=pointer]
|
||||
- columnheader "화면 유형" [ref=e297]:
|
||||
- generic [ref=e299] [cursor=pointer]
|
||||
- rowgroup [ref=e301]:
|
||||
- row [ref=e302]:
|
||||
- gridcell "SCR-002" [ref=e303]
|
||||
- gridcell "매도 의사결정" [ref=e304]
|
||||
- gridcell "T03" [ref=e305]
|
||||
- gridcell "등록·편집 Form" [ref=e306]
|
||||
- row [ref=e307]:
|
||||
- gridcell "SCR-013" [ref=e308]
|
||||
- gridcell "데이터 품질" [ref=e309]
|
||||
- gridcell "T08" [ref=e310]
|
||||
- gridcell "Batch·데이터 운영" [ref=e311]
|
||||
- row [ref=e312]:
|
||||
- gridcell "SCR-015" [ref=e313]
|
||||
- gridcell "모델 운영" [ref=e314]
|
||||
- gridcell "T10" [ref=e315]
|
||||
- gridcell "버전 비교·거버넌스" [ref=e316]
|
||||
- row [ref=e317]:
|
||||
- gridcell "SCR-016" [ref=e318]
|
||||
- gridcell "시장 데이터 수집" [ref=e319]
|
||||
- gridcell "T08" [ref=e320]
|
||||
- gridcell "Batch·데이터 운영" [ref=e321]
|
||||
- row [ref=e322]:
|
||||
- gridcell "SCR-017" [ref=e323]
|
||||
- gridcell "수집 이력" [ref=e324]
|
||||
- gridcell "T08" [ref=e325]
|
||||
- gridcell "Batch·데이터 운영" [ref=e326]
|
||||
- row [ref=e327]:
|
||||
- gridcell "SCR-018" [ref=e328]
|
||||
- gridcell "포트폴리오 리스크" [ref=e329]
|
||||
- gridcell "T07" [ref=e330]
|
||||
- gridcell "Dashboard·Scorecard" [ref=e331]
|
||||
- row [ref=e332]:
|
||||
- gridcell "SCR-019" [ref=e333]
|
||||
- gridcell "리밸런싱 제안" [ref=e334]
|
||||
- gridcell "T03" [ref=e335]
|
||||
- gridcell "등록·편집 Form" [ref=e336]
|
||||
- rowgroup
|
||||
- rowgroup
|
||||
- rowgroup
|
||||
- region [ref=e340]:
|
||||
- generic [ref=e341]:
|
||||
- heading "컴포넌트 동작 프리뷰" [level=2] [ref=e342]
|
||||
- paragraph [ref=e343]: 공유 UI 포트를 실제 상태로 확인하는 내부 전용 카탈로그입니다.
|
||||
- generic [ref=e344]:
|
||||
- generic [ref=e345]:
|
||||
- heading "Actions" [level=3] [ref=e346]
|
||||
- button "기본 버튼" [ref=e347] [cursor=pointer]
|
||||
- button "주의 상태" [ref=e348] [cursor=pointer]
|
||||
- generic [ref=e349]:
|
||||
- heading "Status" [level=3] [ref=e350]
|
||||
- generic [ref=e351]:
|
||||
- generic [ref=e352]: READY
|
||||
- generic [ref=e354]: REVIEW
|
||||
- generic [ref=e356]: BLOCKED
|
||||
- generic [ref=e358]:
|
||||
- heading "Inputs" [level=3] [ref=e359]
|
||||
- generic [ref=e360]:
|
||||
- generic [ref=e361]: 텍스트 필드
|
||||
- textbox "텍스트 필드" [ref=e362]: 샘플 값
|
||||
- generic [ref=e363]:
|
||||
- generic [ref=e364]: 선택 필드
|
||||
- combobox "준비" [ref=e366]
|
||||
- region [ref=e370]:
|
||||
- generic [ref=e371]:
|
||||
- heading "입력 유형 기능 테스트" [level=2] [ref=e372]
|
||||
- paragraph [ref=e373]: 서버 요청 없이 shared UI의 입력·검증·읽기전용 조합을 확인합니다.
|
||||
- generic [ref=e374]:
|
||||
- generic [ref=e375]:
|
||||
- heading "기본 입력" [level=2] [ref=e376]
|
||||
- paragraph [ref=e377]: 필수 항목과 유효성 상태를 조작할 수 있습니다.
|
||||
- button "읽기 전용" [ref=e379] [cursor=pointer]
|
||||
- group "기능 테스트 입력" [ref=e381]:
|
||||
- generic [ref=e382]:
|
||||
- generic [ref=e383]: 이름
|
||||
- textbox "이름" [ref=e384]
|
||||
- generic [ref=e385]:
|
||||
- generic [ref=e386]: 금액
|
||||
- spinbutton "금액" [ref=e388]
|
||||
- generic [ref=e389]:
|
||||
- generic [ref=e390]: 기준일
|
||||
- generic [ref=e391]:
|
||||
- combobox "기준일" [ref=e392]
|
||||
- button "Choose Date" [ref=e393]
|
||||
- generic [ref=e396] [cursor=pointer]:
|
||||
- checkbox "검증 조건을 확인했습니다" [ref=e398]
|
||||
- generic [ref=e399]: 검증 조건을 확인했습니다
|
||||
- generic [ref=e401]:
|
||||
- button "검증 실행" [ref=e402] [cursor=pointer]
|
||||
- button "초기화" [ref=e403] [cursor=pointer]
|
||||
- complementary [ref=e404]:
|
||||
- generic [ref=e405]:
|
||||
- heading "선택한 기능 화면" [level=2] [ref=e406]
|
||||
- paragraph [ref=e407]: 목록에서 화면 ID를 선택한 뒤, “선택 화면 열기”를 누르세요.
|
||||
- text: "데이터 기준시각: 2026-08-02"
|
||||
- contentinfo [ref=e220]: RESEARCH_CANDIDATE_NOT_PRODUCTION
|
||||
- generic "애플리케이션 버전" [ref=e221]: v0.1.0 · UI contract 4.0
|
||||
- generic [ref=e408]:
|
||||
- listbox [ref=e410]:
|
||||
- option "SCR-002 · 매도 의사결정" [ref=e411]
|
||||
- option "SCR-013 · 데이터 품질" [ref=e412]
|
||||
- option "SCR-015 · 모델 운영" [ref=e413]
|
||||
- option "SCR-016 · 시장 데이터 수집" [ref=e414]
|
||||
- option "SCR-017 · 수집 이력" [ref=e415]
|
||||
- option "SCR-018 · 포트폴리오 리스크" [ref=e416]
|
||||
- option "SCR-019 · 리밸런싱 제안" [ref=e417]
|
||||
- status: No selected item
|
||||
@@ -63,6 +63,14 @@
|
||||
| DEBT-028 | `ActivateModelHandler` had no HTTP endpoint, and would have corrupted approval data if wired naively | High (3) | Low (1) | Completed (DB verification pending) | Found via a systematic sweep of every `*Handler` registered in `Program.cs`'s DI container, checking whether each is actually referenced by an `Endpoint.cs` or a job (the same method that found DEBT-026/027) — `ActivateModelHandler` was the only remaining orphan in `Features/ApprovalWorkflow/`: no `POST /approvals/{id}/activate` existed, so an `Approved` proposal could never reach `Active`, the step this whole slice exists for. While wiring it up, found the handler's original call — `_sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Active, userEmail, "Model activated by SRE", ct)` — would have passed the *activating SRE's* email/note through the `approvedBy`/`approvalNotes` parameters, overwriting the checker's real `approved_by`/`approval_notes` on activation, and never touched the schema's `activated_by`/`activated_at` columns at all (they existed since migration `0036` but nothing ever wrote them). Added a dedicated `ApprovalWorkflowSql.ActivateProposalAsync(proposalId, activatedBy, ct)` that only sets `status='ACTIVE'`, `activated_by`, `activated_at`, leaving `approved_by`/`approval_notes` untouched, and switched `ActivateModelHandler` to call it. Added `ActivateApprovalEndpoint` (`POST /approvals/{id}/activate`). Strengthened the existing `Activate_BySreAfterApproval_TransitionsToActive` test to assert `activated_by`/`activated_at` are set and the checker's `approved_by`/`approval_notes` survive activation unchanged — this would have caught the bug. `dotnet build -c Release` clean (0/0). Not run against a live database this session. | @claude | Session 2026-08-09 (BE/scheduler priority pass) |
|
||||
| DEBT-029 | `LogAuditEventCommandHandler` (VS-27 audit trail) is never called by any other slice | High (3) | Medium (2) | Backlog | Found during the same orphaned-handler sweep that found DEBT-027/028 — but unlike those, this one is NOT a missing single endpoint; it's a missing *cross-cutting integration*. `LogAuditEventCommandHandler` (`src/KArtSell.Modules.ModelOperations/Compliance/LogAuditEventHandler.cs`) is the intended call point for every other slice to record an auditable action (per `compliance.audit_event_types`'s seed data: `APPROVAL_PROPOSED`, `APPROVAL_APPROVED`, `MODEL_ACTIVATED`, `SELL_DECISION_MADE`, `SELL_EXECUTED`, etc.) — but nothing in `ApprovalWorkflow/Handlers.cs`, `TradeExecution/TradeHandlers.cs`, `SellDecision/*`, or `PortfolioReconciliation/ReconcileTradeHandler.cs` actually calls it. VS-27 ("Immutable Audit Trail") is marked `COMPLETED` in the WBS tracker with 5/5 tests passing, but those tests only exercise `AuditSql` directly — they don't prove the rest of the system ever produces an audit trail in practice. Net effect: the compliance/GDPR audit trail this system's governance model depends on (CLAUDE.md's "Evidence & Audit: Update/delete are blocked; new state appended as new revision") is currently empty in production regardless of how many approvals/trades/sell-decisions happen, because nothing populates it outside of direct `AuditSql` test calls. **Not fixed this session** — wiring it in touches 4+ handler classes across 3+ slices (a genuine cross-cutting integration, not a single bounded fix like DEBT-026/027/028), and each call site needs to decide what `EventType`/`Details`/`EvidenceLinks` are correct for that action rather than a mechanical change. Recommend one slice at a time, starting with `ApprovalWorkflow` (highest governance stakes) via its outbox events (`ApprovalWorkflowPolicy.CreateStateChangeEvent` already emits an event per transition — a downstream consumer job could call `LogAuditEventCommandHandler` from there instead of wiring it into every handler directly, matching this repo's existing Outbox→Inbox→consumer pattern). | @claude | Session 2026-08-09 (BE/scheduler priority pass, discovery only) |
|
||||
|
||||
### Frontend Shell / Home (KBX Design Philosophy Adoption, V13-FE-007+)
|
||||
|
||||
| ID | Category | Impact | Effort | Status | Notes | Owner | ADR |
|
||||
|----|----------|--------|--------|--------|-------|-------|-----|
|
||||
| DEBT-030 | `HomePage.vue` "확인 필요" section has no real signal source | Medium (2) | Medium (2) | Backlog | `frontend/src/features/home/pages/HomePage.vue`'s Attention section (KBX Business UX-AX Standard §2.4 "Exception Driven") currently always renders the empty state — there is no cross-feature aggregation endpoint yet for failed batch jobs, pending maker-checker approvals, or reconciliation breaks. Only `model-operations` and `sell-decision` features have `queries.ts`; other features (data-quality, marketData, portfolio) have no query hooks to source counts from. Wire real counts feature-by-feature once each has a stable query hook, rather than fabricating a placeholder aggregation API now. | @claude | V13-FE-007 (KBX shell/home adoption) |
|
||||
| DEBT-031 | Workspace tab dirty-guard has no feature screen wired to report dirty state | Low (1) | Medium (2) | Backlog | `frontend/src/shared/shell/workspaceStore.ts`'s `setDirty(screenId, path, dirty)` action and `KsWorkspaceTabs.vue`'s close-confirmation dialog (Business UX-AX Standard §58~59) are implemented and functional, but no feature page currently calls `setDirty`. `StandardScreenBoundary.vue` already receives a `state==='DIRTY'` prop per screen, but nothing bridges that per-screen signal up into the shared workspace store yet. Until a screen calls `setDirty`, tab close always takes the non-dirty path (closes immediately, no confirm). Wire via a small composable (e.g. `useWorkspaceDirtyBridge(screenId, path)`) called from screens that pass `state: 'DIRTY'`, one feature at a time — do not force every screen to adopt it in one sweep. Also note: the confirm dialog only offers "계속 편집"/"변경 버리기" (no generic "저장 후 이동", since there is no cross-screen save-orchestration hook to call). | @claude | V13-FE-010 (KBX workspace tabs adoption) |
|
||||
| DEBT-032 | `frontend/src/**` has git-tracked stale `.js`/`.vue.js` twins next to every `.ts`/`.vue` source, and they can silently shadow the source under default Vite/Vitest module resolution | High (3) | High (3) | Backlog | Discovered while adding two entries to `screen-types/catalogue.ts` (V13-FE-009): `vitest.config.ts` had no `resolve.extensions` override, so Vitest fell back to Vite's default order (`.js` before `.ts`), causing `catalogue.spec.ts`'s extensionless `import '../catalogue'` to silently resolve to a stale, git-tracked `catalogue.js` twin instead of the edited `catalogue.ts` — the new T11/T12 entries were invisible to the test. `vite.config.ts` already declares `extensions: ['.ts', '.tsx', '.vue', '.js', ...]` (so the dev server was never at risk), but `vitest.config.ts` did not match it. Fixed the immediate blocker: added the same `resolve.extensions` order to `vitest.config.ts`, and deleted the three stale twins directly implicated (`screen-types/catalogue.js`, `screen-types/tests/catalogue.spec.js`, `app/router.js` — confirmed unreferenced by any `.gitea/workflows/*.yml` and not emitted by any `package.json` script). **Not fixed**: this is a repo-wide pattern (confirmed present across most of `frontend/src`, deliberately git-committed across multiple past sessions per `git log`, e.g. commit `cada8fe`) — dozens/hundreds of other stale `.js`/`.vue.js` files likely still exist alongside their `.ts`/`.vue` sources and were not swept in this session (out of scope for the KBX design-philosophy adoption this debt was found during). Needs a dedicated session to (a) determine why these were being dual-maintained in the first place — no `package.json` script emits them, so likely a leftover from an earlier tsc/build config or manual habit — and (b) either delete them all (now safe, since `vitest.config.ts`/`vite.config.ts` both prefer `.ts`) or explain why they must stay. | @claude | V13-FE-009 (KBX Fast Entry/Work Queue template adoption, discovery) |
|
||||
|
||||
---
|
||||
|
||||
## Impact/Effort Matrix (Updated: PR 4)
|
||||
|
||||
@@ -51,4 +51,4 @@ V13-FE-003,S0,Cross,UiAdapter Port 정의,COMPLETED,2026-08-09,"docs/CURRENT/V13
|
||||
V13-FE-004,S0,Cross,PrimeVue/AG Grid Adapter 구현,COMPLETED,2026-08-09,"docs/CURRENT/V13-FE-004_ADAPTER_IMPLEMENTATION_RECONCILIATION.md; frontend/src/shared/ui/adapter/primevue; frontend/src/shared/ui/adapter/tests/uiAdapter.contract.spec.ts",FE Architect/QA,"Dependency V13-FE-003 is COMPLETED. PrimeVue/AG Grid remain confined behind adapter v4. Actual targeted Vitest evidence: 2 files / 4 tests passed, exit 0, 2026-08-09. This is contract/accessibility-attribute evidence only; no visual/AT/runtime claim is made."
|
||||
V13-FE-005,S0,Cross,Ks* vendor-neutral components,COMPLETED,2026-08-09,"docs/CURRENT/V13-FE-005_KBX_FORM_COMPONENT_ADOPTION.md; frontend/src/shared/ui/components/KsFormGrid.vue; frontend/src/shared/ui/components/KsFormSection.vue; frontend/src/shared/ui/components/KsFormSpan.vue; frontend/src/shared/ui/components/KsValidationSummary.vue; frontend/src/shared/ui/components/tests/KsFormLayouts.spec.ts",FE Architect/QA,"Dependency V13-FE-004 is COMPLETED. Reimplemented only KBX presentation-only form components against existing K-ArtSell tokens; no KBX package, contracts, provider dependency, routing, permissions, or business policy imported. Actual evidence: targeted Vitest 2 files / 6 tests passed and pnpm typecheck exit 0 on 2026-08-09. Visual/AT/performance baseline remains outside this Slice."
|
||||
V13-FE-006,S0,Cross,AppShell/Page layouts,COMPLETED,2026-08-09,"docs/CURRENT/V13-FE-006_LAYOUT_CONTRACT_RECONCILIATION.md; frontend/src/shared/ui/layouts/tests/layout.contract.spec.ts; frontend/src/shared/ui/layouts/AppShellLayout.vue; frontend/src/shared/ui/layouts/PageLayout.vue",UX/FE/QA/Security,"Dependency V13-FE-005 is COMPLETED. Preserved the existing slot-based layout rather than importing KBX's coupled workspace shell. Actual DOM contract evidence: 1 file / 2 tests passed, exit 0, 2026-08-09; verifies skip navigation, structural landmarks, default automation OFF boundary, and evidence/aside/footer separation. No visual/AT/E2E claim is made."
|
||||
V13-FE-011,S6,Cross,T01 검색목록 화면 템플릿,IN_PROGRESS,TBD,"docs/CURRENT/V13-FE-011_T01_SEARCH_LIST_LAYOUT_SLICE_NOTE.md; frontend/src/shared/ui/screen-types/v2/SearchListCrudPage.vue; frontend/src/shared/ui/screen-types/tests/SearchListCrudPage.spec.ts; evidence/V13-FE-011/t01-search-list-layout_20260809.log",UX/FE,"2026-08-09: Scope limited to adapter-neutral T01 composition: list body plus optional detail region through CrudWorkspaceLayout. Actual execution evidence: targeted Vitest 1 file / 2 tests passed; pnpm typecheck passed. Dependency V13-FE-006 is completed. MVP-A Gate passage, visual/assistive-technology approval, and Playwright evidence are not claimed."
|
||||
V13-FE-011,S6,Cross,T01 검색목록 화면 템플릿,IN_PROGRESS,TBD,"docs/CURRENT/V13-FE-011_T01_SEARCH_LIST_LAYOUT_SLICE_NOTE.md; frontend/src/shared/ui/screen-types/v2/SearchListCrudPage.vue; frontend/src/shared/ui/screen-types/tests/SearchListCrudPage.spec.ts; frontend/src/shared/shell/tests/navigationCatalog.spec.ts; evidence/V13-FE-011/t01-search-list-layout_20260809.log",UX/FE,"2026-08-09: Scope limited to adapter-neutral T01 composition: list body plus optional detail region through CrudWorkspaceLayout and read-only component catalogue visibility in the Design System menu; WBS workspace remains hidden. Actual execution evidence: targeted Vitest 2 files / 3 tests passed; pnpm typecheck passed. Dependency V13-FE-006 is completed. MVP-A Gate passage, visual/assistive-technology approval, and Playwright evidence are not claimed."
|
||||
|
||||
|
@@ -9,8 +9,8 @@
|
||||
|
||||
## Source / Assumption / Unknown / Decision Required
|
||||
|
||||
- **Source:** `DEC-046` approves T01~T10 standard screen types. `FE_COMPONENT.csv` defines T01 as a list screen composed from shared layouts. `UiStandardPage.vue` is the current T01 consumer.
|
||||
- **Assumption:** T01 needs one responsive workspace boundary for its list body and optional detail region; `CrudWorkspaceLayout` already provides that adapter-neutral responsibility.
|
||||
- **Source:** `DEC-046` approves T01~T10 standard screen types. `FE_COMPONENT.csv` defines T01 as a list screen composed from shared layouts. `UiStandardPage.vue` is the current T01 consumer. `KsAppShell.vue` excludes `internalOnly` routes from its shared menu catalogue.
|
||||
- **Assumption:** T01 needs one responsive workspace boundary for its list body and optional detail region; `CrudWorkspaceLayout` already provides that adapter-neutral responsibility. The UI catalogue is read-only design evidence, so it may be discoverable from the Design System menu while the WBS workspace remains hidden.
|
||||
- **Unknown:** MVP-A Gate passage and visual/assistive-technology approval evidence are not present in the tracker.
|
||||
- **Decision Required:** UX/QA approval is required before marking this WBS item completed at MVP-A.
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
- Compose the existing T01 screen type with `CrudWorkspaceLayout`.
|
||||
- Preserve named slots and `StandardScreenBoundary` state handling.
|
||||
- Add a focused DOM contract test for the main/detail layout relationship.
|
||||
- Expose the read-only component catalogue through the `Design System` navigation section; preserve the internal WBS workspace as hidden.
|
||||
|
||||
## Out of scope
|
||||
|
||||
@@ -26,6 +27,6 @@
|
||||
|
||||
## Actual execution evidence
|
||||
|
||||
- `pnpm test -- --run src/shared/ui/screen-types/tests/SearchListCrudPage.spec.ts` — 1 file / 2 tests passed, exit 0.
|
||||
- `pnpm test -- --run src/shared/ui/screen-types/tests/SearchListCrudPage.spec.ts src/shared/shell/tests/navigationCatalog.spec.ts` — 2 files / 3 tests passed, exit 0.
|
||||
- `pnpm typecheck` — `vue-tsc --noEmit`, exit 0.
|
||||
- Preserved output: `evidence/V13-FE-011/t01-search-list-layout_20260809.log`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
# KBX Design Philosophy — Reference Index
|
||||
|
||||
이 디렉터리의 4개 문서는 K-ArtSell Aegis 프론트엔드가 채택하는 **디자인 철학 소스**다. `docs/Design/kbx-foundation-v36/`은 이 문서를 구현한 참조 코드(도메인은 OMS/WMS/ERP로 다르지만 UX 계약은 동일)이며, 이식 대상이 아니라 구현 참고용이다.
|
||||
|
||||
## 문서 역할
|
||||
|
||||
| 문서 | 역할 |
|
||||
|---|---|
|
||||
| `KBX Design System v1.0.md` | Primitive → Business Component → Screen Template 3계층, Design Token 체계(spacing/typography/color/density), 컴포넌트별 API 계약 |
|
||||
| `KBX Business UX-AX Standard v1.0.md` | 7대 설계원칙(Familiar First, Keyboard Accelerated, Grid First, Exception Driven, Predictable Layout, Explicit State, Audit by Default), Desktop 표준 화면 구조, T01~T09 표준 Screen Template, Workspace Tabs/Unsaved Changes 규칙 |
|
||||
| `KBX Reference Screens v1.0.md` | Global Application Shell 치수(Header 56px / Side Nav 220px / Workspace Tabs 40px / Page Header 48px / Command Bar 44px), 화면별 목업 |
|
||||
| `KBX Implementation Contract v1.0.md` | Screen ID 규칙, Command/Lookup/Grid 계약, Keyboard Manager 상세 API |
|
||||
|
||||
## K-ArtSell 적용 원칙
|
||||
|
||||
- Vertical Slice(업무 모듈)는 업무를 구현하고, 공통 계층(`frontend/src/shared/`)은 UX를 구현한다 (Design System §1).
|
||||
- 신규 공통 컴포넌트는 `Kbx*`가 아니라 이미 채택된 `Ks*` 접두사를 따른다(`V13-FE-005`).
|
||||
- Design Token은 `--kbx-*`가 아니라 기존 `--ks-*` 네임스페이스에 통합한다(`design-system/tokens.css`).
|
||||
|
||||
## Screen Template 재매핑 (KBX T01~T09 ↔ K-ArtSell T01~T12)
|
||||
|
||||
K-ArtSell은 `frontend/src/shared/ui/screen-types/catalogue.ts`에서 KBX 템플릿을 금융 자문 도메인 의미로 재정의했다. 번호는 KBX 원본과 일치하지 않는다.
|
||||
|
||||
| KBX (OMS/WMS/ERP 의미) | K-ArtSell 번호 | K-ArtSell 의미 |
|
||||
|---|---|---|
|
||||
| T01 Search/List | T01 | 검색·목록형 CRUD |
|
||||
| T02 Master CRUD | T03 | 등록·편집 Form |
|
||||
| T03 Header+Detail Transaction | T04 / T06 | Master-Detail / 단계 Wizard |
|
||||
| T04 Fast Grid Entry | **T11 (신규)** | 대량 입력 |
|
||||
| T05 Master/Detail Explorer | T04 | Master-Detail |
|
||||
| T06 Work Queue | **T12 (신규)** | 작업 큐 |
|
||||
| T07 Reconcile/Verification | T09 | 대사·예외 처리 |
|
||||
| T08 Excel Import | — (범위 밖) | 데이터 유입이 KRX/OpenDart/KIS API 중심이라 미채택 |
|
||||
| T09 WMS Mobile | — (해당 없음) | 물류 현장 업무 없음 |
|
||||
| — | T02 | 상세 조회형 (KBX에 없는 K-ArtSell 고유 타입) |
|
||||
| — | T05 | 검토·승인 Workbench (maker-checker) |
|
||||
| — | T07 | Dashboard·Scorecard |
|
||||
| — | T08 | Batch·데이터 운영 |
|
||||
| — | T10 | 버전 비교·거버넌스 (모델/정책 승격) |
|
||||
|
||||
## 셸/홈/워크스페이스 이식 현황
|
||||
|
||||
`frontend/src/shared/shell/`에 Global Header · Side Navigation · Workspace Tabs · Ctrl+K 메뉴검색을 Business UX-AX Standard §3~8, §58~59 규격대로 구현한다. 세부 계획은 이식 작업 당시의 계획 문서를 참고(리포지토리 커밋 이력의 `V13-FE-007~010` 참고).
|
||||
@@ -0,0 +1,91 @@
|
||||
## KBX UX/AX Review
|
||||
|
||||
- [ ] 기존 9개 Screen Type 중 하나를 사용했다.
|
||||
- [ ] 신규 Component보다 기존 KBX Component 재사용을 먼저 검토했다.
|
||||
- [ ] 업무 모듈에서 PrimeVue/AG Grid를 직접 import하지 않는다.
|
||||
- [ ] 주요 명령 위치가 KBX Command Bar 규칙과 같다.
|
||||
- [ ] Keyboard 흐름(F2/F3/F8/Tab/Enter/Esc)을 검토했다.
|
||||
- [ ] 입력 가능한 대량 데이터 화면의 Excel 정책을 정의했다.
|
||||
- [ ] 정상 건을 사용자가 불필요하게 확인하는 단계가 없는지 검토했다.
|
||||
- [ ] 오류 메시지가 원인과 다음 행동을 설명한다.
|
||||
- [ ] Client validation을 업무 정합성의 최종 방어선으로 사용하지 않는다.
|
||||
- [ ] 상태 변경, Audit, Concurrency, Idempotency 영향도를 검토했다.
|
||||
- [ ] AI가 없어도 동일 업무를 수행할 수 있다.
|
||||
- [ ] AI Action은 Proposal → Validation → Command 경계를 지킨다.
|
||||
- [ ] 현장 화면은 실제 장비/네트워크 조건의 수용시험 항목을 정의했다.
|
||||
|
||||
## Architecture
|
||||
|
||||
- [ ] `node scripts/validate-kbx.mjs` 통과
|
||||
- [ ] Screen ID / Version 갱신 여부 검토
|
||||
- [ ] 기술부채 또는 KBX 예외가 있다면 이유와 재검토 시점을 기록했다.
|
||||
|
||||
|
||||
## Production readiness
|
||||
- [ ] Long operations do not block the page with a modal spinner
|
||||
- [ ] Mutation retries are idempotent or explicitly disabled
|
||||
- [ ] 409/version conflicts cannot silently overwrite newer data
|
||||
- [ ] Stale operational data exposes freshness/reload where material
|
||||
- [ ] Unexpected errors expose a correlation reference, not stack traces
|
||||
- [ ] Degraded/read-only behavior is defined for affected workflows
|
||||
|
||||
## Component verification
|
||||
- [ ] 공통 Component 변경이면 `COMMON-DS-001`에서 Default/Readonly/Disabled/Error/Loading 상태를 확인했다.
|
||||
- [ ] Keyboard/Focus 계약 변경이면 E2E scenario를 갱신했다.
|
||||
- [ ] 의도된 시각 변경이면 Compact/Comfortable/Touch baseline 변경 사유를 기록했다.
|
||||
- [ ] 공통 Component의 동작/표현 변경이면 Component Version을 검토했다.
|
||||
- [ ] 색상만으로 상태를 전달하거나 Focus Indicator를 제거하지 않았다.
|
||||
|
||||
## Design ↔ Code parity / release
|
||||
- [ ] Design Token 변경은 `packages/kbx-ui/src/tokens/source/kbx.tokens.json`에서 시작했다.
|
||||
- [ ] Semantic/Component Token 값 변경이면 Visual Regression 영향도를 검토했다.
|
||||
- [ ] Core Component 상태가 `COMMON-DS-001`과 Figma contract에 모두 존재한다.
|
||||
- [ ] 새 raw color/px literal을 추가하지 않았다. 필요한 경우 먼저 Token 승격을 검토했다.
|
||||
- [ ] Component/Screen/Token 공개 계약 변경이면 `generated/release-impact.json`의 요구 bump를 확인했다.
|
||||
- [ ] Breaking 변경이면 Migration Guide를 작성했다.
|
||||
|
||||
## API contract / Problem governance
|
||||
- [ ] 업무 모듈에서 raw `/api/...`, `axios`, `fetch`를 직접 사용하지 않는다.
|
||||
- [ ] 신규/변경 Endpoint는 `contracts/api/kbx.api.json`과 동일한 Method/Route/Permission을 가진다.
|
||||
- [ ] Mutation Retry 가능 여부와 Idempotency 정책을 정의했다.
|
||||
- [ ] Validation/Business/Conflict/Permission/NotFound/Integration/System 오류를 KBX Problem으로 표현한다.
|
||||
- [ ] API 공개 계약 변경이면 `generated/release-impact.json`의 SemVer 요구수준을 확인했다.
|
||||
- [ ] Host에서 실제 Swashbuckle OpenAPI snapshot diff를 수행했다.
|
||||
|
||||
## Authorization / Sensitive Data
|
||||
- [ ] 신규 Permission은 `contracts/authorization/kbx.authorization.json`에 등록했습니다.
|
||||
- [ ] Frontend 숨김/Disabled만으로 보안을 처리하지 않고 Backend 최종 검증이 있습니다.
|
||||
- [ ] Sensitive Field는 기본 Masking이며 전체보기/비마스킹 Export 권한을 구분했습니다.
|
||||
- [ ] Sensitive 원문을 Telemetry/AI Context에 넣지 않았습니다.
|
||||
- [ ] 전체보기 또는 민감 데이터 공개가 필요한 경우 Audit 경로를 정의했습니다.
|
||||
|
||||
## KBX v18 Scenario / Test Data
|
||||
|
||||
- [ ] 변경된 Golden Screen/업무 경계의 canonical scenario를 갱신했다.
|
||||
- [ ] Fixture는 synthetic-only이며 Production dump를 포함하지 않는다.
|
||||
- [ ] `idempotency=required` Command의 replay scenario가 있다.
|
||||
- [ ] Host에서 실행한 경우 Scenario Evidence/Correlation ID를 남겼다.
|
||||
|
||||
## External integration / resilience
|
||||
- [ ] Business State와 Integration State를 분리했다.
|
||||
- [ ] 새 외부연계는 `contracts/integrations/kbx.integrations.json`에 등록했다.
|
||||
- [ ] at-least-once 전달은 idempotency 경계를 가진다.
|
||||
- [ ] 짧은 transient retry는 bounded Polly pipeline이고 장기 retry는 Hangfire가 소유한다.
|
||||
- [ ] permanent failure는 사용자 업무 재실행이 아니라 Operations Exception으로 노출된다.
|
||||
- [ ] 실패주입 Scenario가 있다.
|
||||
|
||||
|
||||
## External data / provenance
|
||||
- [ ] 외부 Provider 응답을 화면이 직접 해석하지 않고 canonical normalizer를 거칩니다.
|
||||
- [ ] providerObservedAt / receivedAt / ingestedAt 의미를 혼합하지 않았습니다.
|
||||
- [ ] Stale/Expired 데이터가 최신값처럼 보이지 않습니다.
|
||||
- [ ] request_descriptor에는 Secret/Token이 없고 raw payload 저장은 명시적 검토 없이는 금지합니다.
|
||||
- [ ] KRX 승인 서비스의 TTL/Schema를 공식 서비스 명세 없이 추정하지 않았습니다.
|
||||
|
||||
## Configuration / deployment governance
|
||||
- [ ] 신규 설정은 `contracts/configuration/kbx.configuration.json`에 등록했고 직접 `Environment.GetEnvironmentVariable()`을 사용하지 않았다.
|
||||
- [ ] Secret에는 기본값/예제값을 넣지 않았고 generated env example도 빈 값이다.
|
||||
- [ ] Production은 `predeploy` migration + HTTPS 원칙을 유지한다.
|
||||
- [ ] 환경별 다른 바이너리를 다시 빌드하지 않고 동일 Release Artifact를 승격한다.
|
||||
- [ ] destructive migration이 필요하다면 명시적 승인 marker와 Migration Guide가 있다.
|
||||
- [ ] 배포 전 Configuration Validation / Migration Dry Run / Release Governance evidence를 확인했다.
|
||||
@@ -0,0 +1,54 @@
|
||||
name: KBX Quality Gate
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
architecture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Validate KBX architecture
|
||||
run: node scripts/validate-kbx.mjs
|
||||
- name: Ensure generated manifests are committed
|
||||
run: git diff --exit-code -- generated/ apps/web/src/registry/screens.generated.ts packages/kbx-ui/src/tokens/kbx.css packages/kbx-contracts/src/generated/ backend/Shared/Contracts/Generated/ backend/Shared/Authorization/Generated/ backend/Shared/Telemetry/Generated/ backend/Shared/Experiments/Generated/ backend/Shared/Testing/Generated/ backend/Shared/Integrations/Generated/ backend/Shared/Providers/Generated/ backend/Shared/ExternalData/Generated/ backend/Shared/Configuration/Generated/ design/figma/ contracts/api/openapi.kbx.json deploy/kbx/
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Install and test 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."
|
||||
else
|
||||
echo "Starter has no pnpm-lock.yaml yet; dependency build gate is intentionally deferred."
|
||||
fi
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Build/test when solution exists
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
solutions=( *.sln *.slnx )
|
||||
if [ ${#solutions[@]} -gt 0 ]; then
|
||||
dotnet restore "${solutions[0]}"
|
||||
dotnet build "${solutions[0]}" --no-restore -c Release
|
||||
dotnet test "${solutions[0]}" --no-build -c Release
|
||||
else
|
||||
echo "Reference starter has no .NET solution file; backend compile gate is deferred to host repository."
|
||||
fi
|
||||
@@ -0,0 +1,38 @@
|
||||
name: KBX Release Readiness
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: Target environment (Staging or Production)
|
||||
required: true
|
||||
default: Staging
|
||||
|
||||
jobs:
|
||||
release-readiness:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout immutable candidate
|
||||
uses: actions/checkout@v4
|
||||
- name: Validate KBX contracts and configuration governance
|
||||
run: node scripts/validate-kbx.mjs
|
||||
- name: Verify generated configuration/deployment artifacts
|
||||
run: git diff --exit-code -- generated/configuration-manifest.json packages/kbx-contracts/src/generated/configurationCatalog.ts backend/Shared/Configuration/Generated/KbxConfigurationCatalog.g.cs deploy/kbx/
|
||||
- name: Build/test when host repository is available
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
solutions=( *.sln *.slnx )
|
||||
if [ ${#solutions[@]} -gt 0 ]; then
|
||||
dotnet restore "${solutions[0]}"
|
||||
dotnet build "${solutions[0]}" --no-restore -c Release
|
||||
dotnet test "${solutions[0]}" --no-build -c Release
|
||||
else
|
||||
echo "Starter: host build is deferred."
|
||||
fi
|
||||
- name: Configuration validation gate
|
||||
run: echo "Host must bind target-environment secrets/config and call KbxConfigurationStartupValidator.ValidateOrThrow before promotion."
|
||||
- name: Database migration dry-run gate
|
||||
run: echo "Host must execute DbUp validation/dry-run against an environment-equivalent database before applying migrations."
|
||||
- name: Migration application policy
|
||||
run: echo "Production migrations are pre-deploy; application startup schema mutation is forbidden."
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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`
|
||||
@@ -0,0 +1,10 @@
|
||||
import '@kbx/ui/tokens.css'
|
||||
import type { App } from 'vue'
|
||||
import { kbxLookupRegistryKey } from '@kbx/ui'
|
||||
import { lookupRegistry } from '../lookups/lookupRegistry'
|
||||
import { installScreenRegistry } from '../registry/screens'
|
||||
|
||||
export function installKbx(app: App) {
|
||||
app.provide(kbxLookupRegistryKey, lookupRegistry)
|
||||
installScreenRegistry()
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import * as signalR from '@microsoft/signalr'
|
||||
export interface KbxExperimentChangedEvent { experimentId:string; screenId:string; changedAt:string }
|
||||
export function createExperimentRolloutConnection(url='/hubs/kbx-experiments'){
|
||||
const hub=new signalR.HubConnectionBuilder().withUrl(url).withAutomaticReconnect().build()
|
||||
return {start:()=>hub.state===signalR.HubConnectionState.Disconnected?hub.start():Promise.resolve(),stop:()=>hub.stop(),onChanged:(callback:(event:KbxExperimentChangedEvent)=>void)=>hub.on('ExperimentChanged',callback)}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { KbxApiOperationId, KbxExperimentAssignmentsResponse, KbxExperimentId } from '@kbx/contracts'
|
||||
import { kbxApi } from '../http/generated/kbxApiClient'
|
||||
import { kbxTelemetry } from '../telemetry/kbxTelemetryClient'
|
||||
import { createExperimentRolloutConnection } from './createExperimentRolloutConnection'
|
||||
|
||||
const screenCache=new Map<string,Promise<KbxExperimentAssignmentsResponse>>()
|
||||
const listeners=new Map<string,Set<()=>void>>()
|
||||
let realtimeStarted=false
|
||||
const realtime=createExperimentRolloutConnection()
|
||||
realtime.onChanged(event=>{screenCache.delete(event.screenId);for(const refresh of listeners.get(event.screenId)??[])void refresh()})
|
||||
async function ensureRealtime(){if(realtimeStarted)return;realtimeStarted=true;try{await realtime.start()}catch{realtimeStarted=false}}
|
||||
function assignments(screenId:string,force=false){if(force)screenCache.delete(screenId);if(!screenCache.has(screenId))screenCache.set(screenId,kbxApi.request<KbxExperimentAssignmentsResponse>('common.experiments.assignments' as KbxApiOperationId,{query:{screenId}}));return screenCache.get(screenId)!}
|
||||
export function useKbxExperiment(screenId:string,screenVersion:string,experimentId:KbxExperimentId,surface:string){
|
||||
const variant=ref('control'),enrolled=ref(false);let lastExposure=''
|
||||
const refresh=async()=>{
|
||||
try{const result=await assignments(screenId,true);const assignment=result.assignments.find(x=>x.experimentId===experimentId);if(!assignment||!assignment.enrolled){enrolled.value=false;variant.value='control';lastExposure='';kbxTelemetry.clearExperiment(screenId);return}enrolled.value=true;variant.value=assignment.variant;kbxTelemetry.bindExperiment(screenId,assignment.experimentId,assignment.variant);const exposure=`${assignment.experimentId}:${assignment.variant}`;if(lastExposure!==exposure){lastExposure=exposure;kbxTelemetry.track('experiment.exposed',{screenId,screenVersion,attributes:{surface}})}}catch{/* experiment infrastructure must not block business UI */}
|
||||
}
|
||||
onMounted(async()=>{const set=listeners.get(screenId)??new Set<()=>void>();set.add(refresh);listeners.set(screenId,set);await ensureRealtime();await refresh()})
|
||||
onUnmounted(()=>{listeners.get(screenId)?.delete(refresh);kbxTelemetry.clearExperiment(screenId)})
|
||||
return {variant,enrolled,is:(value:string)=>variant.value===value,refresh}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { KbxHelpContent } from '@kbx/contracts'
|
||||
|
||||
export const helpRegistry: Record<string, KbxHelpContent> = {
|
||||
'OMS-ORD-001': {
|
||||
key: 'OMS-ORD-001', title: '주문관리', purpose: '주문을 조회하고 예외 및 출고대상을 일괄 처리합니다.',
|
||||
steps: ['조회조건을 입력합니다.', 'F3 또는 조회를 실행합니다.', '처리할 주문을 선택한 뒤 업무 명령을 실행합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
},
|
||||
'OMS-ORD-002': {
|
||||
key: 'OMS-ORD-002', title: '주문등록', purpose: '거래처 주문을 Header/Detail 방식으로 등록합니다.',
|
||||
steps: ['거래처와 출고창고를 선택합니다.', '품목코드 또는 F2로 상품을 입력합니다.', '수량·단가를 확인하고 F8로 저장합니다.'],
|
||||
shortcuts: [{ key: 'F2', description: '코드 조회' }, { key: 'F8', description: '저장' }],
|
||||
},
|
||||
'ERP-MST-ITEM-001': {
|
||||
key: 'ERP-MST-ITEM-001', title: '품목관리', purpose: '품목 기준정보와 물류 속성을 관리합니다.',
|
||||
steps: ['왼쪽 목록에서 품목을 선택하거나 신규를 누릅니다.', '기준정보와 물류정보를 입력합니다.', 'F8로 저장합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }, { key: 'F8', description: '저장' }, { key: 'F2', description: '창고 조회' }],
|
||||
cautions: ['사용된 품목은 삭제보다 사용중지를 우선합니다.'],
|
||||
},
|
||||
'ERP-INV-001': {
|
||||
key: 'ERP-INV-001', title: '재고현황', purpose: '품목별 현재고에서 창고·로케이션 근거와 재고이력까지 같은 조회 맥락에서 확인합니다.',
|
||||
steps: ['조회조건을 입력하고 F3을 누릅니다.', '품목을 선택하면 오른쪽 위치재고와 아래 재고이력이 함께 바뀝니다.', '현재고 또는 가용수량을 눌러 산정 근거를 확인합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
cautions: ['화면 수량은 조회 Projection이며 실제 변경·확정 시 서버 재고 정책이 다시 검증합니다.'],
|
||||
},
|
||||
'ERP-PRICE-001': {
|
||||
key: 'ERP-PRICE-001', title: '품목 단가 일괄등록', purpose: '품목별 적용일과 단가를 Excel 방식으로 빠르게 입력하고 일괄 저장합니다.',
|
||||
steps: ['품목코드를 입력하거나 F2로 품목을 선택합니다.', '적용일과 단가를 입력하고 필요하면 Excel 영역을 붙여넣습니다.', '오류가 없으면 F8로 입력 건을 한 번에 저장합니다.'],
|
||||
shortcuts: [{ key: 'F2', description: '품목 조회' }, { key: 'F8', description: '저장' }],
|
||||
cautions: ['같은 품목과 적용일을 요청 안에서 중복 입력할 수 없습니다.', '서버가 품목 존재·권한·단가 범위를 다시 검증하고 저장 이력을 남깁니다.'],
|
||||
},
|
||||
'WMS-PICK-001': {
|
||||
key: 'WMS-PICK-001', title: '출고 피킹', purpose: '위치와 상품을 스캔하여 출고 피킹을 수행합니다.',
|
||||
cautions: ['서버 성공 응답 전에 같은 상품을 다시 스캔하지 마세요. 네트워크 오류는 동일 Idempotency Key로 자동 재시도합니다.'],
|
||||
},
|
||||
'OMS-ORD-003': {
|
||||
key: 'OMS-ORD-003', title: '주문 Excel 업로드', purpose: '주문 데이터를 공통 매핑·검증 절차로 대량 등록하거나 수정합니다.',
|
||||
steps: ['업로드 양식 또는 기존 Excel 파일을 선택합니다.', '자동 매핑 결과를 확인하고 필요한 열만 수정합니다.', '검증 결과에서 오류행을 확인한 뒤 정상 데이터만 반영합니다.'],
|
||||
cautions: ['검증되지 않은 데이터는 반영할 수 없습니다.', '기존 주문은 수정 가능한 업무상태에서만 업데이트됩니다.'],
|
||||
},
|
||||
'COMMON-OPS-001': {
|
||||
key: 'COMMON-OPS-001', title: '업무 예외 센터', purpose: '정상 업무가 아니라 사람이 판단하거나 복구해야 할 예외만 모아 처리합니다.',
|
||||
steps: ['미처리 예외를 조회합니다.', '중요도와 경과시간을 기준으로 우선순위를 확인합니다.', '원 업무 보기 또는 등록된 안전한 재처리 기능으로 해결합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
cautions: ['예외 센터는 원천 업무 데이터의 Source of Truth가 아닙니다.', '원천 조건이 해결되지 않은 예외를 단순 숨김 처리하지 않습니다.'],
|
||||
},
|
||||
'COMMON-REC-001': {
|
||||
key: 'COMMON-REC-001', title: '업무 데이터 대사', purpose: 'OMS·WMS·ERP 사이의 기대값과 실제값 차이를 시스템이 계산해 보여줍니다.',
|
||||
steps: ['대사유형과 결과를 선택합니다.', '불일치 건의 기대값·실제값·원인을 확인합니다.', '조사가 필요한 건만 예외 센터에 등록합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
cautions: ['대사 화면에서 원천 데이터를 직접 수정하지 않습니다.'],
|
||||
},
|
||||
'OMS-CLM-001': {
|
||||
key:'OMS-CLM-001', title:'반품·클레임 관리', purpose:'반품·교환·취소 요청을 기존 주문 맥락에서 조회하고 처리합니다.',
|
||||
steps:['F3으로 미처리 요청을 조회합니다.','주문번호와 사유를 확인합니다.','허용된 상태전이만 실행합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['완료된 클레임은 원 주문·물류 이력과 함께 추적합니다.'],
|
||||
},
|
||||
'ERP-PUR-001': {
|
||||
key:'ERP-PUR-001', title:'구매등록', purpose:'거래처 구매를 Header/Detail 방식으로 입력하고 입고 흐름과 연결합니다.',
|
||||
steps:['거래처와 입고창고를 선택합니다.','품목·수량·단가·납기일을 입력합니다.','F8 저장 후 구매확정합니다.'], shortcuts:[{key:'F2',description:'코드 조회'},{key:'F8',description:'저장'}], cautions:['확정 후에는 입고 실적과 연결되므로 직접 수정 범위를 제한합니다.'],
|
||||
},
|
||||
'ERP-INV-MOVE-001': {
|
||||
key:'ERP-INV-MOVE-001', title:'재고이동', purpose:'창고 간 재고이동을 등록하고 이동중·입고완료 상태를 추적합니다.',
|
||||
steps:['출발·도착창고를 선택합니다.','품목과 이동수량을 입력합니다.','이동확정 후 WMS/입고 흐름에서 완료합니다.'], shortcuts:[{key:'F2',description:'코드 조회'},{key:'F8',description:'저장'}], cautions:['출발창고와 도착창고는 같을 수 없고 가용재고보다 많이 이동할 수 없습니다.'],
|
||||
},
|
||||
'WMS-REC-001': { key:'WMS-REC-001', title:'입고 검수', purpose:'입고예정/ASN과 실물을 스캔해 수량·품목을 검수합니다.', cautions:['예정과 다른 상품·수량은 자동 확정하지 않고 예외로 남깁니다.'] },
|
||||
'WMS-PUT-001': { key:'WMS-PUT-001', title:'입고 적치', purpose:'검수 완료 상품을 추천 로케이션에 적치합니다.', cautions:['추천 위치와 다른 위치는 정상 흐름에서 자동 승인하지 않습니다.'] },
|
||||
'WMS-COUNT-001': { key:'WMS-COUNT-001', title:'재고실사', purpose:'위치·상품을 스캔해 실물수량을 기록하고 장부수량 차이를 예외로 보냅니다.', cautions:['실사 차이는 현장에서 장부수량을 직접 덮어쓰지 않습니다.'] },
|
||||
|
||||
'WMS-WORK-001': { key:'WMS-WORK-001', title:'물류 작업', purpose:'입고·적치·피킹·검수·실사 작업과 예외를 작업자 관점에서 조회하고 시작합니다.', steps:['F3으로 내 작업을 조회합니다.','SLA·진행률·예외를 확인합니다.','작업을 선택해 작업 시작을 실행합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['현장 실행은 각 PDA 작업화면의 서버 검증과 Idempotency 규칙을 따릅니다.'] },
|
||||
|
||||
'COMMON-EXP-001': { key:'COMMON-EXP-001', title:'UX 실험·점진배포', purpose:'되돌릴 수 있는 UX 변경을 제한된 사용자에게 점진 배포하고 기존 UX 지표와 Guardrail을 비교합니다.', steps:['Draft 상태에서 변경 범위와 기준 지표를 검토합니다.','소규모 Rollout으로 시작하고 표본·P95·오류율을 확인합니다.','Guardrail 위반 시 즉시 롤백하고 원인을 기록합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['권한·민감정보·Domain Rule·Validation·Idempotency·WMS Scan 규칙은 실험 대상이 아닙니다.','운영 Threshold는 통계적 유의성을 의미하지 않습니다.'] },
|
||||
|
||||
'COMMON-UX-001': { key:'COMMON-UX-001', title:'UX 품질 지표', purpose:'Task 시간·의미 조작수·Manual Intervention Rate·검증/Import 실패율·예외 해결시간·AI 제안 수용률을 확인합니다.', steps:['기간을 선택하고 F3으로 조회합니다.','표본 수와 P50/P95를 함께 확인합니다.','지표 변화는 위험도·업무오류·현장 VOC와 같이 판단합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['DOM 전체 클릭이나 고객·주문 원문 데이터를 수집하지 않습니다.','Manual Intervention Rate를 낮추기 위해 위험 업무를 무리하게 자동화하지 않습니다.'] },
|
||||
|
||||
'COMMON-DATA-001': { key:'COMMON-DATA-001', title:'외부 데이터 상태', purpose:'KRX·OPENDART·KIS 외부 데이터의 출처·수신시각·신선도·캐시 상태를 확인합니다.', steps:['F3으로 현재 상태를 조회합니다.','Stale/Expired 데이터셋의 최근 수신시각과 정책을 확인합니다.','허용된 데이터셋만 재수집합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['외부 데이터는 Domain 업무원장이 아닙니다.','KRX 승인 서비스는 서비스별 명시적 신선도 정책이 없으면 범용 TTL을 추정하지 않습니다.'] },
|
||||
|
||||
'COMMON-DS-001': { key:'COMMON-DS-001', title:'KBX 컴포넌트 카탈로그', purpose:'공통 컴포넌트의 상태·밀도·키보드·접근성 계약을 독립적으로 재현합니다.', steps:['밀도를 선택합니다.','컴포넌트의 기본·오류·비활성 상태를 비교합니다.','Visual/Keyboard Regression 기준으로 사용합니다.'], cautions:['업무 사용자를 위한 메뉴가 아니라 KBX 개발·검증 화면입니다.'] },
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { kbxApiCatalog, type KbxApiOperationDefinition, type KbxApiOperationId, type KbxApiRequestOptions } from '@kbx/contracts'
|
||||
import { kbxHttp } from '../kbxHttpClient'
|
||||
|
||||
function buildPath(template: string, values: Record<string, string | number> = {}) {
|
||||
return template.replace(/\{([^}:]+)(?::[^}]+)?\}/g, (_, name: string) => {
|
||||
const key = Object.keys(values).find(x => x.toLowerCase() === name.toLowerCase())
|
||||
if (!key) throw new Error(`API path parameter '${name}' is required.`)
|
||||
return encodeURIComponent(String(values[key]))
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestKbxApi<TResponse = unknown, TBody = unknown>(
|
||||
operationId: KbxApiOperationId,
|
||||
options: KbxApiRequestOptions<TBody> = {},
|
||||
): Promise<TResponse> {
|
||||
const operation: KbxApiOperationDefinition = kbxApiCatalog[operationId]
|
||||
if (operation.idempotency === 'required' && !options.idempotencyKey) {
|
||||
throw new Error(`Operation '${operationId}' requires a stable idempotency key.`)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { ...(options.headers ?? {}) }
|
||||
if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey
|
||||
|
||||
const response = await kbxHttp.request<TResponse>({
|
||||
method: operation.method,
|
||||
url: buildPath(operation.path, options.path),
|
||||
params: options.query,
|
||||
data: options.body,
|
||||
headers,
|
||||
responseType: options.responseType ?? operation.responseType ?? 'json',
|
||||
validateStatus: status => operation.successStatuses.some(value => value === status),
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const kbxApi = {
|
||||
request: requestKbxApi,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import axios, { AxiosError, type AxiosRequestConfig } from 'axios'
|
||||
import { isKbxProblem, kbxUnexpectedProblem, type KbxProblem, type KbxSystemProblem } from '@kbx/contracts'
|
||||
|
||||
export const kbxHttp = axios.create({
|
||||
timeout: 30_000,
|
||||
headers: { 'X-KBX-Client': 'web' },
|
||||
})
|
||||
|
||||
kbxHttp.interceptors.request.use(config => {
|
||||
config.headers['X-Correlation-Id'] ??= crypto.randomUUID()
|
||||
return config
|
||||
})
|
||||
|
||||
kbxHttp.interceptors.response.use(
|
||||
response => response,
|
||||
(error: AxiosError) => {
|
||||
const correlationId = String(error.config?.headers?.['X-Correlation-Id'] ?? crypto.randomUUID())
|
||||
|
||||
if (!error.response) {
|
||||
const problem: KbxSystemProblem = {
|
||||
type: 'system',
|
||||
code: 'NETWORK_UNAVAILABLE',
|
||||
title: '서버와 연결할 수 없습니다.',
|
||||
detail: '입력한 내용은 유지됩니다. 네트워크 상태를 확인한 후 다시 시도하세요.',
|
||||
correlationId,
|
||||
retryable: true,
|
||||
}
|
||||
return Promise.reject(problem)
|
||||
}
|
||||
|
||||
const data = error.response.data
|
||||
if (isKbxProblem(data)) {
|
||||
const problem: KbxProblem = {
|
||||
...data,
|
||||
correlationId: data.correlationId ?? correlationId,
|
||||
}
|
||||
return Promise.reject(problem)
|
||||
}
|
||||
|
||||
if (error.response.status === 403) {
|
||||
return Promise.reject({
|
||||
type: 'permission', code: 'PERMISSION_DENIED', title: '이 작업을 수행할 권한이 없습니다.', correlationId,
|
||||
} satisfies KbxProblem)
|
||||
}
|
||||
if (error.response.status === 404) {
|
||||
return Promise.reject({
|
||||
type: 'not-found', code: 'RESOURCE_NOT_FOUND', title: '요청한 데이터를 찾을 수 없습니다.', correlationId,
|
||||
} satisfies KbxProblem)
|
||||
}
|
||||
|
||||
return Promise.reject(kbxUnexpectedProblem(correlationId, `HTTP ${error.response.status}`, error.response.status >= 500))
|
||||
},
|
||||
)
|
||||
|
||||
export function withIdempotency(config: AxiosRequestConfig = {}, key = crypto.randomUUID()): AxiosRequestConfig {
|
||||
return {
|
||||
...config,
|
||||
headers: {
|
||||
...(config.headers ?? {}),
|
||||
'Idempotency-Key': key,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ref } from 'vue'
|
||||
import type { KbxConflictSnapshot, KbxProblem, KbxSystemProblem } from '@kbx/contracts'
|
||||
|
||||
export function useKbxProblemHandler() {
|
||||
const conflict = ref<KbxConflictSnapshot | null>(null)
|
||||
const systemProblem = ref<KbxSystemProblem | null>(null)
|
||||
|
||||
function handle(problem: KbxProblem) {
|
||||
if (problem.type === 'conflict') {
|
||||
conflict.value = {
|
||||
code: problem.code,
|
||||
title: problem.title,
|
||||
currentVersion: problem.currentVersion,
|
||||
correlationId: problem.correlationId,
|
||||
}
|
||||
return
|
||||
}
|
||||
if (problem.type === 'system') {
|
||||
systemProblem.value = problem
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
conflict.value = null
|
||||
systemProblem.value = null
|
||||
}
|
||||
|
||||
return { conflict, systemProblem, handle, clear }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as signalR from '@microsoft/signalr'
|
||||
import type { KbxImportProgressEvent } from '@kbx/contracts'
|
||||
import type { KbxImportProgressConnection } from '@kbx/ui'
|
||||
|
||||
export function createImportProgressConnection(url = '/hubs/import-progress'): KbxImportProgressConnection {
|
||||
const hub = new signalR.HubConnectionBuilder()
|
||||
.withUrl(url)
|
||||
.withAutomaticReconnect()
|
||||
.build()
|
||||
|
||||
return {
|
||||
start: () => hub.state === signalR.HubConnectionState.Disconnected ? hub.start() : Promise.resolve(),
|
||||
stop: () => hub.stop(),
|
||||
subscribe: sessionId => hub.invoke('Subscribe', sessionId),
|
||||
unsubscribe: sessionId => hub.state === signalR.HubConnectionState.Connected
|
||||
? hub.invoke('Unsubscribe', sessionId)
|
||||
: Promise.resolve(),
|
||||
onProgress(callback) {
|
||||
hub.on('ImportProgress', (event: KbxImportProgressEvent) => callback(event))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { isKbxProblem, type KbxLookupProvider } from '@kbx/contracts'
|
||||
import { kbxApi } from '../http/generated/kbxApiClient'
|
||||
|
||||
const lookupOperations = {
|
||||
customer: { search: 'lookup.customers.search', byId: 'lookup.customers.resolveById', byCode: 'lookup.customers.resolveByCode' },
|
||||
item: { search: 'lookup.items.search', byId: 'lookup.items.resolveById', byCode: 'lookup.items.resolveByCode' },
|
||||
warehouse: { search: 'lookup.warehouses.search', byId: 'lookup.warehouses.resolveById', byCode: 'lookup.warehouses.resolveByCode' },
|
||||
} as const
|
||||
|
||||
type LookupEntity = keyof typeof lookupOperations
|
||||
|
||||
function provider(entity: LookupEntity): KbxLookupProvider<string> {
|
||||
const ops = lookupOperations[entity]
|
||||
return {
|
||||
search(request) {
|
||||
return kbxApi.request(ops.search, { query: request })
|
||||
},
|
||||
async resolveById(id) {
|
||||
try { return await kbxApi.request(ops.byId, { path: { id } }) }
|
||||
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
|
||||
},
|
||||
async resolveByCode(code) {
|
||||
try { return await kbxApi.request(ops.byCode, { path: { code } }) }
|
||||
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
|
||||
},
|
||||
} as KbxLookupProvider<string>
|
||||
}
|
||||
|
||||
export const lookupRegistry = {
|
||||
customer: provider('customer'),
|
||||
item: provider('item'),
|
||||
warehouse: provider('warehouse'),
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
KbxAuditTrail, KbxBulkActionBar, KbxButton, KbxCommandBar, KbxDateField, KbxDateRange,
|
||||
KbxInput, KbxFormGrid, KbxFormSection, KbxFormSpan, KbxMoneyField, KbxNumberField, KbxPageHeader, KbxProposalPanel, KbxQuantityField,
|
||||
KbxSelect, KbxStatus, KbxToast, KbxDataState, KbxTabs, KbxDataGrid, KbxSearchPanel, kbxComponentCatalog, kbxTemplateManifest,
|
||||
} from '@kbx/ui'
|
||||
import type { KbxAiProposal, KbxAuditEntry, KbxCommandDefinition, KbxDensity, KbxGridColumn, KbxSearchField, KbxValidationError } from '@kbx/contracts'
|
||||
|
||||
const density=ref<KbxDensity>((new URLSearchParams(location.search).get('density') as KbxDensity) || 'compact')
|
||||
const text=ref('대한상사')
|
||||
const number=ref<number|null>(1200)
|
||||
const money=ref<number|null>(1250000)
|
||||
const qty=ref<number|null>(10)
|
||||
const date=ref('2026-08-08')
|
||||
const from=ref('2026-08-01')
|
||||
const to=ref('2026-08-08')
|
||||
const status=ref('READY')
|
||||
const tab=ref('basic')
|
||||
|
||||
const search=ref<Record<string,unknown>>({keyword:'',status:null,mismatchOnly:false})
|
||||
const rememberSearch=ref(true)
|
||||
const searchFields:KbxSearchField[]=[
|
||||
{key:'keyword',label:'통합검색',type:'text',width:'lg',placeholder:'주문번호/주문자'},
|
||||
{key:'status',label:'상태',type:'select',options:[{value:'READY',label:'출고대기'},{value:'ERROR',label:'오류'}]},
|
||||
{key:'mismatchOnly',label:'오류만 보기',type:'checkbox',primary:false,defaultValue:false},
|
||||
]
|
||||
type DemoRow={id:string;orderNo:string;customerName:string;orderQty:number;amount:number;status:string}
|
||||
const gridRows=ref<DemoRow[]>([
|
||||
{id:'1',orderNo:'TEST-ORD-001',customerName:'대한상사',orderQty:10,amount:128000,status:'출고대기'},
|
||||
{id:'2',orderNo:'TEST-ORD-002',customerName:'서울유통',orderQty:8,amount:96000,status:'재고부족'},
|
||||
{id:'3',orderNo:'TEST-ORD-003',customerName:'부산물류',orderQty:4,amount:52000,status:'신규'},
|
||||
])
|
||||
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},
|
||||
]
|
||||
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:'작성'},
|
||||
])
|
||||
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 duplicateFastRows(rows:DemoRow[]){for(const row of rows)fastRows.value.push({...row,id:`F${fastSeq}`,orderNo:`TEST-FAST-${String(fastSeq++).padStart(3,'0')}`})}
|
||||
|
||||
const commands:KbxCommandDefinition[]=[
|
||||
{id:'search',label:'조회',group:'query',shortcut:'F3'},
|
||||
{id:'save',label:'저장',group:'edit',shortcut:'F8',variant:'primary'},
|
||||
{id:'ship',label:'출고지시',group:'workflow',requiresSelection:true},
|
||||
]
|
||||
const proposal:KbxAiProposal={id:'catalog-proposal',type:'warehouse-change',title:'출고창고 변경 제안',explanation:'가용재고가 있는 창고를 제안한 예시입니다.',capability:'draft',confidence:.87,proposedChanges:[{field:'warehouseId',label:'출고창고',before:'서울센터',after:'인천센터'}],evidence:[{label:'재고 Read Model',sourceType:'domain'}]}
|
||||
const audit:KbxAuditEntry[]=[{id:'1',occurredAt:'2026-08-08 15:32',actor:{type:'user',displayName:'홍길동'},action:'수량 변경',changes:[{field:'qty',label:'출고수량',before:10,after:8}],reason:'고객 요청'}]
|
||||
const groupCounts=computed(()=>Object.fromEntries([...new Set(kbxComponentCatalog.map(x=>x.group))].map(g=>[g,kbxComponentCatalog.filter(x=>x.group===g).length])))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="catalog" :data-kbx-density="density">
|
||||
<KbxPageHeader title="KBX 컴포넌트 카탈로그" breadcrumb="COMMON > KBX" description="Default / Changed / Warning / AI Suggested / Readonly / Disabled / Error / Loading / Empty / Keyboard 상태를 독립적으로 재현합니다." />
|
||||
<nav class="density" aria-label="밀도 선택"><strong>Density</strong><button v-for="d in ['compact','comfortable','touch']" :key="d" type="button" :aria-pressed="density===d" @click="density=d as KbxDensity">{{d}}</button></nav>
|
||||
|
||||
<section class="catalog__summary" aria-label="카탈로그 요약"><span v-for="(count,group) in groupCounts" :key="group"><strong>{{group}}</strong> {{count}}</span></section>
|
||||
|
||||
<section class="catalog__block"><h2>입력</h2><div class="catalog__grid"><div><h3>KbxInput 상태</h3><KbxInput v-model="text" label="거래처명" required/><KbxInput model-value="변경한 값" label="Changed" state="changed" help-text="저장 전 변경값입니다."/><KbxInput model-value="확인 필요" label="Warning" state="warning" warning="업무 규칙을 다시 확인하세요."/><KbxInput model-value="인천센터" label="AI Suggested" state="ai-suggested" help-text="AI 추천값이며 아직 반영되지 않았습니다."/><KbxInput model-value="" label="오류" error="거래처명을 입력하세요."/><KbxInput model-value="읽기전용" label="Readonly" readonly/></div><div><h3>Number / Money / Quantity</h3><KbxNumberField v-model="number" label="일반 숫자" state="changed"/><KbxMoneyField v-model="money" label="단가"/><KbxQuantityField v-model="qty" label="출고수량" :available-quantity="8"/></div><div><h3>Date / Range / Select</h3><KbxDateField v-model="date" label="주문일"/><KbxDateRange v-model:from="from" v-model:to="to" label="주문기간"/><KbxSelect v-model="status" label="상태" :options="[{value:'READY',label:'준비'},{value:'DONE',label:'완료'}]"/><KbxTabs v-model="tab" :items="[{key:'basic',label:'기본'},{key:'audit',label:'변경이력',badge:3}]"/></div></div></section>
|
||||
|
||||
<section class="catalog__block"><h2>Form Composition</h2><p class="catalog__hint">T02/T03 Desktop Form은 KbxFormSection + KbxFormGrid의 2-column을 기본으로 하고 주소·비고처럼 관계상 넓게 보여야 하는 항목만 명시적으로 full span 합니다.</p><KbxFormSection title="기본정보" description="Label 폭과 행/열 간격은 Design Token에서 통제합니다."><KbxFormGrid><KbxInput v-model="text" label="품목명"/><KbxDateField v-model="date" label="적용일"/><KbxFormSpan span="full"><KbxInput model-value="서울특별시 강남구 ..." label="주소" readonly/></KbxFormSpan></KbxFormGrid></KbxFormSection></section>
|
||||
|
||||
<section class="catalog__block"><h2>Search / Grid Runtime</h2><KbxSearchPanel v-model="search" v-model:remember-checked="rememberSearch" :fields="searchFields" remember saved-search/><div class="catalog__grid-demo"><KbxDataGrid :rows="gridRows" :columns="gridColumns" row-key="id" selection="multiple" editable :errors="gridErrors" :changed-cells="[{rowKey:'1',field:'orderQty'}]" :total-count="82415" allow-all-filtered-selection personalization exportable :summary="[{key:'count',label:'조회',kind:'count'},{key:'amount',label:'금액 합계',kind:'sum',field:'amount'}]"/></div></section>
|
||||
|
||||
<section class="catalog__block"><h2>Fast Entry Grid</h2><p class="catalog__hint">행 추가/복제, 선택 행 기준 Fill Down, Excel 다중 셀 붙여넣기 정규화, 오류 탐색을 동일 Grid 계약으로 재현합니다.</p><div class="catalog__grid-demo"><KbxDataGrid :rows="fastRows" :columns="gridColumns" row-key="id" selection="multiple" editable :editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}" @row-add-requested="addFastRow" @row-duplicate-requested="duplicateFastRows"/></div></section>
|
||||
|
||||
<section class="catalog__block"><h2>Loading / Empty / Error</h2><div class="catalog__grid"><KbxDataState state="loading"/><KbxDataState state="empty" title="조회된 주문이 없습니다." detail="조회조건을 변경해 보세요."/><KbxDataState state="error" detail="네트워크 연결을 확인한 후 다시 시도하세요." action-label="다시 조회"/></div></section>
|
||||
|
||||
<section class="catalog__block"><h2>Command / 상태</h2><KbxCommandBar :commands="commands" :selection-count="0"/><KbxBulkActionBar :selection-count="17" :actions="[{id:'ship',label:'출고지시',group:'workflow'},{id:'hold',label:'보류',group:'workflow'}]"/><div class="status-row"><KbxStatus label="작성" semantic="draft"/><KbxStatus label="진행" semantic="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>AI / Audit</h2><div class="catalog__grid"><KbxProposalPanel :proposal="proposal"/><KbxAuditTrail :entries="audit"/></div></section>
|
||||
|
||||
<section class="catalog__block"><h2>T01~T09 Template Contract</h2><p class="catalog__hint">Screen Type을 선택하면 필수 Surface·Core Component·Keyboard·완료 점검까지 함께 결정됩니다. 화면별 임의 Layout은 예외입니다.</p><table><thead><tr><th>Type</th><th>Component</th><th>필수 Surface</th><th>Core Components</th><th>완료 점검</th><th>Reference</th></tr></thead><tbody><tr v-for="template in kbxTemplateManifest" :key="template.code"><td><strong>{{template.code}}</strong><br><small>{{template.type}}</small></td><td>{{template.component}}<br><small>{{template.keyboard.join(' · ')}}</small></td><td>{{template.requiredSurfaces.join(' · ')}}</td><td>{{template.coreComponents.join(' · ')}}</td><td>{{template.completionChecks.join(' · ')}}</td><td>{{template.referenceScreens.join(', ')}}</td></tr></tbody></table></section>
|
||||
|
||||
<section class="catalog__block"><h2>검증 계약</h2><table><thead><tr><th>Component</th><th>Group</th><th>States</th><th>Keyboard</th><th>Focus</th></tr></thead><tbody><tr v-for="entry in kbxComponentCatalog" :key="entry.component"><td>{{entry.component}}</td><td>{{entry.group}}</td><td>{{entry.scenarios.map(x=>x.label).join(', ')}}</td><td>{{entry.accessibility.keyboard?'✓':'-'}}</td><td>{{entry.accessibility.focusVisible?'✓':'-'}}</td></tr></tbody></table></section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.catalog{display:grid;gap:var(--kbx-space-4);padding:var(--kbx-space-4);background:var(--kbx-color-surface);color:var(--kbx-color-text)}.density,.catalog__summary,.status-row{display:flex;align-items:center;gap:var(--kbx-space-2);flex-wrap:wrap}.density button{height:var(--kbx-control-height);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface);border-radius:var(--kbx-radius-sm);padding:0 10px}.density button[aria-pressed="true"]{border-color:var(--kbx-color-primary);color:var(--kbx-color-primary);font-weight:600}.catalog__summary span{padding:6px 8px;background:var(--kbx-color-surface-muted);border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm)}.catalog__block{display:grid;gap:var(--kbx-space-3);padding:var(--kbx-space-3);border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm)}h2,h3{margin:0}.catalog__grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:var(--kbx-space-4)}.catalog__grid>div{display:grid;gap:var(--kbx-space-2)}.catalog__grid-demo{height:360px}.catalog__hint{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}table{width:100%;border-collapse:collapse;font-size:var(--kbx-font-sm)}th,td{padding:7px 8px;border-bottom:1px solid var(--kbx-color-border);text-align:left}th{background:var(--kbx-color-surface-muted);font-weight:600}
|
||||
</style>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
|
||||
export const designSystemCatalogScreen = defineKbxScreen({
|
||||
id:'COMMON-DS-001',
|
||||
version:'1.0.0',
|
||||
module:'COMMON',
|
||||
type:'list', templateCode:'T01',
|
||||
title:'KBX 컴포넌트 카탈로그',
|
||||
description:'공통 컴포넌트의 상태·밀도·키보드·접근성 계약을 재현합니다.',
|
||||
permissions:['kbx.design.read'],
|
||||
helpKey:'COMMON-DS-001',
|
||||
telemetry:{enabled:true},
|
||||
})
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxListPage, KbxDataGrid, KbxDialog, KbxInput, KbxNumberField, KbxSelect, KbxButton } from '@kbx/ui'
|
||||
import type { KbxExperimentOverviewRow, KbxGridColumn } from '@kbx/contracts'
|
||||
import { experimentsScreen } from './experiments.definition'
|
||||
import { experimentApi } from './experimentApi'
|
||||
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},
|
||||
]
|
||||
const dialogOpen=ref(false),mode=ref<'rollout'|'rollback'>('rollout'),reason=ref(''),rolloutPercent=ref<number|null>(0),rolloutState=ref<string|null>('running'),error=ref('')
|
||||
function openManage(next:'rollout'|'rollback'){const item=selected.value[0];if(!item)return;mode.value=next;reason.value='';error.value='';rolloutPercent.value=item.rolloutPercent;rolloutState.value=item.state==='paused'?'paused':'running';dialogOpen.value=true}
|
||||
async function apply(){const item=selected.value[0];if(!item)return;if(!reason.value.trim()){error.value='변경 사유를 입력하세요.';return}if(mode.value==='rollout'){const percent=rolloutPercent.value??0;if(percent<0||percent>100){error.value='배포 비율은 0~100 사이여야 합니다.';return}await experimentApi.rollout(item.experimentId,{rolloutPercent:percent,state:(rolloutState.value==='paused'?'paused':'running'),reason:reason.value.trim()})}else await experimentApi.rollback(item.experimentId,{reason:reason.value.trim()});dialogOpen.value=false;selected.value=[];await query.refetch()}
|
||||
async function command(id:string){if(id==='search')await query.refetch();if(id==='rollout')openManage('rollout');if(id==='rollback')openManage('rollback')}
|
||||
</script>
|
||||
<template><KbxListPage :screen="experimentsScreen" :selection-count="selected.length" @command="command">
|
||||
<template #quick-filter><div class="notice"><strong>안전 범위</strong> — Layout·정보강조·기본필터·Navigation·Copy만 실험합니다. 권한·민감정보·Domain Rule·Validation·WMS Scan Rule은 실험하지 않습니다.</div></template>
|
||||
<template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="experimentId" selection="single" :loading="query.isFetching.value" @selection-changed="value=>selected=value" /></template>
|
||||
<template #summary>판정은 운영 Threshold 기반입니다. 통계적 유의성을 자동 주장하지 않으며, Guardrail 위반은 즉시 롤백 대상으로 봅니다.</template>
|
||||
</KbxListPage>
|
||||
<KbxDialog :open="dialogOpen" :title="mode==='rollback'?'UX 변경 즉시 롤백':'점진배포 설정'" size="md" @update:open="dialogOpen=$event">
|
||||
<div class="dialog-fields">
|
||||
<template v-if="mode==='rollout'">
|
||||
<KbxNumberField v-model="rolloutPercent" label="배포 비율" suffix="%" :min="0" :max="100" />
|
||||
<KbxSelect v-model="rolloutState" label="상태" :options="[{value:'running',label:'실행'},{value:'paused',label:'일시중지'}]" />
|
||||
</template>
|
||||
<KbxInput v-model="reason" label="변경 사유" required :maxlength="500" :error="error" placeholder="지표·장애·VOC 등 변경 근거를 입력하세요." @enter="apply" />
|
||||
<p v-if="mode==='rollback'">롤백하면 Kill Switch가 활성화되고 모든 사용자는 즉시 Control UX로 돌아갑니다. 기존 Assignment와 Audit 이력은 유지됩니다.</p>
|
||||
</div>
|
||||
<template #footer><KbxButton label="취소" @click="dialogOpen=false"/><KbxButton :label="mode==='rollback'?'즉시 롤백':'적용'" :variant="mode==='rollback'?'danger':'primary'" @click="apply"/></template>
|
||||
</KbxDialog>
|
||||
</template>
|
||||
<style scoped>.notice{padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.dialog-fields{display:grid;gap:var(--kbx-space-3)}.dialog-fields p{color:var(--kbx-color-text-muted)}</style>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import type { KbxExperimentOverviewResponse, KbxExperimentRolloutRequest, KbxExperimentRollbackRequest } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
export const experimentApi={
|
||||
overview:()=>kbxApi.request<KbxExperimentOverviewResponse>('common.experiments.overview'),
|
||||
rollout:(experimentId:string,body:KbxExperimentRolloutRequest)=>kbxApi.request('common.experiments.rollout',{path:{experimentId},body}),
|
||||
rollback:(experimentId:string,body:KbxExperimentRollbackRequest)=>kbxApi.request('common.experiments.rollback',{path:{experimentId},body}),
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
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 실험·점진배포',
|
||||
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}],
|
||||
})
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxListPage, KbxDataGrid } from '@kbx/ui'
|
||||
import type { KbxExternalDataStatusRow, KbxGridColumn } from '@kbx/contracts'
|
||||
import { externalDataScreen } from './external-data.definition'
|
||||
import { externalDataApi } from './externalDataApi'
|
||||
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:'cacheEntries',header:'캐시',type:'integer',width:90},
|
||||
{field:'staleEntries',header:'Stale',type:'integer',width:90},
|
||||
{field:'unavailableEntries',header:'사용불가',type:'integer',width:100},
|
||||
{field:'lastReceivedAt',header:'최근 수신',type:'datetime',width:170},
|
||||
{field:'oldestFreshUntil',header:'최초 만료',type:'datetime',width:170},
|
||||
]
|
||||
async function command(id:string){if(id==='search')await query.refetch();if(id==='refresh'){const row=selected.value[0];if(!row)return;await externalDataApi.refresh(row.datasetId);selected.value=[];await query.refetch()}}
|
||||
</script>
|
||||
<template><KbxListPage :screen="externalDataScreen" :selection-count="selected.length" @command="command">
|
||||
<template #quick-filter><div class="notice"><strong>외부 데이터는 업무 원장이 아닙니다.</strong> 화면에는 정규화된 Projection과 출처·신선도를 함께 표시하고, 만료된 데이터를 최신값처럼 사용하지 않습니다.</div></template>
|
||||
<template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="datasetId" selection="single" :loading="query.isFetching.value" @selection-changed="value=>selected=value" /></template>
|
||||
<template #summary>KRX의 승인 서비스별 TTL은 별도 정책 없이는 재수집할 수 없습니다. OPENDART/KIS의 캐시 시간은 KBX 운영정책이며 공급자 공식 호출한도와 구분합니다.</template>
|
||||
</KbxListPage></template>
|
||||
<style scoped>.notice{padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}</style>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const externalDataScreen=defineKbxScreen({
|
||||
id:'COMMON-DATA-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'외부 데이터 상태',
|
||||
description:'KRX·OPENDART·KIS 외부 데이터의 출처·수신시각·신선도·캐시 상태를 확인합니다.',
|
||||
permissions:['common.external-data.read'],helpKey:'COMMON-DATA-001',telemetry:{enabled:true},
|
||||
commands:[
|
||||
{id:'search',label:'조회',group:'query',shortcut:'F3'},
|
||||
{id:'refresh',label:'재수집',group:'workflow',permission:'common.external-data.refresh',requiresSelection:true,minSelection:1,maxSelection:1},
|
||||
],
|
||||
})
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import type { KbxExternalDataStatusResponse } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
export const externalDataApi={
|
||||
status:()=>kbxApi.request<KbxExternalDataStatusResponse>('common.externalData.status'),
|
||||
refresh:(datasetId:string)=>kbxApi.request('common.externalData.refresh',{path:{datasetId}}),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<template><div aria-hidden="true" /></template>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { KbxRouteNotFound } from '@kbx/ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useKbxWorkspaceStore } from '../../../shell/workspaceStore'
|
||||
const router=useRouter();const store=useKbxWorkspaceStore()
|
||||
function home(){void router.replace('/home')}
|
||||
</script>
|
||||
<template><KbxRouteNotFound @home="home" @menu-search="store.menuSearchOpen=true"/></template>
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
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 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() {
|
||||
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 executeCommand(id: string) {
|
||||
const ids = selectedRows.value.map(x => x.id)
|
||||
if (id === 'search') return search()
|
||||
if (id === 'claim' && ids.length) { await operationsApi.claim(ids); return query.refetch() }
|
||||
}
|
||||
|
||||
async function executeAction(action: KbxWorkItemAction) {
|
||||
if (!detail.value) return
|
||||
if (action.kind === 'claim') await operationsApi.claim([detail.value.id])
|
||||
if (action.kind === 'resolve') { const item=detail.value; 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(detail.value.id)
|
||||
if (action.kind === 'navigate') window.dispatchEvent(new CustomEvent('kbx:navigate-source', { detail: detail.value }))
|
||||
detail.value = null
|
||||
await query.refetch()
|
||||
}
|
||||
</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)"
|
||||
@command="executeCommand"
|
||||
>
|
||||
<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>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField, type KbxWorkItem } from '@kbx/ui'
|
||||
|
||||
export const operationsQueueScreen = defineKbxScreen({
|
||||
id: 'COMMON-OPS-001',
|
||||
version: '1.0.0',
|
||||
module: 'COMMON',
|
||||
type: 'queue', templateCode:'T06',
|
||||
title: '업무 예외 센터',
|
||||
description: '정상 건이 아니라 사람이 판단하거나 복구해야 할 업무만 모아 처리합니다.',
|
||||
permissions: ['common.operations.read'],
|
||||
helpKey: 'COMMON-OPS-001',
|
||||
telemetry: { enabled: true },
|
||||
commands: [
|
||||
{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' },
|
||||
{ id: 'claim', label: '내가 처리', group: 'workflow', requiresSelection: true, minSelection: 1, permission: 'common.operations.claim' },
|
||||
{ id: 'excel', label: '엑셀', group: 'output' },
|
||||
],
|
||||
})
|
||||
|
||||
export const operationsSearchFields: KbxSearchField[] = [
|
||||
{ key: 'module', label: '모듈', type: 'select', primary: true, options: [
|
||||
{ value: 'OMS', label: 'OMS' }, { value: 'WMS', label: 'WMS' }, { value: 'ERP', label: 'ERP' },
|
||||
] },
|
||||
{ key: 'severity', label: '심각도', type: 'select', primary: true, options: [
|
||||
{ value: 'critical', label: '긴급' }, { value: 'warning', label: '주의' }, { value: 'info', label: '정보' },
|
||||
] },
|
||||
{ key: 'status', label: '상태', type: 'select', primary: true, options: [
|
||||
{ value: 'open', label: '미처리' }, { value: 'claimed', label: '처리중' }, { value: 'resolved', label: '해결' },
|
||||
] },
|
||||
{ key: 'owner', label: '담당', type: 'select', primary: false, options: [
|
||||
{ value: 'mine', label: '내 업무' }, { value: 'unassigned', label: '미지정' },
|
||||
] },
|
||||
{ key: 'keyword', label: '검색', type: 'text', primary: true, width: 'lg', placeholder: '주문번호, 작업번호, 오류명' },
|
||||
]
|
||||
|
||||
export const operationsColumns: KbxGridColumn<KbxWorkItem>[] = [
|
||||
{ field: 'severity', header: '중요도', type: 'status', width: 82, pinned: 'left' },
|
||||
{ field: 'sourceModule', header: '모듈', width: 76 },
|
||||
{ field: 'title', header: '확인할 업무', width: 250, pinned: 'left' },
|
||||
{ field: 'referenceNo', header: '대상번호', type: 'code', width: 150 },
|
||||
{ field: 'detail', header: '원인/안내', width: 300 },
|
||||
{ field: 'ownerName', header: '담당자', width: 100 },
|
||||
{ field: 'occurredAt', header: '발생시각', type: 'datetime', width: 160 },
|
||||
{ field: 'ageMinutes', header: '경과(분)', type: 'integer', width: 90 },
|
||||
{ field: 'status', header: '처리상태', type: 'status', width: 100 },
|
||||
]
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { KbxWorkQueueResult } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface OperationsFilter {
|
||||
module?: string | null
|
||||
severity?: string | null
|
||||
status?: string | null
|
||||
owner?: string | null
|
||||
code?: string | null
|
||||
keyword?: string | null
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export const operationsApi = {
|
||||
search(filter: OperationsFilter) {
|
||||
return kbxApi.request<KbxWorkQueueResult>('common.operations.search', { query: filter })
|
||||
},
|
||||
claim(ids: string[]) {
|
||||
return kbxApi.request<unknown, { 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 } })
|
||||
},
|
||||
retry(id: string) {
|
||||
return kbxApi.request<unknown>('common.operations.retry', { path: { id } })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const operationsRoutes = [
|
||||
{ path: '/operations/exceptions', name: 'common-operations-exceptions', component: () => import('./OperationsQueuePage.vue') },
|
||||
]
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
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 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() {
|
||||
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 command(id: string) {
|
||||
if (id === 'search') return search()
|
||||
if (id === 'createException' && selectedRows.value.length) {
|
||||
await reconcileApi.createExceptions(selectedRows.value.map(x => x.id))
|
||||
return query.refetch()
|
||||
}
|
||||
}
|
||||
</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)" @command="command">
|
||||
<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>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxReconcileItem, type KbxSearchField } from '@kbx/ui'
|
||||
|
||||
export const reconcileScreen = defineKbxScreen({
|
||||
id: 'COMMON-REC-001',
|
||||
version: '1.0.0',
|
||||
module: 'COMMON',
|
||||
type: 'reconcile', templateCode:'T07',
|
||||
title: '업무 데이터 대사',
|
||||
description: '원천·대상 시스템의 기대값과 실제값을 비교하고 불일치 원인을 추적합니다.',
|
||||
permissions: ['common.reconcile.read'],
|
||||
helpKey: 'COMMON-REC-001',
|
||||
telemetry: { enabled: true },
|
||||
commands: [
|
||||
{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' },
|
||||
{ id: 'createException', label: '예외 등록', group: 'workflow', requiresSelection: true, minSelection: 1, permission: 'common.operations.create' },
|
||||
{ id: 'excel', label: '엑셀', group: 'output' },
|
||||
],
|
||||
})
|
||||
|
||||
export const reconcileSearchFields: KbxSearchField[] = [
|
||||
{ key: 'reconcileType', label: '대사유형', type: 'select', primary: true, options: [
|
||||
{ value: 'OMS_WMS_OUTBOUND_QTY', label: 'OMS↔WMS 출고수량' },
|
||||
{ value: 'ORDER_PICK_QTY', label: '주문↔피킹수량' },
|
||||
{ value: 'INVENTORY_SNAPSHOT', label: '재고 스냅샷' },
|
||||
] },
|
||||
{ key: 'status', label: '결과', type: 'select', primary: true, options: [
|
||||
{ value: 'mismatch', label: '불일치' }, { value: 'pending', label: '확인중' }, { value: 'matched', label: '정상' }, { value: 'resolved', label: '해결' },
|
||||
] },
|
||||
{ key: 'keyword', label: '검색', type: 'text', primary: true, width: 'lg', placeholder: '주문번호, 품목, 참조번호' },
|
||||
]
|
||||
|
||||
export const reconcileColumns: KbxGridColumn<KbxReconcileItem>[] = [
|
||||
{ field: 'referenceNo', header: '참조번호', type: 'code', width: 160, pinned: 'left' },
|
||||
{ field: 'sourceLabel', header: '기준', width: 130 },
|
||||
{ field: 'expectedValue', header: '기대값', width: 130 },
|
||||
{ field: 'targetLabel', header: '비교대상', width: 130 },
|
||||
{ field: 'actualValue', header: '실제값', width: 130 },
|
||||
{ field: 'differenceValue', header: '차이', width: 110 },
|
||||
{ field: 'reasonText', header: '원인', width: 280 },
|
||||
{ field: 'status', header: '상태', type: 'status', width: 100 },
|
||||
{ field: 'occurredAt', header: '확인시각', type: 'datetime', width: 160 },
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { KbxReconcileResult } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface ReconcileFilter {
|
||||
reconcileType?: string | null
|
||||
status?: string | null
|
||||
keyword?: string | null
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export const reconcileApi = {
|
||||
search(filter: ReconcileFilter) {
|
||||
return kbxApi.request<KbxReconcileResult>('common.reconcile.search', { query: filter })
|
||||
},
|
||||
createExceptions(ids: string[]) {
|
||||
return kbxApi.request<unknown, { ids: string[] }>('common.reconcile.createExceptions', { body: { ids } })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const reconcileRoutes = [
|
||||
{ path: '/operations/reconcile', name: 'common-reconcile', component: () => import('./ReconcilePage.vue') },
|
||||
]
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxListPage, KbxSearchPanel, KbxDataGrid } from '@kbx/ui'
|
||||
import type { KbxGridColumn, KbxSearchField, KbxUxMetricRow } from '@kbx/contracts'
|
||||
import { uxMetricsScreen } from './ux-metrics.definition'
|
||||
import { uxMetricsApi } from './uxMetricsApi'
|
||||
const today=new Date();const fromDate=new Date(today);fromDate.setDate(today.getDate()-6)
|
||||
const iso=(d:Date)=>d.toISOString().slice(0,10)
|
||||
const search=reactive({from:iso(fromDate),to:iso(today),screenId:''})
|
||||
const fields:KbxSearchField[]=[{key:'from',label:'시작일',type:'date',primary:true},{key:'to',label:'종료일',type:'date',primary:true},{key:'screenId',label:'화면코드',type:'text',primary:true,width:'md'}]
|
||||
const query=useQuery({queryKey:['kbx','ux-metrics',search],queryFn:()=>uxMetricsApi.get({...search,screenId:search.screenId||undefined}),enabled:false})
|
||||
const columns:KbxGridColumn<KbxUxMetricRow>[]=[
|
||||
{field:'label',header:'지표',width:240},{field:'value',header:'현재값',type:'decimal',width:140},{field:'unit',header:'단위',width:100},{field:'sampleCount',header:'표본',type:'integer',width:110},{field:'p50',header:'P50',type:'decimal',width:110},{field:'p95',header:'P95',type:'decimal',width:110},
|
||||
]
|
||||
async function command(id:string){if(id==='search')await query.refetch()}
|
||||
</script>
|
||||
<template>
|
||||
<KbxListPage :screen="uxMetricsScreen" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':!query.data.value?'idle':query.data.value.metrics.length===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" @command="command">
|
||||
<template #search><KbxSearchPanel v-model="search" :fields="fields" @search="query.refetch()" /></template>
|
||||
<template #quick-filter>
|
||||
<div class="notice"><strong>해석 기준</strong> — DOM 전체 클릭이나 검색어 원문을 수집하지 않습니다. 낮은 수치 자체보다 업무 위험·오류 감소와 함께 판단합니다.</div>
|
||||
</template>
|
||||
<template #content><KbxDataGrid :rows="query.data.value?.metrics ?? []" :columns="columns" row-key="metricKey" :loading="query.isFetching.value" /></template>
|
||||
<template #summary>{{ search.from }} ~ {{ search.to }} · Manual Intervention Rate를 자동화 개선의 핵심 추세로 봅니다.</template>
|
||||
</KbxListPage>
|
||||
</template>
|
||||
<style scoped>.notice{padding:8px 12px;border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:13px}</style>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const uxMetricsScreen=defineKbxScreen({
|
||||
id:'COMMON-UX-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'UX 품질 지표',
|
||||
description:'KBX 의미 이벤트와 업무 결과를 이용해 수작업 개입·업무시간·오류율을 검토합니다.',
|
||||
permissions:['common.ux.read'],helpKey:'COMMON-UX-001',telemetry:{enabled:true},
|
||||
commands:[{id:'search',label:'조회',group:'query',shortcut:'F3'}],
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { KbxUxMetricsResponse } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
export const uxMetricsApi={
|
||||
get:(query:{from:string;to:string;screenId?:string})=>kbxApi.request<KbxUxMetricsResponse>('common.uxTelemetry.metrics',{query}),
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
|
||||
<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}종`}]" @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>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
export interface InventoryMoveLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; availableQty:number; moveQty:number; lotNo:string; remark:string }
|
||||
export const inventoryMoveScreen=defineKbxScreen({ id:'ERP-INV-MOVE-001',version:'1.0.0',module:'ERP',type:'transaction', templateCode:'T03',title:'재고이동',helpKey:'ERP-INV-MOVE-001',permissions:['erp.inventory.move.read'],telemetry:{enabled:true}, description:'창고 간 재고이동을 등록하고 출고·입고 상태를 추적합니다.', commands:[
|
||||
{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.inventory.move.write'},{id:'confirm',label:'이동확정',group:'workflow',variant:'primary',permission:'erp.inventory.move.confirm'},{id:'excel',label:'엑셀',group:'output'}] })
|
||||
export const inventoryMoveColumns:KbxGridColumn<InventoryMoveLine>[]=[
|
||||
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'},{field:'itemName',header:'품목명',width:200},{field:'availableQty',header:'가용재고',type:'quantity',width:100},{field:'moveQty',header:'이동수량',type:'quantity',width:100,editable:true},{field:'lotNo',header:'LOT',type:'code',width:120,editable:true},{field:'remark',header:'비고',width:220,editable:true}]
|
||||
export const inventoryMoveWorkflow:KbxWorkflowDefinition={id:'erp.inventory-move',version:'1.0.0',states:[{value:'DRAFT',label:'작성',semantic:'draft'},{value:'CONFIRMED',label:'확정',semantic:'pending'},{value:'IN_TRANSIT',label:'이동중',semantic:'processing'},{value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true},{value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}],transitions:[{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'이동확정',permission:'erp.inventory.move.confirm',confirm:true},{id:'ship',from:['CONFIRMED'],to:'IN_TRANSIT',label:'출고',permission:'erp.inventory.move.ship'},{id:'receive',from:['IN_TRANSIT'],to:'RECEIVED',label:'입고완료',permission:'erp.inventory.move.receive',confirm:true}]}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const erpInventoryMoveRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/erp/inventory-moves/new', name:'erp-inventory-move-new', component:()=>import('./InventoryMovePage.vue'), meta:{ screenId:'ERP-INV-MOVE-001' } },
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxDataGrid, KbxDrawer, KbxMasterDetailPage, KbxSearchPanel } from '@kbx/ui'
|
||||
import { inventoryApi } from './inventoryApi'
|
||||
import { inventoryHistoryColumns, inventoryItemColumns, inventoryLocationColumns, inventoryScreen, inventorySearchFields, type InventoryItemRow } from './inventory.definition'
|
||||
|
||||
const appliedKeyword=ref('')
|
||||
const searchModel=ref<Record<string, unknown>>({ keyword: '' })
|
||||
const selectedItemId=ref<string|null>(null)
|
||||
const selectedItem=ref<InventoryItemRow|null>(null)
|
||||
const breakdownOpen=ref(false)
|
||||
const itemsQuery=useQuery({queryKey:computed(()=>['erp','inventory',appliedKeyword.value]),queryFn:()=>inventoryApi.search(appliedKeyword.value),enabled:false})
|
||||
const locationsQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'locations']),queryFn:()=>inventoryApi.locations(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
|
||||
const historyQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'history']),queryFn:()=>inventoryApi.history(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
|
||||
|
||||
async function search(){
|
||||
const previous=selectedItemId.value; appliedKeyword.value=String(searchModel.value.keyword??'').trim(); await itemsQuery.refetch()
|
||||
const items=itemsQuery.data.value?.items??[]; const next=items.find(x=>x.itemId===previous)??items[0]??null; activate(next)
|
||||
}
|
||||
function activate(row:InventoryItemRow|null){ selectedItem.value=row; selectedItemId.value=row?.itemId??null }
|
||||
function select(rows:InventoryItemRow[]){ activate(rows[0]??null) }
|
||||
function drill(row:InventoryItemRow){ activate(row); breakdownOpen.value=true }
|
||||
function command(id:string){if(id==='search')return search()}
|
||||
const contextText=computed(()=>selectedItem.value?`${selectedItem.value.itemCode} · ${selectedItem.value.itemName}${selectedItem.value.specification?` · ${selectedItem.value.specification}`:''}`:'품목을 선택하면 로케이션과 재고이력을 함께 조회합니다.')
|
||||
const pageContext=computed(()=>({label:contextText.value,hint:selectedItem.value?'선택 품목 기준으로 위치와 이력을 동기화합니다.':'Master 품목을 선택하세요.',metrics:selectedItem.value?[{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true}]:[]}))
|
||||
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'items',label:'조회 품목',value:itemsQuery.data.value?.totalCount??0},
|
||||
{key:'selected',label:'선택 품목',value:selectedItem.value?.itemCode??'-'},
|
||||
])
|
||||
const drawerSummary=computed(()=>selectedItem.value?[
|
||||
{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},
|
||||
{key:'allocated',label:'할당',value:selectedItem.value.allocatedQty},
|
||||
{key:'hold',label:'보류',value:selectedItem.value.holdQty},
|
||||
{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true},
|
||||
]:[])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxMasterDetailPage :screen="inventoryScreen" breadcrumb="ERP > 재고" master-title="품목" detail-title="창고/로케이션" bottom-title="재고이력" :context="pageContext" :summary-items="summaryItems" :content-state="itemsQuery.error.value?'error':itemsQuery.isFetching.value&&!itemsQuery.data.value?'loading':!itemsQuery.data.value?'idle':itemsQuery.data.value.totalCount===0?'empty':'ready'" :refreshing="itemsQuery.isFetching.value&&Boolean(itemsQuery.data.value)" @command="command">
|
||||
<template #search><KbxSearchPanel v-model="searchModel" :fields="inventorySearchFields" @search="search" /></template>
|
||||
<template #master>
|
||||
<KbxDataGrid :rows="itemsQuery.data.value?.items??[]" :columns="inventoryItemColumns" row-key="itemId" selection="single" :active-row-key="selectedItemId" :loading="itemsQuery.isFetching.value" :error-text="itemsQuery.error.value?'재고를 조회하지 못했습니다.':''" @selection-changed="select" @row-double-clicked="drill" @drill-down-requested="drill" @retry="search" />
|
||||
</template>
|
||||
<template #detail>
|
||||
<KbxDataGrid :rows="locationsQuery.data.value?.items??[]" :columns="inventoryLocationColumns" row-key="key" :loading="locationsQuery.isFetching.value" :error-text="locationsQuery.error.value?'재고 위치를 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고 위치가 없습니다." @retry="()=>locationsQuery.refetch()" />
|
||||
</template>
|
||||
<template #bottom>
|
||||
<KbxDataGrid :rows="historyQuery.data.value?.items??[]" :columns="inventoryHistoryColumns" row-key="entryId" :loading="historyQuery.isFetching.value" :error-text="historyQuery.error.value?'재고이력을 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고이력이 없습니다." @retry="()=>historyQuery.refetch()" />
|
||||
</template>
|
||||
</KbxMasterDetailPage>
|
||||
|
||||
<KbxDrawer v-model:open="breakdownOpen" :title="selectedItem?`${selectedItem.itemCode} 재고 산정 근거`:'재고 산정 근거'">
|
||||
<div v-if="selectedItem" class="inventory-breakdown">
|
||||
<p>{{selectedItem.itemName}} {{selectedItem.specification}}</p>
|
||||
<KbxSummaryBar :items="drawerSummary" />
|
||||
<dl><dt>현재고</dt><dd>물리적으로 기록된 총 재고</dd><dt>할당</dt><dd>주문·작업에 예약된 수량</dd><dt>보류</dt><dd>검사·품질·업무 사유로 출고할 수 없는 수량</dd><dt>가용</dt><dd>현재 정책상 신규 업무에 사용할 수 있는 수량</dd></dl>
|
||||
<p class="inventory-breakdown__note">수량 산정의 최종 기준은 서버 재고 정책이며, 화면 값은 조회 Projection입니다.</p>
|
||||
</div>
|
||||
</KbxDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inventory-breakdown{display:flex;flex-direction:column;gap:var(--kbx-space-3);font-size:var(--kbx-font-sm)}
|
||||
.inventory-breakdown p{margin:0}.inventory-breakdown dl{display:grid;grid-template-columns:80px 1fr;gap:var(--kbx-space-2);margin:0}.inventory-breakdown dt{font-weight:600}.inventory-breakdown dd{margin:0;color:var(--kbx-color-text-muted)}.inventory-breakdown__note{padding:var(--kbx-space-2);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border)}
|
||||
</style>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/ui'
|
||||
|
||||
export interface InventoryItemRow {
|
||||
itemId: string; itemCode: string; itemName: string; specification: string
|
||||
totalQty: number; availableQty: number; allocatedQty: number; holdQty: number
|
||||
}
|
||||
export interface InventoryLocationRow {
|
||||
key: string; warehouseName: string; locationCode: string
|
||||
onHandQty: number; allocatedQty: number; availableQty: number; holdQty: number
|
||||
}
|
||||
export interface InventoryHistoryRow {
|
||||
entryId: string; occurredAt: string; businessType: string; referenceNo: string
|
||||
warehouseName: string; locationCode: string; inboundQty: number; outboundQty: number; balanceQty: number; actor: string
|
||||
}
|
||||
|
||||
export const inventoryScreen = defineKbxScreen({
|
||||
id: 'ERP-INV-001', version: '1.1.0', module: 'ERP', type: 'master-detail', templateCode:'T05', title: '재고현황',
|
||||
description: '품목 현재고에서 창고·로케이션 근거와 재고이력까지 조회 Context를 유지해 탐색합니다.',
|
||||
helpKey: 'ERP-INV-001', permissions: ['erp.inventory.read'], telemetry: { enabled: true },
|
||||
commands: [{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' }, { id: 'excel', label: '엑셀', group: 'output' }],
|
||||
})
|
||||
|
||||
export const inventoryItemColumns: KbxGridColumn<InventoryItemRow>[] = [
|
||||
{ field: 'itemCode', header: '품목코드', type: 'code', width: 120, pinned: 'left' },
|
||||
{ field: 'itemName', header: '품목명', width: 190 },
|
||||
{ field: 'specification', header: '규격', width: 140 },
|
||||
{ field: 'totalQty', header: '현재고', type: 'quantity', width: 100, drilldown:true },
|
||||
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100, drilldown:true },
|
||||
]
|
||||
export const inventoryLocationColumns: KbxGridColumn<InventoryLocationRow>[] = [
|
||||
{ field: 'warehouseName', header: '창고', width: 150 },
|
||||
{ field: 'locationCode', header: '로케이션', type: 'code', width: 120 },
|
||||
{ field: 'onHandQty', header: '현재고', type: 'quantity', width: 100 },
|
||||
{ field: 'allocatedQty', header: '할당', type: 'quantity', width: 90 },
|
||||
{ field: 'holdQty', header: '보류', type: 'quantity', width: 90 },
|
||||
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100 },
|
||||
]
|
||||
export const inventoryHistoryColumns: KbxGridColumn<InventoryHistoryRow>[] = [
|
||||
{ field:'occurredAt', header:'일시', type:'datetime', width:165 },
|
||||
{ field:'businessType', header:'업무', width:110 },
|
||||
{ field:'referenceNo', header:'참조번호', type:'code', width:150 },
|
||||
{ field:'warehouseName', header:'창고', width:130 },
|
||||
{ field:'locationCode', header:'로케이션', type:'code', width:110 },
|
||||
{ field:'inboundQty', header:'입고', type:'quantity', width:90 },
|
||||
{ field:'outboundQty', header:'출고', type:'quantity', width:90 },
|
||||
{ field:'balanceQty', header:'잔량', type:'quantity', width:100 },
|
||||
{ field:'actor', header:'처리자', width:110 },
|
||||
]
|
||||
export const inventorySearchFields: KbxSearchField[] = [{ key: 'keyword', label: '품목', type: 'text', width: 'lg', placeholder: '품목코드/품목명' }]
|
||||
@@ -0,0 +1,8 @@
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
import type { InventoryHistoryRow, InventoryItemRow, InventoryLocationRow } from './inventory.definition'
|
||||
export interface InventorySearchResponse { items: InventoryItemRow[]; totalCount: number }
|
||||
export const inventoryApi = {
|
||||
search(keyword = '') { return kbxApi.request<InventorySearchResponse>('erp.inventory.search', { query: { keyword, page: 1, pageSize: 200 } }) },
|
||||
locations(itemId: string) { return kbxApi.request<{ items: InventoryLocationRow[] }>('erp.inventory.locations', { path: { itemId } }) },
|
||||
history(itemId: string) { return kbxApi.request<{ items: InventoryHistoryRow[] }>('erp.inventory.history', { path: { itemId }, query:{ pageSize:100 } }) },
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { KbxLookupItem } from '@kbx/contracts'
|
||||
import { KbxDataGrid, KbxFastEntryPage, KbxLookupDialog, useKbxPageShortcuts } from '@kbx/ui'
|
||||
import { itemPriceColumns, itemPriceScreen, type ItemPriceRow } from './item-price.definition'
|
||||
import { useItemPriceFastEntry } from './useItemPriceFastEntry'
|
||||
import { useKbxWorkspaceBinding } from '../../../shell/useKbxWorkspaceBinding'
|
||||
|
||||
const vm=useItemPriceFastEntry()
|
||||
useKbxWorkspaceBinding(vm.dirty, async()=>await vm.save())
|
||||
const lookupOpen=ref(false); const activeRow=ref<ItemPriceRow|null>(null)
|
||||
function openLookup(event:{row:ItemPriceRow;entity:string}){ if(event.entity!=='item')return;activeRow.value=event.row;lookupOpen.value=true }
|
||||
function selectItem(item:KbxLookupItem<string>){ if(activeRow.value)vm.applyItemLookup(activeRow.value,item) }
|
||||
const pageContext=computed(()=>({label:'품목 단가 입력',hint:'저장 전 오류를 모두 해소한 뒤 F8로 일괄 저장합니다.',metrics:[{key:'rows',label:'입력',value:vm.enteredCount.value},{key:'errors',label:'오류',value:vm.errors.value.length,tone:vm.errors.value.length?'danger' as const:'default' as const,emphasis:vm.errors.value.length>0}]}))
|
||||
const summary=computed(()=>[
|
||||
{key:'rows',label:'입력',value:`${vm.enteredCount.value.toLocaleString('ko-KR')}건`},
|
||||
{key:'errors',label:'오류',value:`${vm.errors.value.length.toLocaleString('ko-KR')}건`},
|
||||
...(vm.lastResult.value?[{key:'saved',label:'최근 저장',value:`${vm.lastResult.value.saved.toLocaleString('ko-KR')}건`,emphasis:true}]:[]),
|
||||
])
|
||||
useKbxPageShortcuts([{key:'F8',execute:()=>vm.save()}])
|
||||
async function command(id:string){ if(id==='save')await vm.save(); if(id==='new')vm.clear() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxFastEntryPage :screen="itemPriceScreen" :selection-count="0" :dirty="vm.dirty.value" :context="pageContext" :errors="vm.errors.value" :summary-items="summary" breadcrumb="ERP > 기준정보" @command="command">
|
||||
<template #guide>
|
||||
<span><kbd>Enter</kbd> 다음 셀</span><span><kbd>F2</kbd> 품목 조회</span><span><kbd>Ctrl+V</kbd> Excel 붙여넣기</span><span>행 선택 후 아래 채우기/복제</span>
|
||||
</template>
|
||||
<template #notice>
|
||||
<div v-if="vm.lastResult.value" class="price-save-result" role="status">저장 {{vm.lastResult.value.saved.toLocaleString('ko-KR')}}건 · 신규 {{vm.lastResult.value.created.toLocaleString('ko-KR')}} · 수정 {{vm.lastResult.value.updated.toLocaleString('ko-KR')}}</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<KbxDataGrid
|
||||
:rows="vm.rows.value" :columns="itemPriceColumns" row-key="clientId" selection="multiple"
|
||||
:errors="vm.errors.value" editable clipboard
|
||||
:editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}"
|
||||
@row-add-requested="vm.addRow" @row-duplicate-requested="vm.duplicateRows" @cell-changed="vm.onCellChanged" @lookup-requested="openLookup"
|
||||
/>
|
||||
</template>
|
||||
</KbxFastEntryPage>
|
||||
<KbxLookupDialog v-model:visible="lookupOpen" entity="item" title="품목" @select="selectItem" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.price-save-result{min-height:var(--kbx-control-sm);display:flex;align-items:center;padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-success-border);background:var(--kbx-color-success-surface);color:var(--kbx-color-success-text);font-size:var(--kbx-font-sm)}
|
||||
</style>
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { defineKbxScreen, type KbxGridColumn } from '@kbx/ui'
|
||||
|
||||
export interface ItemPriceRow {
|
||||
clientId: string
|
||||
itemId: string | null
|
||||
itemCode: string
|
||||
itemName: string
|
||||
effectiveDate: string
|
||||
unitPrice: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export const itemPriceScreen = defineKbxScreen({
|
||||
id: 'ERP-PRICE-001',
|
||||
version: '1.0.0',
|
||||
module: 'ERP',
|
||||
type: 'fast-entry', templateCode:'T04',
|
||||
title: '품목 단가 일괄등록',
|
||||
description: '품목별 적용일과 표준단가를 Excel처럼 연속 입력하고 한 번에 저장합니다.',
|
||||
helpKey: 'ERP-PRICE-001',
|
||||
permissions: ['erp.item.price.read', 'erp.item.read'],
|
||||
commands: [
|
||||
{ id: 'new', label: '신규', group: 'edit' },
|
||||
{ id: 'save', label: '저장', group: 'edit', variant: 'primary', shortcut: 'F8', permission: 'erp.item.price.write' },
|
||||
{ id: 'excel', label: '엑셀', group: 'output' },
|
||||
],
|
||||
telemetry: { enabled: true },
|
||||
})
|
||||
|
||||
export const itemPriceColumns: KbxGridColumn<ItemPriceRow>[] = [
|
||||
{ field:'itemCode', header:'품목코드', type:'lookup', width:140, pinned:'left', editable:true, lookup:{entity:'item'} },
|
||||
{ field:'itemName', header:'품목명', width:220 },
|
||||
{ field:'effectiveDate', header:'적용일', type:'date', width:120, editable:true },
|
||||
{ field:'unitPrice', header:'단가', type:'money', width:130, editable:true },
|
||||
{ field:'remark', header:'비고', width:260, editable:true },
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface SaveItemPriceRow {
|
||||
clientId: string
|
||||
itemId: string
|
||||
effectiveDate: string
|
||||
unitPrice: number
|
||||
remark?: string
|
||||
}
|
||||
export interface SaveItemPricesRequest { rows: SaveItemPriceRow[] }
|
||||
export interface SaveItemPricesResponse { requested: number; saved: number; created: number; updated: number }
|
||||
export interface ItemLookupResult { id: string; code: string; displayName: string; status?: string }
|
||||
|
||||
export function toKbxProblem(error: unknown): KbxProblem | null { return isKbxProblem(error) ? error : null }
|
||||
|
||||
export const itemPriceApi = {
|
||||
async resolveItemByCode(code: string) {
|
||||
try { return await kbxApi.request<ItemLookupResult>('lookup.items.resolveByCode', { path:{ code } }) }
|
||||
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
|
||||
},
|
||||
save(request: SaveItemPricesRequest, idempotencyKey: string) {
|
||||
return kbxApi.request<SaveItemPricesResponse, SaveItemPricesRequest>('erp.itemPrices.bulkSave', { body:request, idempotencyKey })
|
||||
},
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import type { KbxLookupItem, KbxValidationError } from '@kbx/contracts'
|
||||
import { useKbxDirtyState, useKbxValidation } from '@kbx/ui'
|
||||
import { itemPriceApi, toKbxProblem } from './itemPriceApi'
|
||||
import type { ItemPriceRow } from './item-price.definition'
|
||||
|
||||
function localDate() {
|
||||
const d=new Date(); const m=String(d.getMonth()+1).padStart(2,'0'); const day=String(d.getDate()).padStart(2,'0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
function newRow():ItemPriceRow { return { clientId:crypto.randomUUID(), itemId:null, itemCode:'', itemName:'', effectiveDate:localDate(), unitPrice:0, remark:'' } }
|
||||
|
||||
export function useItemPriceFastEntry() {
|
||||
const rows=ref<ItemPriceRow[]>(Array.from({length:8},()=>newRow()))
|
||||
const validation=useKbxValidation()
|
||||
const { dirty, touch, markSaved, reset:resetDirty }=useKbxDirtyState()
|
||||
const mutation=useMutation({ mutationFn:({request,key}:{request:{rows:Array<{clientId:string;itemId:string;effectiveDate:string;unitPrice:number;remark?:string}>};key:string})=>itemPriceApi.save(request,key) })
|
||||
const lastResult=ref<{requested:number;saved:number;created:number;updated:number}|null>(null)
|
||||
let idempotencyKey=crypto.randomUUID()
|
||||
|
||||
const enteredRows=computed(()=>rows.value.filter(x=>x.itemId||x.itemCode.trim()||Number(x.unitPrice)!==0||x.remark.trim()))
|
||||
function invalidateCommandKey(){ idempotencyKey=crypto.randomUUID() }
|
||||
function addRow(){ rows.value.push(newRow()); validation.clear(); touch(); invalidateCommandKey() }
|
||||
function duplicateRows(source:ItemPriceRow[]){ rows.value.push(...source.map(x=>({...x,clientId:crypto.randomUUID()}))); touch(); invalidateCommandKey() }
|
||||
function clear(){ rows.value=Array.from({length:8},()=>newRow()); validation.clear(); lastResult.value=null; resetDirty(); invalidateCommandKey() }
|
||||
|
||||
function localErrors():KbxValidationError[] {
|
||||
const errors:KbxValidationError[]=[]
|
||||
const active=enteredRows.value
|
||||
if(!active.length) return [{code:'PRICE_ROWS_REQUIRED',message:'저장할 단가를 한 건 이상 입력하세요.'}]
|
||||
const duplicate=new Map<string,string>()
|
||||
for(const row of active){
|
||||
if(!row.itemId) errors.push({rowKey:row.clientId,field:'itemCode',code:'ITEM_REQUIRED',message:'품목을 선택하세요.'})
|
||||
if(!/^\d{4}-\d{2}-\d{2}$/.test(row.effectiveDate)) errors.push({rowKey:row.clientId,field:'effectiveDate',code:'EFFECTIVE_DATE_REQUIRED',message:'적용일을 YYYY-MM-DD 형식으로 입력하세요.'})
|
||||
if(!Number.isFinite(Number(row.unitPrice))||Number(row.unitPrice)<0) errors.push({rowKey:row.clientId,field:'unitPrice',code:'PRICE_NONNEGATIVE',message:'단가는 0 이상이어야 합니다.'})
|
||||
if(row.itemId){ const key=`${row.itemId}|${row.effectiveDate}`; if(duplicate.has(key)){ errors.push({rowKey:row.clientId,field:'effectiveDate',code:'DUPLICATE_ITEM_DATE',message:'같은 품목과 적용일이 중복되었습니다.'}); const first=duplicate.get(key)!; if(!errors.some(x=>x.rowKey===first&&x.code==='DUPLICATE_ITEM_DATE')) errors.push({rowKey:first,field:'effectiveDate',code:'DUPLICATE_ITEM_DATE',message:'같은 품목과 적용일이 중복되었습니다.'}) } else duplicate.set(key,row.clientId) }
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
async function onCellChanged(event:{row:ItemPriceRow;field:keyof ItemPriceRow;newValue:unknown}){
|
||||
if(event.field==='itemCode'){
|
||||
const code=String(event.newValue??'').trim()
|
||||
if(!code) Object.assign(event.row,{itemId:null,itemName:''})
|
||||
else {
|
||||
const item=await itemPriceApi.resolveItemByCode(code)
|
||||
if(!item){ Object.assign(event.row,{itemId:null,itemName:''}); validation.setRowFieldError(event.row.clientId,'itemCode',{code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'}) }
|
||||
else { Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName}); validation.setRowFieldError(event.row.clientId,'itemCode') }
|
||||
}
|
||||
}
|
||||
lastResult.value=null; touch(); invalidateCommandKey()
|
||||
}
|
||||
function applyItemLookup(row:ItemPriceRow,item:KbxLookupItem<string>){ Object.assign(row,{itemId:item.id,itemCode:item.code,itemName:item.displayName}); validation.setRowFieldError(row.clientId,'itemCode'); touch(); invalidateCommandKey() }
|
||||
|
||||
async function save(){
|
||||
validation.clear(); const errors=localErrors(); if(errors.length){validation.setErrors(errors);return false}
|
||||
const active=enteredRows.value
|
||||
try{
|
||||
const result=await mutation.mutateAsync({key:idempotencyKey,request:{rows:active.map(x=>({clientId:x.clientId,itemId:x.itemId!,effectiveDate:x.effectiveDate,unitPrice:Number(x.unitPrice),remark:x.remark||undefined}))}})
|
||||
lastResult.value=result; markSaved(); idempotencyKey=crypto.randomUUID(); return true
|
||||
}catch(error){ const problem=toKbxProblem(error); if(problem) validation.applyProblem(problem); throw error }
|
||||
}
|
||||
return {rows,errors:validation.errors,dirty,saving:computed(()=>mutation.isPending.value),lastResult,enteredCount:computed(()=>enteredRows.value.length),addRow,duplicateRows,onCellChanged,applyItemLookup,save,clear,touch}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { isKbxProblem, kbxFieldReadonly, resolveKbxRecordStatePolicy, type KbxAuditEntry, type KbxConflictSnapshot, type KbxSearchField } from '@kbx/contracts'
|
||||
import { KbxBarcodeField, KbxCheckbox, KbxDataGrid, KbxFormGrid, KbxFormSection, KbxLookup, KbxMasterPage, KbxInput, KbxSearchPanel, useKbxDirtyState, useKbxPageShortcuts } from '@kbx/ui'
|
||||
import { itemApi } from './itemApi'
|
||||
import { itemMasterColumns, itemMasterScreen, itemStatePolicies, itemWorkflow, type ItemMasterRow } from './item.definition'
|
||||
|
||||
const searchModel=reactive({keyword:''});const searchFields:KbxSearchField[]=[{key:'keyword',label:'검색',type:'text',width:'lg',placeholder:'품목코드/품목명'}]
|
||||
const selected=ref<ItemMasterRow[]>([]);const form=reactive<ItemMasterRow>(emptyItem());const status=computed(()=>!form.id?'신규':form.active?'사용':'사용중지');const policy=computed(()=>resolveKbxRecordStatePolicy(itemStatePolicies,status.value))
|
||||
const auditEntries=ref<KbxAuditEntry[]>([]);const conflict=ref<KbxConflictSnapshot|null>(null);const errors=ref<any[]>([]);const {dirty,touch,markSaved,reset:resetDirty}=useKbxDirtyState()
|
||||
const query=useQuery({queryKey:['erp','items'],queryFn:()=>itemApi.search(searchModel.keyword),enabled:false});const rows=computed(()=>query.data.value?.items??[])
|
||||
function emptyItem():ItemMasterRow{return{id:'',code:'',name:'',categoryName:'',specification:'',unit:'EA',barcode:'',defaultWarehouseId:null,defaultWarehouseName:'',lotManaged:false,expiryManaged:false,active:true,version:0}}
|
||||
async function search(){selected.value=[];await query.refetch()}
|
||||
async function choose(rows:ItemMasterRow[]){selected.value=rows;if(!rows[0])return;const latest=await itemApi.get(rows[0].id);Object.assign(form,latest);auditEntries.value=await itemApi.audit(latest.id);errors.value=[];conflict.value=null;resetDirty()}
|
||||
function state(field:string){return kbxFieldReadonly(policy.value,field)}
|
||||
function createNew(){Object.assign(form,emptyItem());selected.value=[];auditEntries.value=[];conflict.value=null;errors.value=[];resetDirty()}
|
||||
function copy(){const source={...form};Object.assign(form,{...source,id:'',code:'',barcode:'',version:0,active:true});auditEntries.value=[];conflict.value=null;resetDirty();touch()}
|
||||
function payload(){return{id:form.id||undefined,version:form.version||undefined,code:form.code.trim(),name:form.name.trim(),categoryName:form.categoryName,specification:form.specification,unit:form.unit,barcode:form.barcode,defaultWarehouseId:form.defaultWarehouseId,lotManaged:form.lotManaged,expiryManaged:form.expiryManaged}}
|
||||
async function save(){errors.value=[];if(!form.code.trim()||!form.name.trim()){errors.value=[...(!form.code.trim()?[{field:'code',code:'REQUIRED',message:'품목코드를 입력하세요.'}]:[]),...(!form.name.trim()?[{field:'name',code:'REQUIRED',message:'품목명을 입력하세요.'}]:[])];return}try{const r=form.id?await itemApi.update(form.id,payload()):await itemApi.create(payload());form.id=r.id;form.version=r.version;form.active=true;markSaved();auditEntries.value=await itemApi.audit(form.id);await query.refetch()}catch(e){if(isKbxProblem(e)&&e.type==='validation'){errors.value=e.errors;return}if(isKbxProblem(e)&&e.type==='conflict'&&form.id){const latest=await itemApi.get(form.id);conflict.value={code:e.code,title:e.title,detail:'저장하지 않은 입력은 유지됩니다. 최신 값과 비교한 뒤 다시 읽을 수 있습니다.',entityId:form.id,requestedVersion:form.version,currentVersion:e.currentVersion??latest.version,changes:[['code','품목코드'],['name','품목명'],['unit','단위'],['barcode','바코드']].map(([field,label])=>({field,label,mine:(form as any)[field],latest:(latest as any)[field]}))};return}throw e}}
|
||||
async function deactivate(){if(!form.id)return;try{const r=await itemApi.deactivate(form.id,form.version);form.version=r.version;form.active=false;markSaved();auditEntries.value=await itemApi.audit(form.id);await query.refetch()}catch(e){if(isKbxProblem(e)&&e.type==='conflict'){const latest=await itemApi.get(form.id);conflict.value={code:e.code,title:e.title,currentVersion:e.currentVersion??latest.version,requestedVersion:form.version,entityId:form.id};return}throw e}}
|
||||
async function reloadConflict(){if(!form.id)return;const latest=await itemApi.get(form.id);Object.assign(form,latest);auditEntries.value=await itemApi.audit(form.id);conflict.value=null;resetDirty()}
|
||||
async function command(id:string){if(id==='search')return search();if(id==='new')return createNew();if(id==='copy')return copy();if(id==='save')return save();if(id==='deactivate')return deactivate()}
|
||||
useKbxPageShortcuts([{key:'F3',execute:search},{key:'F8',execute:save}])
|
||||
</script>
|
||||
<template>
|
||||
<KbxMasterPage :screen="itemMasterScreen" :status="status" :dirty="dirty" :version="form.version||undefined" :errors="errors" :workflow="itemWorkflow" :conflict="conflict" :audit-entries="auditEntries" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':!query.data.value?'idle':query.data.value.items.length===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" breadcrumb="ERP > 기준정보" @command="command" @transition="command" @reload-conflict="reloadConflict" @dismiss-conflict="conflict=null">
|
||||
<template #list><KbxSearchPanel v-model="searchModel" :fields="searchFields" @search="search"/><KbxDataGrid :rows="rows" :columns="itemMasterColumns" row-key="id" selection="single" :active-row-key="form.id||undefined" :loading="query.isFetching.value" @selection-changed="choose"/></template>
|
||||
<template #detail>
|
||||
<KbxFormSection title="기본정보"><KbxFormGrid><KbxInput v-model="form.code" label="품목코드" required :readonly="Boolean(form.id)||state('code')" :error="errors.find(x=>x.field==='code')?.message" @update:model-value="touch"/><KbxInput v-model="form.name" label="품목명" required :readonly="state('name')" :error="errors.find(x=>x.field==='name')?.message" @update:model-value="touch"/><KbxInput v-model="form.categoryName" label="품목그룹" :readonly="state('categoryName')" @update:model-value="touch"/><KbxInput v-model="form.specification" label="규격" :readonly="state('specification')" @update:model-value="touch"/><KbxInput v-model="form.unit" label="단위" :readonly="state('unit')" @update:model-value="touch"/></KbxFormGrid></KbxFormSection>
|
||||
<KbxFormSection title="물류정보"><KbxFormGrid><KbxLookup v-model="form.defaultWarehouseId" entity="warehouse" label="기본창고" :readonly="state('defaultWarehouseId')" @selected="touch"/><KbxBarcodeField v-model="form.barcode" label="바코드" :readonly="state('barcode')" @update:model-value="touch"/><KbxCheckbox v-model="form.lotManaged" label="LOT 관리" :disabled="state('lotManaged')" @update:model-value="touch"/><KbxCheckbox v-model="form.expiryManaged" label="유통기한 관리" :disabled="state('expiryManaged')" @update:model-value="touch"/></KbxFormGrid></KbxFormSection>
|
||||
</template>
|
||||
</KbxMasterPage>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxRecordStatePolicy, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
|
||||
export interface ItemMasterRow { id:string; code:string; name:string; categoryName:string; specification:string; unit:string; barcode:string; defaultWarehouseId:string|null; defaultWarehouseName:string; lotManaged:boolean; expiryManaged:boolean; active:boolean; version:number }
|
||||
|
||||
export const itemWorkflow:KbxWorkflowDefinition={id:'erp.item.lifecycle',version:'1.0.0',states:[{value:'신규',label:'신규',semantic:'draft'},{value:'사용',label:'사용',semantic:'completed'},{value:'사용중지',label:'사용중지',semantic:'disabled',terminal:true}],transitions:[{id:'deactivate',from:['사용'],to:'사용중지',label:'사용중지',permission:'erp.item.write',confirm:true}]}
|
||||
export const itemStatePolicies:KbxRecordStatePolicy[]=[{status:'신규',editability:'editable'},{status:'사용',editability:'editable'},{status:'사용중지',editability:'readonly',message:'사용중지된 품목은 조회·복사·이력확인만 가능합니다.'}]
|
||||
|
||||
export const itemMasterScreen = defineKbxScreen({
|
||||
id: 'ERP-MST-ITEM-001', version: '1.1.0', module: 'ERP', type: 'master', templateCode:'T02', title: '품목관리',
|
||||
description: '품목 기준정보와 물류 속성을 동일한 문법으로 관리합니다.', helpKey:'ERP-MST-ITEM-001', permissions:['erp.item.read'], telemetry:{enabled:true},
|
||||
commands:[
|
||||
{id:'search',label:'조회',group:'query',shortcut:'F3'},
|
||||
{id:'new',label:'신규',group:'edit',permission:'erp.item.create'},
|
||||
{id:'save',label:'저장',group:'edit',variant:'primary',shortcut:'F8',permissionByStatus:{'신규':'erp.item.create','사용':'erp.item.write'},allowedStatuses:['신규','사용'],requiresDirty:true},
|
||||
{id:'copy',label:'복사',group:'edit',permission:'erp.item.create'},
|
||||
{id:'deactivate',label:'사용중지',group:'workflow',permission:'erp.item.write',allowedStatuses:['사용'],confirm:{title:'품목을 사용중지하시겠습니까?',detail:'사용중지 후 신규 업무에서 이 품목을 선택할 수 없습니다. 기존 이력은 유지됩니다.',level:'high',confirmLabel:'사용중지'}},
|
||||
{id:'excel',label:'엑셀',group:'output'},
|
||||
],
|
||||
})
|
||||
export const itemMasterColumns:KbxGridColumn<ItemMasterRow>[]=[{field:'code',header:'품목코드',type:'code',width:120,pinned:'left'},{field:'name',header:'품목명',width:200},{field:'specification',header:'규격',width:160},{field:'unit',header:'단위',width:70},{field:'active',header:'사용',type:'boolean',width:70}]
|
||||
@@ -0,0 +1,14 @@
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
import type { KbxAuditEntry } from '@kbx/contracts'
|
||||
import type { ItemMasterRow } from './item.definition'
|
||||
export interface ItemSearchResponse { items: ItemMasterRow[]; totalCount: number }
|
||||
export interface ItemSaveRequest { id?:string; version?:number; code:string; name:string; categoryName?:string; specification?:string; unit:string; barcode?:string; defaultWarehouseId?:string|null; lotManaged:boolean; expiryManaged:boolean }
|
||||
export interface ItemSaveResponse { id:string; version:number; status:string }
|
||||
export const itemApi = {
|
||||
search(keyword = '') { return kbxApi.request<ItemSearchResponse>('erp.items.search', { query: { keyword, page: 1, pageSize: 200 } }) },
|
||||
get(id: string) { return kbxApi.request<ItemMasterRow>('erp.items.get', { path: { id } }) },
|
||||
create(body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.create', { body }) },
|
||||
update(id:string,body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.update', { path:{id}, body }) },
|
||||
deactivate(id:string,version:number) { return kbxApi.request<ItemSaveResponse>('erp.items.deactivate', { path:{id}, body:{version} }) },
|
||||
audit(id:string) { return kbxApi.request<KbxAuditEntry[]>('erp.items.audit', { path:{id} }) },
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
<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}]" @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>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
export interface PurchaseLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; quantity:number; unitPrice:number; amount:number; dueDate:string }
|
||||
export const purchaseScreen = defineKbxScreen({
|
||||
id:'ERP-PUR-001', version:'1.0.0', module:'ERP', type:'transaction', templateCode:'T03', title:'구매등록', helpKey:'ERP-PUR-001', permissions:['erp.purchase.read'], telemetry:{enabled:true},
|
||||
description:'거래처 구매를 Header/Detail 방식으로 입력하고 확정합니다.',
|
||||
commands:[{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.purchase.write'},{id:'confirm',label:'구매확정',group:'workflow',variant:'primary',permission:'erp.purchase.confirm'},{id:'excel',label:'엑셀',group:'output'}]
|
||||
})
|
||||
export const purchaseColumns: KbxGridColumn<PurchaseLine>[] = [
|
||||
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'}, {field:'itemName',header:'품목명',width:210},
|
||||
{field:'quantity',header:'수량',type:'quantity',width:95,editable:true}, {field:'unitPrice',header:'단가',type:'money',width:120,editable:true}, {field:'amount',header:'금액',type:'money',width:130}, {field:'dueDate',header:'납기일',type:'date',width:110,editable:true}
|
||||
]
|
||||
export const purchaseWorkflow: KbxWorkflowDefinition = { id:'erp.purchase', version:'1.0.0', states:[
|
||||
{value:'DRAFT',label:'작성',semantic:'draft'}, {value:'CONFIRMED',label:'확정',semantic:'processing'}, {value:'PARTIALLY_RECEIVED',label:'부분입고',semantic:'processing'}, {value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true}, {value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}
|
||||
], transitions:[
|
||||
{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'구매확정',permission:'erp.purchase.confirm',confirm:true}, {id:'cancel',from:['DRAFT','CONFIRMED'],to:'CANCELLED',label:'구매취소',permission:'erp.purchase.cancel',reasonRequired:true}
|
||||
] }
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const erpPurchaseRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/erp/purchases/new', name:'erp-purchase-new', component:()=>import('./PurchasePage.vue'), meta:{ screenId:'ERP-PUR-001' } },
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
|
||||
export interface ClaimRow {
|
||||
id: string; claimNo: string; orderNo: string; channelName: string; type: string; reason: string; requestedQty: number; status: string; ownerName: string; requestedAt: string
|
||||
}
|
||||
export const claimScreen = defineKbxScreen({
|
||||
id:'OMS-CLM-001', version:'1.0.0', module:'OMS', type:'list', templateCode:'T01', title:'반품·클레임 관리', helpKey:'OMS-CLM-001', permissions:['oms.claim.read'], telemetry:{ enabled:true },
|
||||
description:'반품·교환·취소 요청을 조회하고 정상 건은 표준 상태전이로 처리합니다.',
|
||||
commands:[
|
||||
{ id:'search', label:'조회', group:'query', shortcut:'F3' },
|
||||
{ id:'approve', label:'승인', group:'workflow', variant:'primary', requiresSelection:true, minSelection:1, permission:'oms.claim.approve' },
|
||||
{ id:'hold', label:'보류', group:'workflow', requiresSelection:true, minSelection:1, permission:'oms.claim.hold' },
|
||||
{ id:'excel', label:'엑셀', group:'output' },
|
||||
]
|
||||
})
|
||||
export const claimSearchFields: KbxSearchField[] = [
|
||||
{ key:'period', label:'요청기간', type:'date-range', range:{ from:'from', to:'to' } },
|
||||
{ key:'type', label:'유형', type:'select', options:[{value:'RETURN',label:'반품'},{value:'EXCHANGE',label:'교환'},{value:'CANCEL',label:'취소'}] },
|
||||
{ key:'status', label:'상태', type:'select', options:[{value:'REQUESTED',label:'접수'},{value:'APPROVED',label:'승인'},{value:'IN_PROGRESS',label:'처리중'},{value:'COMPLETED',label:'완료'},{value:'HOLD',label:'보류'}] },
|
||||
{ key:'keyword', label:'검색', type:'text', width:'lg', placeholder:'클레임번호/주문번호/고객' },
|
||||
]
|
||||
export const claimColumns: KbxGridColumn<ClaimRow>[] = [
|
||||
{ field:'claimNo', header:'클레임번호', type:'code', width:150, pinned:'left' },
|
||||
{ field:'orderNo', header:'주문번호', type:'code', width:150 },
|
||||
{ field:'channelName', header:'채널', width:100 }, { field:'type', header:'유형', width:90 }, { field:'reason', header:'사유', width:220 },
|
||||
{ field:'requestedQty', header:'수량', type:'quantity', width:90 }, { field:'status', header:'상태', type:'status', width:100 }, { field:'ownerName', header:'담당', width:100 }, { field:'requestedAt', header:'요청일시', type:'datetime', width:160 },
|
||||
]
|
||||
export const claimWorkflow: KbxWorkflowDefinition = {
|
||||
id:'oms.claim', version:'1.0.0',
|
||||
states:[
|
||||
{value:'REQUESTED',label:'접수',semantic:'pending'}, {value:'APPROVED',label:'승인',semantic:'processing'}, {value:'IN_PROGRESS',label:'처리중',semantic:'processing'}, {value:'COMPLETED',label:'완료',semantic:'completed',terminal:true}
|
||||
],
|
||||
transitions:[
|
||||
{id:'approve',from:['REQUESTED'],to:'APPROVED',label:'승인',permission:'oms.claim.approve'},
|
||||
{id:'start',from:['APPROVED'],to:'IN_PROGRESS',label:'처리 시작',permission:'oms.claim.process'},
|
||||
{id:'complete',from:['IN_PROGRESS'],to:'COMPLETED',label:'처리 완료',permission:'oms.claim.process',confirm:true},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ClaimRow } from './claims.definition'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface ClaimSearch { from?: string; to?: string; type?: string; status?: string; keyword?: string }
|
||||
|
||||
const transitionOperations = {
|
||||
approve: 'oms.claims.approve',
|
||||
hold: 'oms.claims.hold',
|
||||
start: 'oms.claims.start',
|
||||
complete: 'oms.claims.complete',
|
||||
} as const
|
||||
|
||||
export const claimsApi = {
|
||||
search(filter: ClaimSearch) {
|
||||
return kbxApi.request<{ items: ClaimRow[]; totalCount: number }>('oms.claims.search', { query: filter })
|
||||
},
|
||||
transition(id: string, transition: string) {
|
||||
const operation = transitionOperations[transition as keyof typeof transitionOperations]
|
||||
if (!operation) throw new Error(`Unsupported claim transition: ${transition}`)
|
||||
return kbxApi.request<{ id: string; status: string; version: number }>(operation, { path: { id } })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const omsClaimRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/oms/claims', name:'oms-claims', component:()=>import('./ClaimsPage.vue'), meta:{ screenId:'OMS-CLM-001' } },
|
||||
]
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { KbxExcelImport, KbxImportPage, useKbxImportProgress } from '@kbx/ui'
|
||||
import { orderImportDefinition, orderImportScreen } from './order-import.definition'
|
||||
import { useOrderExcelImport } from './useOrderExcelImport'
|
||||
import { createImportProgressConnection } from '../../../../imports/createImportProgressConnection'
|
||||
|
||||
const vm = useOrderExcelImport()
|
||||
const realtime = useKbxImportProgress(createImportProgressConnection())
|
||||
watch(vm.sessionId, id => { if (id) void realtime.watch(id) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxImportPage :screen="orderImportScreen" :refreshing="vm.busy.value">
|
||||
<KbxExcelImport
|
||||
:definition="orderImportDefinition"
|
||||
:session="vm.session.value"
|
||||
:busy="vm.busy.value"
|
||||
:progress="realtime.lastEvent.value"
|
||||
@upload="vm.upload"
|
||||
@save-mapping="vm.saveMapping"
|
||||
@save-named-mapping="vm.saveNamedMapping"
|
||||
@validate="vm.validate"
|
||||
@commit="vm.commit"
|
||||
@download-template="vm.downloadTemplate"
|
||||
@download-errors="vm.downloadErrors"
|
||||
@cancel="vm.reset"
|
||||
/>
|
||||
</KbxImportPage>
|
||||
</template>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
import { kbxImportField, type KbxImportDefinition } from '@kbx/contracts'
|
||||
|
||||
export const orderImportScreen = defineKbxScreen({
|
||||
id: 'OMS-ORD-003',
|
||||
version: '1.1.0',
|
||||
module: 'OMS',
|
||||
type: 'import', templateCode:'T08',
|
||||
title: '주문 Excel 업로드',
|
||||
helpKey: 'OMS-ORD-003',
|
||||
permissions: ['oms.order.import'],
|
||||
commands: [],
|
||||
telemetry: { enabled: true },
|
||||
})
|
||||
|
||||
export const orderImportDefinition: KbxImportDefinition = {
|
||||
id: 'oms.orders.v1',
|
||||
screenId: 'OMS-ORD-003',
|
||||
entity: 'order',
|
||||
title: '주문 Excel 업로드',
|
||||
allowCreate: true,
|
||||
allowUpdate: true,
|
||||
maxFileSizeBytes: 20 * 1024 * 1024,
|
||||
maxRows: 100_000,
|
||||
fields: [
|
||||
kbxImportField('orderNo'),
|
||||
kbxImportField('orderDate'),
|
||||
kbxImportField('customerCode'),
|
||||
kbxImportField('warehouseCode'),
|
||||
kbxImportField('receiverName'),
|
||||
kbxImportField('phone'),
|
||||
kbxImportField('postalCode'),
|
||||
kbxImportField('address1'),
|
||||
kbxImportField('address2'),
|
||||
kbxImportField('itemCode'),
|
||||
kbxImportField('orderQty'),
|
||||
kbxImportField('unitPrice'),
|
||||
kbxImportField('remark'),
|
||||
],
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import type { KbxImportMapping, KbxImportSession } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../../http/generated/kbxApiClient'
|
||||
|
||||
export const orderImportApi = {
|
||||
createSession(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('importType', 'oms.orders.v1')
|
||||
return kbxApi.request<KbxImportSession, FormData>('common.imports.createSession', { body: form })
|
||||
},
|
||||
|
||||
getSession(id: string) {
|
||||
return kbxApi.request<KbxImportSession>('common.imports.getSession', { path: { sessionId: id } })
|
||||
},
|
||||
|
||||
saveMapping(id: string, mappings: KbxImportMapping[]) {
|
||||
return kbxApi.request<KbxImportSession, { mappings: KbxImportMapping[] }>('common.imports.saveMapping', {
|
||||
path: { sessionId: id }, body: { mappings },
|
||||
})
|
||||
},
|
||||
|
||||
saveNamedMapping(id: string, name: string, mappings: KbxImportMapping[]) {
|
||||
return kbxApi.request<void, { name: string; mappings: KbxImportMapping[] }>('common.imports.saveNamedMapping', {
|
||||
path: { sessionId: id }, body: { name, mappings },
|
||||
})
|
||||
},
|
||||
|
||||
validate(id: string) {
|
||||
return kbxApi.request<KbxImportSession>('common.imports.validate', { path: { sessionId: id } })
|
||||
},
|
||||
|
||||
commit(id: string) {
|
||||
return kbxApi.request<KbxImportSession>('common.imports.commit', { path: { sessionId: id } })
|
||||
},
|
||||
|
||||
async downloadTemplate() {
|
||||
const value = await kbxApi.request<Blob>('common.imports.template', {
|
||||
path: { importType: 'oms.orders.v1' }, responseType: 'blob',
|
||||
})
|
||||
downloadBlob(value, 'OMS_주문_업로드_양식.xlsx')
|
||||
},
|
||||
|
||||
async downloadErrors(id: string) {
|
||||
const value = await kbxApi.request<Blob>('common.imports.errorWorkbook', {
|
||||
path: { sessionId: id }, responseType: 'blob',
|
||||
})
|
||||
downloadBlob(value, 'OMS_주문_업로드_오류.xlsx')
|
||||
},
|
||||
}
|
||||
|
||||
function downloadBlob(value: BlobPart, fileName: string) {
|
||||
const url = URL.createObjectURL(new Blob([value]))
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import type { KbxImportMapping, KbxImportSession } from '@kbx/contracts'
|
||||
import { orderImportApi } from './orderImportApi'
|
||||
import { kbxTelemetry } from '../../../telemetry/kbxTelemetryClient'
|
||||
import { orderImportScreen } from './order-import.definition'
|
||||
|
||||
export function useOrderExcelImport() {
|
||||
const sessionId = ref<string | null>(null)
|
||||
const localSession = ref<KbxImportSession | null>(null)
|
||||
let importStartedAt:number|undefined
|
||||
let terminalRecorded=false
|
||||
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: computed(() => ['excel-import', sessionId.value]),
|
||||
queryFn: () => orderImportApi.getSession(sessionId.value!),
|
||||
enabled: computed(() => !!sessionId.value),
|
||||
refetchInterval: query => {
|
||||
const status = query.state.data?.status
|
||||
return status === 'validating' || status === 'committing' ? 1500 : false
|
||||
},
|
||||
})
|
||||
|
||||
const session = computed(() => sessionQuery.data.value ?? localSession.value)
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: orderImportApi.createSession,
|
||||
onMutate() { importStartedAt=performance.now(); terminalRecorded=false; kbxTelemetry.track('excel.import.start',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,attributes:{importType:'oms-order',rowCountBucket:'unknown'}}) },
|
||||
onSuccess(value) { sessionId.value = value.id; localSession.value = value },
|
||||
onError() { kbxTelemetry.track('excel.import.failed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs:Math.round(performance.now()-(importStartedAt??performance.now())),attributes:{importType:'oms-order',reasonCode:'UPLOAD_FAILED',rowCountBucket:'unknown'}}); terminalRecorded=true },
|
||||
})
|
||||
|
||||
const mappingMutation = useMutation({
|
||||
mutationFn: ({ id, mappings }: { id: string; mappings: KbxImportMapping[] }) => orderImportApi.saveMapping(id, mappings),
|
||||
onSuccess(value) { localSession.value = value },
|
||||
})
|
||||
|
||||
const savedMappingMutation = useMutation({
|
||||
mutationFn: ({ id, name, mappings }: { id: string; name: string; mappings: KbxImportMapping[] }) => orderImportApi.saveNamedMapping(id, name, mappings),
|
||||
})
|
||||
|
||||
const validationMutation = useMutation({
|
||||
mutationFn: orderImportApi.validate,
|
||||
onSuccess(value) { localSession.value = value; void sessionQuery.refetch() },
|
||||
})
|
||||
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: orderImportApi.commit,
|
||||
onSuccess(value) { localSession.value = value; void sessionQuery.refetch() },
|
||||
onError() { kbxTelemetry.track('excel.import.failed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs:Math.round(performance.now()-(importStartedAt??performance.now())),attributes:{importType:'oms-order',reasonCode:'COMMIT_REQUEST_FAILED',rowCountBucket:'unknown'}}); terminalRecorded=true },
|
||||
})
|
||||
|
||||
watch(session, value => {
|
||||
if(!value || terminalRecorded || importStartedAt==null) return
|
||||
const bucket=value.totalRows<1000?'0-999':value.totalRows<10000?'1k-9k':value.totalRows<100000?'10k-99k':'100k+'
|
||||
const durationMs=Math.round(performance.now()-importStartedAt)
|
||||
if(value.status==='completed'||value.status==='partially-completed'){kbxTelemetry.track('excel.import.completed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs,attributes:{importType:'oms-order',result:value.status,rowCountBucket:bucket}});terminalRecorded=true}
|
||||
if(value.status==='failed'){kbxTelemetry.track('excel.import.failed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs,attributes:{importType:'oms-order',reasonCode:'JOB_FAILED',rowCountBucket:bucket}});terminalRecorded=true}
|
||||
})
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionId,
|
||||
busy: computed(() => uploadMutation.isPending.value || mappingMutation.isPending.value || validationMutation.isPending.value || commitMutation.isPending.value),
|
||||
upload: (file: File) => uploadMutation.mutateAsync(file),
|
||||
saveMapping: (mappings: KbxImportMapping[]) => sessionId.value ? mappingMutation.mutateAsync({ id: sessionId.value, mappings }) : Promise.resolve(null),
|
||||
saveNamedMapping: (name: string, mappings: KbxImportMapping[]) => sessionId.value ? savedMappingMutation.mutateAsync({ id: sessionId.value, name, mappings }) : Promise.resolve(),
|
||||
validate: () => sessionId.value ? validationMutation.mutateAsync(sessionId.value) : Promise.resolve(null),
|
||||
commit: () => sessionId.value ? commitMutation.mutateAsync(sessionId.value) : Promise.resolve(null),
|
||||
downloadTemplate: orderImportApi.downloadTemplate,
|
||||
downloadErrors: () => sessionId.value ? orderImportApi.downloadErrors(sessionId.value) : Promise.resolve(),
|
||||
reset: () => { sessionId.value = null; localSession.value = null },
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { KbxLookupItem } from '@kbx/contracts'
|
||||
import { kbxFieldReadonly, resolveKbxRecordStatePolicy } from '@kbx/contracts'
|
||||
import { KbxDataGrid,KbxDateField,KbxFormGrid,KbxFormSection,KbxFormSpan,KbxLookup,KbxLookupDialog,KbxInput,KbxSectionHeader,KbxTransactionPage,useKbxPageShortcuts } from '@kbx/ui'
|
||||
import { orderLineColumns, orderRegisterScreen, orderStatePolicies, orderWorkflow, type OrderLineForm } from './order-register.definition'
|
||||
import { useKbxWorkspaceBinding } from '../../../../shell/useKbxWorkspaceBinding'
|
||||
import { useOrderRegistration } from './useOrderRegistration'
|
||||
const route=useRoute();const vm=useOrderRegistration();useKbxWorkspaceBinding(vm.dirty,async()=>{await vm.save();return !vm.dirty.value})
|
||||
const itemLookupOpen=ref(false);const activeLine=ref<OrderLineForm|null>(null);const statePolicy=computed(()=>resolveKbxRecordStatePolicy(orderStatePolicies,vm.status.value));const readonly=computed(()=>statePolicy.value.editability==='readonly')
|
||||
function fieldReadonly(field:string){return kbxFieldReadonly(statePolicy.value,field)}
|
||||
function openGridLookup(event:{row:OrderLineForm;entity:string}){if(event.entity!=='item'||readonly.value)return;activeLine.value=event.row;itemLookupOpen.value=true}
|
||||
function selectGridItem(item:KbxLookupItem<string>){if(activeLine.value)vm.applyItemLookup(activeLine.value,item)}
|
||||
const summaryItems=computed(()=>[{key:'items',label:'품목',value:`${vm.lines.value.length.toLocaleString('ko-KR')}종`},{key:'qty',label:'총수량',value:vm.totalQty.value},{key:'amount',label:'주문금액',value:`₩${vm.totalAmount.value.toLocaleString('ko-KR')}`,emphasis:true}])
|
||||
useKbxPageShortcuts([{key:'F8',execute:()=>vm.save()}])
|
||||
async function executeCommand(id:string){if(id==='save')await vm.save();if(id==='new')vm.createNew();if(id==='confirm')await vm.confirm()}
|
||||
onMounted(async()=>{const id=String(route.params.orderId??'');if(id)await vm.load(id)})
|
||||
</script>
|
||||
<template>
|
||||
<KbxTransactionPage :screen="orderRegisterScreen" :status="vm.status.value" :dirty="vm.dirty.value" :version="vm.version.value" :errors="vm.errors.value" :workflow="orderWorkflow" :conflict="vm.conflict.value" :audit-entries="vm.auditEntries.value" :summary-items="summaryItems" breadcrumb="OMS > 주문" @command="executeCommand" @transition="executeCommand" @reload-conflict="vm.reloadConflict" @dismiss-conflict="vm.dismissConflict">
|
||||
<template #header>
|
||||
<KbxFormSection title="기본정보"><KbxFormGrid><KbxDateField v-model="vm.header.orderDate" label="주문일" required :readonly="fieldReadonly('orderDate')" :error="vm.fieldError('orderDate')" @update:model-value="vm.touch"/><KbxLookup v-model="vm.header.customerId" entity="customer" label="거래처" required :readonly="fieldReadonly('customerId')" :error="vm.fieldError('customerId')" @selected="vm.touch"/><KbxLookup v-model="vm.header.warehouseId" entity="warehouse" label="출고창고" required :readonly="fieldReadonly('warehouseId')" :error="vm.fieldError('warehouseId')" @selected="vm.touch"/><KbxInput v-model="vm.header.receiverName" label="수취인" required :readonly="fieldReadonly('receiverName')" :error="vm.fieldError('receiverName')" @update:model-value="vm.touch"/><KbxInput v-model="vm.header.phone" label="연락처" required :readonly="fieldReadonly('phone')" :error="vm.fieldError('phone')" @update:model-value="vm.touch"/></KbxFormGrid></KbxFormSection>
|
||||
<KbxFormSection title="배송정보"><KbxFormGrid><KbxInput v-model="vm.header.postalCode" label="우편번호" :readonly="fieldReadonly('postalCode')" @update:model-value="vm.touch"/><KbxFormSpan span="full"><KbxInput v-model="vm.header.address1" label="주소" required :readonly="fieldReadonly('address1')" :error="vm.fieldError('address1')" @update:model-value="vm.touch"/></KbxFormSpan><KbxFormSpan span="full"><KbxInput v-model="vm.header.address2" label="상세주소" :readonly="fieldReadonly('address2')" @update:model-value="vm.touch"/></KbxFormSpan></KbxFormGrid></KbxFormSection>
|
||||
</template>
|
||||
<template #detail><section class="order-lines"><KbxSectionHeader title="상품" :count="vm.lines.value.length"/><KbxDataGrid :rows="vm.lines.value" :columns="orderLineColumns" row-key="clientId" selection="multiple" :errors="vm.errors.value" :editable="!readonly" :editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}" @row-add-requested="vm.addLine" @row-duplicate-requested="vm.duplicateLines" @cell-changed="vm.onCellChanged" @lookup-requested="openGridLookup"/></section></template>
|
||||
</KbxTransactionPage>
|
||||
<KbxLookupDialog v-model:visible="itemLookupOpen" entity="item" title="품목" @select="selectGridItem"/>
|
||||
</template>
|
||||
<style scoped>.order-lines{min-height:var(--kbx-grid-min-height);display:flex;flex-direction:column;gap:var(--kbx-space-2)}</style>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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},
|
||||
]
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
kbxDecimalSchema,
|
||||
kbxNullableEntityIdSchema,
|
||||
kbxRequiredTextSchema,
|
||||
kbxTextSchema,
|
||||
} from '../../../../validation/kbxFieldSchema'
|
||||
|
||||
export const orderHeaderSchema = z.object({
|
||||
orderDate: kbxRequiredTextSchema('orderDate', '주문일을 입력하세요.'),
|
||||
customerId: kbxNullableEntityIdSchema('customerId', '거래처를 선택하세요.'),
|
||||
warehouseId: kbxNullableEntityIdSchema('warehouseId', '출고창고를 선택하세요.'),
|
||||
receiverName: kbxRequiredTextSchema('receiverName'),
|
||||
phone: kbxRequiredTextSchema('phone'),
|
||||
postalCode: kbxTextSchema('postalCode').optional(),
|
||||
address1: kbxRequiredTextSchema('address1'),
|
||||
address2: kbxTextSchema('address2').optional(),
|
||||
})
|
||||
|
||||
export const orderLineSchema = z.object({
|
||||
clientId: z.string(),
|
||||
itemId: kbxNullableEntityIdSchema('itemId', '품목을 선택하세요.'),
|
||||
// Positive quantity is an order rule, so the field dictionary only supplies numeric structure.
|
||||
orderQty: kbxDecimalSchema('orderQty').positive('수량은 0보다 커야 합니다.'),
|
||||
unitPrice: kbxDecimalSchema('unitPrice').nonnegative('단가는 0 이상이어야 합니다.'),
|
||||
})
|
||||
|
||||
export const orderRegisterSchema = z.object({
|
||||
header: orderHeaderSchema,
|
||||
lines: z.array(orderLineSchema).min(1, '주문 품목을 한 건 이상 입력하세요.'),
|
||||
})
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { isKbxProblem, type KbxAuditEntry, type KbxProblem } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../../http/generated/kbxApiClient'
|
||||
export interface RegisterOrderRequest { orderId?:string; version?:number; orderDate:string; customerId:string; warehouseId:string; receiverName:string; phone:string; postalCode?:string; address1:string; address2?:string; lines:Array<{clientId:string;itemId:string;orderQty:number;unitPrice:number;remark?:string}> }
|
||||
export interface RegisterOrderResponse { orderId:string; orderNo:string; version:number; status:string }
|
||||
export interface OrderEditResponse extends RegisterOrderResponse { orderDate:string; customerId:string; warehouseId:string; receiverName:string; phone:string; postalCode?:string; address1:string; address2?:string; lines:Array<{id:string;clientId:string;itemId:string;itemCode:string;itemName:string;orderQty:number;unitPrice:number;amount:number;remark:string}> }
|
||||
export interface ItemLookupResult { id:string; code:string; displayName:string; status?:string }
|
||||
export function toKbxProblem(error:unknown):KbxProblem|null{return isKbxProblem(error)?error:null}
|
||||
export const orderRegisterApi={
|
||||
async resolveItemByCode(code:string){try{return await kbxApi.request<ItemLookupResult>('lookup.items.resolveByCode',{path:{code}})}catch(error){if(isKbxProblem(error)&&error.type==='not-found')return null;throw error}},
|
||||
save(request:RegisterOrderRequest){return request.orderId?kbxApi.request<RegisterOrderResponse,RegisterOrderRequest>('oms.orders.update',{path:{id:request.orderId},body:request}):kbxApi.request<RegisterOrderResponse,RegisterOrderRequest>('oms.orders.register',{body:request})},
|
||||
get(orderId:string){return kbxApi.request<OrderEditResponse>('oms.orders.get',{path:{id:orderId}})},
|
||||
confirm(orderId:string,version:number){return kbxApi.request<RegisterOrderResponse>('oms.orders.confirm',{path:{id:orderId},body:{version},idempotencyKey:`order-confirm:${orderId}:${version}`})},
|
||||
audit(orderId:string){return kbxApi.request<KbxAuditEntry[]>('oms.orders.audit',{path:{id:orderId}})},
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import type { KbxAuditEntry, KbxConflictSnapshot, KbxValidationError } from '@kbx/contracts'
|
||||
import { isKbxProblem } from '@kbx/contracts'
|
||||
import { useKbxDirtyState, useKbxValidation } from '@kbx/ui'
|
||||
import { orderRegisterSchema } from './order-register.schema'
|
||||
import { orderRegisterApi, toKbxProblem } from './orderRegisterApi'
|
||||
import type { OrderLineForm } from './order-register.definition'
|
||||
import { orderRegisterScreen } from './order-register.definition'
|
||||
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
|
||||
interface OrderHeaderForm { orderDate:string;customerId:string|null;warehouseId:string|null;receiverName:string;phone:string;postalCode:string;address1:string;address2:string }
|
||||
function localDate(){const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}
|
||||
function newLine():OrderLineForm{return{clientId:crypto.randomUUID(),itemId:null,itemCode:'',itemName:'',availableQty:null,orderQty:1,unitPrice:0,amount:0,remark:''}}
|
||||
export function useOrderRegistration(){
|
||||
const orderId=ref<string>();const orderNo=ref<string>();const version=ref<number>();const status=ref('신규');const auditEntries=ref<KbxAuditEntry[]>([]);const conflict=ref<KbxConflictSnapshot|null>(null)
|
||||
const header=reactive<OrderHeaderForm>({orderDate:localDate(),customerId:null,warehouseId:null,receiverName:'',phone:'',postalCode:'',address1:'',address2:''});const lines=ref<OrderLineForm[]>([newLine()])
|
||||
const {dirty,touch,markSaved,reset:resetDirty}=useKbxDirtyState();const validation=useKbxValidation();const mutation=useMutation({mutationFn:orderRegisterApi.save})
|
||||
const totalQty=computed(()=>lines.value.reduce((s,l)=>s+Number(l.orderQty||0),0));const totalAmount=computed(()=>lines.value.reduce((s,l)=>s+Number(l.amount||0),0))
|
||||
function addLine(){lines.value.push(newLine());touch()}function duplicateLines(source:OrderLineForm[]){if(!source.length)return;lines.value.push(...source.map(l=>({...l,clientId:crypto.randomUUID()})));touch()}function recalc(l:OrderLineForm){l.amount=Number(l.orderQty||0)*Number(l.unitPrice||0)}
|
||||
async function onCellChanged(event:{row:OrderLineForm;field:keyof OrderLineForm;newValue:unknown}){if(event.field==='itemCode'){const code=String(event.newValue??'').trim();if(!code)Object.assign(event.row,{itemId:null,itemName:'',availableQty:null});else{const item=await orderRegisterApi.resolveItemByCode(code);if(!item){Object.assign(event.row,{itemId:null,itemName:'',availableQty:null});validation.setRowFieldError(event.row.clientId,'itemCode',{code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'})}else{Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName});validation.setRowFieldError(event.row.clientId,'itemCode')}}}if(event.field==='orderQty'||event.field==='unitPrice')recalc(event.row);touch()}
|
||||
function applyItemLookup(line:OrderLineForm,item:{id:string;code:string;displayName:string}){Object.assign(line,{itemId:item.id,itemCode:item.code,itemName:item.displayName});validation.setRowFieldError(line.clientId,'itemCode');touch()}
|
||||
function toValidationErrors():KbxValidationError[]{const parsed=orderRegisterSchema.safeParse({header,lines:lines.value});if(parsed.success)return[];return parsed.error.issues.map(issue=>{const p=issue.path;if(p[0]==='header')return{field:String(p[1]??''),code:'CLIENT_VALIDATION',message:issue.message};if(p[0]==='lines'&&typeof p[1]==='number'){const raw=String(p[2]??'');return{rowKey:lines.value[p[1]]?.clientId,field:raw==='itemId'?'itemCode':raw,code:'CLIENT_VALIDATION',message:issue.message}}return{code:'CLIENT_VALIDATION',message:issue.message}})}
|
||||
async function refreshAudit(){auditEntries.value=orderId.value?await orderRegisterApi.audit(orderId.value):[]}
|
||||
async function load(id:string){const r=await orderRegisterApi.get(id);orderId.value=r.orderId;orderNo.value=r.orderNo;version.value=r.version;status.value=r.status;Object.assign(header,{orderDate:r.orderDate,customerId:r.customerId,warehouseId:r.warehouseId,receiverName:r.receiverName,phone:r.phone,postalCode:r.postalCode??'',address1:r.address1,address2:r.address2??''});lines.value=r.lines.map(l=>({...l,availableQty:null}));validation.clear();conflict.value=null;resetDirty();await refreshAudit()}
|
||||
async function save(){const task=startKbxTask(orderRegisterScreen.id,orderRegisterScreen.version,'save-order');validation.clear();const local=toValidationErrors();if(local.length){validation.setErrors(local);task.abandon('client-validation');return}try{const r=await mutation.mutateAsync({orderId:orderId.value,version:version.value,orderDate:header.orderDate,customerId:header.customerId!,warehouseId:header.warehouseId!,receiverName:header.receiverName,phone:header.phone,postalCode:header.postalCode||undefined,address1:header.address1,address2:header.address2||undefined,lines:lines.value.map(l=>({clientId:l.clientId,itemId:l.itemId!,orderQty:l.orderQty,unitPrice:l.unitPrice,remark:l.remark||undefined}))});orderId.value=r.orderId;orderNo.value=r.orderNo;version.value=r.version;status.value=r.status;markSaved();conflict.value=null;await refreshAudit();task.complete('success')}catch(error){const problem=toKbxProblem(error);if(problem?.type==='validation')validation.applyProblem(problem);if(problem?.type==='conflict'&&orderId.value){const latest=await orderRegisterApi.get(orderId.value);conflict.value={code:problem.code,title:problem.title,detail:'입력 중인 주문은 유지됩니다. 최신 값과 비교한 뒤 다시 읽으세요.',entityId:orderId.value,requestedVersion:version.value,currentVersion:problem.currentVersion??latest.version,changes:[['orderDate','주문일'],['customerId','거래처'],['warehouseId','출고창고'],['receiverName','수취인'],['address1','주소']].map(([field,label])=>({field,label,mine:(header as any)[field],latest:(latest as any)[field]}))}}task.abandon(problem?.type??'system');if(!problem||problem.type==='system')throw error}}
|
||||
async function confirm(){if(!orderId.value||version.value==null||dirty.value)return;try{const r=await orderRegisterApi.confirm(orderId.value,version.value);version.value=r.version;status.value=r.status;await refreshAudit()}catch(e){if(isKbxProblem(e)&&e.type==='conflict'){const latest=await orderRegisterApi.get(orderId.value);conflict.value={code:e.code,title:e.title,requestedVersion:version.value,currentVersion:e.currentVersion??latest.version,entityId:orderId.value};return}throw e}}
|
||||
async function reloadConflict(){if(orderId.value)await load(orderId.value)}
|
||||
function dismissConflict(){conflict.value=null}
|
||||
function createNew(){orderId.value=undefined;orderNo.value=undefined;version.value=undefined;status.value='신규';Object.assign(header,{orderDate:localDate(),customerId:null,warehouseId:null,receiverName:'',phone:'',postalCode:'',address1:'',address2:''});lines.value=[newLine()];auditEntries.value=[];conflict.value=null;validation.clear();resetDirty()}
|
||||
return{orderId,orderNo,version,status,header,lines,dirty,errors:validation.errors,fieldError:validation.fieldError,totalQty,totalAmount,saving:computed(()=>mutation.isPending.value),auditEntries,conflict,addLine,duplicateLines,onCellChanged,applyItemLookup,save,confirm,load,reloadConflict,dismissConflict,createNew,touch}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { KbxDataGrid, KbxListPage, KbxQuickFilterBar, KbxSearchPanel, KbxSummaryBar, useKbxPageShortcuts } from '@kbx/ui'
|
||||
import { orderColumns, orderListScreen, orderSearchFields } from './order-list.definition'
|
||||
import { useOrderSearch } from './useOrderSearch'
|
||||
import { useKbxExperiment } from '../../../../experiments/kbxExperimentClient'
|
||||
|
||||
const vm=useOrderSearch();const router=useRouter()
|
||||
const exceptionSummaryExperiment=useKbxExperiment(orderListScreen.id,orderListScreen.version,'exp.oms.order-list.exception-summary-v2','information-emphasis')
|
||||
useKbxPageShortcuts([{key:'F3',execute:vm.executeSearch}])
|
||||
async function selectQuickFilter(key:string){
|
||||
vm.search.exceptionOnly=false
|
||||
vm.search.status=null
|
||||
if(key==='new')vm.search.status='NEW'
|
||||
if(key==='ready')vm.search.status='READY'
|
||||
if(key==='exceptions')vm.search.exceptionOnly=true
|
||||
await vm.executeSearch()
|
||||
}
|
||||
const quickFilters=computed(()=>{
|
||||
const c=vm.result.value?.counters
|
||||
if(!c)return []
|
||||
return [
|
||||
{key:'all',label:'전체',count:c.all,active:!vm.search.status&&!vm.search.exceptionOnly},
|
||||
{key:'new',label:'신규',count:c.new,active:vm.search.status==='NEW'},
|
||||
{key:'ready',label:'출고대기',count:c.readyToShip,active:vm.search.status==='READY'},
|
||||
{key:'exceptions',label:'오류',count:c.exceptions,active:vm.search.exceptionOnly,tone:'danger' as const},
|
||||
]
|
||||
})
|
||||
const pageContext=computed(()=>vm.result.value?{label:`${vm.appliedSearch.value.from} ~ ${vm.appliedSearch.value.to}`,hint:vm.appliedSearch.value.exceptionOnly?'예외 주문만 조회 중':'현재 적용된 조회조건 기준',metrics:[{key:'result',label:'조회',value:vm.result.value.totalCount},{key:'selected',label:'선택',value:vm.selectionCount.value},{key:'exceptions',label:'오류',value:vm.result.value.counters.exceptions,tone:'danger' as const,emphasis:vm.result.value.counters.exceptions>0}]}:null)
|
||||
const summaryItems=computed(()=>vm.result.value?[
|
||||
{key:'rows',label:'조회',value:`${vm.result.value.totalCount.toLocaleString('ko-KR')}건`},
|
||||
{key:'selected',label:'선택',value:`${vm.selectionCount.value.toLocaleString('ko-KR')}건`},
|
||||
{key:'qty',label:'수량',value:vm.result.value.totalQty},
|
||||
{key:'amount',label:'금액',value:`₩${vm.result.value.totalAmount.toLocaleString('ko-KR')}`,emphasis:true},
|
||||
]:[])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxListPage :screen="orderListScreen" :selection-count="vm.selectionCount.value" :context="pageContext" :content-state="vm.error.value?'error':vm.loading.value&&!vm.result.value?'loading':!vm.result.value?'idle':vm.result.value.totalCount===0?'empty':'ready'" :refreshing="vm.loading.value&&Boolean(vm.result.value)" breadcrumb="OMS > 주문" @command="vm.executeCommand">
|
||||
<template #search><KbxSearchPanel :model-value="vm.search" :fields="orderSearchFields" @update:model-value="value=>Object.assign(vm.search,value)" @search="vm.executeSearch"/></template>
|
||||
<template #quick-filter>
|
||||
<div v-if="vm.result.value&&exceptionSummaryExperiment.is('exception-summary')" class="experiment-summary"><strong>확인 필요</strong> · 오류 {{vm.result.value.counters.exceptions.toLocaleString()}}건 — 기존 조회·처리 문법은 그대로 유지됩니다.</div>
|
||||
<KbxQuickFilterBar v-if="quickFilters.length" :items="quickFilters" @select="selectQuickFilter"/>
|
||||
</template>
|
||||
<template #content><KbxDataGrid :rows="vm.rows.value" :columns="orderColumns" row-key="id" selection="multiple" :loading="vm.loading.value" :total-count="vm.result.value?.totalCount??0" :selection-state="vm.selectionState.value" allow-all-filtered-selection @selection-state-changed="value=>vm.selectionState.value=value" @row-double-clicked="row=>router.push({name:'oms-order-edit',params:{orderId:row.id}})"/></template>
|
||||
<template #summary><KbxSummaryBar v-if="summaryItems.length" :items="summaryItems"/></template>
|
||||
</KbxListPage>
|
||||
</template>
|
||||
|
||||
<style scoped>.experiment-summary{margin-bottom:var(--kbx-space-1);padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}</style>
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/contracts'
|
||||
|
||||
export interface OrderSearchRow {
|
||||
id: string
|
||||
orderNo: string
|
||||
channelName: string
|
||||
orderedAt: string
|
||||
customerName: string
|
||||
itemSummary: string
|
||||
totalQty: number
|
||||
amount: number
|
||||
allocationStatus: string
|
||||
shipmentStatus: string
|
||||
exceptionCount: number
|
||||
}
|
||||
|
||||
export const orderListScreen = defineKbxScreen({
|
||||
id: 'OMS-ORD-001',
|
||||
version: '1.1.0',
|
||||
module: 'OMS',
|
||||
type: 'list', templateCode:'T01',
|
||||
title: '주문관리',
|
||||
description: '주문을 조회하고 예외 및 출고대상을 일괄 처리합니다.',
|
||||
permissions: ['oms.order.read'],
|
||||
helpKey: 'OMS-ORD-001',
|
||||
telemetry: { enabled: true },
|
||||
commands: [
|
||||
{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' },
|
||||
{ id: 'new', label: '신규', group: 'edit', permission: 'oms.order.create' },
|
||||
{
|
||||
id: 'ship', label: '출고지시', group: 'workflow', variant: 'primary',
|
||||
requiresSelection: true, minSelection: 1, permission: 'oms.order.ship'
|
||||
},
|
||||
{ id: 'hold', label: '보류', group: 'workflow', requiresSelection: true, minSelection: 1 },
|
||||
{ id: 'excel', label: '엑셀', group: 'output' },
|
||||
],
|
||||
})
|
||||
|
||||
export const orderColumns: KbxGridColumn<OrderSearchRow>[] = [
|
||||
{ field: 'orderNo', header: '주문번호', type: 'link', width: 150, pinned: 'left' },
|
||||
{ field: 'channelName', header: '판매채널', width: 110 },
|
||||
{ field: 'orderedAt', header: '주문일시', type: 'datetime', width: 160 },
|
||||
{ field: 'customerName', header: '주문자', width: 120 },
|
||||
{ field: 'itemSummary', header: '대표상품', width: 240 },
|
||||
{ field: 'totalQty', header: '수량', type: 'quantity', width: 90 },
|
||||
{ field: 'amount', header: '금액', type: 'money', width: 130 },
|
||||
{ field: 'allocationStatus', header: '재고', type: 'status', width: 100 },
|
||||
{ field: 'shipmentStatus', header: '출고상태', type: 'status', width: 110 },
|
||||
{ field: 'exceptionCount', header: '오류', type: 'integer', width: 80 },
|
||||
]
|
||||
|
||||
export const orderSearchFields: KbxSearchField[] = [
|
||||
{ key: 'period', label: '주문기간', type: 'date-range', range: { from: 'from', to: 'to' } },
|
||||
{ key: 'status', label: '상태', type: 'select', options: [
|
||||
{ value: 'READY', label: '출고대기' },
|
||||
{ value: 'SHIPPED', label: '출고완료' },
|
||||
{ value: 'HOLD', label: '보류' },
|
||||
] },
|
||||
{ key: 'keyword', label: '통합검색', type: 'text', width: 'lg', placeholder: '주문번호/주문자/상품' },
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
import { kbxApi } from '../../../../http/generated/kbxApiClient'
|
||||
import type { KbxBulkSelectionRequest } from '@kbx/contracts'
|
||||
import type { OrderSearchRow } from './order-list.definition'
|
||||
|
||||
export interface OrderSearchFilter {
|
||||
from: string
|
||||
to: string
|
||||
channelId?: string | null
|
||||
status?: string | null
|
||||
exceptionOnly?: boolean
|
||||
keyword?: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type OrderBulkFilter = Omit<OrderSearchFilter, 'page' | 'pageSize'>
|
||||
|
||||
export interface OrderSearchResponse {
|
||||
items: OrderSearchRow[]
|
||||
totalCount: number
|
||||
totalQty: number
|
||||
totalAmount: number
|
||||
counters: { all: number; new: number; readyToShip: number; exceptions: number }
|
||||
}
|
||||
|
||||
export interface ShipOrdersResult { requested: number; accepted: number; rejected: number }
|
||||
|
||||
export const orderApi = {
|
||||
search(filter: OrderSearchFilter) {
|
||||
return kbxApi.request<OrderSearchResponse>('oms.orders.search', { query: filter })
|
||||
},
|
||||
|
||||
ship(selection: KbxBulkSelectionRequest<OrderBulkFilter>) {
|
||||
return kbxApi.request<ShipOrdersResult, KbxBulkSelectionRequest<OrderBulkFilter>>('oms.orders.ship', {
|
||||
body: selection,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
})
|
||||
},
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { KbxSelectionState } from '@kbx/contracts'
|
||||
import { toKbxBulkSelectionRequest } from '@kbx/ui'
|
||||
import { orderApi, type OrderBulkFilter, type OrderSearchFilter } from './orderApi'
|
||||
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
|
||||
import { orderListScreen } from './order-list.definition'
|
||||
|
||||
function todayIso() { return new Date().toISOString().slice(0, 10) }
|
||||
|
||||
export function useOrderSearch() {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const selectionState = ref<KbxSelectionState<string>>({ mode:'explicit', selectedIds:[] })
|
||||
|
||||
const search = reactive<OrderSearchFilter>({
|
||||
from: todayIso(), to: todayIso(), channelId: null, status: null,
|
||||
exceptionOnly: false, keyword: '', page: 1, pageSize: 100,
|
||||
})
|
||||
const appliedSearch = ref<OrderSearchFilter>({ ...search })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: computed(() => ['oms', 'orders', appliedSearch.value]),
|
||||
queryFn: () => orderApi.search({ ...appliedSearch.value }),
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const shipMutation = useMutation({
|
||||
mutationFn: (selection: ReturnType<typeof currentBulkSelection>) => orderApi.ship(selection),
|
||||
onSuccess: async () => {
|
||||
selectionState.value = { mode:'explicit', selectedIds:[] }
|
||||
await queryClient.invalidateQueries({ queryKey: ['oms', 'orders'] })
|
||||
await query.refetch()
|
||||
},
|
||||
})
|
||||
|
||||
const selectionCount = computed(() => selectionState.value.mode === 'all-filtered'
|
||||
? Math.max((query.data.value?.totalCount ?? 0) - (selectionState.value.excludedIds?.length ?? 0), 0)
|
||||
: selectionState.value.selectedIds.length)
|
||||
|
||||
function currentBulkFilter(): OrderBulkFilter {
|
||||
return {
|
||||
from: appliedSearch.value.from, to: appliedSearch.value.to, channelId: appliedSearch.value.channelId,
|
||||
status: appliedSearch.value.status, exceptionOnly: appliedSearch.value.exceptionOnly, keyword: appliedSearch.value.keyword,
|
||||
}
|
||||
}
|
||||
|
||||
function currentBulkSelection() {
|
||||
return toKbxBulkSelectionRequest(selectionState.value, currentBulkFilter())
|
||||
}
|
||||
|
||||
async function executeSearch() {
|
||||
const started=performance.now();selectionState.value={mode:'explicit',selectedIds:[]};appliedSearch.value={...search}
|
||||
kbxTelemetry.track('command.execute',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,attributes:{commandId:'search',operationKind:'query'}})
|
||||
kbxTelemetry.track('search.execute',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,attributes:{resultBucket:'requested'}})
|
||||
await query.refetch()
|
||||
kbxTelemetry.track('command.succeeded',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,durationMs:Math.round(performance.now()-started),attributes:{commandId:'search',operationKind:'query'}})
|
||||
}
|
||||
|
||||
async function executeCommand(commandId: string) {
|
||||
const handlers: Record<string, () => unknown | Promise<unknown>> = {
|
||||
search: executeSearch,
|
||||
new: () => router.push({ name: 'oms-order-new' }),
|
||||
ship: async () => {
|
||||
const task=startKbxTask(orderListScreen.id,orderListScreen.version,'ship-orders');task.interaction('bulk-command','ship')
|
||||
kbxTelemetry.track('grid.bulk_action',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,taskSessionId:task.id,attributes:{commandId:'ship',countBucket:selectionCount.value<10?'1-9':selectionCount.value<100?'10-99':'100+',selectionMode:selectionState.value.mode}})
|
||||
try{await shipMutation.mutateAsync(currentBulkSelection());task.complete('success')}catch(error){task.abandon('failed');throw error}
|
||||
},
|
||||
hold: () => Promise.resolve(),
|
||||
excel: () => Promise.resolve(),
|
||||
}
|
||||
return handlers[commandId]?.()
|
||||
}
|
||||
|
||||
return {
|
||||
search,
|
||||
appliedSearch,
|
||||
rows: computed(() => query.data.value?.items ?? []),
|
||||
result: computed(() => query.data.value),
|
||||
loading: computed(() => query.isFetching.value),
|
||||
error: computed(() => query.error.value),
|
||||
selectionState,
|
||||
selectionCount,
|
||||
executeSearch,
|
||||
executeCommand,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { KbxBarcodeCapture,KbxWmsActionButton,KbxWmsMobilePage } from '@kbx/ui'
|
||||
import { countingScreen } from './counting.definition'
|
||||
const online=ref(true);const stage=ref<'location'|'item'|'counted'>('location');const location=ref('');const item=ref('');const counted=ref(0);const bookQty=ref(12);const msg=ref('실사 위치를 스캔하세요.')
|
||||
function scan(v:string){if(stage.value==='location'){location.value=v;stage.value='item';msg.value='상품을 스캔하세요.';return}if(stage.value==='item'){item.value=v;counted.value++;msg.value='계속 스캔하거나 수량 확정하세요.'}}
|
||||
function finish(){stage.value='counted';msg.value=counted.value===bookQty.value?'장부수량과 일치합니다.':'차이가 있어 관리자 확인 대상으로 등록합니다.'}
|
||||
</script>
|
||||
<template><KbxWmsMobilePage :screen="countingScreen" :progress="stage==='counted'?'실사완료':'실사중'" :online="online"><p class="msg">{{msg}}</p><div v-if="location" class="box"><small>LOCATION</small><strong>{{location}}</strong></div><div v-if="item" class="box"><small>상품</small><strong>{{item}}</strong><p>실사 {{counted}} · 장부 {{bookQty}}</p></div><KbxBarcodeCapture v-if="stage!=='counted'" :enabled="online" :label="stage==='location'?'위치 스캔':'상품 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='item'" label="수량 확정" @click="finish"/><KbxWmsActionButton v-if="stage==='counted'" label="다음 위치" @click="stage='location';location='';item='';counted=0;msg='실사 위치를 스캔하세요.'"/></template></KbxWmsMobilePage></template>
|
||||
<style scoped>.msg{font-weight:650}.box{padding:16px;border:1px solid var(--kbx-color-border);border-radius:8px;margin-bottom:12px}.box small{display:block;color:var(--kbx-color-text-muted)}.box strong{font-size:28px}.box p{font-size:20px;font-weight:650}</style>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const countingScreen=defineKbxScreen({id:'WMS-COUNT-001',version:'1.0.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'재고실사',helpKey:'WMS-COUNT-001',permissions:['wms.inventory.count'],telemetry:{enabled:true}})
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsCountingRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/wms/counting/:taskId', name:'wms-counting', component:()=>import('./WmsCountingPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-COUNT-001' } },
|
||||
]
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { KbxWmsExceptionType } from '@kbx/contracts'
|
||||
import { KbxWmsActionButton } from '@kbx/ui'
|
||||
|
||||
defineProps<{ busy?: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [KbxWmsExceptionType, string]
|
||||
}>()
|
||||
|
||||
const type = ref<KbxWmsExceptionType>('no-stock')
|
||||
const memo = ref('')
|
||||
|
||||
const options: { value: KbxWmsExceptionType; label: string }[] = [
|
||||
{ value: 'no-stock', label: '실물 없음' },
|
||||
{ value: 'short-quantity', label: '수량 부족' },
|
||||
{ value: 'wrong-location', label: '위치 오류' },
|
||||
{ value: 'damaged-item', label: '상품 이상' },
|
||||
{ value: 'barcode-issue', label: '바코드 문제' },
|
||||
{ value: 'other', label: '기타' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="backdrop" @click.self="$emit('close')">
|
||||
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="exception-title">
|
||||
<h2 id="exception-title">피킹 문제 신고</h2>
|
||||
<p>가장 가까운 사유 하나만 선택하세요.</p>
|
||||
<label v-for="option in options" :key="option.value" class="reason">
|
||||
<input v-model="type" type="radio" :value="option.value">
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
<label class="memo">메모 <small>선택 입력</small>
|
||||
<textarea v-model="memo" rows="3" maxlength="300" />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
|
||||
<KbxWmsActionButton label="문제 등록" :busy="busy" @click="$emit('submit', type, memo)" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:30; }
|
||||
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; max-height:85dvh; overflow:auto; }
|
||||
h2 { margin:0 0 4px; font-size:20px; }
|
||||
p { margin:0 0 14px; color:var(--kbx-color-text-muted); }
|
||||
.reason { min-height:48px; display:flex; align-items:center; gap:10px; border-bottom:1px solid var(--kbx-color-border); }
|
||||
.reason input { width:20px; height:20px; }
|
||||
.memo { display:block; margin-top:16px; font-weight:600; }
|
||||
.memo small { font-weight:400; color:var(--kbx-color-text-muted); }
|
||||
textarea { width:100%; margin-top:6px; border:1px solid var(--kbx-color-border); border-radius:6px; padding:10px; font:inherit; box-sizing:border-box; }
|
||||
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
</style>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { KbxWmsActionButton } from '@kbx/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
current: number
|
||||
required: number
|
||||
busy?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; submit: [number] }>()
|
||||
const quantity = ref(props.current)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="backdrop" @click.self="$emit('close')">
|
||||
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="quantity-title">
|
||||
<h2 id="quantity-title">피킹 수량 입력</h2>
|
||||
<p>상품 바코드를 최소 1회 확인한 뒤 사용합니다.</p>
|
||||
<label>
|
||||
피킹 수량
|
||||
<input v-model.number="quantity" type="number" inputmode="decimal" :min="1" :max="required">
|
||||
</label>
|
||||
<div class="hint">필요수량 {{ required }}</div>
|
||||
<div class="actions">
|
||||
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
|
||||
<KbxWmsActionButton
|
||||
label="수량 적용"
|
||||
:busy="busy"
|
||||
:disabled="quantity <= 0 || quantity > required"
|
||||
@click="$emit('submit', quantity)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:31; }
|
||||
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; }
|
||||
h2 { margin:0 0 4px; font-size:20px; }
|
||||
p { margin:0 0 16px; color:var(--kbx-color-text-muted); }
|
||||
label { display:block; font-weight:600; }
|
||||
input { width:100%; min-height:52px; margin-top:6px; padding:0 12px; box-sizing:border-box; font:inherit; font-size:22px; text-align:right; border:1px solid var(--kbx-color-border-strong); border-radius:6px; }
|
||||
.hint { margin-top:6px; text-align:right; color:var(--kbx-color-text-muted); font-size:13px; }
|
||||
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:18px; }
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
|
||||
export const pickingScreen = defineKbxScreen({
|
||||
id: 'WMS-PICK-001',
|
||||
version: '1.0.0',
|
||||
module: 'WMS',
|
||||
type: 'wms-mobile', templateCode:'T09',
|
||||
title: '출고 피킹',
|
||||
helpKey: 'WMS-PICK-001',
|
||||
permissions: ['wms.picking.execute'],
|
||||
telemetry: { enabled: true },
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
KbxWmsExceptionCommand,
|
||||
KbxWmsPickingTask,
|
||||
KbxWmsScanCommand,
|
||||
KbxWmsScanResult,
|
||||
KbxWmsSetQuantityCommand,
|
||||
} from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export function getPickingTask(taskId: string) {
|
||||
return kbxApi.request<KbxWmsPickingTask>('wms.picking.getTask', { path: { taskId } })
|
||||
}
|
||||
|
||||
export function startPickingTask(taskId: string, expectedVersion: number, idempotencyKey = crypto.randomUUID()) {
|
||||
return kbxApi.request<KbxWmsPickingTask, { expectedVersion: number }>('wms.picking.start', {
|
||||
path: { taskId }, body: { expectedVersion }, idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function scanPickingBarcode(command: KbxWmsScanCommand) {
|
||||
return kbxApi.request<KbxWmsScanResult, KbxWmsScanCommand>('wms.picking.scan', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function reportPickingException(command: KbxWmsExceptionCommand) {
|
||||
return kbxApi.request<KbxWmsPickingTask, KbxWmsExceptionCommand>('wms.picking.reportException', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function setPickingQuantity(command: KbxWmsSetQuantityCommand) {
|
||||
return kbxApi.request<KbxWmsScanResult, KbxWmsSetQuantityCommand>('wms.picking.setQuantity', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const wmsPickingRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/wms/picking/:taskId',
|
||||
name: 'wms-picking-task',
|
||||
component: () => import('./WmsPickingPage.vue'),
|
||||
props: route => ({ taskId: String(route.params.taskId) }),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,234 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import type {
|
||||
KbxBarcodeEvent,
|
||||
KbxWmsExceptionType,
|
||||
KbxWmsPickingTask,
|
||||
KbxWmsScanCommand,
|
||||
KbxWmsSetQuantityCommand,
|
||||
} from '@kbx/contracts'
|
||||
import { isKbxProblem, isRetryableKbxProblem } from '@kbx/contracts'
|
||||
import { playKbxWmsFeedback, useKbxNetworkState } from '@kbx/ui'
|
||||
import {
|
||||
getPickingTask,
|
||||
reportPickingException,
|
||||
scanPickingBarcode,
|
||||
setPickingQuantity,
|
||||
startPickingTask,
|
||||
} from './pickingApi'
|
||||
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
|
||||
import { pickingScreen } from './picking.definition'
|
||||
import { queuePickingScan, readPickingRetryQueue, removePickingScan } from './wmsRetryQueue'
|
||||
|
||||
function newIdempotencyKey(taskId: string) {
|
||||
return `${taskId}:${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
export function useWmsPicking(taskId: string) {
|
||||
const { online } = useKbxNetworkState()
|
||||
const task = ref<KbxWmsPickingTask | null>(null)
|
||||
const message = ref('')
|
||||
const pendingCommands = ref(0)
|
||||
const syncing = ref(false)
|
||||
const exceptionOpen = ref(false)
|
||||
const quantityOpen = ref(false)
|
||||
let taskTelemetry:ReturnType<typeof startKbxTask>|undefined
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['wms-picking-task', taskId],
|
||||
queryFn: () => getPickingTask(taskId),
|
||||
})
|
||||
|
||||
watch(() => query.data.value, value => {
|
||||
if (value) task.value = value
|
||||
}, { immediate: true })
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => startPickingTask(taskId, task.value?.version ?? 0),
|
||||
onSuccess(result) {
|
||||
taskTelemetry=startKbxTask(pickingScreen.id,pickingScreen.version,'wms-picking')
|
||||
task.value = result
|
||||
message.value = result.message ?? '피킹을 시작합니다.'
|
||||
playKbxWmsFeedback('neutral')
|
||||
},
|
||||
})
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: (command: KbxWmsScanCommand) => scanPickingBarcode(command),
|
||||
onSuccess(result, command) {
|
||||
removePickingScan(command.idempotencyKey)
|
||||
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
|
||||
task.value = result.task
|
||||
message.value = result.message
|
||||
playKbxWmsFeedback(result.feedback)
|
||||
if(result.task.stage==='completed'){taskTelemetry?.complete('success');taskTelemetry=undefined}
|
||||
},
|
||||
onError(error, command) {
|
||||
// 4xx is an authoritative server rejection. Only network/5xx ambiguity is safe-retried.
|
||||
if (isKbxProblem(error) && !isRetryableKbxProblem(error)) {
|
||||
message.value = error.title ?? '현재 작업 상태를 다시 확인하세요.'
|
||||
playKbxWmsFeedback('error')
|
||||
void query.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
// The request may have committed even if the response was lost. Reuse the exact idempotency key.
|
||||
queuePickingScan(command)
|
||||
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
|
||||
message.value = '서버 확인이 필요합니다. 연결되면 같은 작업을 안전하게 재전송합니다.'
|
||||
playKbxWmsFeedback('warning')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
const quantityMutation = useMutation({
|
||||
mutationFn: (pickedQty: number) => {
|
||||
if (!task.value?.currentLine) throw new Error('Current line not loaded')
|
||||
const command: KbxWmsSetQuantityCommand = {
|
||||
taskId,
|
||||
lineId: task.value.currentLine.lineId,
|
||||
pickedQty,
|
||||
idempotencyKey: newIdempotencyKey(taskId),
|
||||
expectedVersion: task.value.version,
|
||||
}
|
||||
return setPickingQuantity(command)
|
||||
},
|
||||
onSuccess(result) {
|
||||
task.value = result.task
|
||||
message.value = result.message
|
||||
quantityOpen.value = false
|
||||
playKbxWmsFeedback(result.feedback)
|
||||
},
|
||||
onError(error) {
|
||||
message.value = isKbxProblem(error)
|
||||
? error.title
|
||||
: '수량을 반영하지 못했습니다.'
|
||||
playKbxWmsFeedback('error')
|
||||
void query.refetch()
|
||||
},
|
||||
})
|
||||
|
||||
const exceptionMutation = useMutation({
|
||||
mutationFn: ({ type, memo }: { type: KbxWmsExceptionType; memo?: string }) => {
|
||||
if (!task.value) throw new Error('Task not loaded')
|
||||
return reportPickingException({
|
||||
taskId,
|
||||
lineId: task.value.currentLine?.lineId,
|
||||
type,
|
||||
memo,
|
||||
idempotencyKey: newIdempotencyKey(taskId),
|
||||
expectedVersion: task.value.version,
|
||||
})
|
||||
},
|
||||
onSuccess(result, input) {
|
||||
task.value = result
|
||||
kbxTelemetry.track('manual.intervention',{screenId:pickingScreen.id,screenVersion:pickingScreen.version,taskSessionId:taskTelemetry?.id,attributes:{workType:'wms-picking',reasonCode:input.type,exceptionType:input.type}})
|
||||
exceptionOpen.value = false
|
||||
message.value = result.message ?? '예외를 등록했습니다.'
|
||||
playKbxWmsFeedback('warning')
|
||||
},
|
||||
})
|
||||
|
||||
const scanEnabled = computed(() =>
|
||||
Boolean(
|
||||
task.value &&
|
||||
online.value &&
|
||||
pendingCommands.value === 0 &&
|
||||
!scanMutation.isPending.value &&
|
||||
['await-location', 'await-item'].includes(task.value.stage),
|
||||
),
|
||||
)
|
||||
|
||||
const scanLabel = computed(() => {
|
||||
switch (task.value?.stage) {
|
||||
case 'await-location': return 'LOCATION SCAN'
|
||||
case 'await-item': return 'ITEM SCAN'
|
||||
case 'processing': return 'PROCESSING'
|
||||
case 'completed': return 'COMPLETED'
|
||||
default: return 'SCAN READY'
|
||||
}
|
||||
})
|
||||
|
||||
async function handleScan(event: KbxBarcodeEvent) {
|
||||
if (!task.value || !scanEnabled.value) return
|
||||
taskTelemetry?.interaction('scan')
|
||||
kbxTelemetry.track('command.execute',{screenId:pickingScreen.id,screenVersion:pickingScreen.version,taskSessionId:taskTelemetry?.id,attributes:{commandId:'scan',operationKind:'command'}})
|
||||
|
||||
const command: KbxWmsScanCommand = {
|
||||
taskId,
|
||||
barcode: event.normalizedValue,
|
||||
source: event.source,
|
||||
idempotencyKey: newIdempotencyKey(taskId),
|
||||
expectedVersion: task.value.version,
|
||||
occurredAt: new Date(event.occurredAt).toISOString(),
|
||||
}
|
||||
|
||||
await scanMutation.mutateAsync(command).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function flushRetryQueue() {
|
||||
if (!online.value || syncing.value) return
|
||||
const queued = readPickingRetryQueue()
|
||||
.filter(x => x.command.taskId === taskId)
|
||||
.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt))
|
||||
if (!queued.length) return
|
||||
|
||||
syncing.value = true
|
||||
try {
|
||||
// Authoritative picking is sequence-sensitive. Replay one-by-one and stop at first failure.
|
||||
for (const item of queued) {
|
||||
try {
|
||||
const result = await scanPickingBarcode(item.command)
|
||||
task.value = result.task
|
||||
message.value = result.message
|
||||
removePickingScan(item.command.idempotencyKey)
|
||||
} catch (error) {
|
||||
if (isKbxProblem(error) && !isRetryableKbxProblem(error)) {
|
||||
removePickingScan(item.command.idempotencyKey)
|
||||
message.value = error.title ?? '작업 상태가 변경되었습니다. 최신 상태를 확인하세요.'
|
||||
playKbxWmsFeedback('error')
|
||||
await query.refetch()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(online, value => {
|
||||
if (value) void flushRetryQueue()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
|
||||
if (online.value) void flushRetryQueue()
|
||||
})
|
||||
|
||||
return {
|
||||
task,
|
||||
message,
|
||||
online,
|
||||
pendingCommands,
|
||||
syncing,
|
||||
scanEnabled,
|
||||
scanLabel,
|
||||
exceptionOpen,
|
||||
quantityOpen,
|
||||
loading: query.isLoading,
|
||||
starting: startMutation.isPending,
|
||||
scanning: scanMutation.isPending,
|
||||
reportingException: exceptionMutation.isPending,
|
||||
settingQuantity: quantityMutation.isPending,
|
||||
start: () => startMutation.mutateAsync(),
|
||||
handleScan,
|
||||
openException: () => { exceptionOpen.value = true },
|
||||
openQuantity: () => { quantityOpen.value = true },
|
||||
closeQuantity: () => { quantityOpen.value = false },
|
||||
setQuantity: (quantity: number) => quantityMutation.mutateAsync(quantity),
|
||||
closeException: () => { exceptionOpen.value = false },
|
||||
reportException: (type: KbxWmsExceptionType, memo?: string) => exceptionMutation.mutateAsync({ type, memo }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { KbxBarcodeCapture,KbxWmsActionButton,KbxWmsMobilePage } from '@kbx/ui'
|
||||
import { putawayScreen } from './putaway.definition'
|
||||
const online=ref(true);const stage=ref<'item'|'location'|'done'>('item');const item=ref('');const suggested=ref('A-01-03');const location=ref('');const msg=ref('적치할 상품을 스캔하세요.')
|
||||
function scan(v:string){if(stage.value==='item'){item.value=v;stage.value='location';msg.value=`추천 위치 ${suggested.value}를 스캔하세요.`;return}if(stage.value==='location'){if(v!==suggested.value){msg.value=`다른 위치입니다. ${suggested.value}로 이동하세요.`;return}location.value=v;stage.value='done';msg.value='적치가 완료되었습니다.'}}
|
||||
</script>
|
||||
<template><KbxWmsMobilePage :screen="putawayScreen" :progress="stage==='done'?'완료':'적치중'" :online="online"><p class="msg">{{msg}}</p><div v-if="item" class="item"><small>상품</small><strong>{{item}}</strong></div><div v-if="stage!=='item'" class="location"><small>추천 LOCATION</small><strong>{{suggested}}</strong></div><KbxBarcodeCapture v-if="stage!=='done'" :enabled="online" :label="stage==='item'?'상품 스캔':'위치 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='done'" label="다음 상품" @click="stage='item';item='';location='';msg='적치할 상품을 스캔하세요.'"/></template></KbxWmsMobilePage></template>
|
||||
<style scoped>.msg{font-weight:650}.item,.location{padding:16px;border:1px solid var(--kbx-color-border);border-radius:8px;margin-bottom:12px}.item small,.location small{display:block;color:var(--kbx-color-text-muted)}.item strong{font-size:22px}.location strong{font-size:32px}</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const putawayScreen=defineKbxScreen({id:'WMS-PUT-001',version:'1.0.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'입고 적치',helpKey:'WMS-PUT-001',permissions:['wms.putaway.execute'],telemetry:{enabled:true}})
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsPutawayRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/wms/putaway/:taskId', name:'wms-putaway', component:()=>import('./WmsPutawayPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-PUT-001' } },
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user