Merge pull request 'jskim-node' (#36) from jskim-node into main

Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/36
This commit is contained in:
jskim
2026-04-28 00:24:51 +00:00
606 changed files with 190806 additions and 3800 deletions
+3
View File
@@ -238,3 +238,6 @@ frontend/playwright.config.ts
frontend/tests/
frontend/test-results/
db/checkpoints/
# Playwright MCP 산출물 (커밋 금지)
.playwright-mcp/
@@ -1,50 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- button "사이드바 접기/펼치기" [ref=e4] [cursor=pointer]:
- img [ref=e5]
- generic [ref=e7]:
- generic [ref=e8]: W
- generic [ref=e9]: WACE 솔루션
- generic [ref=e10]:
- generic [ref=e12] [cursor=pointer]:
- img [ref=e13]
- generic [ref=e16]: 대시보드
- generic [ref=e17] [cursor=pointer]:
- generic [ref=e18]:
- img [ref=e19]
- generic [ref=e24]: 사용자 관리
- img [ref=e25]
- generic [ref=e27] [cursor=pointer]:
- generic [ref=e28]:
- img [ref=e29]
- generic [ref=e31]: 제품 관리
- img [ref=e32]
- generic [ref=e35] [cursor=pointer]:
- img [ref=e36]
- generic [ref=e37]: 통계/분석
- generic [ref=e38] [cursor=pointer]:
- generic [ref=e39]:
- img [ref=e40]
- generic [ref=e43]: 시스템 설정
- img [ref=e44]
- generic [ref=e47] [cursor=pointer]:
- generic [ref=e48]:
- generic [ref=e49]:
- generic [ref=e50]: 박개발
- generic [ref=e51]: 개발팀
- generic [ref=e52]:
- generic [ref=e54]: 대시보드
- generic [ref=e55]: 컨텐츠 영역
- generic [ref=e56]:
- strong [ref=e57]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e58]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e59]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e60]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,50 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- button "사이드바 접기/펼치기" [ref=e4] [cursor=pointer]:
- img [ref=e5]
- generic [ref=e7]:
- generic [ref=e8]: W
- generic [ref=e9]: WACE 솔루션
- generic [ref=e10]:
- generic [ref=e12] [cursor=pointer]:
- img [ref=e13]
- generic [ref=e16]: 대시보드
- generic [ref=e17] [cursor=pointer]:
- generic [ref=e18]:
- img [ref=e19]
- generic [ref=e24]: 사용자 관리
- img [ref=e25]
- generic [ref=e27] [cursor=pointer]:
- generic [ref=e28]:
- img [ref=e29]
- generic [ref=e31]: 제품 관리
- img [ref=e32]
- generic [ref=e35] [cursor=pointer]:
- img [ref=e36]
- generic [ref=e37]: 통계/분석
- generic [ref=e38] [cursor=pointer]:
- generic [ref=e39]:
- img [ref=e40]
- generic [ref=e43]: 시스템 설정
- img [ref=e44]
- generic [ref=e47] [cursor=pointer]:
- generic [ref=e48]:
- generic [ref=e49]:
- generic [ref=e50]: 박개발
- generic [ref=e51]: 개발팀
- generic [ref=e52]:
- generic [ref=e54]: 대시보드
- generic [ref=e55]: 컨텐츠 영역
- generic [ref=e56]:
- strong [ref=e57]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e58]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e59]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e60]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,38 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- button "사이드바 접기/펼치기" [active] [ref=e4] [cursor=pointer]:
- img [ref=e5]
- generic [ref=e8]: W
- generic [ref=e61]:
- generic [ref=e62] [cursor=pointer]:
- img [ref=e63]
- generic: 대시보드
- generic [ref=e66] [cursor=pointer]:
- img [ref=e67]
- generic: 사용자 관리
- generic [ref=e72] [cursor=pointer]:
- img [ref=e73]
- generic: 제품 관리
- generic [ref=e75] [cursor=pointer]:
- img [ref=e76]
- generic: 통계/분석
- generic [ref=e77] [cursor=pointer]:
- img [ref=e78]
- generic: 시스템 설정
- generic [ref=e48] [cursor=pointer]:
- generic [ref=e52]:
- generic [ref=e54]: 대시보드
- generic [ref=e55]: 컨텐츠 영역
- generic [ref=e56]:
- strong [ref=e57]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e58]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e59]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e60]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,49 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- button "사이드바 접기/펼치기" [ref=e4] [cursor=pointer]:
- img [ref=e5]
- generic [ref=e8]: W
- generic [ref=e61]:
- generic [ref=e62] [cursor=pointer]:
- img [ref=e63]
- generic: 대시보드
- generic [ref=e66] [cursor=pointer]:
- img [ref=e67]
- generic: 사용자 관리
- generic [ref=e81]:
- generic [ref=e82]: 사용자 관리
- generic [ref=e83]:
- img [ref=e84]
- text: 사용자 목록
- generic [ref=e87]:
- img [ref=e88]
- text: 권한 설정
- generic [ref=e91]:
- img [ref=e92]
- text: 부서 관리
- generic [ref=e72] [cursor=pointer]:
- img [ref=e73]
- generic: 제품 관리
- generic [ref=e75] [cursor=pointer]:
- img [ref=e76]
- generic: 통계/분석
- generic [ref=e77] [cursor=pointer]:
- img [ref=e78]
- generic: 시스템 설정
- generic [ref=e48] [cursor=pointer]:
- generic [ref=e52]:
- generic [ref=e54]: 대시보드
- generic [ref=e55]: 컨텐츠 영역
- generic [ref=e56]:
- strong [ref=e57]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e58]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e59]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e60]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,50 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- button "사이드바 접기/펼치기" [ref=e4] [cursor=pointer]:
- img [ref=e5]
- generic [ref=e7]:
- generic [ref=e8]: W
- generic [ref=e9]: WACE 솔루션
- generic [ref=e10]:
- generic [ref=e12] [cursor=pointer]:
- img [ref=e13]
- generic [ref=e16]: 대시보드
- generic [ref=e17] [cursor=pointer]:
- generic [ref=e18]:
- img [ref=e19]
- generic [ref=e24]: 사용자 관리
- img [ref=e25]
- generic [ref=e27] [cursor=pointer]:
- generic [ref=e28]:
- img [ref=e29]
- generic [ref=e31]: 제품 관리
- img [ref=e32]
- generic [ref=e35] [cursor=pointer]:
- img [ref=e36]
- generic [ref=e37]: 통계/분석
- generic [ref=e38] [cursor=pointer]:
- generic [ref=e39]:
- img [ref=e40]
- generic [ref=e43]: 시스템 설정
- img [ref=e44]
- generic [ref=e47] [cursor=pointer]:
- generic [ref=e48]:
- generic [ref=e49]:
- generic [ref=e50]: 박개발
- generic [ref=e51]: 개발팀
- generic [ref=e52]:
- generic [ref=e54]: 대시보드
- generic [ref=e55]: 컨텐츠 영역
- generic [ref=e56]:
- strong [ref=e57]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e58]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e59]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e60]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,38 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- button "사이드바 접기/펼치기" [active] [ref=e4] [cursor=pointer]:
- img [ref=e5]
- generic [ref=e8]: W
- generic [ref=e61]:
- generic [ref=e62] [cursor=pointer]:
- img [ref=e63]
- generic: 대시보드
- generic [ref=e66] [cursor=pointer]:
- img [ref=e67]
- generic: 사용자 관리
- generic [ref=e72] [cursor=pointer]:
- img [ref=e73]
- generic: 제품 관리
- generic [ref=e75] [cursor=pointer]:
- img [ref=e76]
- generic: 통계/분석
- generic [ref=e77] [cursor=pointer]:
- img [ref=e78]
- generic: 시스템 설정
- generic [ref=e48] [cursor=pointer]:
- generic [ref=e52]:
- generic [ref=e54]: 대시보드
- generic [ref=e55]: 컨텐츠 영역
- generic [ref=e56]:
- strong [ref=e57]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e58]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e59]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e60]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,49 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- button "사이드바 접기/펼치기" [ref=e4] [cursor=pointer]:
- img [ref=e5]
- generic [ref=e8]: W
- generic [ref=e61]:
- generic [ref=e62] [cursor=pointer]:
- img [ref=e63]
- generic: 대시보드
- generic [ref=e66] [cursor=pointer]:
- img [ref=e67]
- generic: 사용자 관리
- generic [ref=e81]:
- generic [ref=e82]: 사용자 관리
- generic [ref=e83]:
- img [ref=e84]
- text: 사용자 목록
- generic [ref=e87]:
- img [ref=e88]
- text: 권한 설정
- generic [ref=e91]:
- img [ref=e92]
- text: 부서 관리
- generic [ref=e72] [cursor=pointer]:
- img [ref=e73]
- generic: 제품 관리
- generic [ref=e75] [cursor=pointer]:
- img [ref=e76]
- generic: 통계/분석
- generic [ref=e77] [cursor=pointer]:
- img [ref=e78]
- generic: 시스템 설정
- generic [ref=e48] [cursor=pointer]:
- generic [ref=e52]:
- generic [ref=e54]: 대시보드
- generic [ref=e55]: 컨텐츠 영역
- generic [ref=e56]:
- strong [ref=e57]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e58]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e59]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e60]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,50 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- button "사이드바 접기/펼치기" [ref=e4] [cursor=pointer]:
- img [ref=e5]
- generic [ref=e7]:
- generic [ref=e8]: W
- generic [ref=e9]: WACE 솔루션
- generic [ref=e10]:
- generic [ref=e12] [cursor=pointer]:
- img [ref=e13]
- generic [ref=e16]: 대시보드
- generic [ref=e17] [cursor=pointer]:
- generic [ref=e18]:
- img [ref=e19]
- generic [ref=e24]: 사용자 관리
- img [ref=e25]
- generic [ref=e27] [cursor=pointer]:
- generic [ref=e28]:
- img [ref=e29]
- generic [ref=e31]: 제품 관리
- img [ref=e32]
- generic [ref=e35] [cursor=pointer]:
- img [ref=e36]
- generic [ref=e37]: 통계/분석
- generic [ref=e38] [cursor=pointer]:
- generic [ref=e39]:
- img [ref=e40]
- generic [ref=e43]: 시스템 설정
- img [ref=e44]
- generic [ref=e47] [cursor=pointer]:
- generic [ref=e48]:
- generic [ref=e49]:
- generic [ref=e50]: 박개발
- generic [ref=e51]: 개발팀
- generic [ref=e52]:
- generic [ref=e54]: 대시보드
- generic [ref=e55]: 컨텐츠 영역
- generic [ref=e56]:
- strong [ref=e57]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e58]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e59]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e60]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,50 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- button "사이드바 접기/펼치기" [ref=e4] [cursor=pointer]:
- img [ref=e5]
- generic [ref=e7]:
- generic [ref=e8]: W
- generic [ref=e9]: WACE 솔루션
- generic [ref=e10]:
- generic [ref=e12] [cursor=pointer]:
- img [ref=e13]
- generic [ref=e16]: 대시보드
- generic [ref=e17] [cursor=pointer]:
- generic [ref=e18]:
- img [ref=e19]
- generic [ref=e24]: 사용자 관리
- img [ref=e25]
- generic [ref=e27] [cursor=pointer]:
- generic [ref=e28]:
- img [ref=e29]
- generic [ref=e31]: 제품 관리
- img [ref=e32]
- generic [ref=e35] [cursor=pointer]:
- img [ref=e36]
- generic [ref=e37]: 통계/분석
- generic [ref=e38] [cursor=pointer]:
- generic [ref=e39]:
- img [ref=e40]
- generic [ref=e43]: 시스템 설정
- img [ref=e44]
- generic [ref=e47] [cursor=pointer]:
- generic [ref=e48]:
- generic [ref=e49]:
- generic [ref=e50]: 박개발
- generic [ref=e51]: 개발팀
- generic [ref=e52]:
- generic [ref=e54]: 대시보드
- generic [ref=e55]: 컨텐츠 영역
- generic [ref=e56]:
- strong [ref=e57]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e58]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e59]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e60]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,50 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: W
- generic [ref=e6]: WACE 솔루션
- button "사이드바 접기/펼치기" [ref=e7] [cursor=pointer]:
- img [ref=e8]
- generic [ref=e11]:
- generic [ref=e13] [cursor=pointer]:
- img [ref=e14]
- generic [ref=e17]: 대시보드
- generic [ref=e18] [cursor=pointer]:
- generic [ref=e19]:
- img [ref=e20]
- generic [ref=e25]: 사용자 관리
- img [ref=e26]
- generic [ref=e28] [cursor=pointer]:
- generic [ref=e29]:
- img [ref=e30]
- generic [ref=e32]: 제품 관리
- img [ref=e33]
- generic [ref=e36] [cursor=pointer]:
- img [ref=e37]
- generic [ref=e38]: 통계/분석
- generic [ref=e39] [cursor=pointer]:
- generic [ref=e40]:
- img [ref=e41]
- generic [ref=e44]: 시스템 설정
- img [ref=e45]
- generic [ref=e48] [cursor=pointer]:
- generic [ref=e49]:
- generic [ref=e50]:
- generic [ref=e51]: 박개발
- generic [ref=e52]: 개발팀
- generic [ref=e53]:
- generic [ref=e55]: 대시보드
- generic [ref=e56]: 컨텐츠 영역
- generic [ref=e57]:
- strong [ref=e58]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e59]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e60]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e61]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,39 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: W
- button "사이드바 접기/펼치기" [active] [ref=e7] [cursor=pointer]:
- img [ref=e62]
- generic [ref=e65]:
- generic [ref=e66] [cursor=pointer]:
- img [ref=e67]
- generic: 대시보드
- generic [ref=e70] [cursor=pointer]:
- img [ref=e71]
- generic: 사용자 관리
- generic [ref=e76] [cursor=pointer]:
- img [ref=e77]
- generic: 제품 관리
- generic [ref=e79] [cursor=pointer]:
- img [ref=e80]
- generic: 통계/분석
- generic [ref=e81] [cursor=pointer]:
- img [ref=e82]
- generic: 시스템 설정
- generic [ref=e49] [cursor=pointer]:
- generic [ref=e53]:
- generic [ref=e55]: 대시보드
- generic [ref=e56]: 컨텐츠 영역
- generic [ref=e57]:
- strong [ref=e58]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e59]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e60]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e61]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,50 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: W
- generic [ref=e6]: WACE 솔루션
- button "사이드바 접기/펼치기" [ref=e7] [cursor=pointer]:
- img [ref=e8]
- generic [ref=e11]:
- generic [ref=e13] [cursor=pointer]:
- img [ref=e14]
- generic [ref=e17]: 대시보드
- generic [ref=e18] [cursor=pointer]:
- generic [ref=e19]:
- img [ref=e20]
- generic [ref=e25]: 사용자 관리
- img [ref=e26]
- generic [ref=e28] [cursor=pointer]:
- generic [ref=e29]:
- img [ref=e30]
- generic [ref=e32]: 제품 관리
- img [ref=e33]
- generic [ref=e36] [cursor=pointer]:
- img [ref=e37]
- generic [ref=e38]: 통계/분석
- generic [ref=e39] [cursor=pointer]:
- generic [ref=e40]:
- img [ref=e41]
- generic [ref=e44]: 시스템 설정
- img [ref=e45]
- generic [ref=e48] [cursor=pointer]:
- generic [ref=e49]:
- generic [ref=e50]:
- generic [ref=e51]: 박개발
- generic [ref=e52]: 개발팀
- generic [ref=e53]:
- generic [ref=e55]: 대시보드
- generic [ref=e56]: 컨텐츠 영역
- generic [ref=e57]:
- strong [ref=e58]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e59]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e60]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e61]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,39 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: W
- button "사이드바 접기/펼치기" [active] [ref=e7] [cursor=pointer]:
- img [ref=e62]
- generic [ref=e65]:
- generic [ref=e66] [cursor=pointer]:
- img [ref=e67]
- generic: 대시보드
- generic [ref=e70] [cursor=pointer]:
- img [ref=e71]
- generic: 사용자 관리
- generic [ref=e76] [cursor=pointer]:
- img [ref=e77]
- generic: 제품 관리
- generic [ref=e79] [cursor=pointer]:
- img [ref=e80]
- generic: 통계/분석
- generic [ref=e81] [cursor=pointer]:
- img [ref=e82]
- generic: 시스템 설정
- generic [ref=e49] [cursor=pointer]:
- generic [ref=e53]:
- generic [ref=e55]: 대시보드
- generic [ref=e56]: 컨텐츠 영역
- generic [ref=e57]:
- strong [ref=e58]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e59]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e60]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e61]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,50 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: W
- generic [ref=e6]: WACE 솔루션
- button "사이드바 접기" [ref=e7] [cursor=pointer]:
- img [ref=e8]
- generic [ref=e11]:
- generic [ref=e13] [cursor=pointer]:
- img [ref=e14]
- generic [ref=e17]: 대시보드
- generic [ref=e18] [cursor=pointer]:
- generic [ref=e19]:
- img [ref=e20]
- generic [ref=e25]: 사용자 관리
- img [ref=e26]
- generic [ref=e28] [cursor=pointer]:
- generic [ref=e29]:
- img [ref=e30]
- generic [ref=e32]: 제품 관리
- img [ref=e33]
- generic [ref=e36] [cursor=pointer]:
- img [ref=e37]
- generic [ref=e38]: 통계/분석
- generic [ref=e39] [cursor=pointer]:
- generic [ref=e40]:
- img [ref=e41]
- generic [ref=e44]: 시스템 설정
- img [ref=e45]
- generic [ref=e48] [cursor=pointer]:
- generic [ref=e49]:
- generic [ref=e50]:
- generic [ref=e51]: 박개발
- generic [ref=e52]: 개발팀
- generic [ref=e53]:
- generic [ref=e55]: 대시보드
- generic [ref=e56]: 컨텐츠 영역
- generic [ref=e57]:
- strong [ref=e58]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e59]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e60]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e61]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,38 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- generic [ref=e5]: W
- button "사이드바 펼치기" [ref=e63] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e67]:
- generic [ref=e68] [cursor=pointer]:
- img [ref=e69]
- generic: 대시보드
- generic [ref=e72] [cursor=pointer]:
- img [ref=e73]
- generic: 사용자 관리
- generic [ref=e78] [cursor=pointer]:
- img [ref=e79]
- generic: 제품 관리
- generic [ref=e81] [cursor=pointer]:
- img [ref=e82]
- generic: 통계/분석
- generic [ref=e83] [cursor=pointer]:
- img [ref=e84]
- generic: 시스템 설정
- generic [ref=e49] [cursor=pointer]:
- generic [ref=e53]:
- generic [ref=e55]: 대시보드
- generic [ref=e56]: 컨텐츠 영역
- generic [ref=e57]:
- strong [ref=e58]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e59]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e60]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e61]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1,49 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- generic [ref=e5]: W
- button "사이드바 펼치기" [ref=e63] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e67]:
- generic [ref=e68] [cursor=pointer]:
- img [ref=e69]
- generic: 대시보드
- generic [ref=e72] [cursor=pointer]:
- img [ref=e73]
- generic: 사용자 관리
- generic [ref=e87]:
- generic [ref=e88]: 사용자 관리
- generic [ref=e89]:
- img [ref=e90]
- text: 사용자 목록
- generic [ref=e93]:
- img [ref=e94]
- text: 권한 설정
- generic [ref=e97]:
- img [ref=e98]
- text: 부서 관리
- generic [ref=e78] [cursor=pointer]:
- img [ref=e79]
- generic: 제품 관리
- generic [ref=e81] [cursor=pointer]:
- img [ref=e82]
- generic: 통계/분석
- generic [ref=e83] [cursor=pointer]:
- img [ref=e84]
- generic: 시스템 설정
- generic [ref=e49] [cursor=pointer]:
- generic [ref=e53]:
- generic [ref=e55]: 대시보드
- generic [ref=e56]: 컨텐츠 영역
- generic [ref=e57]:
- strong [ref=e58]: 사이드바 프로토타입
- text: • 사이드바 우측
- strong [ref=e59]: ◀ 버튼
- text: 을 클릭하면 축소
- text: • 축소 상태에서 아이콘
- strong [ref=e60]: hover → 툴팁
- text: 표시
- text: • 하위 메뉴가 있는 아이콘
- strong [ref=e61]: 클릭 → 플라이아웃
- text: 팝업
- text: • 리프 메뉴 아이콘 클릭 → 바로 이동
@@ -1 +0,0 @@
- generic [ref=e2]: "{\"success\": false, \"error\": \"Invalid endpoint\"}"
@@ -1,27 +0,0 @@
- generic [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- heading "📦 판매품목 목록" [level=3] [ref=e7]
- generic [ref=e8]:
- text:
- strong [ref=e9]: "0"
- text:
- combobox [ref=e10]:
- option "⚙️ Group by" [selected]
- option "통화"
- option "단위"
- option "상태"
- generic [ref=e11]:
- generic [ref=e12] [cursor=pointer]:
- checkbox "미사용 포함" [ref=e13]
- generic [ref=e14]: 미사용 포함
- button " 품목 추가" [ref=e15]
- button "✏️ 수정" [disabled] [ref=e16]
- button "⏸️ 사용/미사용" [disabled] [ref=e17]
- generic [ref=e20]:
- generic [ref=e21]:
- heading "🏢 거래처별 정보" [level=3] [ref=e22]
- button " 거래처 추가" [disabled] [ref=e23]
- generic [ref=e25]:
- generic [ref=e26]: 📭
- generic [ref=e27]: 왼쪽에서 품목을 선택하세요
@@ -1,27 +0,0 @@
- generic [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- heading "📦 판매품목 목록" [level=3] [ref=e7]
- generic [ref=e8]:
- text:
- strong [ref=e9]: "0"
- text:
- combobox [ref=e10]:
- option "⚙️ Group by" [selected]
- option "통화"
- option "단위"
- option "상태"
- generic [ref=e11]:
- generic [ref=e12] [cursor=pointer]:
- checkbox "미사용 포함" [ref=e13]
- generic [ref=e14]: 미사용 포함
- button " 품목 추가" [ref=e15]
- button "✏️ 수정" [disabled] [ref=e16]
- button "⏸️ 사용/미사용" [disabled] [ref=e17]
- generic [ref=e20]:
- generic [ref=e21]:
- heading "🏢 거래처별 정보" [level=3] [ref=e22]
- button " 거래처 추가" [disabled] [ref=e23]
- generic [ref=e25]:
- generic [ref=e26]: 📭
- generic [ref=e27]: 왼쪽에서 품목을 선택하세요
@@ -1,26 +0,0 @@
- generic [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- heading "🏢 거래처 목록" [level=3] [ref=e7]
- generic [ref=e8]:
- text:
- strong [ref=e9]: "0"
- text:
- combobox [ref=e10] [cursor=pointer]:
- option "⚙️ Group by" [selected]
- option "거래 유형"
- option "상태"
- generic [ref=e11]:
- generic [ref=e12] [cursor=pointer]:
- checkbox "미사용 포함" [ref=e13]
- generic [ref=e14]: 미사용 포함
- button " 거래처 등록" [ref=e15]
- button "✏️ 수정" [disabled] [ref=e16]
- button "⏸️ 사용/미사용" [disabled] [ref=e17]
- generic [ref=e20]:
- generic [ref=e21]:
- heading "📦 거래처별 품목 정보" [level=3] [ref=e22]
- button " 품목 추가" [disabled] [ref=e23]
- generic [ref=e25]:
- generic [ref=e26]: 📭
- generic [ref=e27]: 왼쪽에서 거래처를 선택하세요
-2
View File
@@ -947,7 +947,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -2185,7 +2184,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
+2
View File
@@ -156,6 +156,7 @@ import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
import cuttingPlanRoutes from "./routes/cuttingPlanRoutes"; // 절단계획 관리
import salesOrderBulkRoutes from "./routes/salesOrderBulkRoutes"; // 수주 엑셀 일괄업로드 (제일그라스)
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별)
import reportCellValueRoutes from "./routes/reportCellValueRoutes"; // 리포트 셀 커스텀 입력값 (input 셀)
@@ -383,6 +384,7 @@ app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
app.use("/api/cutting-plan", cuttingPlanRoutes); // 절단계획 관리
app.use("/api/sales-order", salesOrderBulkRoutes); // 수주 엑셀 일괄업로드
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장)
app.use("/api/report-cell-values", reportCellValueRoutes); // 리포트 셀 커스텀 입력값
@@ -0,0 +1,378 @@
/**
* copyChecklistToSplit 단위 테스트
*
* 대상: /src/controllers/popProductionController.ts 의 copyChecklistToSplit
*
* 전략: 실제 DB 연결 없이 client.query 를 Jest mock 으로 주입한다.
* 테스트 대상 함수는 외부에서 주입된 client 만을 사용하여 쿼리를 실행하므로
* pg/Pool 전체를 모킹할 필요가 없다 (순수한 query router 로직 검증).
*
* 커버 분기:
* - A-1 wi_* 커스텀 템플릿 존재 (wi_process_work_item) -> wi_* 에서 복사
* - A-2 wi_* 미존재 또는 workInstructionNo 미지정 -> 원본 process_work_item 에서 복사
* - skipAStrategy=true -> A 전략 전체 skip, B 전략 진입
* - A 에서 0 건 -> B 전략 fallthrough
* - routingDetailId=null -> 곧장 B 전략
* - B 전략 = 마스터 wop 의 기존 process_work_result 구조 복사
*/
import { copyChecklistToSplit } from "../popProductionController";
type QueryCall = { text: string; values?: unknown[] };
/**
* client.query mock 헬퍼.
* calls 배열에 호출 인자를 순서대로 저장하고, responses 큐에서 응답을 순서대로 반환한다.
* responses 가 고갈되면 기본값 { rows: [], rowCount: 0 } 을 반환한다.
*/
function makeClient(
responses: Array<{ rows?: unknown[]; rowCount?: number }>,
): {
client: { query: jest.Mock };
calls: QueryCall[];
} {
const calls: QueryCall[] = [];
const queue = [...responses];
const client = {
query: jest.fn((text: string, values?: unknown[]) => {
calls.push({ text, values });
const next = queue.shift();
return Promise.resolve(next ?? { rows: [], rowCount: 0 });
}),
};
return { client, calls };
}
const COMPANY = "TESTCO";
const USER = "tester01";
const MASTER_WOP = "master-wop-id";
const WOP_RESULT = "wop-result-id";
const ROUTING_DETAIL = "routing-detail-id";
const WI_NO = "WI-20260424-001";
describe("copyChecklistToSplit", () => {
describe("A-1: wi_* 커스텀 템플릿 우선 복사", () => {
it("workInstructionNo 지정 + wi_process_work_item row 존재 시 wi_* 템플릿에서 복사한다", async () => {
const { client, calls } = makeClient([
{ rows: [{ "?column?": 1 }], rowCount: 1 },
{ rows: [], rowCount: 3 },
]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
expect(inserted).toBe(3);
expect(client.query).toHaveBeenCalledTimes(2);
expect(calls[0].text).toContain("FROM wi_process_work_item");
expect(calls[0].values).toEqual([
WI_NO,
ROUTING_DETAIL,
COMPANY,
]);
expect(calls[1].text).toContain("INSERT INTO process_work_result");
expect(calls[1].text).toContain("FROM wi_process_work_item wi");
expect(calls[1].text).toContain("wi_process_work_item_detail wid");
expect(calls[1].values).toEqual([
WOP_RESULT,
USER,
ROUTING_DETAIL,
COMPANY,
WI_NO,
]);
});
it("A-1 결과가 0건이면 B 전략으로 fallthrough 하여 마스터 스냅샷에서 복사한다", async () => {
const { client, calls } = makeClient([
{ rows: [{ "?column?": 1 }], rowCount: 1 },
{ rows: [], rowCount: 0 },
{ rows: [], rowCount: 7 },
]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
expect(inserted).toBe(7);
expect(client.query).toHaveBeenCalledTimes(3);
expect(calls[2].text).toContain("FROM process_work_result");
expect(calls[2].text).toContain("WHERE work_order_process_id = $3");
expect(calls[2].values).toEqual([
WOP_RESULT,
USER,
MASTER_WOP,
COMPANY,
]);
});
});
describe("A-2: 원본 템플릿 fallback", () => {
it("workInstructionNo 미지정 시 원본 process_work_item 에서 복사한다", async () => {
const { client, calls } = makeClient([{ rows: [], rowCount: 5 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
);
expect(inserted).toBe(5);
expect(client.query).toHaveBeenCalledTimes(1);
expect(calls[0].text).toContain("FROM process_work_item pwi");
expect(calls[0].text).toContain("process_work_item_detail pwd");
expect(calls[0].text).not.toContain("wi_process_work_item");
expect(calls[0].values).toEqual([
WOP_RESULT,
USER,
ROUTING_DETAIL,
COMPANY,
]);
});
it("workInstructionNo 지정됐지만 wi_* row 가 0개면 원본 템플릿에서 복사한다", async () => {
const { client, calls } = makeClient([
{ rows: [], rowCount: 0 },
{ rows: [], rowCount: 4 },
]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
expect(inserted).toBe(4);
expect(client.query).toHaveBeenCalledTimes(2);
expect(calls[0].text).toContain("SELECT 1 FROM wi_process_work_item");
expect(calls[1].text).toContain("FROM process_work_item pwi");
expect(calls[1].text).not.toContain("wi_process_work_item");
});
it("A-2 결과가 0건이면 B 전략으로 fallthrough 한다", async () => {
const { client, calls } = makeClient([
{ rows: [], rowCount: 0 },
{ rows: [], rowCount: 2 },
]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
);
expect(inserted).toBe(2);
expect(client.query).toHaveBeenCalledTimes(2);
expect(calls[1].text).toContain("FROM process_work_result");
expect(calls[1].text).toContain("WHERE work_order_process_id = $3");
});
});
describe("skipAStrategy: A 전략 전체 건너뛰기", () => {
it("skipAStrategy=true 이면 routingDetailId 와 workInstructionNo 가 있어도 B 전략만 실행한다", async () => {
const { client, calls } = makeClient([{ rows: [], rowCount: 10 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO, skipAStrategy: true },
);
expect(inserted).toBe(10);
expect(client.query).toHaveBeenCalledTimes(1);
expect(calls[0].text).toContain("FROM process_work_result");
expect(calls[0].text).toContain("WHERE work_order_process_id = $3");
expect(calls[0].text).not.toContain("wi_process_work_item");
expect(calls[0].text).not.toContain("process_work_item pwi");
expect(calls[0].values).toEqual([
WOP_RESULT,
USER,
MASTER_WOP,
COMPANY,
]);
});
it("skipAStrategy=false (명시) 는 기본 동작과 동일하다", async () => {
const { client } = makeClient([{ rows: [], rowCount: 2 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ skipAStrategy: false },
);
expect(inserted).toBe(2);
expect(client.query).toHaveBeenCalledTimes(1);
});
});
describe("B: routingDetailId 없음", () => {
it("routingDetailId=null 이면 A 전략 skip, 곧장 B 전략 실행", async () => {
const { client, calls } = makeClient([{ rows: [], rowCount: 6 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
null,
COMPANY,
USER,
);
expect(inserted).toBe(6);
expect(client.query).toHaveBeenCalledTimes(1);
expect(calls[0].text).toContain("FROM process_work_result");
expect(calls[0].values).toEqual([
WOP_RESULT,
USER,
MASTER_WOP,
COMPANY,
]);
});
it("routingDetailId=null + workInstructionNo 지정은 workInstructionNo 무시하고 B 전략 실행", async () => {
const { client, calls } = makeClient([{ rows: [], rowCount: 1 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
null,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
expect(inserted).toBe(1);
expect(client.query).toHaveBeenCalledTimes(1);
expect(calls[0].text).not.toContain("wi_process_work_item");
expect(calls[0].text).toContain("FROM process_work_result");
});
});
describe("엣지 케이스", () => {
it("B 전략에서도 0 건이면 0 을 반환한다", async () => {
const { client } = makeClient([{ rows: [], rowCount: 0 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
null,
COMPANY,
USER,
);
expect(inserted).toBe(0);
});
it("rowCount 가 undefined 면 0 을 반환한다 (null safety)", async () => {
const { client } = makeClient([{ rows: [] }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
null,
COMPANY,
USER,
);
expect(inserted).toBe(0);
});
it("wi_* 체크 쿼리에 company_code 가 필터로 포함된다 (멀티테넌시)", async () => {
const { client, calls } = makeClient([
{ rows: [], rowCount: 0 },
{ rows: [], rowCount: 1 },
]);
await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
expect(calls[0].text).toContain("company_code = $3");
expect(calls[0].values?.[2]).toBe(COMPANY);
});
it("모든 INSERT 쿼리는 파라미터 바인딩을 사용한다 (문자열 삽입 금지)", async () => {
const { client, calls } = makeClient([
{ rows: [{ "?column?": 1 }], rowCount: 1 },
{ rows: [], rowCount: 1 },
]);
await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
// 모든 호출에서 values 가 존재하고 query 텍스트에 placeholder 가 있어야 한다
for (const call of calls) {
expect(call.values).toBeDefined();
expect(Array.isArray(call.values)).toBe(true);
expect(call.text).toMatch(/\$\d+/);
}
});
it("B 전략은 항상 masterProcessId 로 소스 스냅샷을 조회한다", async () => {
const { client, calls } = makeClient([{ rows: [], rowCount: 3 }]);
await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
null,
COMPANY,
USER,
);
const insertSql = calls[0].text;
expect(insertSql).toContain("FROM process_work_result");
expect(insertSql).toContain("WHERE work_order_process_id = $3");
expect(calls[0].values?.[2]).toBe(MASTER_WOP);
expect(calls[0].values?.[3]).toBe(COMPANY);
});
});
});
+21 -3
View File
@@ -103,7 +103,10 @@ export class AuthController {
} else if (popResult.childMenus.length === 1) {
popLandingPath = popResult.childMenus[0].menu_url;
} else if (popResult.childMenus.length > 1) {
popLandingPath = "/pop";
const userCompanyCode = loginResult.userInfo.companyCode;
if (userCompanyCode && userCompanyCode !== "*") {
popLandingPath = `/${userCompanyCode}/pop/main`;
}
}
logger.debug(`POP 랜딩 경로: ${popLandingPath}`);
@@ -227,10 +230,24 @@ export class AuthController {
}
}
// 새로운 JWT 토큰 발급 (company_code만 변경)
// 전환 대상 회사명 조회
let targetCompanyName: string | undefined;
if (companyCode === "*") {
targetCompanyName = "공통";
} else {
const { query: dbQuery } = await import("../database/db");
const companyRows = await dbQuery<{ company_name: string }>(
"SELECT company_name FROM company_mng WHERE company_code = $1",
[companyCode.trim()]
);
targetCompanyName = companyRows[0]?.company_name || companyCode.trim();
}
// 새로운 JWT 토큰 발급 (company_code + company_name 변경)
const newPersonBean: PersonBean = {
...currentUser,
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
companyCode: companyCode.trim(),
companyName: targetCompanyName,
};
const newToken = JwtUtils.generateToken(newPersonBean);
@@ -355,6 +372,7 @@ export class AuthController {
deptName: dbUserInfo.deptName || "",
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
companyName: userInfo.companyName || dbUserInfo.companyName || "", // JWT 토큰 우선 (회사 전환 시 갱신됨)
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
userTypeName: dbUserInfo.userTypeName || "일반사용자",
email: dbUserInfo.email || "",
@@ -403,6 +403,56 @@ export async function createMoldInspection(req: AuthenticatedRequest, res: Respo
}
}
export async function updateMoldInspection(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const {
inspection_item, inspection_cycle, inspection_method,
inspection_content, lower_limit, upper_limit, unit,
is_active, checklist, remarks,
} = req.body;
if (!inspection_item) {
res.status(400).json({ success: false, message: "점검항목명은 필수입니다." });
return;
}
const sql = `
UPDATE mold_inspection_item SET
inspection_item = $1,
inspection_cycle = $2,
inspection_method = $3,
inspection_content = $4,
lower_limit = $5,
upper_limit = $6,
unit = $7,
is_active = COALESCE($8, is_active),
checklist = $9,
remarks = $10
WHERE id = $11 AND company_code = $12
RETURNING *
`;
const params = [
inspection_item, inspection_cycle || null, inspection_method || null,
inspection_content || null, lower_limit || null, upper_limit || null,
unit || null, is_active, checklist || null, remarks || null,
id, companyCode,
];
const result = await query(sql, params);
if (result.length === 0) {
res.status(404).json({ success: false, message: "점검항목을 찾을 수 없습니다." });
return;
}
res.json({ success: true, data: result[0], message: "점검항목이 수정되었습니다." });
} catch (error: any) {
logger.error("점검항목 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteMoldInspection(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
@@ -481,6 +531,52 @@ export async function createMoldPart(req: AuthenticatedRequest, res: Response):
}
}
export async function updateMoldPart(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const {
part_name, replacement_cycle, unit, specification,
manufacturer, manufacturer_code, image_path, remarks,
} = req.body;
if (!part_name) {
res.status(400).json({ success: false, message: "부품명은 필수입니다." });
return;
}
const sql = `
UPDATE mold_part SET
part_name = $1,
replacement_cycle = $2,
unit = $3,
specification = $4,
manufacturer = $5,
manufacturer_code = $6,
image_path = $7,
remarks = $8
WHERE id = $9 AND company_code = $10
RETURNING *
`;
const params = [
part_name, replacement_cycle || null, unit || null,
specification || null, manufacturer || null, manufacturer_code || null,
image_path || null, remarks || null, id, companyCode,
];
const result = await query(sql, params);
if (result.length === 0) {
res.status(404).json({ success: false, message: "부품을 찾을 수 없습니다." });
return;
}
res.json({ success: true, data: result[0], message: "부품이 수정되었습니다." });
} catch (error: any) {
logger.error("부품 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteMoldPart(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
@@ -10,6 +10,7 @@
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { resolveCategoryCode } from "../utils/categoryUtils";
import { adjustInventory } from "../utils/inventoryUtils";
import { logger } from "../utils/logger";
@@ -127,6 +128,14 @@ export async function create(req: AuthenticatedRequest, res: Response) {
const insertedRows: any[] = [];
for (const item of items) {
// 저장용 value_code (조건 분기는 원본 item.outbound_type 유지)
const resolvedItemOutboundType = await resolveCategoryCode(
client,
"outbound_mng",
"outbound_type",
item.outbound_type,
);
const result = await client.query(
`INSERT INTO outbound_mng (
id, company_code, outbound_number, outbound_type, outbound_date,
@@ -152,7 +161,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
[
companyCode,
outbound_number || item.outbound_number,
item.outbound_type,
resolvedItemOutboundType,
outbound_date || item.outbound_date,
item.reference_number || null,
item.customer_code || null,
@@ -260,7 +269,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
locCode,
String(-outQty),
afterQty,
item.outbound_type || "출고",
resolvedItemOutboundType || "출고",
userId,
],
);
@@ -161,6 +161,38 @@ export async function deletePkgUnit(
}
}
// ──────────────────────────────────────────────
// 품목별 포장단위 조회 (item_number → pkg_unit 목록)
// ──────────────────────────────────────────────
export async function getPkgUnitsByItem(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { itemNumber } = req.params;
const pool = getPool();
const result = await pool.query(
`SELECT pu.id, pu.pkg_code, pu.pkg_name, pu.pkg_type, pu.status,
pu.width_mm, pu.length_mm, pu.height_mm,
pu.self_weight_kg, pu.max_load_kg, pu.volume_l,
pui.pkg_qty
FROM pkg_unit_item pui
JOIN pkg_unit pu ON pui.pkg_code = pu.pkg_code AND pui.company_code = pu.company_code
WHERE pui.item_number = $1 AND pui.company_code = $2 AND pu.status = 'ACTIVE'
ORDER BY pu.pkg_name`,
[itemNumber, companyCode]
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목별 포장단위 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ──────────────────────────────────────────────
// 포장단위 매칭품목 (pkg_unit_item) CRUD
// ──────────────────────────────────────────────
@@ -405,6 +437,38 @@ export async function deleteLoadingUnit(
}
}
// ──────────────────────────────────────────────
// 포장코드별 적재함 조회 (pkg_code → loading_unit 목록)
// ──────────────────────────────────────────────
export async function getLoadingUnitsByPkg(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { pkgCode } = req.params;
const pool = getPool();
const result = await pool.query(
`SELECT lu.id, lu.loading_code, lu.loading_name, lu.loading_type, lu.status,
lu.width_mm, lu.length_mm, lu.height_mm,
lu.self_weight_kg, lu.max_load_kg, lu.max_stack,
lup.max_load_qty, lup.load_method
FROM loading_unit_pkg lup
JOIN loading_unit lu ON lup.loading_code = lu.loading_code AND lup.company_code = lu.company_code
WHERE lup.pkg_code = $1 AND lup.company_code = $2 AND lu.status = 'ACTIVE'
ORDER BY lu.loading_name`,
[pkgCode, companyCode]
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("포장별 적재함 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ──────────────────────────────────────────────
// 적재함 포장구성 (loading_unit_pkg) CRUD
// ──────────────────────────────────────────────
File diff suppressed because it is too large Load Diff
@@ -477,8 +477,9 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
for (let i = 0; i < supplierIds.length; i++) {
await client.query(
// 본서버 id 컬럼이 uuid 타입, 개발서버는 varchar — ::text 캐스팅하면 본서버에서 타입 불일치 오류 발생
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_id, seq_order)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4)`,
VALUES (gen_random_uuid(), $1, $2, $3, $4)`,
[companyCode, newDetailId, supplierIds[i], i]
);
}
@@ -10,6 +10,7 @@
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { resolveCategoryCode } from "../utils/categoryUtils";
import { adjustInventory } from "../utils/inventoryUtils";
import { logger } from "../utils/logger";
@@ -155,12 +156,19 @@ export async function create(req: AuthenticatedRequest, res: Response) {
.json({ success: false, message: "입고 품목이 없습니다." });
}
// 첫 번째 아이템에서 inbound_type 추출 (헤더용)
const inboundType = items[0].inbound_type || null;
// 헤더용 inbound_type: 단일이면 그 값, 혼합이면 "혼합입고"
const uniqueInboundTypes = [...new Set(items.map((i: any) => i.inbound_type).filter(Boolean))];
let inboundType = uniqueInboundTypes.length === 1
? uniqueInboundTypes[0]
: uniqueInboundTypes.length > 1
? "혼합입고"
: (items[0].inbound_type || null);
const inboundNumber = inbound_number || items[0].inbound_number;
await client.query("BEGIN");
inboundType = await resolveCategoryCode(client, "inbound_mng", "inbound_type", inboundType);
// 1. 헤더 — 같은 (company_code, inbound_number) 헤더가 있으면 reuse, 없으면 INSERT (멱등성)
let headerRow: any;
const existingHeader = await client.query(
@@ -183,11 +191,13 @@ export async function create(req: AuthenticatedRequest, res: Response) {
id, company_code, inbound_number, inbound_type, inbound_date,
warehouse_code, location_code,
inbound_status, inspector, manager, memo,
source_table, source_id,
created_date, created_by, writer, status
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4::date,
$5, $6,
$7, $8, $9, $10,
$12, $13,
NOW(), $11, $11, '입고'
) RETURNING *`,
[
@@ -202,6 +212,8 @@ export async function create(req: AuthenticatedRequest, res: Response) {
manager || items[0].manager || null,
memo || items[0].memo || null,
userId,
items.length === 1 ? (items[0].source_table || null) : null,
items.length === 1 ? (items[0].source_id || null) : null,
],
);
headerRow = headerResult.rows[0];
@@ -224,6 +236,14 @@ export async function create(req: AuthenticatedRequest, res: Response) {
const item = items[i];
const seqNo = i + 1;
// 저장용 value_code (조건 분기는 원본 item.inbound_type 유지)
const resolvedItemInboundType = await resolveCategoryCode(
client,
"inbound_mng",
"inbound_type",
item.inbound_type || inboundType,
);
// 2a. inbound_detail INSERT
const detailResult = await client.query(
`INSERT INTO inbound_detail (
@@ -245,7 +265,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
companyCode,
inboundNumber,
seqNo,
item.inbound_type || inboundType,
resolvedItemInboundType,
item.item_number || null,
item.item_name || null,
item.spec || null,
@@ -325,18 +345,17 @@ export async function create(req: AuthenticatedRequest, res: Response) {
locCode,
String(inQty),
afterQty,
item.inbound_type || "입고",
resolvedItemInboundType || "입고",
userId,
],
);
}
// 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지
if (
item.inbound_type === "구매입고" &&
item.source_id &&
item.source_table === "purchase_order_mng"
) {
// 2c. source_table 기준 소스 데이터 업데이트 (이중 입고 방지)
const srcTable = item.source_table;
const srcId = item.source_id;
if (srcTable === "purchase_order_mng" && srcId) {
await client.query(
`UPDATE purchase_order_mng
SET received_qty = CAST(
@@ -354,17 +373,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode],
[item.inbound_qty || 0, srcId, companyCode],
);
}
// 구매입고인 경우 purchase_detail 품목별 입고수량 업데이트
if (
item.inbound_type === "구매입고" &&
item.source_id &&
item.source_table === "purchase_detail"
) {
// 1. 해당 purchase_detail의 received_qty 누적 업데이트
} else if (srcTable === "purchase_detail" && srcId) {
await client.query(
`UPDATE purchase_detail SET
received_qty = CAST(
@@ -377,17 +388,15 @@ export async function create(req: AuthenticatedRequest, res: Response) {
),
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode],
[item.inbound_qty || 0, srcId, companyCode],
);
// 2. 발주 헤더 상태 업데이트
const detailInfo = await client.query(
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode],
[srcId, companyCode],
);
if (detailInfo.rows.length > 0) {
const purchaseNo = detailInfo.rows[0].purchase_no;
// 잔량 있는 디테일이 있는지 확인
const unreceived = await client.query(
`SELECT id FROM purchase_detail
WHERE purchase_no = $1 AND company_code = $2
@@ -419,6 +428,28 @@ export async function create(req: AuthenticatedRequest, res: Response) {
[newStatus, purchaseNo, companyCode],
);
}
} else if (srcTable === "work_order_process" && srcId) {
// 생산입고: target_warehouse_id 세팅 (이중 입고 방지)
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
await client.query(
`UPDATE work_order_process
SET target_warehouse_id = $3,
target_location_code = $4,
writer = $5,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
AND target_warehouse_id IS NULL`,
[srcId, companyCode, whCode, locCode || null, userId],
);
} else if (srcTable && srcId) {
// 미처리 소스 테이블 — 추후 업데이트 로직 추가 필요
logger.warn("입고 소스 업데이트 미처리", {
source_table: srcTable,
source_id: srcId,
inbound_type: item.inbound_type,
item_number: item.item_number,
});
}
}
@@ -502,6 +533,9 @@ export async function update(req: AuthenticatedRequest, res: Response) {
const oldLocCode = oldHeader.location_code || null;
const itemCode = oldDetail?.item_number || oldHeader.item_number || null;
const inboundNumber = oldHeader.inbound_number;
const inboundType = oldDetail?.inbound_type || oldHeader.inbound_type;
const srcTable = oldHeader.source_table;
const srcId = oldHeader.source_id;
const newQty =
inbound_qty !== undefined && inbound_qty !== null
@@ -645,6 +679,122 @@ export async function update(req: AuthenticatedRequest, res: Response) {
}
}
// 발주 롤백: 구매입고인 경우 수량 delta를 원본 purchase_order_mng / purchase_detail에 반영
if (
qtyChanged &&
inboundType === "구매입고" &&
srcId &&
(srcTable === "purchase_order_mng" || srcTable === "purchase_detail")
) {
const delta = newQty - oldQty;
if (srcTable === "purchase_order_mng") {
await client.query(
`UPDATE purchase_order_mng
SET received_qty = CAST(
GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0) AS text
),
remain_qty = CAST(
GREATEST(
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0),
0
) AS text
),
status = CASE
WHEN GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0) <= 0
THEN '발주확정'
WHEN GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0)
>= COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
THEN '입고완료'
ELSE '부분입고'
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[delta, srcId, companyCode],
);
} else if (srcTable === "purchase_detail") {
await client.query(
`UPDATE purchase_detail SET
received_qty = CAST(
GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0) AS text
),
balance_qty = CAST(
GREATEST(
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0),
0
) AS text
),
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[delta, srcId, companyCode],
);
const detailInfo = await client.query(
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
[srcId, companyCode],
);
if (detailInfo.rows.length > 0) {
const purchaseNo = detailInfo.rows[0].purchase_no;
const unreceived = await client.query(
`SELECT id FROM purchase_detail
WHERE purchase_no = $1 AND company_code = $2
AND COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0
LIMIT 1`,
[purchaseNo, companyCode],
);
const anyReceived = await client.query(
`SELECT id FROM purchase_detail
WHERE purchase_no = $1 AND company_code = $2
AND COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0
LIMIT 1`,
[purchaseNo, companyCode],
);
const newStatus =
anyReceived.rows.length === 0
? "발주확정"
: unreceived.rows.length === 0
? "입고완료"
: "부분입고";
await client.query(
`UPDATE purchase_order_mng SET
status = $1,
received_qty = (
SELECT CAST(COALESCE(SUM(CAST(NULLIF(received_qty, '') AS numeric)), 0) AS text)
FROM purchase_detail
WHERE purchase_no = $2 AND company_code = $3
),
remain_qty = (
SELECT CAST(COALESCE(SUM(
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
), 0) AS text)
FROM purchase_detail
WHERE purchase_no = $2 AND company_code = $3
),
updated_date = NOW()
WHERE purchase_no = $2 AND company_code = $3`,
[newStatus, purchaseNo, companyCode],
);
}
}
}
// 생산입고 롤백: 수량 변경 시 work_order_process.target_warehouse_id를 NULL로 복귀
// → POP 생산입고 화면에서 잔량 기준으로 다시 조회 (received_qty는 inbound_detail 집계)
if (qtyChanged && srcTable === "work_order_process" && srcId) {
await client.query(
`UPDATE work_order_process
SET target_warehouse_id = NULL,
target_location_code = NULL,
updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[srcId, companyCode],
);
}
await client.query("COMMIT");
logger.info("입고 수정", {
@@ -656,6 +806,9 @@ export async function update(req: AuthenticatedRequest, res: Response) {
newQty,
oldWhCode,
newWhCode,
inboundType,
srcTable,
srcId,
});
return res.json({
@@ -1044,6 +1197,8 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
si.instruction_no,
si.instruction_date,
si.partner_id,
si.partner_id AS partner_code,
COALESCE(cm.customer_name, si.partner_id) AS partner_name,
si.status AS instruction_status,
sid.item_code,
sid.item_name,
@@ -1056,6 +1211,9 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id
AND si.company_code = sid.company_code
LEFT JOIN customer_mng cm
ON cm.customer_code = si.partner_id
AND cm.company_code = si.company_code
WHERE ${whereClause}
ORDER BY si.instruction_date DESC, si.instruction_no
LIMIT ${limit} OFFSET ${offset}`,
@@ -1126,6 +1284,104 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
}
}
// 생산입고용: 실적이 등록된 작업지시 공정 데이터 조회 (미입고분)
export async function getProductionResults(
req: AuthenticatedRequest,
res: Response,
) {
try {
const companyCode = req.user!.companyCode;
const { processCode, keyword, pageSize } = req.query;
if (!processCode) {
return res
.status(400)
.json({ success: false, message: "processCode 필수" });
}
const limit = Math.min(500, Math.max(1, Number(pageSize) || 50));
const params: any[] = [companyCode, processCode];
let paramIdx = 3;
let keywordCondition = "";
if (keyword) {
keywordCondition = `AND (wi.work_instruction_no ILIKE $${paramIdx} OR COALESCE(ii.item_name, '') ILIKE $${paramIdx} OR COALESCE(ii.item_number, '') ILIKE $${paramIdx})`;
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const dataResult = await pool.query(
`SELECT
wop.id,
wop.wo_id,
wi.work_instruction_no,
wi.start_date AS order_date,
wop.process_code,
wop.process_name,
wop.seq_no,
COALESCE(ii.item_number, wi.item_id) AS item_code,
COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name,
COALESCE(ii.size, '') AS spec,
COALESCE(ii.material, '') AS material,
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0) AS order_qty,
COALESCE(rcv.received_qty, 0) AS received_qty,
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0)
- COALESCE(rcv.received_qty, 0) AS remain_qty,
'work_order_process' AS source_table,
wop.result_status,
COALESCE(ii.image, NULL) AS image,
CASE WHEN EXISTS (
SELECT 1 FROM item_inspection_info iii
WHERE iii.company_code = wop.company_code
AND COALESCE(iii.is_active, 'Y') = 'Y'
AND iii.item_code = COALESCE(ii.item_number, wi.item_id)
) THEN 'self' ELSE NULL END AS inspection_type
FROM work_order_process wop
JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
LEFT JOIN (
SELECT DISTINCT ON (id, company_code)
id, item_number, item_name, size, material, image, company_code
FROM item_info
ORDER BY id, company_code, created_date DESC
) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code
LEFT JOIN (
SELECT im.source_id,
SUM(COALESCE(CAST(NULLIF(id.inbound_qty::text, '') AS numeric), 0)) AS received_qty
FROM inbound_detail id
JOIN inbound_mng im
ON id.inbound_id = im.inbound_number
AND id.company_code = im.company_code
WHERE im.source_table = 'work_order_process'
AND im.company_code = $1
GROUP BY im.source_id
) rcv ON rcv.source_id = wop.id
WHERE wop.company_code = $1
AND wop.process_code = $2
AND wop.parent_process_id IS NULL
AND (wop.is_rework IS NULL OR wop.is_rework != 'Y')
AND COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0
AND (
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0)
- COALESCE(rcv.received_qty, 0)
) > 0
${keywordCondition}
ORDER BY wi.work_instruction_no, CAST(wop.seq_no AS int)
LIMIT ${limit}`,
params,
);
return res.json({ success: true, data: dataResult.rows });
} catch (error: any) {
logger.error("생산입고 소스 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 입고번호 자동생성
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {
@@ -0,0 +1,45 @@
/**
* 제일그라스 수주 엑셀 일괄 업로드 컨트롤러
*/
import { Request, Response } from "express";
import logger from "../utils/logger";
import { excelBulkUpload } from "../services/salesOrderBulkService";
export async function bulkUploadHandler(req: Request, res: Response): Promise<void> {
try {
const user = (req as any).user || {};
const companyCode = user.companyCode;
const userId = user.userId || user.username || "system";
if (!companyCode) {
res.status(401).json({ success: false, message: "로그인 정보 없음" });
return;
}
const { rows, autoCreateItems, defaultItemDivision } = req.body || {};
if (!Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다." });
return;
}
const result = await excelBulkUpload({
companyCode,
userId,
rows,
autoCreateItems,
defaultItemDivision,
});
res.status(200).json({
success: true,
message: "업로드 완료",
data: result,
});
} catch (err: any) {
logger.error("수주 일괄 업로드 실패:", err);
res.status(500).json({
success: false,
message: err?.message || "업로드 실패",
});
}
}
@@ -1185,12 +1185,15 @@ export async function editTableData(
if (companyCode !== "*") {
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
if (hasCompanyCodeColumn) {
// 1. 원본 데이터의 company_code 확인
if (originalData?.id) {
// 1. 원본 데이터의 company_code 확인 (id exact match 필수)
if (originalData?.id !== undefined && originalData?.id !== null && originalData?.id !== "") {
try {
// ⚠️ 기존 search: { id: String(id) } 는 내부에서 ILIKE '%id%' 부분일치로 변환되어
// 다른 회사의 id (예: 116 검색 시 1116, 1160 등)가 매칭되어 cross-company 오탐 발생.
// 반드시 operator:equals 를 명시하여 정확 일치 조회할 것.
const existing = await tableManagementService.getTableData(tableName, {
page: 1, size: 1,
search: { id: String(originalData.id) },
search: { id: { value: String(originalData.id), operator: "equals" } },
});
const existingRow = existing.data?.[0];
if (existingRow && existingRow.company_code && existingRow.company_code !== companyCode && existingRow.company_code !== "*") {
@@ -6,6 +6,7 @@ import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { numberingRuleService } from "../services/numberingRuleService";
import { copyChecklistToSplit } from "./popProductionController";
// 자동 마이그레이션: work_instruction_detail에 routing_version_id + 품목별 일정/설비/작업조/작업자 컬럼 추가
let _migrationDone = false;
@@ -717,6 +718,80 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
}
}
/**
* wi_* 편집 시 마스터 체크리스트 스냅샷을 재투영한다.
* 접수(work_order_process_result) 가 0건일 때만 동기화되며, 1건 이상이면 스냅샷 불변.
* 트랜잭션 내에서 호출되어야 한다 (caller 가 BEGIN/COMMIT 관리).
*
* @param routingDetailId null 이면 해당 작업지시의 모든 routing detail 동기화
* @returns synced: 실제 동기화 수행 여부, affectedProcesses: 재복사된 마스터 공정 수
*/
async function syncMasterChecklistFromWi(
client: { query: (text: string, values?: any[]) => Promise<any> },
workInstructionNo: string,
routingDetailId: string | null,
companyCode: string,
userId: string,
): Promise<{ synced: boolean; affectedProcesses: number; reason?: string }> {
// 1. 작업지시 id 조회
const wiRow = await client.query(
`SELECT id FROM work_instruction WHERE work_instruction_no = $1 AND company_code = $2`,
[workInstructionNo, companyCode],
);
if (wiRow.rowCount === 0) {
return { synced: false, affectedProcesses: 0, reason: "work_instruction not found" };
}
const wiId = wiRow.rows[0].id as string;
// 2. advisory lock — 편집/접수 동시성 보호
await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [
`wi_snapshot:${companyCode}:${wiId}`,
]);
// 3. 접수 건수 확인
const acceptCount = await client.query(
`SELECT COUNT(*)::int AS cnt FROM work_order_process_result wopr
JOIN work_order_process wop ON wop.id = wopr.work_order_process_id
WHERE wop.wo_id = $1 AND wop.company_code = $2 AND wopr.company_code = $2`,
[wiId, companyCode],
);
if ((acceptCount.rows[0]?.cnt ?? 0) > 0) {
return { synced: false, affectedProcesses: 0, reason: "accepted_count > 0" };
}
// 4. 대상 마스터 공정 목록
const masterQuery = routingDetailId
? `SELECT id, routing_detail_id FROM work_order_process
WHERE wo_id = $1 AND routing_detail_id = $2 AND company_code = $3`
: `SELECT id, routing_detail_id FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`;
const masterParams = routingDetailId
? [wiId, routingDetailId, companyCode]
: [wiId, companyCode];
const masters = await client.query(masterQuery, masterParams);
let affected = 0;
for (const m of masters.rows) {
// 5. 기존 마스터 스냅샷 삭제
await client.query(
`DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`,
[m.id, companyCode],
);
// 6. 재복사 — copyChecklistToSplit 재활용 (wi_* 우선, 없으면 원본 fallback)
await copyChecklistToSplit(
client,
m.id,
m.id,
m.routing_detail_id,
companyCode,
userId,
{ workInstructionNo },
);
affected++;
}
return { synced: true, affectedProcesses: affected };
}
// ─── 원본 공정작업기준 -> 작업지시 전용 복사 ───
export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
@@ -783,6 +858,8 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response)
}
}
const sync = await syncMasterChecklistFromWi(client, wiNo, null, companyCode, userId);
logger.info("[work-instruction] wi_* copy 후 마스터 스냅샷 동기화", { wiNo, ...sync });
await client.query("COMMIT");
logger.info("공정작업기준 복사 완료", { companyCode, wiNo, routingVersionId });
return res.json({ success: true });
@@ -850,6 +927,8 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
}
}
const sync = await syncMasterChecklistFromWi(client, wiNo, routingDetailId, companyCode, userId);
logger.info("[work-instruction] wi_* save 후 마스터 스냅샷 동기화", { wiNo, routingDetailId, ...sync });
await client.query("COMMIT");
logger.info("작업지시 공정작업기준 저장 완료", { companyCode, wiNo, routingDetailId });
return res.json({ success: true });
@@ -869,6 +948,7 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
export async function resetWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { wiNo } = req.params;
const pool = getPool();
const client = await pool.connect();
@@ -889,6 +969,8 @@ export async function resetWorkStandard(req: AuthenticatedRequest, res: Response
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
const sync = await syncMasterChecklistFromWi(client, wiNo, null, companyCode, userId);
logger.info("[work-instruction] wi_* reset 후 마스터 스냅샷 원본 복원", { wiNo, ...sync });
await client.query("COMMIT");
logger.info("작업지시 공정작업기준 초기화", { companyCode, wiNo });
return res.json({ success: true });
+4
View File
@@ -12,9 +12,11 @@ import {
deleteMoldSerial,
getMoldInspections,
createMoldInspection,
updateMoldInspection,
deleteMoldInspection,
getMoldParts,
createMoldPart,
updateMoldPart,
deleteMoldPart,
getMoldSerialSummary,
} from "../controllers/moldController";
@@ -41,11 +43,13 @@ router.get("/:moldCode/serial-summary", getMoldSerialSummary);
// 점검항목
router.get("/:moldCode/inspections", getMoldInspections);
router.post("/:moldCode/inspections", createMoldInspection);
router.put("/inspections/:id", updateMoldInspection);
router.delete("/inspections/:id", deleteMoldInspection);
// 부품
router.get("/:moldCode/parts", getMoldParts);
router.post("/:moldCode/parts", createMoldPart);
router.put("/parts/:id", updateMoldPart);
router.delete("/parts/:id", deleteMoldPart);
export default router;
@@ -2,8 +2,10 @@ import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
getPkgUnitsByItem,
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitsByPkg,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
} from "../controllers/packagingController";
@@ -18,6 +20,9 @@ router.post("/pkg-units", createPkgUnit);
router.put("/pkg-units/:id", updatePkgUnit);
router.delete("/pkg-units/:id", deletePkgUnit);
// 품목별 포장단위 조회
router.get("/pkg-units-by-item/:itemNumber", getPkgUnitsByItem);
// 포장단위 매칭품목
router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems);
router.post("/pkg-unit-items", createPkgUnitItem);
@@ -29,6 +34,9 @@ router.post("/loading-units", createLoadingUnit);
router.put("/loading-units/:id", updateLoadingUnit);
router.delete("/loading-units/:id", deleteLoadingUnit);
// 포장코드별 적재함 조회
router.get("/loading-units-by-pkg/:pkgCode", getLoadingUnitsByPkg);
// 적재함 포장구성
router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
@@ -23,6 +23,8 @@ import {
saveMaterialInput,
getMaterialInputs,
getChecklistItems,
getProcessList,
getProcessResult,
} from "../controllers/popProductionController";
const router = Router();
@@ -51,5 +53,7 @@ router.get("/bom-materials/:processId", getBomMaterials);
router.post("/material-input", saveMaterialInput);
router.get("/material-inputs/:processId", getMaterialInputs);
router.get("/checklist-items/:processId", getChecklistItems);
router.get("/processes", getProcessList);
router.get("/result/:id", getProcessResult);
export default router;
@@ -28,6 +28,9 @@ router.get("/source/shipments", receivingController.getShipments);
// 소스 데이터: 품목 (기타입고)
router.get("/source/items", receivingController.getItems);
// 소스 데이터: 생산실적 (생산입고)
router.get("/source/production-results", receivingController.getProductionResults);
// 입고 등록
router.post("/", receivingController.create);
@@ -0,0 +1,10 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { bulkUploadHandler } from "../controllers/salesOrderBulkController";
const router = Router();
// POST /api/sales-order/excel-bulk-upload
router.post("/excel-bulk-upload", authenticateToken, bulkUploadHandler);
export default router;
+3 -1
View File
@@ -57,9 +57,11 @@ export async function addBomHistory(
export async function getBomHeader(bomId: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom");
// b.* 에 포함된 item_type(BOM 유형)을 i.division으로 덮어쓰지 않도록 alias 제거.
// 품목 분류가 필요하면 프론트에서 i.division 컬럼을 직접 사용.
const sql = `
SELECT b.*,
i.item_name, i.item_number, i.division as item_type,
i.item_name, i.item_number,
COALESCE(NULLIF(b.unit, ''), NULLIF(i.unit, ''), NULLIF(i.inventory_unit, '')) as unit,
i.unit as item_unit,
i.inventory_unit as item_inventory_unit,
@@ -0,0 +1,377 @@
/**
* (COMPANY_9)
* - (item_info에 / )
* - UPSERT (sales_order_mng)
* - INSERT (sales_order_detail)
* -
*/
import { getPool } from "../database/db";
import { numberingRuleService } from "./numberingRuleService";
import logger from "../utils/logger";
// 업로드 요청 페이로드 — 프론트 매핑 결과
export interface BulkRow {
// 마스터 후보 필드
order_no?: string;
partner_code?: string;
partner_name?: string;
order_date?: string;
due_date?: string;
status?: string;
// 디테일 필드
part_code?: string;
part_name?: string;
spec?: string;
width?: number | string;
height?: number | string;
thickness?: number | string;
area?: number | string;
unit?: string;
qty?: number | string;
unit_price?: number | string;
amount?: number | string;
memo?: string;
// 품목 자동등록 옵션 필드
division?: string;
}
export interface BulkUploadPayload {
companyCode: string;
userId: string;
rows: BulkRow[];
autoCreateItems?: boolean;
defaultItemDivision?: string; // e.g. 'CAT_DIV_RAW_MAT'
}
export interface BulkUploadResult {
itemsCreated: number;
mastersCreated: number;
detailsCreated: number;
warnings: string[];
errors: string[];
}
const ITEM_NUMBER_PREFIX = "R"; // 자동 생성 품번 접두어
function toNum(v: any): number {
if (v === null || v === undefined || v === "") return 0;
const n = Number(String(v).replace(/,/g, ""));
return isNaN(n) ? 0 : n;
}
function normStr(v: any): string {
if (v === null || v === undefined) return "";
return String(v).trim();
}
/**
* item_info에서 , INSERT
* : { itemNumber: string, created: boolean }
*/
async function resolveOrCreateItem(
client: any,
companyCode: string,
row: BulkRow,
defaultDivision: string,
userId: string
): Promise<{ itemNumber: string; created: boolean } | null> {
const partName = normStr(row.part_name);
const partCode = normStr(row.part_code);
if (!partName && !partCode) return null;
// 1) part_code 직접 매칭 우선
if (partCode) {
const r = await client.query(
`SELECT item_number FROM item_info
WHERE company_code = $1 AND item_number = $2
LIMIT 1`,
[companyCode, partCode]
);
if (r.rows.length > 0) return { itemNumber: r.rows[0].item_number, created: false };
}
// 2) part_name + 규격 매칭
if (partName) {
const w = toNum(row.width);
const h = toNum(row.height);
const t = toNum(row.thickness);
const r2 = await client.query(
`SELECT item_number FROM item_info
WHERE company_code = $1
AND item_name = $2
AND COALESCE(width::numeric,0) = $3
AND COALESCE(height::numeric,0) = $4
AND COALESCE(thickness::numeric,0) = $5
LIMIT 1`,
[companyCode, partName, w, h, t]
);
if (r2.rows.length > 0) return { itemNumber: r2.rows[0].item_number, created: false };
}
// 3) 자동 생성
if (!partName) return null; // 품명 없으면 생성 불가
// 간단 채번: R_YYYYMMDD_NNNN (company 내 해당 prefix 최대값+1)
const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const prefix = `${ITEM_NUMBER_PREFIX}_${today}_`;
const seqR = await client.query(
`SELECT COUNT(*)::int AS c FROM item_info
WHERE company_code = $1 AND item_number LIKE $2`,
[companyCode, `${prefix}%`]
);
const seq = (Number(seqR.rows[0]?.c) || 0) + 1;
const itemNumber = `${prefix}${String(seq).padStart(4, "0")}`;
const division = normStr(row.division) || defaultDivision;
const unit = normStr(row.unit) || "EA";
await client.query(
`INSERT INTO item_info (
id, company_code, item_number, item_name,
size, width, height, thickness,
unit, division, status, writer, created_date
) VALUES (
gen_random_uuid()::text, $1, $2, $3,
$4, $5, $6, $7,
$8, $9, 'active', $10, NOW()
)`,
[
companyCode,
itemNumber,
partName,
normStr(row.spec),
toNum(row.width) || null,
toNum(row.height) || null,
toNum(row.thickness) || null,
unit,
division,
userId,
]
);
return { itemNumber, created: true };
}
/**
* numbering_rules에 , ORD-YYYYMMDD-NNNN
*/
async function allocateOrderNo(companyCode: string, client: any): Promise<string> {
// 기존 규칙 조회
const ruleRes = await client.query(
`SELECT rule_id FROM numbering_rules
WHERE company_code = $1 AND table_name = 'sales_order_mng' AND column_name = 'order_no'
LIMIT 1`,
[companyCode]
);
if (ruleRes.rows.length > 0) {
try {
const code = await numberingRuleService.allocateCode(
ruleRes.rows[0].rule_id,
companyCode
);
if (code) return code;
} catch (e: any) {
logger.warn(`allocateCode 실패 → 폴백 사용: ${e?.message}`);
}
}
// 폴백
const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const prefix = `ORD-${today}-`;
const r = await client.query(
`SELECT COUNT(*)::int AS c FROM sales_order_mng
WHERE company_code = $1 AND order_no LIKE $2`,
[companyCode, `${prefix}%`]
);
const seq = (Number(r.rows[0]?.c) || 0) + 1;
return `${prefix}${String(seq).padStart(4, "0")}`;
}
export async function excelBulkUpload(
payload: BulkUploadPayload
): Promise<BulkUploadResult> {
const pool = getPool();
const client = await pool.connect();
const result: BulkUploadResult = {
itemsCreated: 0,
mastersCreated: 0,
detailsCreated: 0,
warnings: [],
errors: [],
};
const autoCreate = payload.autoCreateItems !== false; // 기본 true
const defaultDivision = payload.defaultItemDivision || "CAT_DIV_RAW_MAT";
try {
await client.query("BEGIN");
// 1) 각 행의 품목 확정 (part_code를 확정값으로 채움)
const resolved: Array<{ row: BulkRow; partCode: string }> = [];
for (let i = 0; i < payload.rows.length; i++) {
const row = payload.rows[i];
if (!autoCreate && !normStr(row.part_code)) {
// 자동생성 비활성 + part_code 비어있음: 매칭만 시도
const pr = await resolveOrCreateItem(
client, payload.companyCode, row, defaultDivision, payload.userId
);
if (!pr) {
result.warnings.push(`${i + 1}: 품목 매칭 실패 (품명=${row.part_name})`);
continue;
}
resolved.push({ row, partCode: pr.itemNumber });
} else {
const pr = await resolveOrCreateItem(
client, payload.companyCode, row, defaultDivision, payload.userId
);
if (!pr) {
result.warnings.push(`${i + 1}: 품목 정보 부족 (품명/품번 모두 없음)`);
continue;
}
if (pr.created) result.itemsCreated++;
resolved.push({ row, partCode: pr.itemNumber });
}
}
if (resolved.length === 0) {
await client.query("ROLLBACK");
result.errors.push("유효한 행이 없습니다.");
return result;
}
// 2) order_no 기준 그룹핑
// - 엑셀에 order_no 있는 행은 동일 번호끼리 묶음
// - 비어있는 행들은 하나의 그룹 "(auto)" 으로 묶어 자동 채번 1건
const groups = new Map<string, Array<{ row: BulkRow; partCode: string }>>();
for (const item of resolved) {
const key = normStr(item.row.order_no) || "__AUTO__";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(item);
}
// 3) 그룹별 마스터 UPSERT + 디테일 INSERT
let autoOrderNo: string | null = null;
for (const [key, items] of Array.from(groups.entries())) {
let orderNo = key;
if (key === "__AUTO__") {
if (!autoOrderNo) autoOrderNo = await allocateOrderNo(payload.companyCode, client);
orderNo = autoOrderNo;
}
// 마스터 존재 확인
const mRes = await client.query(
`SELECT id FROM sales_order_mng
WHERE company_code = $1 AND order_no = $2 LIMIT 1`,
[payload.companyCode, orderNo]
);
// 대표값 (첫 행 기준)
const first = items[0].row;
// partner_id 조회 (name으로) — 없어도 무방
let partnerId = normStr(first.partner_code);
if (!partnerId && normStr(first.partner_name)) {
const pRes = await client.query(
`SELECT id FROM customer_mng
WHERE company_code = $1 AND customer_name = $2 LIMIT 1`,
[payload.companyCode, normStr(first.partner_name)]
);
if (pRes.rows.length > 0) partnerId = pRes.rows[0].id;
}
if (mRes.rows.length === 0) {
// INSERT
await client.query(
`INSERT INTO sales_order_mng (
company_code, order_no, order_date, due_date, partner_id,
status, manager_id, created_date, created_by
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, NOW(), $8
)`,
[
payload.companyCode,
orderNo,
normStr(first.order_date) || new Date().toISOString().slice(0, 10),
normStr(first.due_date) || null,
partnerId || null,
normStr(first.status) || "수주",
payload.userId,
payload.userId,
]
);
result.mastersCreated++;
}
// 디테일 INSERT
// 기존 디테일의 최대 seq_no 조회
const sRes = await client.query(
`SELECT COALESCE(MAX(NULLIF(seq_no,'')::int), 0)::int AS max_seq
FROM sales_order_detail
WHERE company_code = $1 AND order_no = $2`,
[payload.companyCode, orderNo]
);
let seqStart = (Number(sRes.rows[0]?.max_seq) || 0) + 1;
for (const it of items) {
const r = it.row;
const w = toNum(r.width);
const h = toNum(r.height);
const qty = toNum(r.qty);
const price = toNum(r.unit_price);
const area = normStr(r.area) || (w > 0 && h > 0 ? (w * h / 91808).toFixed(4) : "");
const amount = normStr(r.amount) || (qty && price ? String(qty * price) : "");
await client.query(
`INSERT INTO sales_order_detail (
id, company_code, order_no, seq_no,
part_code, part_name, spec,
width, height, thickness, area,
unit, qty, unit_price, amount,
delivery_partner_code, due_date, memo,
writer, created_date
) VALUES (
gen_random_uuid()::text, $1, $2, $3,
$4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13, $14,
$15, $16, $17,
$18, NOW()
)`,
[
payload.companyCode,
orderNo,
String(seqStart++),
it.partCode,
normStr(r.part_name),
normStr(r.spec),
w || null, h || null, toNum(r.thickness) || null, area || null,
normStr(r.unit) || null,
qty || null,
price || null,
amount || null,
normStr(r.partner_code) || null,
normStr(r.due_date) || null,
normStr(r.memo) || null,
payload.userId,
]
);
result.detailsCreated++;
}
}
await client.query("COMMIT");
return result;
} catch (err: any) {
await client.query("ROLLBACK");
logger.error(`excelBulkUpload 실패: ${err?.message}`, err);
result.errors.push(err?.message || "알 수 없는 오류");
return result;
} finally {
client.release();
}
}
@@ -5936,65 +5936,12 @@ export class ScreenManagementService {
);
}
} else {
// 일반 사용자: 회사별 우선, 없으면 템플릿에서 자동 복제
// 일반 사용자: 회사별 레이아웃만 조회 (fallback/자동 복제 없음)
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
// 회사별 레이아웃이 없으면 템플릿에서 자동 복제
if (!layout && companyCode !== "*") {
// 1. 공통(*) 템플릿 조회
let templateLayout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = '*'`,
[screenId],
);
// 2. 공통 없으면 COMPANY_7(탑씰) 폴백
if (!templateLayout) {
templateLayout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = 'COMPANY_7'`,
[screenId],
);
}
// 3. 템플릿이 있으면 해당 회사용으로 복제
if (templateLayout) {
console.log(`POP 레이아웃 자동 복제: screen_id=${screenId}, 대상 회사=${companyCode}`);
// 회사명 조회 (레이아웃 내 회사명 치환용)
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode],
);
const companyName = companyInfo?.company_name || companyCode;
let clonedData = JSON.parse(JSON.stringify(templateLayout.layout_data));
// layout_data 내 회사명 텍스트 치환 (탑씰 관련 문자열 → 대상 회사명)
const layoutStr = JSON.stringify(clonedData);
const replacedStr = layoutStr
.replace(/\(주\)탑씰/g, companyName)
.replace(/탑씰/g, companyName)
.replace(/TOPSEAL/gi, companyName);
clonedData = JSON.parse(replacedStr);
// 해당 회사 코드로 INSERT (UPSERT)
await query(
`INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
VALUES ($1, $2, $3, NOW(), NOW(), 'SYSTEM', 'SYSTEM')
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = 'SYSTEM'`,
[screenId, companyCode, JSON.stringify(clonedData)],
);
console.log(`POP 레이아웃 자동 복제 완료: screen_id=${screenId}, company=${companyCode}`);
layout = { layout_data: clonedData };
}
}
}
if (!layout) {
@@ -6133,11 +6080,10 @@ export class ScreenManagementService {
[],
);
} else {
// 일반 회사: 해당 회사 레이아웃 + 공통(*)/COMPANY_7 템플릿도 포함
// (getLayoutPop에서 자동 복제하므로 템플릿이 있으면 해당 회사도 사용 가능)
// 일반 회사: 해당 회사 레이아웃만 조회 (자동 복제/fallback 없음)
result = await query<{ screen_id: number }>(
`SELECT DISTINCT screen_id FROM screen_layouts_pop
WHERE company_code IN ($1, '*', 'COMPANY_7')`,
WHERE company_code = $1`,
[companyCode],
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { PoolClient } from "pg";
/**
* value_label category_values value_code .
* label .
*
* company_code label value_code
* , .
*/
export async function resolveCategoryCode(
client: PoolClient,
tableName: string,
columnName: string,
label: string | null | undefined,
): Promise<string | null> {
if (!label) return label ?? null;
const result = await client.query(
`SELECT DISTINCT value_code FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_label = $3
AND is_active = true
LIMIT 1`,
[tableName, columnName, label],
);
return result.rows[0]?.value_code ?? label;
}
@@ -19,6 +19,11 @@ function getAiAssistantDir(): string {
* AI ( , backend는 )
*/
export function startAiAssistant(): void {
if (process.env.DISABLE_AI_ASSISTANT === "1") {
logger.info("⏭️ AI 어시스턴트 스킵 (DISABLE_AI_ASSISTANT=1)");
return;
}
const aiDir = getAiAssistantDir();
const appPath = path.join(aiDir, "src", "app.js");
@@ -59,6 +59,7 @@ export default function EquipmentInfoPage() {
const [equipCount, setEquipCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
const [checkedEquipIds, setCheckedEquipIds] = useState<string[]>([]);
// 우측 탭
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
@@ -258,7 +259,15 @@ export default function EquipmentInfoPage() {
}
} catch { /* 채번 규칙 없으면 수동 입력 */ }
};
const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); };
const openEquipEdit = () => {
if (checkedEquipIds.length > 1) { toast.error("수정은 한 건만 선택해주세요."); return; }
const targetId = checkedEquipIds[0] || selectedEquipId;
const target = equipments.find((e) => e.id === targetId);
if (!target) { toast.error("수정할 설비를 선택해주세요."); return; }
setEquipForm({ ...target });
setEquipEditMode(true);
setEquipModalOpen(true);
};
const handleEquipSave = async () => {
if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; }
@@ -287,12 +296,16 @@ export default function EquipmentInfoPage() {
};
const handleEquipDelete = async () => {
if (!selectedEquipId) return;
const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" });
const targetIds = checkedEquipIds.length > 0 ? checkedEquipIds : (selectedEquipId ? [selectedEquipId] : []);
if (targetIds.length === 0) { toast.error("삭제할 설비를 선택해주세요."); return; }
const ok = await confirm(`선택한 ${targetIds.length}건의 설비를 삭제하시겠습니까?`, { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] });
toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments();
await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: targetIds.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedEquipIds([]);
if (selectedEquipId && targetIds.includes(selectedEquipId)) setSelectedEquipId(null);
fetchEquipments();
} catch { toast.error("삭제 실패"); }
};
@@ -398,7 +411,12 @@ export default function EquipmentInfoPage() {
const allItems = new Map<string, any>();
for (const res of results) {
const rows = res.data?.data?.data || res.data?.data?.rows || [];
for (const row of rows) allItems.set(row.id, row);
for (const row of rows) {
// item_info 이미지 컬럼 이중화 대응: `image`(품목정보 UI가 저장하는 기본 컬럼) 우선,
// `image_path`는 과거 데이터 레거시 fallback. 옵션 객체는 image_path 필드로 정규화해 하류 호환 유지.
const normalized = { ...row, image_path: row.image || row.image_path || "" };
allItems.set(row.id, normalized);
}
}
setConsumableItemOptions(Array.from(allItems.values()));
} catch { setConsumableItemOptions([]); }
@@ -530,9 +548,9 @@ export default function EquipmentInfoPage() {
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={checkedEquipIds.length === 0 && !selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<div className="h-4 w-px bg-border" />
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={checkedEquipIds.length === 0 && !selectedEquipId} onClick={handleEquipDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"><Trash2 className="w-3.5 h-3.5 mr-1" /> {checkedEquipIds.length > 0 && `(${checkedEquipIds.length})`}</Button>
<div className="h-4 w-px bg-border" />
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
<Settings2 className="w-3.5 h-3.5" />
@@ -546,6 +564,10 @@ export default function EquipmentInfoPage() {
emptyMessage="등록된 설비가 없어요"
selectedId={selectedEquipId}
onSelect={(id) => setSelectedEquipId(id)}
showCheckbox
checkboxClickOnly
checkedIds={checkedEquipIds}
onCheckedChange={setCheckedEquipIds}
onRowDoubleClick={() => openEquipEdit()}
showPagination={true}
draggableColumns={false}
@@ -929,11 +951,16 @@ export default function EquipmentInfoPage() {
{consumableItemOptions.length > 0 ? (
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
// 품목 선택 시 기준정보(item_info)의 unit/size/image(또는 image_path) 를 자동 주입
// 주의: unit/specification/image_path는 이전 품목 값 잔류 방지를 위해 fallback(p.xxx) 절대 금지. 빈값은 빈값으로 덮어쓴다.
// item_info의 이미지 컬럼이 `image` 기본, `image_path`는 레거시 fallback.
// loadConsumableItems에서 이미 image_path로 정규화되지만, 방어적으로 양쪽 모두 확인.
setConsumableForm((p) => ({
...p,
consumable_name: v,
specification: item?.size || p.specification || "",
unit: item?.unit || p.unit || "",
specification: item?.size || "",
unit: item?.unit || "",
image_path: item?.image || item?.image_path || "",
manufacturer: item?.manufacturer || p.manufacturer || "",
}));
}}>
@@ -955,15 +982,46 @@ export default function EquipmentInfoPage() {
)}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.replacement_cycle || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.unit || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.specification || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" /></div>
<div className="space-y-1.5">
<Label className="text-sm flex items-center gap-1.5">
{consumableItemOptions.length > 0 && <span className="text-[10px] text-muted-foreground font-normal">( )</span>}
</Label>
<Input
value={consumableForm.unit || ""}
onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))}
placeholder="단위"
className={cn("h-9", consumableItemOptions.length > 0 && "bg-muted cursor-not-allowed")}
readOnly={consumableItemOptions.length > 0}
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm flex items-center gap-1.5">
{consumableItemOptions.length > 0 && <span className="text-[10px] text-muted-foreground font-normal">( )</span>}
</Label>
<Input
value={consumableForm.specification || ""}
onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))}
placeholder="규격"
className={cn("h-9", consumableItemOptions.length > 0 && "bg-muted cursor-not-allowed")}
readOnly={consumableItemOptions.length > 0}
/>
</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.manufacturer || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm flex items-center gap-1.5">
{consumableItemOptions.length > 0 && <span className="text-[10px] text-muted-foreground font-normal">( )</span>}
</Label>
{/* value 변경 시 확실한 리마운트를 위해 key에 image_path 바인딩 (이전 미리보기 잔류 방지) */}
<ImageUpload
key={`consumable-image-${consumableForm.image_path || "empty"}`}
value={consumableForm.image_path || ""}
onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
tableName={CONSUMABLE_TABLE}
columnName="image_path"
disabled={consumableItemOptions.length > 0}
/>
</div>
</div>
<DialogFooter className="flex items-center justify-between sm:justify-between">
<label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -23,6 +23,7 @@ import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { exportToExcel } from "@/lib/utils/excelExport";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { useCategoryLabelMap, makeParseRemark } from "@/lib/categoryLabel";
const HISTORY_TABLE = "inventory_history";
@@ -65,6 +66,13 @@ const parseRemark = (remark: string | null | undefined): string => {
export default function InboundOutboundPage() {
const { user } = useAuth();
// remark의 value_code → value_label 변환용 카테고리 맵 (상위 parseRemark 를 shadow)
const codeLabelMap = useCategoryLabelMap([
{ table: "inbound_mng", column: "inbound_type" },
{ table: "outbound_mng", column: "outbound_type" },
]);
const parseRemark = useMemo(() => makeParseRemark(codeLabelMap), [codeLabelMap]);
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -127,6 +127,7 @@ const TAB_CONFIGS: TabConfig[] = [
columns: [
{ key: "carrier_code", label: "운송업체", width: "120px" },
{ key: "route_code", label: "구간코드", width: "120px" },
{ key: "route_name", label: "구간명", width: "160px" },
{ key: "base_fee", label: "기본요금", width: "110px", align: "right", formatNumber: true },
{ key: "unit", label: "단위", width: "70px", align: "center" },
{ key: "unit_fee", label: "단가", width: "110px", align: "right", formatNumber: true },
@@ -358,6 +359,15 @@ export default function LogisticsInfoPage() {
loadReferences();
}, [loadReferences]);
// 참조 코드 → 업체명/구간명 변환 (목록 셀용 — 코드 제외, 업체명만 표시)
const getRefName = useCallback((refKey: "carrier" | "route", code: string): string => {
if (!code) return "-";
const opts = refKey === "carrier" ? carrierOptions : routeOptions;
const lbl = opts.find((o) => o.code === code)?.label || "";
const parts = String(lbl).split(" - ");
return parts.length > 1 ? parts.slice(1).join(" - ") : code;
}, [carrierOptions, routeOptions]);
// 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨)
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
if (loadedCategories.current.has(tableColumn)) return;
@@ -414,6 +424,12 @@ export default function LogisticsInfoPage() {
fetchTabData(activeTab);
}, [activeTab, fetchTabData]);
// 마운트 시 5개 탭 전체 카운트 로드 — 탭 배지 숫자가 진입 즉시 정확히 표시되도록
useEffect(() => {
TAB_CONFIGS.forEach((c) => fetchTabData(c.key));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 탭 변경
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab as TabKey);
@@ -826,21 +842,27 @@ export default function LogisticsInfoPage() {
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => {
// 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환
const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey);
const refField = tab.formFields.find((f) => f.key === col.key && f.referenceKey);
let customRender: ((value: any, row: any) => React.ReactNode) | undefined;
if (formField?.categoryKey) {
customRender = (value: any) => {
const opts = categoryOptions[formField.categoryKey!] || [];
const matched = opts.find((o: any) => o.value === value);
return matched?.label || value || "-";
};
} else if (refField?.referenceKey) {
customRender = (value: any) => getRefName(refField.referenceKey!, value);
} else if (col.key === "route_name") {
customRender = (_v: any, row: any) => getRefName("route", row.route_code);
}
return {
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
render: formField?.categoryKey
? (value: any) => {
const opts = categoryOptions[formField.categoryKey!] || [];
const matched = opts.find((o: any) => o.value === value);
return matched?.label || value || "-";
}
: undefined,
render: customRender,
};
})}
data={tsMap[tab.key].groupData(displayData)}
@@ -9,7 +9,7 @@
* / ,
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -62,6 +62,7 @@ import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { toast } from "sonner";
import { useCategoryLabelMap, makeParseRemark } from "@/lib/categoryLabel";
import { exportToExcel } from "@/lib/utils/excelExport";
const STOCK_TABLE = "inventory_stock";
@@ -118,6 +119,13 @@ export default function InventoryStatusPage() {
const { user } = useAuth();
const ts = useTableSettings("c16-inventory", STOCK_TABLE, STOCK_COLUMNS);
// remark의 value_code → value_label 변환 파서
const codeLabelMap = useCategoryLabelMap([
{ table: "inbound_mng", column: "inbound_type" },
{ table: "outbound_mng", column: "outbound_type" },
]);
const parseRemark = useMemo(() => makeParseRemark(codeLabelMap), [codeLabelMap]);
// 좌측: 재고 목록
const [stockItems, setStockItems] = useState<any[]>([]);
const [stockLoading, setStockLoading] = useState(false);
@@ -750,7 +758,7 @@ export default function InventoryStatusPage() {
{h.reference_number || h.reference_no || ""}
</TableCell>
<TableCell className="truncate max-w-[150px]">
{h.remark || h.reason || ""}
{parseRemark(h.remark) || h.reason || ""}
</TableCell>
<TableCell>{userMap[h.writer] || userMap[h.created_by] || h.writer || h.created_by || ""}</TableCell>
</TableRow>
+118 -41
View File
@@ -376,21 +376,56 @@ export default function MoldInfoPage() {
};
// ─── 점검항목 CRUD ───
const handleAddInspection = async () => {
const openInspectionEdit = (ins: any) => {
// 기존 lower/upper 값에서 기준값/오차 역산
const lower = parseFloat(ins.lower_limit);
const upper = parseFloat(ins.upper_limit);
let reference_value = "";
let tolerance = "";
if (!isNaN(lower) && !isNaN(upper)) {
reference_value = String((upper + lower) / 2);
tolerance = String((upper - lower) / 2);
}
setInspectionForm({ ...ins, reference_value, tolerance });
setInspectionModalOpen(true);
};
const handleSaveInspection = async () => {
if (!selectedMoldCode) return;
if (!inspectionForm.inspection_item) {
toast.error("점검항목명은 필수예요.");
return;
}
// 숫자 방법일 때 기준값/오차에서 lower/upper 자동 계산
const payload: Record<string, any> = { ...inspectionForm };
if (payload.inspection_method === "숫자") {
const ref = parseFloat(payload.reference_value);
const tol = parseFloat(payload.tolerance);
if (!isNaN(ref) && !isNaN(tol)) {
payload.lower_limit = String(ref - tol);
payload.upper_limit = String(ref + tol);
} else {
payload.lower_limit = null;
payload.upper_limit = null;
}
}
delete payload.reference_value;
delete payload.tolerance;
setSaving(true);
try {
await apiClient.post(`${API}/${selectedMoldCode}/inspections`, inspectionForm);
toast.success("점검항목이 등록되었어요.");
if (payload.id) {
await apiClient.put(`${API}/inspections/${payload.id}`, payload);
toast.success("점검항목이 수정되었어요.");
} else {
await apiClient.post(`${API}/${selectedMoldCode}/inspections`, payload);
toast.success("점검항목이 등록되었어요.");
}
setInspectionModalOpen(false);
setInspectionForm({});
fetchInspections(selectedMoldCode);
} catch {
toast.error("등록에 실패했어요.");
} catch (err: any) {
toast.error(err?.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
@@ -409,7 +444,12 @@ export default function MoldInfoPage() {
};
// ─── 부품 CRUD ───
const handleAddPart = async () => {
const openPartEdit = (p: any) => {
setPartForm({ ...p });
setPartModalOpen(true);
};
const handleSavePart = async () => {
if (!selectedMoldCode) return;
if (!partForm.part_name) {
toast.error("부품명은 필수예요.");
@@ -421,13 +461,18 @@ export default function MoldInfoPage() {
}
setSaving(true);
try {
await apiClient.post(`${API}/${selectedMoldCode}/parts`, partForm);
toast.success("부품이 등록되었어요.");
if (partForm.id) {
await apiClient.put(`${API}/parts/${partForm.id}`, partForm);
toast.success("부품이 수정되었어요.");
} else {
await apiClient.post(`${API}/${selectedMoldCode}/parts`, partForm);
toast.success("부품이 등록되었어요.");
}
setPartModalOpen(false);
setPartForm({});
fetchParts(selectedMoldCode);
} catch {
toast.error("등록에 실패했어요.");
} catch (err: any) {
toast.error(err?.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
@@ -840,7 +885,11 @@ export default function MoldInfoPage() {
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableRow
key={s.id}
className="cursor-pointer"
onDoubleClick={() => { setSerialForm(s); setSerialModalOpen(true); }}
>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
@@ -935,7 +984,11 @@ export default function MoldInfoPage() {
</TableHeader>
<TableBody>
{inspections.map((ins: any) => (
<TableRow key={ins.id}>
<TableRow
key={ins.id}
className="cursor-pointer"
onDoubleClick={() => openInspectionEdit(ins)}
>
<TableCell className="text-[13px] font-medium">{ins.inspection_item}</TableCell>
<TableCell className="text-[13px]">{ins.inspection_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{ins.inspection_method || "-"}</TableCell>
@@ -943,14 +996,24 @@ export default function MoldInfoPage() {
<TableCell className="text-[13px] font-mono">{ins.upper_limit || "-"}</TableCell>
<TableCell className="text-[13px]">{ins.unit || "-"}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeleteInspection(ins.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => openInspectionEdit(ins)}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeleteInspection(ins.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -994,7 +1057,11 @@ export default function MoldInfoPage() {
</TableHeader>
<TableBody>
{parts.map((p: any) => (
<TableRow key={p.id}>
<TableRow
key={p.id}
className="cursor-pointer"
onDoubleClick={() => openPartEdit(p)}
>
<TableCell className="text-[13px] font-medium">{p.part_name}</TableCell>
<TableCell className="text-[13px]">{p.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{p.unit || "-"}</TableCell>
@@ -1002,14 +1069,24 @@ export default function MoldInfoPage() {
<TableCell className="text-[13px]">{p.manufacturer || "-"}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{p.remarks || "-"}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeletePart(p.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => openPartEdit(p)}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeletePart(p.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -1241,8 +1318,8 @@ export default function MoldInfoPage() {
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
<DialogTitle>{inspectionForm.id ? "점검항목 수정" : "점검항목 등록"}</DialogTitle>
<DialogDescription>{inspectionForm.id ? "점검항목 정보를 수정해주세요." : "금형 점검항목을 등록해주세요."}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
<div className="flex flex-col gap-1.5 col-span-2">
@@ -1295,8 +1372,8 @@ export default function MoldInfoPage() {
<Input
type="number"
className="h-9 text-sm font-mono"
value={inspectionForm.lower_limit || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, lower_limit: e.target.value })}
value={inspectionForm.reference_value ?? ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, reference_value: e.target.value })}
placeholder="기준값"
/>
</div>
@@ -1305,8 +1382,8 @@ export default function MoldInfoPage() {
<Input
type="number"
className="h-9 text-sm font-mono"
value={inspectionForm.upper_limit || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, upper_limit: e.target.value })}
value={inspectionForm.tolerance ?? ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, tolerance: e.target.value })}
placeholder="허용 오차"
/>
</div>
@@ -1334,9 +1411,9 @@ export default function MoldInfoPage() {
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setInspectionModalOpen(false)}></Button>
<Button size="sm" onClick={handleAddInspection} disabled={saving}>
<Button size="sm" onClick={handleSaveInspection} disabled={saving}>
{saving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
{inspectionForm.id ? "수정하기" : "등록하기"}
</Button>
</DialogFooter>
</DialogContent>
@@ -1346,8 +1423,8 @@ export default function MoldInfoPage() {
<Dialog open={partModalOpen} onOpenChange={setPartModalOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
<DialogTitle>{partForm.id ? "부품 수정" : "부품 등록"}</DialogTitle>
<DialogDescription>{partForm.id ? "부품 정보를 수정해주세요." : "금형 부품을 등록해주세요."}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
<div className="flex flex-col gap-1.5 col-span-2">
@@ -1420,9 +1497,9 @@ export default function MoldInfoPage() {
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setPartModalOpen(false)}></Button>
<Button size="sm" onClick={handleAddPart} disabled={saving}>
<Button size="sm" onClick={handleSavePart} disabled={saving}>
{saving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
{partForm.id ? "수정하기" : "등록하기"}
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,420 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react";
import Webcam from "react-webcam";
import { BrowserMultiFormatReader, NotFoundException } from "@zxing/library";
export interface BarcodeScanModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
targetField?: string;
barcodeFormat?: "all" | "1d" | "2d";
autoSubmit?: boolean;
onScanSuccess: (barcode: string) => void;
userId?: string;
}
export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
open,
onOpenChange,
targetField,
barcodeFormat = "all",
autoSubmit = false,
onScanSuccess,
userId = "guest",
}) => {
const [isScanning, setIsScanning] = useState(false);
const [scannedCode, setScannedCode] = useState<string>("");
const [manualInput, setManualInput] = useState<string>("");
const [error, setError] = useState<string>("");
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const webcamRef = useRef<Webcam>(null);
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
const manualInputRef = useRef<HTMLInputElement>(null);
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
useEffect(() => {
if (open) {
setScannedCode("");
setManualInput("");
setError("");
setIsScanning(false);
setHasPermission(null);
codeReaderRef.current = new BrowserMultiFormatReader();
}
return () => {
stopScanning();
if (codeReaderRef.current) {
codeReaderRef.current.reset();
}
};
}, [open]);
// 카메라 권한 요청
const requestCameraPermission = async () => {
// navigator.mediaDevices 지원 확인
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
setHasPermission(false);
setError(
"이 브라우저는 카메라 접근을 지원하지 않거나, 보안 컨텍스트(HTTPS 또는 localhost)가 아닙니다. " +
"현재 프로토콜: " + window.location.protocol
);
toast.error("카메라 접근이 불가능합니다.");
return;
}
try {
// 후면 카메라 먼저 시도, 실패하면 전면 카메라 fallback
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
} catch {
stream = await navigator.mediaDevices.getUserMedia({ video: true });
}
setHasPermission(true);
stream.getTracks().forEach((track) => track.stop());
} catch (err: any) {
setHasPermission(false);
if (err.name === "NotAllowedError") {
setError("카메라 접근이 거부되었습니다. 브라우저 설정에서 카메라 권한을 허용해주세요.");
toast.error("카메라 권한이 거부되었습니다.");
} else if (err.name === "NotFoundError") {
setError("카메라를 찾을 수 없습니다. 카메라가 연결되어 있는지 확인해주세요.");
toast.error("카메라를 찾을 수 없습니다.");
} else if (err.name === "NotReadableError") {
setError("카메라가 이미 다른 애플리케이션에서 사용 중입니다.");
toast.error("카메라가 사용 중입니다.");
} else if (err.name === "NotSupportedError") {
setError("보안 컨텍스트(HTTPS 또는 localhost)가 아니어서 카메라를 사용할 수 없습니다.");
toast.error("HTTPS 환경이 필요합니다.");
} else {
setError(`카메라 접근 오류: ${err.name} - ${err.message}`);
toast.error("카메라 접근 중 오류가 발생했습니다.");
}
}
};
// 스캔 시작
const startScanning = () => {
setIsScanning(true);
setError("");
setScannedCode("");
scanIntervalRef.current = setInterval(() => {
scanBarcode();
}, 500);
};
// 스캔 중지
const stopScanning = () => {
setIsScanning(false);
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
};
// 바코드 스캔
const scanBarcode = async () => {
if (!webcamRef.current || !codeReaderRef.current) return;
try {
const imageSrc = webcamRef.current.getScreenshot();
if (!imageSrc) return;
const img = new Image();
img.src = imageSrc;
await new Promise((resolve) => {
img.onload = resolve;
});
const result = await codeReaderRef.current.decodeFromImageElement(img);
if (result) {
const barcode = result.getText();
setScannedCode(barcode);
stopScanning();
toast.success(`바코드 스캔 완료: ${barcode}`);
if (autoSubmit) {
onScanSuccess(barcode);
}
}
} catch (err) {
if (!(err instanceof NotFoundException)) {
// NotFoundException은 정상 (바코드 미인식)
}
}
};
// 수동 확인 버튼 (스캔 결과 또는 직접 입력)
const handleConfirm = () => {
const code = scannedCode || manualInput.trim();
if (code) {
onScanSuccess(code); // 호출 측에서 검색 필드를 덮어쓰기
onOpenChange(false);
} else {
toast.error("바코드를 스캔하거나 직접 입력해주세요.");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
{targetField && ` (대상 필드: ${targetField})`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 카메라 권한 요청 대기 중 */}
{hasPermission === null && (
<div className="rounded-md border border-primary bg-primary/10 p-4">
<div className="flex items-start gap-3">
<Camera className="mt-0.5 h-5 w-5 flex-shrink-0 text-primary" />
<div className="flex-1 space-y-3 text-xs sm:text-sm">
<div>
<p className="font-semibold text-primary"> </p>
<p className="mt-1 text-muted-foreground">
.
</p>
</div>
<div className="rounded-md bg-background/50 p-3">
<p className="mb-2 font-medium text-foreground"> :</p>
<ul className="ml-4 list-disc space-y-1 text-muted-foreground">
<li> </li>
<li> <strong>&quot;&quot;</strong> </li>
<li> </li>
</ul>
</div>
<div className="flex items-center gap-2">
<Button
onClick={requestCameraPermission}
className="h-9 text-xs sm:h-10 sm:text-sm"
>
<Camera className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
)}
{/* 카메라 권한 거부됨 */}
{hasPermission === false && (
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-destructive" />
<div className="flex-1 space-y-3 text-xs sm:text-sm">
<div>
<p className="font-semibold text-destructive"> </p>
<p className="mt-1 text-destructive/80">{error}</p>
</div>
<div className="rounded-md bg-background/50 p-3">
<p className="mb-2 font-medium text-foreground"> :</p>
<ol className="ml-4 list-decimal space-y-1 text-muted-foreground">
<li> </li>
<li><strong>&quot;&quot;</strong> <strong>&quot;&quot;</strong> </li>
<li> </li>
</ol>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={requestCameraPermission}
className="h-8 text-xs"
>
</Button>
</div>
</div>
</div>
</div>
)}
{/* 웹캠 뷰 */}
{hasPermission && (
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
<Webcam
ref={webcamRef}
audio={false}
screenshotFormat="image/jpeg"
videoConstraints={{
facingMode: { ideal: "environment" },
}}
onUserMediaError={() => {
// environment 카메라 실패 시 자동 fallback (Webcam 내부 처리)
}}
className="h-full w-full object-cover"
/>
{/* 스캔 가이드 오버레이 */}
{isScanning && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-3/5 w-4/5 rounded-lg border-4 border-primary/70 animate-pulse" />
<div className="absolute bottom-4 left-0 right-0 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
<Scan className="h-4 w-4 animate-pulse text-primary" />
...
</div>
</div>
</div>
)}
{/* 스캔 완료 오버레이 */}
{scannedCode && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<div className="text-center">
<CheckCircle2 className="mx-auto h-16 w-16 text-success" />
<p className="mt-2 text-sm font-medium"> !</p>
<p className="mt-1 font-mono text-lg font-bold text-primary">{scannedCode}</p>
</div>
</div>
)}
</div>
)}
{/* 수동 입력 (카메라 사용 불가 시 또는 외장 스캐너 사용 시) */}
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground"> </p>
<div className="flex gap-2">
<input
ref={manualInputRef}
type="text"
value={manualInput}
onChange={(e) => setManualInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && manualInput.trim()) {
e.preventDefault();
onScanSuccess(manualInput.trim());
onOpenChange(false);
}
}}
placeholder="바코드/QR 번호 입력 후 Enter"
className="flex-1 h-11 rounded-lg border border-border px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
autoFocus={hasPermission === false}
/>
<Button
onClick={() => {
if (manualInput.trim()) {
onScanSuccess(manualInput.trim());
onOpenChange(false);
}
}}
disabled={!manualInput.trim()}
className="h-11 px-4"
>
</Button>
</div>
</div>
{/* 바코드 포맷 정보 */}
<div className="rounded-md border border-border bg-muted/50 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="text-[10px] text-muted-foreground sm:text-xs">
<p className="font-medium"> </p>
<p className="mt-1">
{barcodeFormat === "all" && "1D/2D 바코드 모두 지원 (Code 128, QR Code 등)"}
{barcodeFormat === "1d" && "1D 바코드 (Code 128, Code 39, EAN-13, UPC-A)"}
{barcodeFormat === "2d" && "2D 바코드 (QR Code, Data Matrix)"}
</p>
</div>
</div>
</div>
{/* 에러 메시지 */}
{error && (
<div className="rounded-md border border-destructive bg-destructive/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
<p className="text-xs text-destructive">{error}</p>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
{!isScanning && !scannedCode && hasPermission && (
<Button
onClick={startScanning}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Camera className="mr-2 h-4 w-4" />
</Button>
)}
{isScanning && (
<Button
variant="destructive"
onClick={stopScanning}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<CameraOff className="mr-2 h-4 w-4" />
</Button>
)}
{scannedCode && (
<Button
variant="outline"
onClick={() => {
setScannedCode("");
startScanning();
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Camera className="mr-2 h-4 w-4" />
</Button>
)}
{scannedCode && !autoSubmit && (
<Button
onClick={handleConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<CheckCircle2 className="mr-2 h-4 w-4" />
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,82 @@
"use client";
import React from "react";
export interface ConfirmModalProps {
open: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: "primary" | "danger" | "success";
onConfirm: () => void;
onCancel: () => void;
}
/**
* POP (native confirm() )
* , bottom-sheet
*/
export function ConfirmModal({
open,
title,
message,
confirmText = "확인",
cancelText = "취소",
variant = "primary",
onConfirm,
onCancel,
}: ConfirmModalProps) {
if (!open) return null;
const confirmBg =
variant === "danger"
? "bg-gradient-to-b from-red-500 to-red-600 hover:from-red-600 hover:to-red-700"
: variant === "success"
? "bg-gradient-to-b from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700"
: "bg-gradient-to-b from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700";
return (
<div className="fixed inset-0 z-[100]" onClick={onCancel}>
{/* Overlay */}
<div className="absolute inset-0 bg-black/50" />
{/* Center modal */}
<div className="absolute inset-0 flex items-center justify-center p-6">
<div
className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Body */}
<div className="px-6 py-7 text-center">
{title && (
<h3 className="text-lg font-bold text-gray-900 mb-3">{title}</h3>
)}
<p className="text-base text-gray-700 whitespace-pre-line leading-relaxed">
{message}
</p>
</div>
{/* Buttons */}
<div className="flex border-t border-gray-100">
<button
type="button"
onClick={onCancel}
className="flex-1 py-4 text-base font-semibold text-gray-600 hover:bg-gray-50 active:bg-gray-100 transition-colors"
>
{cancelText}
</button>
<div className="w-px bg-gray-100" />
<button
type="button"
onClick={onConfirm}
className={`flex-1 py-4 text-base font-bold text-white transition-all active:scale-[0.98] ${confirmBg}`}
>
{confirmText}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,195 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { getChosung } from "../inbound/SupplierModal";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface EquipmentItem {
id: string;
equipment_code: string;
equipment_name: string;
}
interface EquipmentModalProps {
open: boolean;
onClose: () => void;
onSelect: (equipment: EquipmentItem) => void;
items: EquipmentItem[];
loading?: boolean;
title?: string;
searchPlaceholder?: string;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const AVATAR_COLORS = [
"#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#ec4899", "#14b8a6", "#f97316", "#6366f1",
"#84cc16", "#e11d48", "#0ea5e9", "#a855f7", "#10b981",
];
function getAvatarColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function EquipmentModal({
open,
onClose,
onSelect,
items,
loading = false,
title = "설비 선택",
searchPlaceholder = "설비명 또는 코드 검색...",
}: EquipmentModalProps) {
const [search, setSearch] = useState("");
const [sortMode, setSortMode] = useState<"korean" | "abc">("korean");
useEffect(() => {
if (open) setSearch("");
}, [open]);
const grouped = useMemo(() => {
const filtered = items.filter((e) =>
e.equipment_name.toLowerCase().includes(search.toLowerCase()) ||
e.equipment_code.toLowerCase().includes(search.toLowerCase())
);
const sorted = [...filtered].sort((a, b) => {
if (sortMode === "abc") return a.equipment_name.localeCompare(b.equipment_name, "en");
return a.equipment_name.localeCompare(b.equipment_name, "ko");
});
const groups: { letter: string; items: EquipmentItem[] }[] = [];
const map = new Map<string, EquipmentItem[]>();
for (const e of sorted) {
const first = e.equipment_name.trim().charAt(0);
const letter = getChosung(first);
if (!map.has(letter)) map.set(letter, []);
map.get(letter)!.push(e);
}
for (const [letter, list] of map) {
groups.push({ letter, items: list });
}
return groups;
}, [items, search, sortMode]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white rounded-2xl w-[80vw] h-[80vh] flex flex-col shadow-2xl overflow-hidden z-10">
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div className="flex items-center gap-3">
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
<div className="flex gap-1.5">
<button
onClick={() => setSortMode("korean")}
className={`px-3 py-1 rounded-full text-xs font-medium border transition-all ${
sortMode === "korean"
? "bg-gray-900 text-white border-gray-900"
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-50"
}`}
>
</button>
<button
onClick={() => setSortMode("abc")}
className={`px-3 py-1 rounded-full text-xs font-medium border transition-all ${
sortMode === "abc"
? "bg-gray-900 text-white border-gray-900"
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-50"
}`}
>
ABC
</button>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 hover:bg-gray-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-5 py-3">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition-all"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-5">
{loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
...
</div>
) : grouped.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
{search ? "검색 결과가 없습니다" : "등록된 설비가 없습니다"}
</div>
) : (
grouped.map((group) => (
<div key={group.letter} className="mb-2">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-bold text-blue-500 min-w-[20px]">{group.letter}</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
<div className="grid grid-cols-4 min-[900px]:grid-cols-5 gap-x-2 gap-y-1">
{group.items.map((equipment) => {
const displayName = equipment.equipment_name.trim();
const initial = displayName.charAt(0);
const color = getAvatarColor(equipment.equipment_name);
return (
<button
key={equipment.id}
onClick={() => { onSelect(equipment); onClose(); }}
className="flex flex-col items-center gap-1 py-1.5 px-3 w-full rounded-xl hover:bg-gray-50 active:scale-95 transition-all cursor-pointer border-none bg-transparent"
>
<div
className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl flex items-center justify-center text-white text-xl sm:text-2xl font-bold shadow-sm shrink-0"
style={{ background: color }}
>
{initial}
</div>
<span className="text-xs sm:text-sm font-medium text-gray-700 text-center leading-tight w-full truncate px-1">
{equipment.equipment_name}
</span>
</button>
);
})}
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
import { ButtonHTMLAttributes, forwardRef } from "react";
import { COLOR_MAP, type PopColor } from "./theme";
type Size = "sm" | "md" | "lg";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
color?: PopColor;
size?: Size;
icon?: React.ReactNode;
}
const SIZE_CLASSES: Record<Size, string> = {
sm: "min-w-[96px] min-h-[40px] text-sm px-3",
md: "min-w-[144px] min-h-[48px] text-base px-4",
lg: "min-w-[200px] min-h-[56px] text-lg px-6",
};
const COMMON =
"rounded-xl font-semibold text-white shadow-md transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2";
const PopButton = forwardRef<HTMLButtonElement, Props>(
({ color = "blue", size = "md", icon, children, className, ...rest }, ref) => {
const tokens = COLOR_MAP[color];
const classes = [
COMMON,
SIZE_CLASSES[size],
tokens.buttonBg,
tokens.buttonBgHover,
tokens.ring,
className,
]
.filter(Boolean)
.join(" ");
return (
<button ref={ref} className={classes} {...rest}>
{icon && <span className="shrink-0">{icon}</span>}
{children}
</button>
);
}
);
PopButton.displayName = "PopButton";
export default PopButton;
@@ -0,0 +1,38 @@
"use client";
import { HTMLAttributes, forwardRef } from "react";
import { COLOR_MAP, type PopColor } from "./theme";
interface Props extends HTMLAttributes<HTMLDivElement> {
selected?: boolean;
color?: PopColor;
interactive?: boolean;
}
const BASE =
"w-full min-h-[180px] rounded-2xl bg-white border shadow-sm p-4 flex flex-col transition-all";
const PopCard = forwardRef<HTMLDivElement, Props>(
(
{ selected = false, color = "blue", interactive = true, className, ...rest },
ref
) => {
const tokens = COLOR_MAP[color];
const classes = [
BASE,
selected ? tokens.border : "border-gray-200",
interactive ? "hover:shadow-md hover:border-gray-300 cursor-pointer" : "",
selected ? tokens.ringSelected : "",
className,
]
.filter(Boolean)
.join(" ");
return <div ref={ref} className={classes} {...rest} />;
}
);
PopCard.displayName = "PopCard";
export default PopCard;
@@ -0,0 +1,77 @@
import { HTMLAttributes } from "react";
type Cols = 1 | 2 | 3 | 4;
interface ColProfile {
base?: Cols;
md?: Cols;
lg?: Cols;
xl?: Cols;
"2xl"?: Cols;
}
interface Props extends HTMLAttributes<HTMLDivElement> {
cols?: ColProfile;
gap?: "sm" | "md" | "lg";
}
const BASE_COLS: Record<Cols, string> = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
};
const MD_COLS: Record<Cols, string> = {
1: "md:grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
};
const LG_COLS: Record<Cols, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
};
const XL_COLS: Record<Cols, string> = {
1: "xl:grid-cols-1",
2: "xl:grid-cols-2",
3: "xl:grid-cols-3",
4: "xl:grid-cols-4",
};
const XXL_COLS: Record<Cols, string> = {
1: "2xl:grid-cols-1",
2: "2xl:grid-cols-2",
3: "2xl:grid-cols-3",
4: "2xl:grid-cols-4",
};
const GAP: Record<"sm" | "md" | "lg", string> = {
sm: "gap-3",
md: "gap-4",
lg: "gap-6",
};
export default function PopCardGrid({
cols = { base: 1, md: 2, xl: 3 },
gap = "md",
className,
...rest
}: Props) {
const { base = 1, md, lg, xl, "2xl": xxl } = cols;
const classes = [
"grid w-full",
GAP[gap],
BASE_COLS[base],
md != null ? MD_COLS[md] : "",
lg != null ? LG_COLS[lg] : "",
xl != null ? XL_COLS[xl] : "",
xxl != null ? XXL_COLS[xxl] : "",
className,
]
.filter(Boolean)
.join(" ");
return <div className={classes} {...rest} />;
}
@@ -0,0 +1,88 @@
"use client";
import { ReactNode, useEffect } from "react";
type Size = "sm" | "md" | "lg" | "xl";
interface Props {
open: boolean;
onClose: () => void;
size?: Size;
title?: string;
children: ReactNode;
footer?: ReactNode;
hideCloseButton?: boolean;
}
const SIZE_CLASSES: Record<Size, string> = {
sm: "w-[min(90vw,420px)] max-h-[80vh]",
md: "w-[min(90vw,640px)] max-h-[85vh]",
lg: "w-[min(95vw,900px)] max-h-[90vh]",
xl: "w-[min(98vw,1200px)] max-h-[95vh]",
};
export default function PopModal({
open,
onClose,
size = "md",
title,
children,
footer,
hideCloseButton = false,
}: Props) {
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
if (!open) return null;
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className={`${SIZE_CLASSES[size]} bg-white rounded-2xl shadow-xl flex flex-col`}
onClick={(e) => e.stopPropagation()}
>
{(title != null || !hideCloseButton) && (
<header className="flex items-center justify-between px-5 py-4 border-b border-gray-200 shrink-0">
{title != null && (
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
)}
{!hideCloseButton && (
<button
type="button"
onClick={onClose}
className="ml-auto w-10 h-10 flex items-center justify-center rounded-xl text-gray-500 hover:bg-gray-100 text-xl font-medium"
aria-label="닫기"
>
&times;
</button>
)}
</header>
)}
<main className="flex-1 overflow-y-auto p-4">{children}</main>
{footer && (
<footer className="border-t border-gray-200 p-4 shrink-0">
{footer}
</footer>
)}
</div>
</div>
);
}
@@ -0,0 +1,445 @@
"use client";
import React, { useState, useEffect, useRef, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
import { CompanySwitchModal } from "@/components/pop/shell/CompanySwitchModal";
interface PopShellProps {
children: ReactNode;
showBanner?: boolean;
title?: string;
showBack?: boolean;
headerRight?: ReactNode;
fullBleed?: boolean;
}
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight, fullBleed = false }: PopShellProps) {
const router = useRouter();
// 회사 고정 PopShell — popHomePath는 해당 회사 메인으로 직박
const popHomePath = "/COMPANY_10/pop/main";
const { user, logout, switchCompany } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
const deptName = user?.deptName || "";
const initial = displayName.charAt(0);
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
const [companySwitchOpen, setCompanySwitchOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [showFullscreenSplash, setShowFullscreenSplash] = useState(false);
const [hours, setHours] = useState("00");
const [minutes, setMinutes] = useState("00");
const [seconds, setSeconds] = useState("00");
const [dateStr, setDateStr] = useState("2026-01-01");
const [colonVisible, setColonVisible] = useState(true);
const [profileOpen, setProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
// 전체화면이 아닌 상태에서 POP 진입 시 스플래시 표시 — 세션당 1회
useEffect(() => {
if (sessionStorage.getItem("pop-fullscreen-asked")) return;
if (!document.fullscreenElement) {
setShowFullscreenSplash(true);
sessionStorage.setItem("pop-fullscreen-asked", "1");
}
}, []);
const handleEnterFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
}
} catch {
// 전체화면 미지원 시 무시
}
setShowFullscreenSplash(false);
};
const handleSkipFullscreen = () => {
setShowFullscreenSplash(false);
};
useEffect(() => {
setMounted(true);
function tick() {
const now = new Date();
setHours(String(now.getHours()).padStart(2, "0"));
setMinutes(String(now.getMinutes()).padStart(2, "0"));
setSeconds(String(now.getSeconds()).padStart(2, "0"));
setDateStr(
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`
);
}
tick();
const clockInterval = setInterval(tick, 1000);
const blinkInterval = setInterval(() => {
setColonVisible((v) => !v);
}, 500);
return () => {
clearInterval(clockInterval);
clearInterval(blinkInterval);
};
}, []);
// Profile dropdown: close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setProfileOpen(false);
}
}
if (profileOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [profileOpen]);
const handlePcMode = async () => {
setProfileOpen(false);
if (document.fullscreenElement) {
try { await document.exitFullscreen(); } catch {}
}
router.push("/");
};
const handlePopHome = () => {
setProfileOpen(false);
router.push(popHomePath);
};
const toggleFullscreen = async () => {
setProfileOpen(false);
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch {
// fullscreen not supported
}
};
const handleLogout = () => {
setProfileOpen(false);
logout();
};
const handleCompanySwitch = async (companyCode: string) => {
const currentCode = user?.companyCode || user?.company_code;
if (companyCode === currentCode) return;
const result = await switchCompany(companyCode);
if (result.success) {
window.location.reload();
} else {
alert(result.message || "회사 전환에 실패했습니다.");
}
};
// POP 설정에서 배너 텍스트 로드 (POP화면설정에서 관리)
const { settings: popSettings } = usePopSettings();
const mainConfig = (popSettings as any)?.screens?.main;
const bannerEnabled = mainConfig?.bannerEnabled ?? true;
const bannerText = mainConfig?.bannerText;
const marqueeText = bannerText || "";
return (
<div className="min-h-screen min-h-dvh flex flex-col" style={{ background: "#F5F5F5" }}>
{/* ===== FULLSCREEN SPLASH ===== */}
{showFullscreenSplash && (
<div className="fixed inset-0 z-[100] flex items-center justify-center" style={{ background: "rgba(26,26,46,0.92)", backdropFilter: "blur(8px)" }}>
<div className="flex flex-col items-center gap-6 px-6 text-center">
<div
className="w-20 h-20 rounded-2xl bg-blue-500 flex items-center justify-center"
style={{ boxShadow: "0 8px 32px rgba(59,130,246,.4)" }}
>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold text-white mb-2">POP </h2>
<p className="text-white/60 text-sm"> </p>
</div>
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
onClick={handleEnterFullscreen}
className="w-full py-3.5 rounded-xl bg-blue-500 text-white font-semibold text-base hover:bg-blue-600 active:scale-[0.97] transition-all"
style={{ boxShadow: "0 4px 16px rgba(59,130,246,.35)" }}
>
</button>
<button
onClick={handleSkipFullscreen}
className="w-full py-3 rounded-xl bg-white/10 text-white/70 font-medium text-sm hover:bg-white/20 active:scale-[0.97] transition-all"
>
</button>
</div>
</div>
</div>
)}
{/* ===== HEADER ===== */}
<header
className="sticky top-0 z-50 grid grid-cols-5 items-center px-4 sm:px-6 lg:px-8 py-3"
style={{ background: "#1a1a2e" }}
>
{/* Left: Back + Logo + Company */}
<div className="col-span-2 justify-self-start flex items-center gap-3 min-w-0">
{showBack && (
<button
onClick={() => router.back()}
className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 hover:bg-white/20 active:scale-95 transition-all"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
<div
className="flex items-center gap-3 min-w-0 cursor-pointer"
onClick={() => router.push(popHomePath)}
>
<div
className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shrink-0"
style={{ boxShadow: "0 4px 12px rgba(59,130,246,.35)" }}
>
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
</div>
<div className="flex flex-col min-w-0">
{title ? (
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{title}
</span>
) : (
<>
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{user?.companyName || "POP"}
</span>
<span className="text-white text-xs font-medium leading-tight">
</span>
</>
)}
</div>
</div>
</div>
{/* Center: Clock (desktop) */}
<div className="col-span-1 justify-self-center hidden sm:flex flex-col items-center">
{mounted && (
<>
<div
className="flex items-center text-white font-bold text-2xl tracking-wider"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<span>{hours}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{minutes}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{seconds}</span>
</div>
<span className="text-white text-xs font-medium mt-0.5">{dateStr}</span>
</>
)}
</div>
{/* Right: Mobile clock + Profile */}
<div className="col-span-2 justify-self-end flex items-center gap-3">
{/* Mobile clock */}
{mounted && (
<div className="sm:hidden flex items-center gap-1.5 text-white text-sm">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{hours}:{minutes}
</span>
</div>
)}
{/* Custom header right content (e.g. cart icon) */}
{headerRight}
{/* 회사 전환 버튼 (최고관리자만) */}
{isSuperAdmin && (
<button
onClick={() => setCompanySwitchOpen(true)}
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 active:scale-95 transition-all"
title="회사 전환"
>
<svg className="w-4 h-4 text-white/70" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<span className="text-xs text-white/70 font-medium"></span>
</button>
)}
<div className="hidden sm:block h-5 w-px bg-white/20" />
{/* Profile with Dropdown */}
<div className="relative" ref={profileRef}>
<button
onClick={() => setProfileOpen((v) => !v)}
className="flex items-center gap-2.5 cursor-pointer"
>
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm text-white font-semibold leading-tight">{displayName}</span>
<span className="text-xs text-white font-medium leading-tight">{deptName}</span>
</div>
<div
className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-sm font-bold text-white shrink-0 transition-transform active:scale-95"
style={{ boxShadow: "0 2px 8px rgba(59,130,246,.35)" }}
>
{initial}
</div>
</button>
{/* Profile Dropdown */}
<div
className={`absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-[60] transition-all duration-200 origin-top-right ${
profileOpen
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 -translate-y-1 pointer-events-none"
}`}
>
{/* User Info */}
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900">{displayName}</p>
<p className="text-xs text-gray-400 mt-0.5">{deptName || user?.userId}</p>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={handlePcMode}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🖥</span>
<span>PC </span>
</button>
<button
onClick={toggleFullscreen}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">📱</span>
<span> ()</span>
</button>
<button
onClick={handlePopHome}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🏠</span>
<span>POP </span>
</button>
</div>
{/* Logout */}
<div className="border-t border-gray-100 py-1">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 text-sm text-red-500 hover:bg-red-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🚪</span>
<span></span>
</button>
</div>
</div>
</div>
</div>
</header>
{/* 회사 전환 모달 (최고관리자만) */}
{isSuperAdmin && (
<CompanySwitchModal
open={companySwitchOpen}
onClose={() => setCompanySwitchOpen(false)}
onSelect={handleCompanySwitch}
currentCompanyCode={user?.companyCode || user?.company_code}
/>
)}
{/* ===== NOTICE BANNER (Marquee) ===== */}
{showBanner && bannerEnabled && bannerText && <div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center gap-3">
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-amber-600 text-sm">📢</span>
<span className="text-xs font-bold text-amber-700"></span>
</div>
<div className="overflow-hidden whitespace-nowrap flex-1">
<div
className="inline-block text-sm text-amber-800"
style={{
animation: "popMarquee 30s linear infinite",
}}
>
{marqueeText}
</div>
</div>
</div>}
{/* ===== MAIN CONTENT ===== */}
<main className={fullBleed
? "flex-1 overflow-hidden"
: "max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto"
}>
{children}
</main>
{/* FOOTER 삭제 — POP 화면에서 불필요 */}
{/* Marquee keyframes */}
<style jsx global>{`
@keyframes popMarquee {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
`}</style>
</div>
);
}
@@ -0,0 +1,188 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface SimpleKeypadModalProps {
open: boolean;
onClose: () => void;
onConfirm: (qty: number) => void;
maxQty: number;
itemName: string;
initialQty?: number;
}
/* ------------------------------------------------------------------ */
/* Numpad Keys */
/* ------------------------------------------------------------------ */
const KEYS = [
{ label: "7", action: "7" },
{ label: "8", action: "8" },
{ label: "9", action: "9" },
{ label: "\u2190", action: "backspace" },
{ label: "4", action: "4" },
{ label: "5", action: "5" },
{ label: "6", action: "6" },
{ label: "C", action: "clear" },
{ label: "1", action: "1" },
{ label: "2", action: "2" },
{ label: "3", action: "3" },
{ label: "MAX", action: "max" },
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function SimpleKeypadModal({
open,
onClose,
onConfirm,
maxQty,
itemName,
initialQty,
}: SimpleKeypadModalProps) {
const [qty, setQty] = useState("0");
/* Reset on open */
useEffect(() => {
if (open) {
setQty(initialQty !== undefined && initialQty > 0 ? String(initialQty) : "0");
}
}, [open, initialQty]);
const qtyNum = parseInt(qty, 10) || 0;
const isOverMax = qtyNum > maxQty;
/* Numpad input handler */
const handleInput = useCallback(
(key: string) => {
setQty((prev) => {
switch (key) {
case "backspace":
return prev.length <= 1 ? "0" : prev.slice(0, -1);
case "clear":
return "0";
case "max":
return String(maxQty);
default: {
const next = prev === "0" ? key : prev + key;
const num = parseInt(next, 10);
if (isNaN(num)) return prev;
return next;
}
}
});
},
[maxQty],
);
const handleConfirm = () => {
if (qtyNum <= 0) return;
const finalQty = Math.min(qtyNum, maxQty);
onConfirm(finalQty);
onClose();
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Overlay */}
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
{/* Panel */}
<div className="relative bg-white w-[90%] max-w-[360px] rounded-2xl shadow-2xl z-10 overflow-hidden">
{/* Header - blue gradient */}
<div
className="flex items-center justify-between px-4 py-3"
style={{ background: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)" }}
>
<span className="text-[13px] text-white/90 bg-white/20 px-3 py-1 rounded-full">
{maxQty.toLocaleString()} EA
</span>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white hover:bg-white/30 active:scale-95 transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="p-4">
<p className="text-center text-sm font-semibold text-gray-700 mb-1">
</p>
<p className="text-center text-xs text-gray-400 mb-3 truncate px-2">
{itemName}
</p>
{/* Display */}
<input
type="text"
readOnly
value={qtyNum.toLocaleString()}
className={`w-full px-4 py-3 text-right text-3xl font-bold border-2 rounded-xl bg-gray-50 mb-3 ${
isOverMax ? "border-red-300 text-red-500" : "border-gray-200 text-gray-900"
}`}
style={{ fontVariantNumeric: "tabular-nums" }}
/>
{isOverMax && (
<p className="text-center text-xs text-red-500 font-medium mb-2">
{maxQty.toLocaleString()}EA ({maxQty.toLocaleString()}EA)
</p>
)}
{/* Numpad grid: 4x3 + bottom row */}
<div className="grid grid-cols-4 gap-2.5">
{KEYS.map((key) => (
<button
key={key.action}
onClick={() => handleInput(key.action)}
className={`h-14 rounded-xl text-lg font-semibold active:scale-95 transition-all ${
key.action === "backspace" || key.action === "clear"
? "bg-amber-100 text-amber-700 hover:bg-amber-200"
: key.action === "max"
? "bg-blue-100 text-blue-700 hover:bg-blue-200 text-sm"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
}`}
>
{key.label}
</button>
))}
{/* Bottom row: 0 (span 2) + Confirm (span 2) */}
<button
onClick={() => handleInput("0")}
className="col-span-2 h-14 rounded-xl text-lg font-semibold bg-gray-100 text-gray-900 hover:bg-gray-200 active:scale-95 transition-all"
>
0
</button>
<button
onClick={handleConfirm}
disabled={qtyNum <= 0}
className={`col-span-2 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all ${
qtyNum <= 0 ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{
background: qtyNum <= 0
? "#9ca3af"
: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
>
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,107 @@
// POP 9-color palette. 키는 부위별 완성 리터럴 — JIT 스캔 대상.
// 동적 문자열 생성 금지(`bg-${x}` 등). 반드시 COLOR_MAP[color].부위 조회로 접근.
export type PopColor =
| 'blue'
| 'purple'
| 'cyan'
| 'green'
| 'red'
| 'pink'
| 'teal'
| 'orange'
| 'amber';
export interface PopColorTokens {
buttonBg: string;
buttonBgHover: string;
ring: string;
ringSelected: string;
text: string;
bg50: string;
border: string;
}
export const COLOR_MAP: Record<PopColor, PopColorTokens> = {
blue: {
buttonBg: 'bg-gradient-to-b from-blue-400 to-blue-700',
buttonBgHover: 'hover:from-blue-500 hover:to-blue-800',
ring: 'focus:ring-blue-500',
ringSelected: 'ring-2 ring-blue-500',
text: 'text-blue-600',
bg50: 'bg-blue-50',
border: 'border-blue-200',
},
purple: {
buttonBg: 'bg-gradient-to-b from-purple-400 to-purple-700',
buttonBgHover: 'hover:from-purple-500 hover:to-purple-800',
ring: 'focus:ring-purple-500',
ringSelected: 'ring-2 ring-purple-500',
text: 'text-purple-600',
bg50: 'bg-purple-50',
border: 'border-purple-200',
},
cyan: {
buttonBg: 'bg-gradient-to-b from-cyan-400 to-cyan-700',
buttonBgHover: 'hover:from-cyan-500 hover:to-cyan-800',
ring: 'focus:ring-cyan-500',
ringSelected: 'ring-2 ring-cyan-500',
text: 'text-cyan-600',
bg50: 'bg-cyan-50',
border: 'border-cyan-200',
},
green: {
buttonBg: 'bg-gradient-to-b from-green-400 to-green-700',
buttonBgHover: 'hover:from-green-500 hover:to-green-800',
ring: 'focus:ring-green-500',
ringSelected: 'ring-2 ring-green-500',
text: 'text-green-600',
bg50: 'bg-green-50',
border: 'border-green-200',
},
red: {
buttonBg: 'bg-gradient-to-b from-red-400 to-red-700',
buttonBgHover: 'hover:from-red-500 hover:to-red-800',
ring: 'focus:ring-red-500',
ringSelected: 'ring-2 ring-red-500',
text: 'text-red-600',
bg50: 'bg-red-50',
border: 'border-red-200',
},
pink: {
buttonBg: 'bg-gradient-to-b from-pink-400 to-pink-700',
buttonBgHover: 'hover:from-pink-500 hover:to-pink-800',
ring: 'focus:ring-pink-500',
ringSelected: 'ring-2 ring-pink-500',
text: 'text-pink-600',
bg50: 'bg-pink-50',
border: 'border-pink-200',
},
teal: {
buttonBg: 'bg-gradient-to-b from-teal-400 to-teal-700',
buttonBgHover: 'hover:from-teal-500 hover:to-teal-800',
ring: 'focus:ring-teal-500',
ringSelected: 'ring-2 ring-teal-500',
text: 'text-teal-600',
bg50: 'bg-teal-50',
border: 'border-teal-200',
},
orange: {
buttonBg: 'bg-gradient-to-b from-orange-400 to-orange-700',
buttonBgHover: 'hover:from-orange-500 hover:to-orange-800',
ring: 'focus:ring-orange-500',
ringSelected: 'ring-2 ring-orange-500',
text: 'text-orange-600',
bg50: 'bg-orange-50',
border: 'border-orange-200',
},
amber: {
buttonBg: 'bg-gradient-to-b from-amber-400 to-amber-700',
buttonBgHover: 'hover:from-amber-500 hover:to-amber-800',
ring: 'focus:ring-amber-500',
ringSelected: 'ring-2 ring-amber-500',
text: 'text-amber-600',
bg50: 'bg-amber-50',
border: 'border-amber-200',
},
};
@@ -0,0 +1,27 @@
/**
* useCartSync - DB (hardcoded re-export)
*
* @/hooks/pop/useCartSync ,
* hardcoded import할 re-export한다.
*
* :
* ```typescript
* import { useCartSync } from "../common/useCartSync";
* const cart = useCartSync("inbound");
* ```
*/
export { useCartSync } from "@/hooks/pop/useCartSync";
export type {
UseCartSyncReturn,
CartChanges,
CartCategory,
} from "@/hooks/pop/useCartSync";
// 타입도 함께 re-export (hardcoded 컴포넌트에서 필요할 수 있음)
export type {
CartItem,
CartItemWithId,
CartSyncStatus,
CartItemStatus,
} from "@/lib/registry/pop-components/types";
@@ -0,0 +1,598 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface ChangeOrder {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
received_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
inspection_type: "self" | "request" | null;
/** Item image URL from item_info.image (may be null) */
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface ChangeInboundProps {
/** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */
cart: import("../common/useCartSync").UseCartSyncReturn;
/** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */
onCartClick: () => void;
/** 카트 저장 중 상태 (버튼 스피너/비활성화용) */
saving: boolean;
/** 입고 유형 — 카트 품목에 기록됨 */
inboundType: string;
/** 소스 테이블명 — 카트 품목별 sourceTable */
sourceTable: string;
}
const STORAGE_KEY = "pop_supplier_change";
export function ChangeInbound({ cart, onCartClick, saving, inboundType, sourceTable }: ChangeInboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
/* State */
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
const [orders, setOrders] = useState<ChangeOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
/* NumberPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<ChangeOrder | null>(null);
/* Barcode scan modal state */
const [supplierScanOpen, setSupplierScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
/* Inline supplier search state */
const [supplierSearchText, setSupplierSearchText] = useState("");
const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false);
const [allSuppliers, setAllSuppliers] = useState<Supplier[]>([]);
const supplierInputRef = useRef<HTMLInputElement>(null);
const supplierDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all suppliers for inline search
* TODO: API
*/
const fetchAllSuppliers = useCallback(async () => {
setAllSuppliers([]);
}, []);
useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedSupplier(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectSupplier = (s: Supplier | null) => {
setSelectedSupplier(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
/* Filtered suppliers for inline dropdown */
const filteredSuppliers = useMemo(() => {
if (!supplierSearchText.trim()) return [];
return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim()));
}, [allSuppliers, supplierSearchText]);
/* Close dropdown on outside click */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
supplierDropdownRef.current &&
!supplierDropdownRef.current.contains(e.target as Node) &&
supplierInputRef.current &&
!supplierInputRef.current.contains(e.target as Node)
) {
setSupplierDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch return orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
/* Initial load */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
/* Filter orders by selected supplier */
const filteredOrders = selectedSupplier
? orders.filter((o) =>
o.supplier_code === selectedSupplier.customer_code ||
o.supplier_name === selectedSupplier.customer_name
)
: orders;
/* Filter by keyword */
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.purchase_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
/* Open numpad for an order */
const openNumpad = (order: ChangeOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
/* Add to cart with numpad result */
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
// 공급사 검증: 카트에 이미 다른 공급사 품목이 있으면 차단
if (cart.cartItems.length > 0) {
const existingSupplier = String(cart.cartItems[0].row.supplier_code || "");
if (existingSupplier && existingSupplier !== order.supplier_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
supplier_code: order.supplier_code,
supplier_name: order.supplier_name,
purchase_no: order.purchase_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
inbound_type: inboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Search */
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/inbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* Cart button — 교환입고 라인, 발주품목 위. 테마 teal */}
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.teal.buttonBg} ${COLOR_MAP.teal.buttonBgHover} shadow-[0_4px_12px_rgba(13,148,136,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area (2 columns on tablet+) ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Supplier search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedSupplier && (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{selectedSupplier.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setSupplierModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedSupplier
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedSupplier ? selectedSupplier.customer_name : "거래처를 선택하세요"}
</button>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setSupplierScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.teal.buttonBg} ${COLOR_MAP.teal.buttonBgHover} shadow-[0_4px_12px_rgba(20,184,166,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedSupplier && (
<button
onClick={() => { selectSupplier(null); setSupplierSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Supplier dropdown removed — use modal instead */}
</div>
</div>
{/* Item search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-teal-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedSupplier ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 발주번호 검색..."
disabled={!selectedSupplier}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedSupplier
? "focus:border-teal-400 focus:ring-2 focus:ring-teal-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedSupplier}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.teal.buttonBg} ${COLOR_MAP.teal.buttonBgHover} ${
!selectedSupplier ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(20,184,166,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-[11px] text-gray-400">
{selectedSupplier ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedSupplier ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-teal-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedSupplier ? "해당 거래처의 교환 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-teal-500 rounded-lg hover:bg-teal-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-teal-300"
}`}
>
{/* Green left bar for in-cart items */}
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
{/* === Header row: item code + item name + inspection badge === */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-teal-100 text-teal-700 border border-teal-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
{/* === Body row: image + info + action === */}
<div className="flex gap-2.5">
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.purchase_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
{/* Action column: qty display + add/cancel button */}
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
{/* Qty display - clickable to open numpad */}
<button
onClick={() => {
if (!inCart) openNumpad(order);
}}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-teal-50 border-teal-200 hover:bg-teal-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-teal-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{/* Add / Cancel button */}
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all ${COLOR_MAP.teal.buttonBg} ${COLOR_MAP.teal.buttonBgHover}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={supplierModalOpen}
onClose={() => setSupplierModalOpen(false)}
onSelect={(s) => selectSupplier(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
{/* Barcode scan modal for supplier */}
<BarcodeScanModal
open={supplierScanOpen}
onOpenChange={setSupplierScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setSupplierScanOpen(false);
// 스캔 결과로 거래처 검색 (거래처명 또는 코드 매칭)
const match = allSuppliers.find(
(s) =>
s.customer_code === barcode ||
s.customer_name.includes(barcode)
);
if (match) {
selectSupplier(match);
setSupplierSearchText("");
} else {
// 매칭 안 되면 검색 텍스트에 넣어서 드롭다운 표시
setSupplierSearchText(barcode);
setSupplierDropdownOpen(true);
}
}}
/>
{/* Barcode scan modal for item */}
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="교환 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
// 스캔 결과로 품목 필터
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,598 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface ErrorOrder {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
received_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
inspection_type: "self" | "request" | null;
/** Item image URL from item_info.image (may be null) */
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface ErrorInboundProps {
/** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */
cart: import("../common/useCartSync").UseCartSyncReturn;
/** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */
onCartClick: () => void;
/** 카트 저장 중 상태 (버튼 스피너/비활성화용) */
saving: boolean;
/** 입고 유형 — 카트 품목에 기록됨 */
inboundType: string;
/** 소스 테이블명 — 카트 품목별 sourceTable */
sourceTable: string;
}
const STORAGE_KEY = "pop_supplier_error";
export function ErrorInbound({ cart, onCartClick, saving, inboundType, sourceTable }: ErrorInboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
/* State */
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
const [orders, setOrders] = useState<ErrorOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
/* NumberPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<ErrorOrder | null>(null);
/* Barcode scan modal state */
const [supplierScanOpen, setSupplierScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
/* Inline supplier search state */
const [supplierSearchText, setSupplierSearchText] = useState("");
const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false);
const [allSuppliers, setAllSuppliers] = useState<Supplier[]>([]);
const supplierInputRef = useRef<HTMLInputElement>(null);
const supplierDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all suppliers for inline search
* TODO: API
*/
const fetchAllSuppliers = useCallback(async () => {
setAllSuppliers([]);
}, []);
useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedSupplier(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectSupplier = (s: Supplier | null) => {
setSelectedSupplier(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
/* Filtered suppliers for inline dropdown */
const filteredSuppliers = useMemo(() => {
if (!supplierSearchText.trim()) return [];
return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim()));
}, [allSuppliers, supplierSearchText]);
/* Close dropdown on outside click */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
supplierDropdownRef.current &&
!supplierDropdownRef.current.contains(e.target as Node) &&
supplierInputRef.current &&
!supplierInputRef.current.contains(e.target as Node)
) {
setSupplierDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch return orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
/* Initial load */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
/* Filter orders by selected supplier */
const filteredOrders = selectedSupplier
? orders.filter((o) =>
o.supplier_code === selectedSupplier.customer_code ||
o.supplier_name === selectedSupplier.customer_name
)
: orders;
/* Filter by keyword */
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.purchase_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
/* Open numpad for an order */
const openNumpad = (order: ErrorOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
/* Add to cart with numpad result */
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
// 공급사 검증: 카트에 이미 다른 공급사 품목이 있으면 차단
if (cart.cartItems.length > 0) {
const existingSupplier = String(cart.cartItems[0].row.supplier_code || "");
if (existingSupplier && existingSupplier !== order.supplier_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
supplier_code: order.supplier_code,
supplier_name: order.supplier_name,
purchase_no: order.purchase_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
inbound_type: inboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Search */
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/inbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* Cart button — 불량입고 라인, 발주품목 위. 테마 red */}
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.red.buttonBg} ${COLOR_MAP.red.buttonBgHover} shadow-[0_4px_12px_rgba(220,38,38,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area (2 columns on tablet+) ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Supplier search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedSupplier && (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{selectedSupplier.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setSupplierModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedSupplier
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedSupplier ? selectedSupplier.customer_name : "거래처를 선택하세요"}
</button>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setSupplierScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.red.buttonBg} ${COLOR_MAP.red.buttonBgHover} shadow-[0_4px_12px_rgba(239,68,68,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedSupplier && (
<button
onClick={() => { selectSupplier(null); setSupplierSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Supplier dropdown removed — use modal instead */}
</div>
</div>
{/* Item search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-red-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedSupplier ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 발주번호 검색..."
disabled={!selectedSupplier}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedSupplier
? "focus:border-red-400 focus:ring-2 focus:ring-red-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedSupplier}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.red.buttonBg} ${COLOR_MAP.red.buttonBgHover} ${
!selectedSupplier ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(239,68,68,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-[11px] text-gray-400">
{selectedSupplier ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedSupplier ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-red-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedSupplier ? "해당 거래처의 불량 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-red-500 rounded-lg hover:bg-red-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-red-300"
}`}
>
{/* Green left bar for in-cart items */}
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
{/* === Header row: item code + item name + inspection badge === */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-red-100 text-red-700 border border-red-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
{/* === Body row: image + info + action === */}
<div className="flex gap-2.5">
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.purchase_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
{/* Action column: qty display + add/cancel button */}
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
{/* Qty display - clickable to open numpad */}
<button
onClick={() => {
if (!inCart) openNumpad(order);
}}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-red-50 border-red-200 hover:bg-red-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-red-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{/* Add / Cancel button */}
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all ${COLOR_MAP.red.buttonBg} ${COLOR_MAP.red.buttonBgHover}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={supplierModalOpen}
onClose={() => setSupplierModalOpen(false)}
onSelect={(s) => selectSupplier(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
{/* Barcode scan modal for supplier */}
<BarcodeScanModal
open={supplierScanOpen}
onOpenChange={setSupplierScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setSupplierScanOpen(false);
// 스캔 결과로 거래처 검색 (거래처명 또는 코드 매칭)
const match = allSuppliers.find(
(s) =>
s.customer_code === barcode ||
s.customer_name.includes(barcode)
);
if (match) {
selectSupplier(match);
setSupplierSearchText("");
} else {
// 매칭 안 되면 검색 텍스트에 넣어서 드롭다운 표시
setSupplierSearchText(barcode);
setSupplierDropdownOpen(true);
}
}}
/>
{/* Barcode scan modal for item */}
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="불량 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
// 스캔 결과로 품목 필터
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,737 @@
"use client";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import React, { useCallback, useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
import { InspectionModal, type InspectionResult } from "./InspectionModal";
import type { PackageEntry } from "./NumberPadModal";
/* ------------------------------------------------------------------ */
/* Warehouse type */
/* ------------------------------------------------------------------ */
interface Warehouse {
warehouse_code: string;
warehouse_name: string;
warehouse_type?: string;
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface CartItem {
id: string;
/** cart_items 테이블의 PK (UUID) — DB 삭제용 */
dbId?: string;
/** purchase_detail or purchase_order_mng */
source_table: string;
/** PK of the source row */
source_id: string;
purchase_no: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
remain_qty: number;
/** User-entered quantity */
inbound_qty: number;
unit_price: number;
supplier_code: string;
supplier_name: string;
order_date: string;
inspection_required?: boolean;
inspection_type?: "self" | "request" | null;
packages?: PackageEntry[];
inspectionResult?: InspectionResult | null;
}
interface InboundCartProps {
open: boolean;
onClose: () => void;
items: CartItem[];
onUpdateQty: (id: string, qty: number) => void;
onRemove: (id: string) => void;
onClear: () => void;
supplierName?: string;
onUpdateItems?: (items: CartItem[]) => void;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InboundCart({
open,
onClose,
items,
onUpdateQty,
onRemove,
onClear,
supplierName,
onUpdateItems,
}: InboundCartProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
const [confirming, setConfirming] = useState(false);
const [resultMsg, setResultMsg] = useState<string | null>(null);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionTarget, setInspectionTarget] = useState<CartItem | null>(
null,
);
/* Warehouse state */
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [selectedWarehouse, setSelectedWarehouse] = useState<string>("");
/* Fetch warehouses on mount */
const fetchWarehouses = useCallback(async () => {
try {
const res = await apiClient.get("/receiving/warehouses");
const data: Warehouse[] = res.data?.data ?? [];
setWarehouses(data);
if (data.length > 0 && !selectedWarehouse) {
setSelectedWarehouse(data[0].warehouse_code);
}
} catch {
// Keep empty - user can still confirm without warehouse
}
}, [selectedWarehouse]);
useEffect(() => {
if (open) {
fetchWarehouses();
}
}, [open, fetchWarehouses]);
const totalQty = items.reduce((s, i) => s + i.inbound_qty, 0);
const totalAmount = items.reduce(
(s, i) => s + i.inbound_qty * i.unit_price,
0,
);
/* Toggle select */
const toggleSelect = (id: string) => {
setSelectedItems((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (selectedItems.size === items.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(items.map((i) => i.id)));
}
};
/* Open inspection modal */
const openInspection = (item: CartItem) => {
setInspectionTarget(item);
setInspectionModalOpen(true);
};
/* Handle inspection complete */
const handleInspectionComplete = (result: InspectionResult) => {
if (!inspectionTarget || !onUpdateItems) return;
const updated = items.map((item) =>
item.id === inspectionTarget.id
? { ...item, inspectionResult: result }
: item,
);
onUpdateItems(updated);
setInspectionTarget(null);
};
/* Confirm inbound — PC receivingController.create 와 동일한 body 구조 */
const handleConfirm = async () => {
if (items.length === 0) return;
if (!selectedWarehouse) {
setResultMsg("오류: 입고 창고를 선택해주세요.");
return;
}
setConfirming(true);
setResultMsg(null);
try {
// 1. 입고번호 채번 (RCV-YYYY-XXXX)
let inboundNumber: string | undefined;
try {
const numRes = await apiClient.get("/receiving/generate-number");
if (numRes.data?.success && numRes.data?.data) {
inboundNumber = numRes.data.data;
}
} catch {
// 채번 실패 시 백엔드가 처리
}
// 2. POST /api/receiving — PC create 와 동일한 payload
const payload = {
inbound_number: inboundNumber,
inbound_date: new Date().toISOString().slice(0, 10),
warehouse_code: selectedWarehouse,
inbound_type: "구매입고",
items: items.map((item, idx) => ({
inbound_type: "구매입고",
item_number: item.item_code,
item_name: item.item_name,
spec: item.spec || "",
material: item.material || "",
unit: "EA",
inbound_qty: String(item.inbound_qty),
unit_price: String(item.unit_price || 0),
total_amount: String(
(item.inbound_qty || 0) * (item.unit_price || 0),
),
reference_number: item.purchase_no,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
inspection_status: item.inspectionResult?.completed
? "검사완료"
: item.inspection_required
? "검사대기"
: "합격",
source_table: item.source_table,
source_id: item.source_id || item.id,
seq_no: idx + 1,
})),
};
const res = await apiClient.post("/receiving", payload);
if (res.data?.success) {
// 2-1. 검사 결과가 있는 항목 → inspection_result에 저장
const insertedDetails: any[] =
res.data?.data?.details ?? res.data?.data?.items ?? [];
const inboundHeaderNo =
res.data?.data?.header?.inbound_number || inboundNumber || "";
const inspectionPromises = items
.map((item, idx) => {
if (!item.inspectionResult?.completed) return null;
const matchedDetail = insertedDetails[idx] ?? {};
const referenceId =
matchedDetail.id ||
matchedDetail.detail_id ||
`${inboundHeaderNo}-${idx + 1}`;
const goodQty = item.inspectionResult.goodQty || 0;
const badQty = item.inspectionResult.badQty || 0;
const totalQty = goodQty + badQty;
const overallJudgment = badQty === 0 ? "합격" : "불합격";
return apiClient
.post("/pop/inspection-result", {
inspectionNumber: item.inspectionResult.inspectionNumber, // 카트에서 받은 검사번호 재사용
referenceTable: "inbound_mng",
referenceId,
screenId: "pop_inbound_inspection",
itemId: item.item_id || null,
itemCode: item.item_code,
itemName: item.item_name,
inspectionType: "입고검사",
overallJudgment,
totalQty,
goodQty,
badQty,
defectDescription: badQty > 0 ? `불량 ${badQty}` : "",
memo: item.inspectionResult.remark || "",
supplierCode: item.supplier_code || null,
supplierName: item.supplier_name || null,
isCompleted: true,
items: item.inspectionResult.items.map((insp: any) => ({
inspectionInfoId: insp.id || null,
inspectionItemName: insp.inspection_item_name,
inspectionStandard: insp.inspection_standard,
passCriteria: insp.pass_criteria,
isRequired: insp.is_required || "Y",
measuredValue: insp.measured_value || "",
judgment: insp.result || null,
})),
})
.catch((err) => {
console.error(
"[inspection_result 저장 실패]",
item.item_code,
err?.message,
);
});
})
.filter(Boolean);
if (inspectionPromises.length > 0) {
await Promise.allSettled(inspectionPromises);
}
// 3. cart_items DB 정리 (백그라운드, 논블로킹)
// cart_items.row_key 로 삭제 (row_key = source_id 로 저장됨)
const rowKeys = items
.map((item) => item.source_id || item.id)
.filter(Boolean);
if (rowKeys.length > 0) {
apiClient
.post("/pop/execute-action", {
tasks: [{ type: "cart-save" }],
cartChanges: {
toDelete: rowKeys,
},
})
.catch(() => {
// cart cleanup 실패 시 무시
});
}
const inboundNo =
res.data?.data?.header?.inbound_number || inboundNumber || "";
setResultMsg(`${items.length}건 입고 등록 완료! (${inboundNo})`);
setTimeout(() => {
onClear();
onClose();
router.push(companyPath("/pop/inbound"));
}, 1500);
} else {
setResultMsg(
`오류: ${res.data?.message || "입고 등록에 실패했습니다."}`,
);
}
} catch (err: unknown) {
const msg =
err instanceof Error ? err.message : "입고 등록에 실패했습니다.";
setResultMsg(`오류: ${msg}`);
} finally {
setConfirming(false);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
{/* Overlay */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* Panel */}
<div className="relative bg-white w-full sm:max-w-lg sm:rounded-2xl rounded-t-2xl max-h-[90vh] flex flex-col shadow-2xl z-10 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-blue-500 flex items-center justify-center">
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22"
/>
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-gray-900"> </h3>
{supplierName && (
<p className="text-xs text-gray-400">{supplierName}</p>
)}
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 hover:bg-gray-200 transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Select all bar */}
{items.length > 0 && (
<div className="flex items-center gap-3 px-5 py-2 border-b border-gray-50 bg-gray-50/50">
<button
onClick={toggleSelectAll}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all shrink-0 ${
selectedItems.size === items.length
? "bg-blue-500 border-blue-500"
: "border-gray-300 hover:border-blue-400"
}`}
>
{selectedItems.size === items.length && (
<svg
className="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</button>
<span className="text-xs text-gray-500">
({selectedItems.size}/{items.length})
</span>
</div>
)}
{/* Items */}
<div className="flex-1 overflow-y-auto px-5 py-3">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg
className="w-12 h-12 mb-3 opacity-30"
fill="none"
stroke="currentColor"
strokeWidth={1}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22"
/>
</svg>
<p className="text-sm"> </p>
</div>
) : (
<div className="flex flex-col gap-3">
{items.map((item) => (
<div
key={item.id}
className="bg-gray-50 rounded-xl p-3 border border-gray-100"
>
{/* Top row: checkbox + name + delete */}
<div className="flex items-start gap-2.5 mb-2">
{/* Checkbox */}
<button
onClick={() => toggleSelect(item.id)}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all shrink-0 mt-0.5 ${
selectedItems.has(item.id)
? "bg-blue-500 border-blue-500"
: "border-gray-300 hover:border-blue-400"
}`}
>
{selectedItems.has(item.id) && (
<svg
className="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">
{item.item_name}
</p>
<p className="text-[11px] text-gray-400 mt-0.5">
{item.item_code} | {item.purchase_no}
</p>
</div>
{/* Delete button */}
<button
onClick={() => onRemove(item.id)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-white bg-red-400 hover:bg-red-500 transition-colors shrink-0"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
{/* Spec row */}
{(item.spec || item.material) && (
<p className="text-[11px] text-gray-400 mb-2 ml-[30px]">
{[item.spec, item.material].filter(Boolean).join(" | ")}
</p>
)}
{/* Package info */}
{item.packages && item.packages.length > 0 && (
<div className="ml-[30px] mb-2 px-2.5 py-2 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[10px] font-bold text-white bg-green-500 px-1.5 py-0.5 rounded-full">
</span>
<span className="text-[10px] text-green-600 font-semibold">
{"\uD83D\uDCE6"}{" "}
{item.packages
.map(
(p) =>
`${p.count}${p.unit.label} x ${p.qtyPerUnit.toLocaleString()} = ${(p.count * p.qtyPerUnit).toLocaleString()}EA`,
)
.join(", ")}
</span>
</div>
</div>
)}
{/* Inspection row */}
{(item.inspection_type === "self" ||
item.inspection_type === "request") && (
<div className="ml-[30px] mb-2">
<button
onClick={() => openInspection(item)}
className={`flex items-center gap-2 w-full px-3 py-2 rounded-md border text-left transition-all ${
item.inspectionResult?.completed
? "bg-green-50 border-green-300"
: item.inspection_type === "self"
? "bg-blue-50 border-blue-200 hover:bg-blue-100"
: "bg-amber-50 border-amber-200 hover:bg-amber-100"
}`}
>
<span className="text-[13px] font-semibold">
{item.inspection_type === "self"
? "검사"
: "검사의뢰"}
</span>
<span
className={`text-[11px] px-1.5 py-0.5 rounded ${
item.inspection_required
? "bg-red-100 text-red-600"
: "bg-blue-100 text-blue-600"
}`}
>
{item.inspection_required ? "필수" : "선택"}
</span>
<span
className={`ml-auto text-[12px] font-semibold ${
item.inspectionResult?.completed
? "text-green-600"
: "text-gray-400"
}`}
>
{item.inspectionResult?.completed ? "완료" : "대기"}
</span>
<svg
className="w-4 h-4 text-gray-400 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
)}
{/* Qty controls */}
<div className="flex items-center justify-between ml-[30px]">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">
: {item.remain_qty.toLocaleString()}
</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() =>
onUpdateQty(
item.id,
Math.max(1, item.inbound_qty - 1),
)
}
className="w-8 h-8 rounded-lg bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 12h-15"
/>
</svg>
</button>
<input
type="number"
value={item.inbound_qty}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 0)
onUpdateQty(item.id, Math.min(v, item.remain_qty));
}}
className="w-16 h-8 text-center text-sm font-semibold border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100"
style={{ fontVariantNumeric: "tabular-nums" }}
/>
<button
onClick={() =>
onUpdateQty(
item.id,
Math.min(item.remain_qty, item.inbound_qty + 1),
)
}
className="w-8 h-8 rounded-lg bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer summary + confirm */}
{items.length > 0 && (
<div className="border-t border-gray-100 px-5 py-4">
{/* Result message */}
{resultMsg && (
<div
className={`mb-3 p-3 rounded-xl text-sm font-medium ${
resultMsg.startsWith("오류")
? "bg-red-50 text-red-700"
: "bg-green-50 text-green-700"
}`}
>
{resultMsg}
</div>
)}
{/* Warehouse selection */}
<div className="mb-3">
<label className="text-xs font-semibold text-gray-500 mb-1 block">
</label>
<select
value={selectedWarehouse}
onChange={(e) => setSelectedWarehouse(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100 bg-white"
>
{warehouses.length === 0 ? (
<option value=""> </option>
) : (
warehouses.map((wh) => (
<option key={wh.warehouse_code} value={wh.warehouse_code}>
{wh.warehouse_name} ({wh.warehouse_code})
</option>
))
)}
</select>
</div>
{/* Summary */}
<div className="flex items-center justify-between mb-3 text-sm">
<span className="text-gray-500">
{" "}
<span className="font-bold text-gray-900">{items.length}</span>
</span>
<div className="flex items-center gap-3">
<span className="text-gray-500">
:{" "}
<span className="font-bold text-blue-600">
{totalQty.toLocaleString()}
</span>
</span>
{totalAmount > 0 && (
<span className="text-gray-400 text-xs">
({totalAmount.toLocaleString()})
</span>
)}
</div>
</div>
{/* Buttons */}
<div className="flex gap-3">
<button
onClick={() => {
onClear();
}}
className="flex-1 h-12 rounded-xl border border-gray-200 text-sm font-semibold text-gray-600 hover:bg-gray-50 active:scale-[0.98] transition-all"
>
</button>
<button
onClick={handleConfirm}
disabled={confirming || items.length === 0}
className="flex-[2] h-12 rounded-xl text-sm font-bold text-white active:scale-[0.98] transition-all disabled:opacity-50"
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
boxShadow: "0 4px 12px rgba(59,130,246,.3)",
}}
>
{confirming ? "처리 중..." : `입고 확정 (${items.length}건)`}
</button>
</div>
</div>
)}
</div>
{/* Inspection Modal */}
{inspectionTarget && (
<InspectionModal
open={inspectionModalOpen}
onClose={() => {
setInspectionModalOpen(false);
setInspectionTarget(null);
}}
onComplete={handleInspectionComplete}
itemCode={inspectionTarget.item_code}
itemName={inspectionTarget.item_name}
totalQty={inspectionTarget.inbound_qty}
initialResult={inspectionTarget.inspectionResult}
/>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,890 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier } from "./SupplierModal";
import {
getReceivingList,
updateReceiving,
deleteReceiving,
getReceivingWarehouses,
type InboundItem,
type WarehouseOption,
} from "@/lib/api/receiving";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface InboundRecord extends InboundItem {
detail_id?: string;
seq_no?: number;
detail_inbound_type?: string;
header_memo?: string;
}
const STATUS_OPTIONS = ["입고완료", "부분입고", "대기"];
const INSPECTION_OPTIONS = ["대기", "검사완료", "합격", "불합격"];
const INBOUND_TYPE_OPTIONS = [
{ value: "all", label: "전체" },
{ value: "구매입고", label: "구매입고" },
{ value: "생산입고", label: "생산입고" },
{ value: "외주입고", label: "외주입고" },
{ value: "사급자재입고", label: "사급자재입고" },
{ value: "반품입고", label: "반품입고" },
{ value: "반납입고", label: "반납입고" },
{ value: "불량입고", label: "불량입고" },
{ value: "교환입고", label: "교환입고" },
{ value: "외주자재회수", label: "외주자재회수" },
{ value: "기타입고", label: "기타입고" },
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InboundManage() {
const router = useRouter();
const companyPath = usePopCompanyPath();
const today = new Date().toISOString().slice(0, 10);
/* ── Filters ── */
const [inboundDate, setInboundDate] = useState(today);
const [inboundType, setInboundType] = useState("all");
const [keyword, setKeyword] = useState("");
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(
null,
);
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
/* ── Data ── */
const [records, setRecords] = useState<InboundRecord[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
/* ── Edit modal ── */
const [editRecord, setEditRecord] = useState<InboundRecord | null>(null);
const [editForm, setEditForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
/* ── Helpers ── */
const getRowKey = (r: InboundRecord) => r.detail_id || r.id;
/* ---------------------------------------------------------------- */
/* Fetch */
/* ---------------------------------------------------------------- */
const fetchRecords = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (inboundDate) {
params.date_from = inboundDate;
params.date_to = inboundDate;
}
if (inboundType !== "all") params.inbound_type = inboundType;
if (keyword.trim()) params.search_keyword = keyword.trim();
const res = await getReceivingList(params);
if (res.success) {
let data = res.data as unknown as InboundRecord[];
if (selectedSupplier?.customer_code) {
data = data.filter(
(r) => r.supplier_code === selectedSupplier.customer_code,
);
}
setRecords(data);
setSelectedIds(new Set());
}
} catch (e) {
console.error("입고 목록 조회 실패", e);
} finally {
setLoading(false);
}
}, [inboundDate, inboundType, keyword, selectedSupplier]);
useEffect(() => {
getReceivingWarehouses()
.then((res) => {
if (res.success) setWarehouses(res.data);
})
.catch(() => {});
}, []);
useEffect(() => {
fetchRecords();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ---------------------------------------------------------------- */
/* Selection */
/* ---------------------------------------------------------------- */
const toggleSelect = (key: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleSelectAll = () => {
if (selectedIds.size === records.length) setSelectedIds(new Set());
else setSelectedIds(new Set(records.map(getRowKey)));
};
/* ---------------------------------------------------------------- */
/* Delete */
/* ---------------------------------------------------------------- */
const handleDelete = async () => {
if (selectedIds.size === 0) return;
const headerIds = new Set<string>();
records.forEach((r) => {
if (selectedIds.has(getRowKey(r))) headerIds.add(r.id);
});
if (
!confirm(
`선택한 ${headerIds.size}건의 입고를 삭제하시겠습니까?\n(재고가 롤백됩니다)`,
)
)
return;
setDeleting(true);
try {
for (const hid of headerIds) {
await deleteReceiving(hid);
}
await fetchRecords();
} catch (e: any) {
alert(`삭제 실패: ${e?.message || "알 수 없는 오류"}`);
} finally {
setDeleting(false);
}
};
/* ---------------------------------------------------------------- */
/* Edit */
/* ---------------------------------------------------------------- */
const openEdit = (record: InboundRecord) => {
setEditRecord(record);
setEditForm({
inbound_date: record.inbound_date?.slice(0, 10) || today,
inbound_qty: record.inbound_qty ?? 0,
unit_price: record.unit_price ?? 0,
total_amount: record.total_amount ?? 0,
lot_number: record.lot_number || "",
warehouse_code: record.warehouse_code || "",
location_code: record.location_code || "",
inbound_status: record.inbound_status || "입고완료",
inspection_status: record.inspection_status || "대기",
inspector: record.inspector || "",
manager: record.manager || "",
memo: record.memo || "",
});
};
const handleEditFromSelection = () => {
if (selectedIds.size !== 1) {
alert("수정할 항목을 1건만 선택해주세요.");
return;
}
const key = Array.from(selectedIds)[0];
const rec = records.find((r) => getRowKey(r) === key);
if (rec) openEdit(rec);
};
const updateField = (key: string, value: any) => {
setEditForm((prev) => {
const next = { ...prev, [key]: value };
if (key === "inbound_qty" || key === "unit_price") {
next.total_amount =
Math.round(
(Number(next.inbound_qty) || 0) *
(Number(next.unit_price) || 0) *
100,
) / 100;
}
return next;
});
};
const handleSave = async () => {
if (!editRecord) return;
setSaving(true);
try {
const payload: Record<string, any> = { ...editForm };
if (editRecord.detail_id) payload.detail_id = editRecord.detail_id;
await updateReceiving(editRecord.id, payload as Partial<InboundItem>);
setEditRecord(null);
await fetchRecords();
} catch (e: any) {
alert(`수정 실패: ${e?.message || "알 수 없는 오류"}`);
} finally {
setSaving(false);
}
};
/* ---------------------------------------------------------------- */
/* Render */
/* ---------------------------------------------------------------- */
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/inbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">
</h1>
<p className="text-xs text-gray-400 mt-0.5">
, ,
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
disabled={selectedIds.size !== 1}
onClick={handleEditFromSelection}
className="px-4 py-2 rounded-lg text-sm font-semibold text-white active:scale-95 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
}}
>
</button>
<button
disabled={selectedIds.size === 0 || deleting}
onClick={handleDelete}
className="px-4 py-2 rounded-lg text-sm font-semibold text-white active:scale-95 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: "linear-gradient(to bottom, #f87171, #dc2626)",
}}
>
{deleting ? "삭제 중..." : "삭제"}
</button>
</div>
</div>
{/* ===== Search / Filter ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
{/* 입고일 */}
<div>
<label className="text-xs font-semibold text-gray-500 mb-1 block">
</label>
<input
type="date"
value={inboundDate}
onChange={(e) => setInboundDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
/>
</div>
{/* 입고유형 */}
<div>
<label className="text-xs font-semibold text-gray-500 mb-1 block">
</label>
<select
value={inboundType}
onChange={(e) => setInboundType(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 bg-white"
>
{INBOUND_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 거래처 */}
<div>
<label className="text-xs font-semibold text-gray-500 mb-1 block">
</label>
<button
onClick={() => setSupplierModalOpen(true)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm text-left outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 bg-white flex items-center justify-between"
>
<span
className={
selectedSupplier
? "text-gray-900 truncate"
: "text-gray-400"
}
>
{selectedSupplier ? selectedSupplier.customer_name : "전체"}
</span>
{selectedSupplier ? (
<span
onClick={(e) => {
e.stopPropagation();
setSelectedSupplier(null);
}}
className="text-gray-400 hover:text-gray-600 ml-1 shrink-0"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</span>
) : (
<svg
className="w-4 h-4 text-gray-400 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
/>
</svg>
)}
</button>
</div>
{/* 검색어 + 검색버튼 */}
<div className="col-span-2">
<label className="text-xs font-semibold text-gray-500 mb-1 block">
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") fetchRecords();
}}
placeholder="입고번호, 품목명, 거래처명..."
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
/>
<button
onClick={fetchRecords}
disabled={loading}
className="min-w-[48px] min-h-[40px] rounded-lg flex items-center justify-center text-white active:scale-95 transition-all disabled:opacity-50"
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
}}
>
{loading ? (
<svg
className="w-5 h-5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
)}
</button>
</div>
</div>
</div>
</div>
{/* ===== Record list ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={
records.length > 0 && selectedIds.size === records.length
}
onChange={toggleSelectAll}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-xs font-semibold text-gray-500">
</span>
</div>
<span className="text-sm text-gray-500">
{selectedIds.size > 0
? `${selectedIds.size}건 선택`
: `${records.length}`}
</span>
</div>
{loading && records.length === 0 ? (
<div className="flex items-center justify-center py-16">
<svg
className="w-8 h-8 animate-spin text-blue-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
</div>
) : records.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg
className="w-16 h-16 mb-4 opacity-20"
fill="none"
stroke="currentColor"
strokeWidth={1}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
<p className="text-sm font-medium text-gray-500 mb-1">
</p>
<p className="text-xs text-gray-400">
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{records.map((record) => {
const key = getRowKey(record);
return (
<div
key={key}
className={`relative rounded-xl border p-3 transition-all cursor-pointer ${
selectedIds.has(key)
? "ring-2 ring-blue-500 border-blue-300 bg-blue-50/30"
: "border-gray-200 bg-white hover:border-blue-300"
}`}
onClick={() => toggleSelect(key)}
>
{/* Card header */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<input
type="checkbox"
checked={selectedIds.has(key)}
onChange={() => toggleSelect(key)}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-[11px] text-gray-400 font-medium">
{record.inbound_number}
</span>
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-600">
{record.detail_inbound_type || record.inbound_type}
</span>
<span
className={`ml-auto text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${
record.inbound_status === "입고완료"
? "bg-green-50 text-green-600"
: record.inbound_status === "부분입고"
? "bg-amber-50 text-amber-600"
: "bg-gray-50 text-gray-500"
}`}
>
{record.inbound_status || "입고"}
</span>
<button
onClick={(e) => {
e.stopPropagation();
openEdit(record);
}}
className="ml-1 w-7 h-7 rounded-lg bg-gray-50 hover:bg-blue-50 flex items-center justify-center text-gray-400 hover:text-blue-600 transition-colors"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</button>
</div>
{/* Card body */}
<div className="flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700 truncate">
{record.item_name || "-"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-500 truncate">
{record.item_number || "-"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700">
{record.supplier_name || "-"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-bold text-blue-700">
{Number(record.inbound_qty).toLocaleString()}{" "}
{record.unit || "EA"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700">
{record.inbound_date?.slice(0, 10) || "-"}
</span>
</div>
{(record as any).warehouse_name && (
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700">
{(record as any).warehouse_name}
</span>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Edit Modal ===== */}
{editRecord && (
<div
className="fixed inset-0 z-50 bg-black/40"
onClick={() => setEditRecord(null)}
>
<div
className="absolute inset-x-0 bottom-0 sm:inset-auto sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 bg-white w-full sm:max-w-lg max-h-[90vh] rounded-t-2xl sm:rounded-2xl overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Modal header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gradient-to-b from-blue-50 to-white shrink-0">
<div className="min-w-0">
<h2 className="text-lg font-bold text-gray-900"> </h2>
<p className="text-[11px] text-gray-400 truncate">
{editRecord.inbound_number} | {editRecord.item_name}
</p>
</div>
<button
onClick={() => setEditRecord(null)}
className="w-9 h-9 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 ml-2"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Modal body */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-5">
{/* 기본 정보 */}
<FieldGroup title="기본 정보">
<FormField
label="입고일"
type="date"
value={editForm.inbound_date}
onChange={(v) => updateField("inbound_date", v)}
/>
<FormField
label="입고상태"
type="select"
value={editForm.inbound_status}
onChange={(v) => updateField("inbound_status", v)}
options={STATUS_OPTIONS}
/>
</FieldGroup>
{/* 수량/금액 */}
<FieldGroup title="수량/금액">
<FormField
label="수량"
type="number"
value={editForm.inbound_qty}
onChange={(v) => updateField("inbound_qty", v)}
/>
<FormField
label="단가"
type="number"
value={editForm.unit_price}
onChange={(v) => updateField("unit_price", v)}
/>
<FormField
label="금액"
type="number"
value={editForm.total_amount}
onChange={(v) => updateField("total_amount", v)}
readOnly
/>
</FieldGroup>
{/* 입고 상세 */}
<FieldGroup title="입고 상세">
<FormField
label="LOT번호"
value={editForm.lot_number}
onChange={(v) => updateField("lot_number", v)}
/>
<FormField
label="창고"
type="select"
value={editForm.warehouse_code}
onChange={(v) => updateField("warehouse_code", v)}
options={warehouses.map((w) => ({
value: w.warehouse_code,
label: w.warehouse_name,
}))}
emptyLabel="선택..."
/>
<FormField
label="위치"
value={editForm.location_code}
onChange={(v) => updateField("location_code", v)}
/>
<FormField
label="검사상태"
type="select"
value={editForm.inspection_status}
onChange={(v) => updateField("inspection_status", v)}
options={INSPECTION_OPTIONS}
/>
<FormField
label="검사자"
value={editForm.inspector}
onChange={(v) => updateField("inspector", v)}
/>
<FormField
label="담당자"
value={editForm.manager}
onChange={(v) => updateField("manager", v)}
/>
</FieldGroup>
{/* 메모 */}
<div>
<h3 className="text-xs font-bold text-gray-500 mb-2 uppercase tracking-wider">
</h3>
<textarea
value={editForm.memo}
onChange={(e) => updateField("memo", e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 resize-none"
placeholder="메모 입력..."
/>
</div>
</div>
{/* Modal footer */}
<div className="flex items-center gap-3 px-4 py-3 border-t border-gray-100 bg-gray-50/50 shrink-0">
<button
onClick={() => setEditRecord(null)}
className="flex-1 py-3 rounded-xl border border-gray-200 text-sm font-semibold text-gray-600 active:scale-95 transition-all"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 rounded-xl text-sm font-semibold text-white active:scale-95 transition-all disabled:opacity-50"
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
}}
>
{saving ? "저장 중..." : "저장"}
</button>
</div>
</div>
</div>
)}
{/* ===== Supplier Modal ===== */}
<SupplierModal
open={supplierModalOpen}
onClose={() => setSupplierModalOpen(false)}
onSelect={(supplier) => setSelectedSupplier(supplier)}
/>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
function FieldGroup({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div>
<h3 className="text-xs font-bold text-gray-500 mb-2 uppercase tracking-wider">
{title}
</h3>
<div className="grid grid-cols-2 gap-3">{children}</div>
</div>
);
}
interface FormFieldProps {
label: string;
value: any;
onChange: (v: any) => void;
type?: "text" | "number" | "date" | "select";
options?: string[] | { value: string; label: string }[];
emptyLabel?: string;
readOnly?: boolean;
}
function FormField({
label,
value,
onChange,
type = "text",
options,
emptyLabel,
readOnly,
}: FormFieldProps) {
const baseClass =
"w-full px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100";
return (
<div>
<label className="text-[11px] font-semibold text-gray-400 mb-1 block">
{label}
</label>
{type === "select" && options ? (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${baseClass} bg-white`}
>
{emptyLabel && <option value="">{emptyLabel}</option>}
{options.map((opt) => {
const v = typeof opt === "string" ? opt : opt.value;
const l = typeof opt === "string" ? opt : opt.label;
return (
<option key={v} value={v}>
{l}
</option>
);
})}
</select>
) : (
<input
type={type}
value={value}
onChange={(e) =>
onChange(
type === "number" ? Number(e.target.value) : e.target.value,
)
}
readOnly={readOnly}
className={`${baseClass} ${readOnly ? "bg-gray-50 text-gray-500" : ""}`}
/>
)}
</div>
);
}
@@ -0,0 +1,727 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface InspectionItem {
id: string;
inspection_item_name: string;
inspection_standard: string;
inspection_method: string;
pass_criteria: string;
is_required: string;
/** User-entered measured value */
measured_value: string;
/** "pass" | "fail" | null */
result: "pass" | "fail" | null;
}
export interface InspectionResult {
items: InspectionItem[];
goodQty: number;
badQty: number;
remark: string;
completed: boolean;
inspectionNumber?: string; // 검사 완료 시 채번 받음 (재사용)
}
interface InspectionModalProps {
open: boolean;
onClose: () => void;
onComplete: (result: InspectionResult) => void;
onCancel?: () => void; // 취소 = 검사 무효화 (완료 → 대기)
itemCode: string;
itemName: string;
totalQty: number;
initialResult?: InspectionResult | null;
}
/* ------------------------------------------------------------------ */
/* Dummy inspection items (fallback) */
/* ------------------------------------------------------------------ */
const DUMMY_INSPECTION_ITEMS: InspectionItem[] = [
{
id: "dummy-1",
inspection_item_name: "외관 검사",
inspection_standard: "스크래치, 변색, 찍힘 없음",
inspection_method: "육안 검사",
pass_criteria: "이상 없음",
is_required: "Y",
measured_value: "",
result: null,
},
{
id: "dummy-2",
inspection_item_name: "치수 검사",
inspection_standard: "규격 +-0.5mm",
inspection_method: "캘리퍼스 측정",
pass_criteria: "허용 오차 이내",
is_required: "Y",
measured_value: "",
result: null,
},
{
id: "dummy-3",
inspection_item_name: "수량 검사",
inspection_standard: "발주 수량과 일치",
inspection_method: "전수 검사",
pass_criteria: "수량 일치",
is_required: "Y",
measured_value: "",
result: null,
},
{
id: "dummy-4",
inspection_item_name: "포장 상태",
inspection_standard: "포장 손상 없음",
inspection_method: "육안 검사",
pass_criteria: "이상 없음",
is_required: "",
measured_value: "",
result: null,
},
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InspectionModal({
open,
onClose,
onComplete,
onCancel,
itemCode,
itemName,
totalQty,
initialResult,
}: InspectionModalProps) {
const [inspItems, setInspItems] = useState<InspectionItem[]>([]);
const [loading, setLoading] = useState(false);
const [goodQty, setGoodQty] = useState(0);
const [badQty, setBadQty] = useState(0);
/* NumPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTitle, setNumpadTitle] = useState("");
const [numpadValue, setNumpadValue] = useState("");
const [numpadMax, setNumpadMax] = useState<number | undefined>(undefined);
const numpadCallbackRef = React.useRef<((val: string) => void) | null>(null);
const openNumpad = (
title: string,
currentValue: string | number,
onConfirm: (v: string) => void,
max?: number,
) => {
setNumpadTitle(title);
setNumpadValue(String(currentValue || ""));
setNumpadMax(max);
numpadCallbackRef.current = onConfirm;
setNumpadOpen(true);
};
const [remark, setRemark] = useState("");
/* Fetch inspection items from DB */
const fetchInspectionItems = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.get("/pop/inspection-result/info", {
params: { itemCode },
});
const data = res.data?.data;
if (Array.isArray(data) && data.length > 0) {
setInspItems(
data.map((r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
inspection_item_name: String(r.inspection_item_name ?? ""),
inspection_standard: String(r.inspection_standard ?? ""),
inspection_method: String(r.inspection_method ?? ""),
pass_criteria: String(r.pass_criteria ?? ""),
is_required: String(r.is_required ?? "Y"),
measured_value: "",
result: null,
})),
);
} else {
setInspItems(DUMMY_INSPECTION_ITEMS.map((i) => ({ ...i })));
}
} catch {
setInspItems(DUMMY_INSPECTION_ITEMS.map((i) => ({ ...i })));
} finally {
setLoading(false);
}
}, [itemCode]);
/* Init on open */
useEffect(() => {
if (!open) return;
if (initialResult) {
setInspItems(initialResult.items.map((i) => ({ ...i })));
setGoodQty(initialResult.goodQty);
setBadQty(initialResult.badQty);
setRemark(initialResult.remark);
} else {
fetchInspectionItems();
setGoodQty(totalQty);
setBadQty(0);
setRemark("");
}
}, [open, initialResult, fetchInspectionItems, totalQty]);
/* Update item */
const updateItem = (
id: string,
field: "measured_value" | "result",
value: string,
) => {
setInspItems((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
[field]:
field === "result"
? item.result === value
? null
: value
: value,
}
: item,
),
);
};
/* Handle good/bad qty sync */
const handleGoodQtyChange = (val: number) => {
const v = Math.max(0, Math.min(val, totalQty));
setGoodQty(v);
setBadQty(totalQty - v);
};
const handleBadQtyChange = (val: number) => {
const v = Math.max(0, Math.min(val, totalQty));
setBadQty(v);
setGoodQty(totalQty - v);
};
/* 검사 완료 가능 여부: 필수 항목이 모두 pass */
const canComplete = inspItems
.filter((it) => it.is_required === "Y")
.every((it) => it.result === "pass");
/* Complete */
const [allocating, setAllocating] = useState(false);
const handleComplete = async () => {
if (!canComplete) return;
setAllocating(true);
try {
// 1. 기존 inspectionNumber 있으면 재사용, 없으면 채번 호출
let inspectionNumber = initialResult?.inspectionNumber;
if (!inspectionNumber) {
try {
const res = await apiClient.post(
"/pop/inspection-result/allocate-number",
);
inspectionNumber = res.data?.data?.inspectionNumber;
} catch (err) {
console.error("[검사번호 채번 실패]", err);
}
}
// 2. 결과 전달 (채번 포함)
onComplete({
items: inspItems,
goodQty,
badQty,
remark,
completed: true,
inspectionNumber,
});
onClose();
} finally {
setAllocating(false);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50" onClick={onClose}>
{/* Overlay */}
<div className="absolute inset-0 bg-black/40" />
{/* Bottom sheet */}
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-full max-w-2xl bg-white rounded-t-3xl shadow-2xl flex flex-col z-10"
style={{ maxHeight: "90vh" }}
onClick={(e) => e.stopPropagation()}
>
{/* Handle bar */}
<div className="pt-3 pb-2 flex justify-center rounded-t-3xl shrink-0">
<div className="w-10 h-1 rounded-full bg-gray-300" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 pb-3 border-b border-gray-100 shrink-0">
<h3 className="text-lg font-bold text-gray-900"></h3>
<button
onClick={onClose}
className="w-9 h-9 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100 transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto px-5 py-4 bg-gray-50">
{/* Item summary */}
<div
className="flex flex-wrap items-center gap-2 px-3.5 py-2.5 mb-4 rounded-lg border border-sky-200"
style={{
background: "linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)",
}}
>
<span className="text-xs font-semibold text-sky-700 bg-white px-2 py-0.5 rounded shrink-0">
{itemCode}
</span>
<span className="text-sm font-semibold text-gray-900 flex-1 truncate min-w-0">
{itemName}
</span>
<span className="text-sm font-bold text-green-600 shrink-0">
{totalQty.toLocaleString()} EA
</span>
</div>
{/* Inspection items section */}
<div className="bg-white rounded-xl border border-gray-200 p-3.5 mb-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700"> </h4>
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
{inspItems.length}
</span>
</div>
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-gray-400">
<svg
className="animate-spin w-5 h-5 mr-2 text-blue-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
...
</div>
) : inspItems.length === 0 ? (
<div className="text-center py-6 text-sm text-gray-400">
</div>
) : (
<div className="flex flex-col gap-2.5 max-h-[300px] overflow-y-auto">
{inspItems.map((item) => (
<div
key={item.id}
className={`bg-gray-50 border rounded-lg p-3 ${
item.is_required === "Y"
? "border-l-[3px] border-l-red-400 border-gray-200"
: "border-gray-200"
}`}
>
{/* Item header */}
<div className="flex items-start justify-between mb-2">
<span className="text-[13px] font-semibold text-gray-900">
{item.inspection_item_name}
</span>
{item.is_required === "Y" && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-100 text-red-600 font-semibold shrink-0 ml-2">
</span>
)}
</div>
{/* Info grid */}
<div className="grid grid-cols-[auto_1fr] gap-x-2.5 gap-y-1 text-xs mb-2.5">
{item.inspection_standard && (
<>
<span className="text-gray-400"></span>
<span className="text-gray-700">
{item.inspection_standard}
</span>
</>
)}
{item.inspection_method && (
<>
<span className="text-gray-400"></span>
<span className="text-gray-700">
{item.inspection_method}
</span>
</>
)}
{item.pass_criteria && (
<>
<span className="text-gray-400"></span>
<span className="text-gray-700">
{item.pass_criteria}
</span>
</>
)}
</div>
{/* Input + result buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
<div className="flex gap-1">
<button
onClick={() => updateItem(item.id, "result", "pass")}
className={`w-9 h-9 rounded-md border text-base flex items-center justify-center transition-all ${
item.result === "pass"
? "bg-green-100 border-green-400 text-green-700"
: "border-gray-200 text-gray-400 hover:bg-green-50 hover:border-green-300"
}`}
>
{"\u2713"}
</button>
<button
onClick={() => updateItem(item.id, "result", "fail")}
className={`w-9 h-9 rounded-md border text-base flex items-center justify-center transition-all ${
item.result === "fail"
? "bg-red-100 border-red-400 text-red-700"
: "border-gray-200 text-gray-400 hover:bg-red-50 hover:border-red-300"
}`}
>
{"\u2717"}
</button>
</div>
{/* Camera placeholder */}
<button
className="w-9 h-9 rounded-md border border-gray-200 text-gray-400 flex items-center justify-center hover:bg-gray-50 transition-colors"
title="사진 촬영 (준비 중)"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"
/>
</svg>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Final judgment section */}
<div className="bg-white rounded-xl border border-gray-200 p-3.5 mb-4">
<h4 className="text-sm font-semibold text-gray-700 mb-3">
</h4>
{/* Good / Bad qty */}
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-green-600">
</label>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() =>
openNumpad(
"양품 수량",
goodQty,
(v) => handleGoodQtyChange(parseInt(v, 10) || 0),
totalQty,
)
}
className="flex-1 min-w-0 h-10 px-2 text-center text-sm font-semibold border-2 border-green-400 rounded-lg bg-green-50 text-green-700 hover:bg-green-100 transition-all"
>
{goodQty.toLocaleString()}
</button>
<span className="text-[11px] text-gray-500 shrink-0">EA</span>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-red-600">
</label>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() =>
openNumpad(
"불량 수량",
badQty,
(v) => handleBadQtyChange(parseInt(v, 10) || 0),
totalQty,
)
}
className="flex-1 min-w-0 h-10 px-2 text-center text-sm font-semibold border-2 border-red-400 rounded-lg bg-red-50 text-red-700 hover:bg-red-100 transition-all"
>
{badQty.toLocaleString()}
</button>
<span className="text-[11px] text-gray-500 shrink-0">EA</span>
</div>
</div>
</div>
{/* Total summary */}
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 rounded-lg border border-gray-200">
<span className="text-[13px] text-gray-500"> </span>
<span className="text-[15px] font-bold text-gray-900">
{totalQty.toLocaleString()} EA
</span>
</div>
</div>
{/* Remark */}
<div className="bg-white rounded-xl border border-gray-200 p-3.5 mb-4">
<h4 className="text-sm font-semibold text-gray-700 mb-2"></h4>
<textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder="검사 관련 특이사항 입력"
rows={3}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100 resize-none"
/>
</div>
</div>
{/* Footer */}
<div className="flex gap-2.5 px-5 py-4 border-t border-gray-200 bg-white shrink-0">
<button
onClick={() => {
if (initialResult?.completed && onCancel) {
// 이미 완료된 검사 → 무효화 (완료 → 대기로 풀림)
onCancel();
}
onClose();
}}
className="flex-1 h-12 rounded-xl border border-gray-200 text-sm font-semibold text-gray-600 hover:bg-gray-50 active:scale-[0.98] transition-all"
>
{initialResult?.completed ? "검사 취소" : "취소"}
</button>
<button
className="flex-1 h-12 rounded-xl border border-gray-200 text-sm font-semibold text-gray-500 hover:bg-gray-50 active:scale-[0.98] transition-all"
title="준비 중"
>
</button>
<button
onClick={handleComplete}
disabled={!canComplete || allocating}
className={`flex-[2] h-12 rounded-xl text-sm font-bold text-white transition-all ${
!canComplete || allocating
? "opacity-40 cursor-not-allowed"
: "active:scale-[0.98]"
}`}
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
boxShadow:
!canComplete || allocating
? "none"
: "0 4px 12px rgba(59,130,246,.3)",
}}
title={
!canComplete
? "필수 검사 항목을 모두 합격(✓)으로 체크해주세요"
: ""
}
>
{allocating ? "처리 중..." : "검사 완료"}
</button>
</div>
</div>
{/* ===== NumPad ===== */}
{numpadOpen && (
<div
className="absolute inset-0 z-20 flex items-end justify-center"
onClick={() => setNumpadOpen(false)}
>
<div className="absolute inset-0 bg-black/40" />
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-full max-w-md bg-white rounded-t-3xl shadow-2xl flex flex-col z-30"
onClick={(e) => e.stopPropagation()}
>
<div className="pt-3 pb-2 flex justify-center">
<div className="w-10 h-1 rounded-full bg-gray-300" />
</div>
<div className="flex items-center justify-between px-5 pb-3 border-b border-gray-100">
<h4 className="text-base font-bold text-gray-900">
{numpadTitle}
</h4>
<button
onClick={() => setNumpadOpen(false)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="px-5 pt-4 pb-2">
<div className="h-16 flex items-center justify-end px-4 bg-gray-50 rounded-xl border-2 border-gray-200 mb-3">
<span
className="text-3xl font-bold text-gray-900"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{numpadValue || "0"}
</span>
</div>
{numpadMax !== undefined && (
<p className="text-[11px] text-gray-400 text-right mb-2">
{numpadMax.toLocaleString()}
</p>
)}
</div>
<div className="px-5 pb-5">
<div className="grid grid-cols-3 gap-2 mb-2">
{["7", "8", "9", "4", "5", "6", "1", "2", "3"].map((k) => (
<button
key={k}
onClick={() =>
setNumpadValue((v) => (v === "0" || v === "" ? k : v + k))
}
className="h-14 rounded-xl bg-gray-100 text-2xl font-bold text-gray-800 active:scale-95 active:bg-gray-200 transition-all"
>
{k}
</button>
))}
<button
onClick={() =>
setNumpadValue((v) =>
v.includes(".") ? v : (v || "0") + ".",
)
}
className="h-14 rounded-xl bg-gray-100 text-2xl font-bold text-gray-800 active:scale-95 transition-all"
>
.
</button>
<button
onClick={() =>
setNumpadValue((v) =>
v === "0" || v === "" ? "0" : v + "0",
)
}
className="h-14 rounded-xl bg-gray-100 text-2xl font-bold text-gray-800 active:scale-95 transition-all"
>
0
</button>
<button
onClick={() =>
setNumpadValue((v) => (v.length <= 1 ? "" : v.slice(0, -1)))
}
className="h-14 rounded-xl bg-gray-200 text-base font-bold text-gray-600 active:scale-95 transition-all"
>
</button>
</div>
<div className="flex gap-2 mb-3">
<button
onClick={() => setNumpadValue("")}
className="flex-1 h-11 rounded-xl bg-gray-100 text-gray-600 text-sm font-bold active:scale-95"
>
</button>
{numpadMax !== undefined && (
<button
onClick={() => setNumpadValue(String(numpadMax))}
className="flex-1 h-11 rounded-xl bg-blue-50 text-blue-600 text-sm font-bold active:scale-95"
>
({numpadMax})
</button>
)}
</div>
<button
onClick={() => {
if (numpadCallbackRef.current) {
let val = numpadValue;
if (numpadMax !== undefined) {
const n = parseInt(val, 10) || 0;
val = String(Math.min(n, numpadMax));
}
numpadCallbackRef.current(val);
}
setNumpadOpen(false);
}}
className="w-full h-12 rounded-xl text-sm font-bold text-white active:scale-95 transition-all"
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
}}
>
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,178 @@
"use client";
import React, { useEffect, useState } from "react";
import { getLoadingUnitsByPkg, type LoadingUnitByPkg } from "@/lib/api/packaging";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface LoadingUnitSelection {
loading_code: string;
loading_name: string;
loading_type: string;
max_load_qty: number;
}
interface LoadingUnitModalProps {
open: boolean;
onClose: () => void;
onSelect: (lu: LoadingUnitSelection) => void;
onClear: () => void;
pkgCodes: string[];
currentLoading?: { loading_code: string; loading_name: string } | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function LoadingUnitModal({
open,
onClose,
onSelect,
onClear,
pkgCodes,
currentLoading,
}: LoadingUnitModalProps) {
const [units, setUnits] = useState<LoadingUnitByPkg[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || pkgCodes.length === 0) return;
let cancelled = false;
setLoading(true);
const uniqueCodes = [...new Set(pkgCodes)];
Promise.all(uniqueCodes.map((code) => getLoadingUnitsByPkg(code)))
.then((results) => {
if (cancelled) return;
const merged = new Map<string, LoadingUnitByPkg>();
results.forEach((res) => {
res.data.forEach((lu) => {
if (!merged.has(lu.loading_code)) {
merged.set(lu.loading_code, lu);
}
});
});
setUnits(Array.from(merged.values()));
})
.catch(() => {
if (!cancelled) setUnits([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, pkgCodes]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
/>
<div className="relative bg-white w-full sm:max-w-md sm:rounded-2xl rounded-t-2xl max-h-[80vh] flex flex-col shadow-2xl z-10 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 className="text-lg font-bold text-gray-900">
</h3>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 hover:bg-gray-200 transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
<span className="ml-2 text-sm text-gray-500">
...
</span>
</div>
) : units.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-8">
</p>
) : (
<div className="flex flex-col gap-2.5">
{units.map((lu) => (
<button
key={lu.loading_code}
onClick={() => {
onSelect({
loading_code: lu.loading_code,
loading_name: lu.loading_name,
loading_type: lu.loading_type,
max_load_qty: lu.max_load_qty,
});
onClose();
}}
className={`flex items-center gap-3 p-3 border-2 rounded-xl bg-white text-left active:scale-[0.98] transition-all ${
currentLoading?.loading_code === lu.loading_code
? "border-purple-400 bg-purple-50"
: "border-gray-200 hover:border-purple-400 hover:bg-purple-50"
}`}
>
<span className="text-2xl shrink-0">
{"\uD83D\uDEA2"}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">
{lu.loading_name}
</p>
<p className="text-[11px] text-gray-400">
{lu.loading_code} |{" "}
{lu.loading_type || "-"}
</p>
</div>
{lu.max_load_qty > 0 && (
<span className="text-[10px] font-medium text-purple-600 bg-purple-50 px-2 py-1 rounded-full shrink-0">
{lu.max_load_qty}
</span>
)}
</button>
))}
</div>
)}
</div>
{/* Footer — 해제 버튼 (적재함 이미 선택된 경우) */}
{currentLoading && (
<div className="px-4 pb-4">
<button
onClick={() => {
onClear();
onClose();
}}
className="w-full py-3 rounded-xl border-2 border-dashed border-gray-300 text-sm font-medium text-gray-500 hover:border-gray-400 hover:bg-gray-50 active:scale-[0.98] transition-all"
>
</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,596 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import type { PackageUnit } from "./PackagingModal";
import { getPkgUnitsByItem, type PkgUnitByItem } from "@/lib/api/packaging";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface PackageEntry {
unit: PackageUnit;
count: number;
qtyPerUnit: number;
}
export interface LoadingUnitSelection {
loading_code: string;
loading_name: string;
loading_type: string;
max_load_qty: number;
}
interface NumberPadModalProps {
open: boolean;
onClose: () => void;
onConfirm: (qty: number, packages: PackageEntry[]) => void;
maxQty: number;
itemName: string;
itemNumber?: string;
initialPackages?: PackageEntry[];
}
type Step = "list" | "packaging" | "count";
/* ------------------------------------------------------------------ */
/* Numpad Keys */
/* ------------------------------------------------------------------ */
const KEYS = [
{ label: "7", action: "7" },
{ label: "8", action: "8" },
{ label: "9", action: "9" },
{ label: "\u2190", action: "backspace" },
{ label: "4", action: "4" },
{ label: "5", action: "5" },
{ label: "6", action: "6" },
{ label: "C", action: "clear" },
{ label: "1", action: "1" },
{ label: "2", action: "2" },
{ label: "3", action: "3" },
{ label: "MAX", action: "max" },
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function NumberPadModal({
open,
onClose,
onConfirm,
maxQty,
itemName,
itemNumber,
initialPackages,
}: NumberPadModalProps) {
const [step, setStep] = useState<Step>("list");
const [packages, setPackages] = useState<PackageEntry[]>([]);
const [packageUnits, setPackageUnits] = useState<PackageUnit[]>([]);
const [pkgLoading, setPkgLoading] = useState(false);
const [selectedUnit, setSelectedUnit] = useState<PackageUnit | null>(null);
const [count, setCount] = useState("0");
const [editingIndex, setEditingIndex] = useState<number | null>(null);
/* Fetch packaging units from DB */
useEffect(() => {
if (!open || !itemNumber) {
setPackageUnits([]);
return;
}
let cancelled = false;
setPkgLoading(true);
getPkgUnitsByItem(itemNumber)
.then((res) => {
if (cancelled) return;
setPackageUnits(
res.data.map((pu: PkgUnitByItem) => ({
label: pu.pkg_name,
icon: "\uD83D\uDCE6",
value: pu.pkg_code,
pkg_qty: pu.pkg_qty,
}))
);
})
.catch(() => {
if (!cancelled) setPackageUnits([]);
})
.finally(() => {
if (!cancelled) setPkgLoading(false);
});
return () => {
cancelled = true;
};
}, [open, itemNumber]);
/* Reset on open */
useEffect(() => {
if (open) {
setPackages(initialPackages ? [...initialPackages] : []);
setStep("list");
setSelectedUnit(null);
setCount("0");
setEditingIndex(null);
}
}, [open, initialPackages]);
/* Computed values */
const totalPackagedQty = packages.reduce(
(s, p) => s + p.count * p.qtyPerUnit,
0
);
const unpackedQty = Math.max(0, maxQty - totalPackagedQty);
const countNum = parseInt(count, 10) || 0;
/* 편집 중이면 해당 행 수량만큼은 "사용 가능"으로 되돌려줌 */
const editingEntry =
editingIndex !== null ? packages[editingIndex] ?? null : null;
const editingReservedQty = editingEntry
? editingEntry.count * editingEntry.qtyPerUnit
: 0;
const availableForCurrent = unpackedQty + editingReservedQty;
const currentPkgQty = selectedUnit?.pkg_qty ?? 0;
const maxCountForCurrent =
currentPkgQty > 0 ? Math.floor(availableForCurrent / currentPkgQty) : 0;
/* Numpad input */
const handleInput = useCallback(
(key: string) => {
setCount((prev) => {
switch (key) {
case "backspace":
return prev.length <= 1 ? "0" : prev.slice(0, -1);
case "clear":
return "0";
case "max":
return String(maxCountForCurrent);
default: {
const next = prev === "0" ? key : prev + key;
const num = parseInt(next, 10);
if (isNaN(num)) return prev;
return String(Math.min(num, maxCountForCurrent));
}
}
});
},
[maxCountForCurrent]
);
/* Step handlers */
const openAddFlow = () => {
setEditingIndex(null);
setSelectedUnit(null);
setCount("0");
setStep("packaging");
};
const openEditFlow = (idx: number) => {
const entry = packages[idx];
if (!entry) return;
setEditingIndex(idx);
setSelectedUnit(entry.unit);
setCount(String(entry.count));
setStep("count");
};
const handleSelectUnit = (unit: PackageUnit) => {
setSelectedUnit(unit);
setCount("0");
setStep("count");
};
const handleCountConfirm = () => {
if (!selectedUnit || countNum <= 0) return;
const qtyPerUnit = selectedUnit.pkg_qty ?? 0;
setPackages((prev) => {
if (editingIndex !== null) {
return prev.map((p, i) =>
i === editingIndex ? { ...p, count: countNum, qtyPerUnit } : p
);
}
const existing = prev.findIndex(
(p) => p.unit.value === selectedUnit.value
);
if (existing >= 0) {
return prev.map((p, i) =>
i === existing ? { ...p, count: p.count + countNum } : p
);
}
return [...prev, { unit: selectedUnit, count: countNum, qtyPerUnit }];
});
setStep("list");
setSelectedUnit(null);
setCount("0");
setEditingIndex(null);
};
const handleDelete = (idx: number) => {
setPackages((prev) => prev.filter((_, i) => i !== idx));
};
const handleBack = () => {
if (step === "count") {
if (editingIndex !== null) {
setStep("list");
setSelectedUnit(null);
setCount("0");
setEditingIndex(null);
} else {
setStep("packaging");
setSelectedUnit(null);
setCount("0");
}
} else if (step === "packaging") {
setStep("list");
}
};
const handleFinalConfirm = () => {
if (packages.length === 0) return;
const finalQty = Math.min(totalPackagedQty, maxQty);
onConfirm(finalQty, packages);
onClose();
};
if (!open) return null;
/* ------------------------------------------------------------------ */
/* Render helpers */
/* ------------------------------------------------------------------ */
const renderNumpad = (
currentValue: string,
onKey: (key: string) => void,
onConfirmStep: () => void,
confirmLabel: string,
confirmDisabled: boolean
) => (
<>
<input
type="text"
readOnly
value={parseInt(currentValue, 10).toLocaleString()}
className="w-full px-4 py-3 text-right text-3xl font-bold border-2 border-gray-200 rounded-xl bg-gray-50 text-gray-900 mb-3"
style={{ fontVariantNumeric: "tabular-nums" }}
/>
<div className="grid grid-cols-4 gap-2.5">
{KEYS.map((key) => (
<button
key={key.action}
onClick={() => onKey(key.action)}
className={`h-14 rounded-xl text-lg font-semibold active:scale-95 transition-all ${
key.action === "backspace" || key.action === "clear"
? "bg-amber-100 text-amber-700 hover:bg-amber-200"
: key.action === "max"
? "bg-blue-100 text-blue-700 hover:bg-blue-200 text-sm"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
}`}
>
{key.label}
</button>
))}
<button
onClick={() => onKey("0")}
className="col-span-2 h-14 rounded-xl text-lg font-semibold bg-gray-100 text-gray-900 hover:bg-gray-200 active:scale-95 transition-all"
>
0
</button>
<button
onClick={onConfirmStep}
disabled={confirmDisabled}
className={`col-span-2 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all ${
confirmDisabled ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{
background: confirmDisabled
? "#9ca3af"
: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
>
{confirmLabel}
</button>
</div>
</>
);
const renderPackagingGrid = (onSelect: (unit: PackageUnit) => void) => (
<>
{pkgLoading ? (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="ml-2 text-sm text-gray-500"> ...</span>
</div>
) : packageUnits.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400">
</div>
) : (
<div className="grid grid-cols-3 gap-3">
{packageUnits.map((unit) => (
<button
key={unit.value}
onClick={() => onSelect(unit)}
className="flex flex-col items-center gap-2 py-4 px-3 border-2 border-gray-200 rounded-xl bg-white text-sm font-medium text-gray-700 hover:border-blue-400 hover:bg-blue-50 active:scale-95 transition-all min-h-[80px]"
>
<span className="text-2xl">{unit.icon}</span>
<span className="text-center leading-tight">{unit.label}</span>
{unit.pkg_qty && unit.pkg_qty > 0 && (
<span className="text-[10px] text-gray-400">
{unit.pkg_qty}EA/
</span>
)}
</button>
))}
</div>
)}
</>
);
/* Header: 단계별 색상 */
const headerBg =
step === "count" && editingIndex !== null
? "linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%)"
: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)";
const headerBadge =
step === "count" && editingIndex !== null
? "수정"
: `최대 ${maxQty.toLocaleString()} EA`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white w-[90%] max-w-[420px] rounded-2xl shadow-2xl z-10 overflow-hidden">
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3"
style={{ background: headerBg }}
>
<div className="flex items-center gap-2">
{step !== "list" && (
<button
onClick={handleBack}
className="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white hover:bg-white/30 active:scale-95 transition-all"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
</button>
)}
<span className="text-[13px] text-white/90 bg-white/20 px-3 py-1 rounded-full">
{headerBadge}
</span>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white hover:bg-white/30 active:scale-95 transition-all"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Body */}
<div className="p-4">
{/* ====== LIST: 목록 + 추가 + 확인 ====== */}
{step === "list" && (
<>
{/* 요약 (포장 수량 / 미포장 잔량) */}
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="bg-blue-50 border border-blue-200 rounded-lg px-3 py-2">
<p className="text-[10px] text-blue-600 font-semibold">
</p>
<p
className="text-lg font-black text-blue-700"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{totalPackagedQty.toLocaleString()} EA
</p>
</div>
<div
className={`border rounded-lg px-3 py-2 ${
unpackedQty > 0
? "bg-amber-50 border-amber-200"
: "bg-green-50 border-green-200"
}`}
>
<p
className={`text-[10px] font-semibold ${
unpackedQty > 0 ? "text-amber-600" : "text-green-600"
}`}
>
</p>
<p
className={`text-lg font-black ${
unpackedQty > 0 ? "text-amber-700" : "text-green-700"
}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{unpackedQty.toLocaleString()} EA
</p>
</div>
</div>
{/* 등록된 포장 목록 */}
<div className="space-y-2 mb-3 max-h-[40vh] overflow-y-auto">
{packages.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400 border-2 border-dashed border-gray-200 rounded-xl">
</div>
) : (
packages.map((p, idx) => (
<div
key={idx}
className="flex items-center gap-2 px-3 py-2.5 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg"
>
<span className="text-xl">{p.unit.icon}</span>
<div className="flex-1 min-w-0">
<p
className="text-sm font-bold text-gray-900"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{p.count.toLocaleString()}
{p.unit.label} × {p.qtyPerUnit.toLocaleString()}EA
</p>
<p
className="text-xs font-bold text-green-700"
style={{ fontVariantNumeric: "tabular-nums" }}
>
= {(p.count * p.qtyPerUnit).toLocaleString()} EA
</p>
</div>
<button
onClick={() => openEditFlow(idx)}
className="w-9 h-9 rounded-lg bg-blue-100 text-blue-700 hover:bg-blue-200 flex items-center justify-center active:scale-95 transition-all"
title="수정"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125"
/>
</svg>
</button>
<button
onClick={() => handleDelete(idx)}
className="w-9 h-9 rounded-lg bg-red-100 text-red-700 hover:bg-red-200 flex items-center justify-center active:scale-95 transition-all"
title="삭제"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
))
)}
</div>
{/* 품목명 */}
<p className="text-xs text-gray-400 truncate max-w-full px-1 mb-3 text-center">
{itemName}
</p>
{/* 버튼 */}
<div className="flex gap-2">
<button
onClick={openAddFlow}
disabled={unpackedQty <= 0}
className={`flex-1 h-12 rounded-xl text-sm font-bold border-2 border-dashed transition-all ${
unpackedQty <= 0
? "border-gray-200 text-gray-300 cursor-not-allowed"
: "border-blue-400 text-blue-700 hover:bg-blue-50 active:scale-95"
}`}
>
+
</button>
<button
onClick={handleFinalConfirm}
disabled={packages.length === 0}
className={`flex-1 h-12 rounded-xl text-sm font-bold text-white transition-all ${
packages.length === 0
? "opacity-40 cursor-not-allowed"
: "active:scale-95"
}`}
style={{
background:
packages.length === 0
? "#9ca3af"
: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
>
</button>
</div>
</>
)}
{/* ====== PACKAGING: 포장단위 선택 ====== */}
{step === "packaging" && (
<>
<p className="text-center text-sm font-semibold text-gray-700 mb-1">
</p>
<p className="text-center text-xs text-gray-400 mb-4">
{unpackedQty.toLocaleString()} EA
</p>
{renderPackagingGrid(handleSelectUnit)}
</>
)}
{/* ====== COUNT: 개수 입력 ====== */}
{step === "count" && selectedUnit && (
<>
<p className="text-center text-sm font-semibold text-gray-700 mb-1">
{selectedUnit.icon} {selectedUnit.label} ?
</p>
<p className="text-center text-xs text-gray-400 mb-1">
{currentPkgQty.toLocaleString()}EA
</p>
<p className="text-center text-xs text-blue-600 mb-3">
{availableForCurrent.toLocaleString()}EA · {" "}
{maxCountForCurrent.toLocaleString()}
</p>
{renderNumpad(
count,
handleInput,
handleCountConfirm,
editingIndex !== null ? "수정" : "추가",
countNum <= 0
)}
{countNum > 0 && (
<div className="mt-3 text-center text-sm font-semibold px-3 py-2 rounded-lg bg-blue-50 text-blue-700 border border-blue-200">
{countNum}
{selectedUnit.label} × {currentPkgQty.toLocaleString()}EA ={" "}
{(countNum * currentPkgQty).toLocaleString()}EA
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,122 @@
"use client";
import React, { useEffect, useState } from "react";
import { getPkgUnitsByItem, type PkgUnitByItem } from "@/lib/api/packaging";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface PackageUnit {
label: string;
icon: string;
value: string;
pkg_qty?: number;
}
interface PackagingModalProps {
open: boolean;
onClose: () => void;
onSelect: (unit: PackageUnit) => void;
itemNumber?: string;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function PackagingModal({ open, onClose, onSelect, itemNumber }: PackagingModalProps) {
const [units, setUnits] = useState<PackageUnit[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !itemNumber) {
setUnits([]);
return;
}
let cancelled = false;
setLoading(true);
getPkgUnitsByItem(itemNumber)
.then((res) => {
if (cancelled) return;
const mapped: PackageUnit[] = res.data.map((pu: PkgUnitByItem) => ({
label: pu.pkg_name,
icon: "\uD83D\uDCE6",
value: pu.pkg_code,
pkg_qty: pu.pkg_qty,
}));
setUnits(mapped);
})
.catch(() => {
if (!cancelled) setUnits([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [open, itemNumber]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
{/* Overlay */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* Panel */}
<div className="relative bg-white w-[90%] max-w-[360px] rounded-2xl shadow-2xl z-10 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 className="text-lg font-bold text-gray-900">
<span className="mr-1.5">{"\uD83D\uDCE6"}</span>
</h3>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 hover:bg-gray-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="p-5">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="ml-2 text-sm text-gray-500"> ...</span>
</div>
) : units.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400">
</div>
) : (
<div className="grid grid-cols-3 gap-3">
{units.map((unit) => (
<button
key={unit.value}
onClick={() => {
onSelect(unit);
onClose();
}}
className="flex flex-col items-center gap-2 py-4 px-3 border-2 border-gray-200 rounded-xl bg-white text-sm font-medium text-gray-700 hover:border-blue-400 hover:bg-blue-50 active:scale-95 transition-all min-h-[80px]"
>
<span className="text-2xl">{unit.icon}</span>
<span className="text-center leading-tight">{unit.label}</span>
{unit.pkg_qty && unit.pkg_qty > 0 && (
<span className="text-[10px] text-gray-400">{unit.pkg_qty}EA/</span>
)}
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,679 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { apiClient } from "@/lib/api/client";
import { SupplierModal, type Supplier, type PartnerSourceConfig, matchChosung } from "./SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface ProductionOrder {
id: string;
work_instruction_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
received_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
inspection_type: "self" | "request" | null;
/** Item image URL from item_info.image (may be null) */
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface ProductionInboundProps {
/** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */
cart: import("../common/useCartSync").UseCartSyncReturn;
/** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */
onCartClick: () => void;
/** 카트 저장 중 상태 (버튼 스피너/비활성화용) */
saving: boolean;
/** 입고 유형 — 카트 품목에 기록됨 */
inboundType: string;
/** 소스 테이블명 — 카트 품목별 sourceTable */
sourceTable: string;
}
const STORAGE_KEY = "pop_process_production";
/** process_mng 테이블 소스 설정 — SupplierModal에 전달하여 공정 목록 조회 */
const PROCESS_SOURCE: PartnerSourceConfig = {
tableName: "process_mng",
fields: {
code: "process_code",
name: "process_name",
},
};
export function ProductionInbound({ cart, onCartClick, saving, inboundType, sourceTable }: ProductionInboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
/* State */
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
const [orders, setOrders] = useState<ProductionOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
/* NumberPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<ProductionOrder | null>(null);
/* Per-order edited quantities (before adding to cart) */
const [editedQtys, setEditedQtys] = useState<Record<string, number>>({});
/* Ref to always call the latest saveToDb (avoids stale closure in setTimeout) */
const saveToDbRef = useRef(cart.saveToDb);
useEffect(() => { saveToDbRef.current = cart.saveToDb; });
/* Barcode scan modal state */
const [supplierScanOpen, setSupplierScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
/* Inline supplier search state */
const [supplierSearchText, setSupplierSearchText] = useState("");
const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false);
const [allSuppliers, setAllSuppliers] = useState<Supplier[]>([]);
const supplierInputRef = useRef<HTMLInputElement>(null);
const supplierDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all processes for inline search — process_mng 테이블 조회 */
const fetchAllSuppliers = useCallback(async () => {
try {
const res = await apiClient.post("/table-management/tables/process_mng/data", {
page: 1,
size: 500,
autoFilter: true,
sort: { columnName: "process_code", order: "asc" },
});
const data = res.data?.data?.data ?? res.data?.data?.rows ?? [];
const list: Supplier[] = (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
customer_name: String(r.process_name ?? ""),
customer_code: String(r.process_code ?? ""),
}));
setAllSuppliers(list);
} catch {
setAllSuppliers([]);
}
}, []);
useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 공정 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedSupplier(parsed);
} catch {}
}
}, []);
/* 공정 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectSupplier = (s: Supplier | null) => {
setSelectedSupplier(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
/* Filtered suppliers for inline dropdown */
const filteredSuppliers = useMemo(() => {
if (!supplierSearchText.trim()) return [];
return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim()));
}, [allSuppliers, supplierSearchText]);
/* Close dropdown on outside click */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
supplierDropdownRef.current &&
!supplierDropdownRef.current.contains(e.target as Node) &&
supplierInputRef.current &&
!supplierInputRef.current.contains(e.target as Node)
) {
setSupplierDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch production results — 선택된 공정의 실적 등록된 작업지시 조회 */
const fetchOrders = useCallback(async (searchKeyword?: string) => {
if (!selectedSupplier) {
setOrders([]);
return;
}
setLoading(true);
setFetchError(null);
try {
const params: Record<string, string> = {
processCode: selectedSupplier.customer_code,
pageSize: "50",
};
if (searchKeyword) params.keyword = searchKeyword;
const res = await apiClient.get("/receiving/source/production-results", { params });
const data = res.data?.data;
if (Array.isArray(data) && data.length > 0) {
setOrders(data.map((r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
work_instruction_no: String(r.work_instruction_no ?? ""),
order_date: String(r.order_date ?? "").slice(0, 10),
supplier_code: String(r.process_code ?? ""),
supplier_name: String(r.process_name ?? ""),
item_code: String(r.item_code ?? ""),
item_name: String(r.item_name ?? ""),
spec: String(r.spec ?? ""),
material: String(r.material ?? ""),
order_qty: Number(r.order_qty ?? 0),
received_qty: Number(r.received_qty ?? 0),
remain_qty: Number(r.remain_qty ?? 0),
unit_price: 0,
status: String(r.result_status ?? ""),
due_date: "",
source_table: String(r.source_table ?? "work_order_process"),
inspection_type: r.inspection_type === "self" ? "self"
: r.inspection_type === "request" ? "request"
: null,
image: r.image ? String(r.image) : null,
})));
} else {
setOrders([]);
}
} catch {
setOrders([]);
setFetchError("데이터를 불러오지 못했습니다. 네트워크 상태를 확인해주세요.");
} finally {
setLoading(false);
}
}, [selectedSupplier]);
/* 공정 선택 변경 시 자동 재조회 */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
/* Filter by keyword (프론트 필터링) */
const displayOrders = keyword
? orders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.work_instruction_no.toLowerCase().includes(keyword.toLowerCase())
)
: orders;
/* Open numpad for an order */
const openNumpad = (order: ProductionOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
/* Numpad confirm: only update local edited qty (do NOT add to cart) */
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const finalQty = Math.min(qty, numpadTarget.remain_qty);
setEditedQtys((prev) => ({ ...prev, [numpadTarget.id]: finalQty }));
setNumpadTarget(null);
};
/* Add to cart with currently displayed qty */
const handleAddToCart = (order: ProductionOrder) => {
if (cart.isItemInCart(order.id)) return;
// 공정 검증: 카트에 이미 다른 공정 품목이 있으면 차단
if (cart.cartItems.length > 0) {
const existingSupplier = String(cart.cartItems[0].row.supplier_code || "");
if (existingSupplier && existingSupplier !== order.supplier_code) {
alert("다른 공정의 품목이 이미 장바구니에 있습니다.\n같은 공정의 품목만 담을 수 있습니다.");
return;
}
}
const displayQty = editedQtys[order.id] ?? order.remain_qty;
const finalQty = Math.min(displayQty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
supplier_code: order.supplier_code,
supplier_name: order.supplier_name,
purchase_no: order.work_instruction_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
inbound_type: inboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setEditedQtys((prev) => {
const next = { ...prev };
delete next[order.id];
return next;
});
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Search */
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/inbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* Cart button — 생산입고 라인, 발주품목 위. 테마 green */}
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.green.buttonBg} ${COLOR_MAP.green.buttonBgHover} shadow-[0_4px_12px_rgba(22,163,74,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area (2 columns on tablet+) ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Supplier search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedSupplier && (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{selectedSupplier.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setSupplierModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedSupplier
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedSupplier ? selectedSupplier.customer_name : "공정을 선택하세요"}
</button>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setSupplierScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.green.buttonBg} ${COLOR_MAP.green.buttonBgHover} shadow-[0_4px_12px_rgba(34,197,94,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedSupplier && (
<button
onClick={() => { selectSupplier(null); setSupplierSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Supplier dropdown removed — use modal instead */}
</div>
</div>
{/* Item search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-green-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedSupplier ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 생산지시번호 검색..."
disabled={!selectedSupplier}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedSupplier
? "focus:border-green-400 focus:ring-2 focus:ring-green-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedSupplier}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.green.buttonBg} ${COLOR_MAP.green.buttonBgHover} ${
!selectedSupplier ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(34,197,94,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-[11px] text-gray-400">
{selectedSupplier ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedSupplier ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-green-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedSupplier ? "해당 공정의 생산 품목이 없습니다" : "공정을 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-green-500 rounded-lg hover:bg-green-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-green-300"
}`}
>
{/* Green left bar for in-cart items */}
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
{/* === Header row: item code + item name + inspection badge === */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-green-100 text-green-700 border border-green-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
{/* === Body row: image + info + action === */}
<div className="flex gap-2.5">
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.work_instruction_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
{/* Action column: qty display + add/cancel button */}
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
{/* Qty display - clickable to open numpad (edit qty before adding) */}
<button
onClick={() => {
if (!inCart) openNumpad(order);
}}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: editedQtys[order.id] !== undefined
? "bg-green-50 border-green-300 hover:bg-green-100 cursor-pointer active:scale-95"
: "bg-green-50 border-green-200 hover:bg-green-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${
inCart ? "text-gray-400"
: editedQtys[order.id] !== undefined ? "text-green-700"
: "text-green-700"
}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: (editedQtys[order.id] ?? order.remain_qty).toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{/* Add / Cancel button */}
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => handleAddToCart(order)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all"
style={{
background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)",
}}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={supplierModalOpen}
onClose={() => setSupplierModalOpen(false)}
onSelect={(s) => selectSupplier(s)}
source={PROCESS_SOURCE}
title="공정 선택"
searchPlaceholder="공정명 또는 코드 검색..."
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
{/* Barcode scan modal for supplier */}
<BarcodeScanModal
open={supplierScanOpen}
onOpenChange={setSupplierScanOpen}
targetField="공정"
autoSubmit
onScanSuccess={(barcode) => {
setSupplierScanOpen(false);
const match = allSuppliers.find(
(s) =>
s.customer_code === barcode ||
s.customer_name.includes(barcode)
);
if (match) {
selectSupplier(match);
setSupplierSearchText("");
} else {
setSupplierSearchText(barcode);
setSupplierDropdownOpen(true);
}
}}
/>
{/* Barcode scan modal for item */}
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="생산 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,684 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { apiClient } from "@/lib/api/client";
import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface PurchaseOrder {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
received_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
inspection_type: "self" | "request" | null;
/** Item image URL from item_info.image (may be null) */
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Dummy data (fallback) */
/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface PurchaseInboundProps {
/** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */
cart: import("../common/useCartSync").UseCartSyncReturn;
/** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */
onCartClick: () => void;
/** 카트 저장 중 상태 (버튼 스피너/비활성화용) */
saving: boolean;
/** 입고 유형 (예: "구매입고") — 카트 품목에 기록됨 */
inboundType: string;
/** 소스 테이블명 (예: "purchase_detail") — 카트 품목별 sourceTable */
sourceTable: string;
}
const STORAGE_KEY = "pop_supplier_purchase";
export function PurchaseInbound({ cart, onCartClick, saving, inboundType, sourceTable }: PurchaseInboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
/* State */
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
const [orders, setOrders] = useState<PurchaseOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
/* NumberPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<PurchaseOrder | null>(null);
/* Per-order edited quantities (before adding to cart) */
const [editedQtys, setEditedQtys] = useState<Record<string, number>>({});
/* Ref to always call the latest saveToDb (avoids stale closure in setTimeout) */
const saveToDbRef = useRef(cart.saveToDb);
useEffect(() => { saveToDbRef.current = cart.saveToDb; });
/* Barcode scan modal state */
const [supplierScanOpen, setSupplierScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
/* Inline supplier search state */
const [supplierSearchText, setSupplierSearchText] = useState("");
const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false);
const [allSuppliers, setAllSuppliers] = useState<Supplier[]>([]);
const supplierInputRef = useRef<HTMLInputElement>(null);
const supplierDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all suppliers for inline search (supplier_mng = )
* > API로 (autoFilter )
*/
const fetchAllSuppliers = useCallback(async () => {
try {
const res = await apiClient.post("/table-management/tables/supplier_mng/data", {
page: 1,
size: 500,
autoFilter: true,
sort: { columnName: "supplier_code", order: "desc" },
});
const data =
res.data?.data?.data ?? res.data?.data?.rows ?? [];
const list: Supplier[] = (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
customer_name: String(r.supplier_name ?? r.customer_name ?? r.name ?? ""),
customer_code: String(r.supplier_code ?? r.customer_code ?? r.code ?? ""),
business_number: String(r.business_number ?? ""),
phone: String(r.contact_phone ?? r.phone ?? ""),
address: String(r.address ?? ""),
}));
setAllSuppliers(list);
} catch {
setAllSuppliers([]);
}
}, []);
useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedSupplier(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectSupplier = (s: Supplier | null) => {
setSelectedSupplier(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
/* Filtered suppliers for inline dropdown */
const filteredSuppliers = useMemo(() => {
if (!supplierSearchText.trim()) return [];
return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim()));
}, [allSuppliers, supplierSearchText]);
/* Close dropdown on outside click */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
supplierDropdownRef.current &&
!supplierDropdownRef.current.contains(e.target as Node) &&
supplierInputRef.current &&
!supplierInputRef.current.contains(e.target as Node)
) {
setSupplierDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch purchase orders */
const fetchOrders = useCallback(async (searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
const params: Record<string, string> = { pageSize: "50" };
if (searchKeyword) params.keyword = searchKeyword;
const res = await apiClient.get("/receiving/source/purchase-orders", { params });
const data = res.data?.data;
if (Array.isArray(data) && data.length > 0) {
setOrders(data.map((r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
purchase_no: String(r.purchase_no ?? ""),
order_date: String(r.order_date ?? "").slice(0, 10),
supplier_code: String(r.supplier_code ?? ""),
supplier_name: String(r.supplier_name ?? ""),
item_code: String(r.item_code ?? ""),
item_name: String(r.item_name ?? ""),
spec: String(r.spec ?? ""),
material: String(r.material ?? ""),
order_qty: Number(r.order_qty ?? 0),
received_qty: Number(r.received_qty ?? 0),
remain_qty: Number(r.remain_qty ?? 0),
unit_price: Number(r.unit_price ?? 0),
status: String(r.status ?? ""),
due_date: String(r.due_date ?? "").slice(0, 10),
source_table: String(r.source_table ?? "purchase_detail"),
inspection_type: r.inspection_type === "self" ? "self"
: r.inspection_type === "request" ? "request"
: null,
image: r.image ? String(r.image) : null,
})));
} else {
setOrders([]);
}
} catch {
setOrders([]);
setFetchError("데이터를 불러오지 못했습니다. 네트워크 상태를 확인해주세요.");
} finally {
setLoading(false);
}
}, []);
/* Initial load */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
/* Filter orders by selected supplier */
const filteredOrders = selectedSupplier
? orders.filter((o) =>
o.supplier_code === selectedSupplier.customer_code ||
o.supplier_name === selectedSupplier.customer_name
)
: orders;
/* Filter by keyword */
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.purchase_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
/* Open numpad for an order */
const openNumpad = (order: PurchaseOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
/* Numpad confirm: only update local edited qty (do NOT add to cart) */
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const finalQty = Math.min(qty, numpadTarget.remain_qty);
setEditedQtys((prev) => ({ ...prev, [numpadTarget.id]: finalQty }));
setNumpadTarget(null);
};
/* Add to cart with currently displayed qty */
const handleAddToCart = (order: PurchaseOrder) => {
if (cart.isItemInCart(order.id)) return;
// 공급사 검증: 카트에 이미 다른 공급사 품목이 있으면 차단
if (cart.cartItems.length > 0) {
const existingSupplier = String(cart.cartItems[0].row.supplier_code || "");
if (existingSupplier && existingSupplier !== order.supplier_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
return;
}
}
const displayQty = editedQtys[order.id] ?? order.remain_qty;
const finalQty = Math.min(displayQty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
supplier_code: order.supplier_code,
supplier_name: order.supplier_name,
purchase_no: order.purchase_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
inbound_type: inboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
// 담긴 후 editedQtys에서 제거
setEditedQtys((prev) => {
const next = { ...prev };
delete next[order.id];
return next;
});
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Search */
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/inbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* Cart button — 구매입고 라인, 발주품목 위. 스캐너 버튼(48px) 대비 3배 너비(144px), 장바구니 라벨 */}
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.blue.buttonBg} ${COLOR_MAP.blue.buttonBgHover} shadow-[0_4px_12px_rgba(59,130,246,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area (2 columns on tablet+) ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Supplier search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedSupplier && (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{selectedSupplier.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setSupplierModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedSupplier
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedSupplier ? selectedSupplier.customer_name : "거래처를 선택하세요"}
</button>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setSupplierScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.blue.buttonBg} ${COLOR_MAP.blue.buttonBgHover} shadow-[0_4px_12px_rgba(59,130,246,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedSupplier && (
<button
onClick={() => { selectSupplier(null); setSupplierSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Supplier dropdown removed — use modal instead */}
</div>
</div>
{/* Item search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-blue-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedSupplier ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 발주번호 검색..."
disabled={!selectedSupplier}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedSupplier
? "focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedSupplier}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.blue.buttonBg} ${COLOR_MAP.blue.buttonBgHover} ${
!selectedSupplier ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(59,130,246,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-[11px] text-gray-400">
{selectedSupplier ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedSupplier ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-blue-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedSupplier ? "해당 거래처의 미입고 발주가 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-blue-500 rounded-lg hover:bg-blue-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-blue-300"
}`}
>
{/* Green left bar for in-cart items */}
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
{/* === Header row: item code + item name + inspection badge === */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
{/* === Body row: image + info + action === */}
<div className="flex gap-2.5">
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.purchase_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
{/* Action column: qty display + add/cancel button */}
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
{/* Qty display - clickable to open numpad (edit qty before adding) */}
<button
onClick={() => {
if (!inCart) openNumpad(order);
}}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: editedQtys[order.id] !== undefined
? "bg-green-50 border-green-300 hover:bg-green-100 cursor-pointer active:scale-95"
: "bg-blue-50 border-blue-200 hover:bg-blue-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${
inCart ? "text-gray-400"
: editedQtys[order.id] !== undefined ? "text-green-700"
: "text-blue-700"
}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: (editedQtys[order.id] ?? order.remain_qty).toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{/* Add / Cancel button */}
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => handleAddToCart(order)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all"
style={{
background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)",
}}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={supplierModalOpen}
onClose={() => setSupplierModalOpen(false)}
onSelect={(s) => selectSupplier(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
{/* Barcode scan modal for supplier */}
<BarcodeScanModal
open={supplierScanOpen}
onOpenChange={setSupplierScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setSupplierScanOpen(false);
// 스캔 결과로 거래처 검색 (거래처명 또는 코드 매칭)
const match = allSuppliers.find(
(s) =>
s.customer_code === barcode ||
s.customer_name.includes(barcode)
);
if (match) {
selectSupplier(match);
setSupplierSearchText("");
} else {
// 매칭 안 되면 검색 텍스트에 넣어서 드롭다운 표시
setSupplierSearchText(barcode);
setSupplierDropdownOpen(true);
}
}}
/>
{/* Barcode scan modal for item */}
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="발주 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
// 스캔 결과로 품목 필터
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,598 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface RecoveryOrder {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
received_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
inspection_type: "self" | "request" | null;
/** Item image URL from item_info.image (may be null) */
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface RecoveryInboundProps {
/** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */
cart: import("../common/useCartSync").UseCartSyncReturn;
/** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */
onCartClick: () => void;
/** 카트 저장 중 상태 (버튼 스피너/비활성화용) */
saving: boolean;
/** 입고 유형 — 카트 품목에 기록됨 */
inboundType: string;
/** 소스 테이블명 — 카트 품목별 sourceTable */
sourceTable: string;
}
const STORAGE_KEY = "pop_supplier_recovery";
export function RecoveryInbound({ cart, onCartClick, saving, inboundType, sourceTable }: RecoveryInboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
/* State */
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
const [orders, setOrders] = useState<RecoveryOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
/* NumberPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<RecoveryOrder | null>(null);
/* Barcode scan modal state */
const [supplierScanOpen, setSupplierScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
/* Inline supplier search state */
const [supplierSearchText, setSupplierSearchText] = useState("");
const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false);
const [allSuppliers, setAllSuppliers] = useState<Supplier[]>([]);
const supplierInputRef = useRef<HTMLInputElement>(null);
const supplierDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all suppliers for inline search
* TODO: API
*/
const fetchAllSuppliers = useCallback(async () => {
setAllSuppliers([]);
}, []);
useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedSupplier(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectSupplier = (s: Supplier | null) => {
setSelectedSupplier(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
/* Filtered suppliers for inline dropdown */
const filteredSuppliers = useMemo(() => {
if (!supplierSearchText.trim()) return [];
return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim()));
}, [allSuppliers, supplierSearchText]);
/* Close dropdown on outside click */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
supplierDropdownRef.current &&
!supplierDropdownRef.current.contains(e.target as Node) &&
supplierInputRef.current &&
!supplierInputRef.current.contains(e.target as Node)
) {
setSupplierDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch return orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
/* Initial load */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
/* Filter orders by selected supplier */
const filteredOrders = selectedSupplier
? orders.filter((o) =>
o.supplier_code === selectedSupplier.customer_code ||
o.supplier_name === selectedSupplier.customer_name
)
: orders;
/* Filter by keyword */
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.purchase_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
/* Open numpad for an order */
const openNumpad = (order: RecoveryOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
/* Add to cart with numpad result */
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
// 공급사 검증: 카트에 이미 다른 공급사 품목이 있으면 차단
if (cart.cartItems.length > 0) {
const existingSupplier = String(cart.cartItems[0].row.supplier_code || "");
if (existingSupplier && existingSupplier !== order.supplier_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
supplier_code: order.supplier_code,
supplier_name: order.supplier_name,
purchase_no: order.purchase_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
inbound_type: inboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Search */
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/inbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* Cart button — 외주자재회수 라인, 발주품목 위. 테마 pink */}
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.pink.buttonBg} ${COLOR_MAP.pink.buttonBgHover} shadow-[0_4px_12px_rgba(219,39,119,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area (2 columns on tablet+) ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Supplier search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedSupplier && (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{selectedSupplier.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setSupplierModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedSupplier
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedSupplier ? selectedSupplier.customer_name : "거래처를 선택하세요"}
</button>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setSupplierScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.pink.buttonBg} ${COLOR_MAP.pink.buttonBgHover} shadow-[0_4px_12px_rgba(236,72,153,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedSupplier && (
<button
onClick={() => { selectSupplier(null); setSupplierSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Supplier dropdown removed — use modal instead */}
</div>
</div>
{/* Item search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-pink-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedSupplier ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 발주번호 검색..."
disabled={!selectedSupplier}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedSupplier
? "focus:border-pink-400 focus:ring-2 focus:ring-pink-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedSupplier}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.pink.buttonBg} ${COLOR_MAP.pink.buttonBgHover} ${
!selectedSupplier ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(236,72,153,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-[11px] text-gray-400">
{selectedSupplier ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedSupplier ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-pink-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedSupplier ? "해당 거래처의 회수 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-pink-500 rounded-lg hover:bg-pink-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-pink-300"
}`}
>
{/* Green left bar for in-cart items */}
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
{/* === Header row: item code + item name + inspection badge === */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-pink-100 text-pink-700 border border-pink-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
{/* === Body row: image + info + action === */}
<div className="flex gap-2.5">
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.purchase_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
{/* Action column: qty display + add/cancel button */}
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
{/* Qty display - clickable to open numpad */}
<button
onClick={() => {
if (!inCart) openNumpad(order);
}}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-pink-50 border-pink-200 hover:bg-pink-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-pink-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{/* Add / Cancel button */}
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all ${COLOR_MAP.pink.buttonBg} ${COLOR_MAP.pink.buttonBgHover}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={supplierModalOpen}
onClose={() => setSupplierModalOpen(false)}
onSelect={(s) => selectSupplier(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
{/* Barcode scan modal for supplier */}
<BarcodeScanModal
open={supplierScanOpen}
onOpenChange={setSupplierScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setSupplierScanOpen(false);
// 스캔 결과로 거래처 검색 (거래처명 또는 코드 매칭)
const match = allSuppliers.find(
(s) =>
s.customer_code === barcode ||
s.customer_name.includes(barcode)
);
if (match) {
selectSupplier(match);
setSupplierSearchText("");
} else {
// 매칭 안 되면 검색 텍스트에 넣어서 드롭다운 표시
setSupplierSearchText(barcode);
setSupplierDropdownOpen(true);
}
}}
/>
{/* Barcode scan modal for item */}
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="회수 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
// 스캔 결과로 품목 필터
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,601 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface ReturnOrder {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
received_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
inspection_type: "self" | "request" | null;
/** Item image URL from item_info.image (may be null) */
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface ReturnExternalInboundProps {
/** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */
cart: import("../common/useCartSync").UseCartSyncReturn;
/** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */
onCartClick: () => void;
/** 카트 저장 중 상태 (버튼 스피너/비활성화용) */
saving: boolean;
/** 입고 유형 — 카트 품목에 기록됨 */
inboundType: string;
/** 소스 테이블명 — 카트 품목별 sourceTable */
sourceTable: string;
}
const STORAGE_KEY = "pop_supplier_return-external";
export function ReturnExternalInbound({ cart, onCartClick, saving, inboundType, sourceTable }: ReturnExternalInboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
/* State */
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
const [orders, setOrders] = useState<ReturnOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
/* NumberPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<ReturnOrder | null>(null);
/* Barcode scan modal state */
const [supplierScanOpen, setSupplierScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
/* Inline supplier search state */
const [supplierSearchText, setSupplierSearchText] = useState("");
const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false);
const [allSuppliers, setAllSuppliers] = useState<Supplier[]>([]);
const supplierInputRef = useRef<HTMLInputElement>(null);
const supplierDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all suppliers for inline search
* TODO: API
*/
const fetchAllSuppliers = useCallback(async () => {
setAllSuppliers([]);
}, []);
useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedSupplier(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectSupplier = (s: Supplier | null) => {
setSelectedSupplier(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
/* Filtered suppliers for inline dropdown */
const filteredSuppliers = useMemo(() => {
if (!supplierSearchText.trim()) return [];
return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim()));
}, [allSuppliers, supplierSearchText]);
/* Close dropdown on outside click */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
supplierDropdownRef.current &&
!supplierDropdownRef.current.contains(e.target as Node) &&
supplierInputRef.current &&
!supplierInputRef.current.contains(e.target as Node)
) {
setSupplierDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch return orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
/* Initial load */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
/* Filter orders by selected supplier */
const filteredOrders = selectedSupplier
? orders.filter((o) =>
o.supplier_code === selectedSupplier.customer_code ||
o.supplier_name === selectedSupplier.customer_name
)
: orders;
/* Filter by keyword */
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.purchase_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
/* Open numpad for an order */
const openNumpad = (order: ReturnOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
/* Add to cart with numpad result */
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
// 공급사 검증: 카트에 이미 다른 공급사 품목이 있으면 차단
if (cart.cartItems.length > 0) {
const existingSupplier = String(cart.cartItems[0].row.supplier_code || "");
if (existingSupplier && existingSupplier !== order.supplier_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
supplier_code: order.supplier_code,
supplier_name: order.supplier_name,
purchase_no: order.purchase_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
inbound_type: inboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Search */
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/inbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* Cart button — 반품입고 라인, 발주품목 위. 테마 amber */}
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.amber.buttonBg} ${COLOR_MAP.amber.buttonBgHover} shadow-[0_4px_12px_rgba(217,119,6,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area (2 columns on tablet+) ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Supplier search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedSupplier && (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{selectedSupplier.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setSupplierModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedSupplier
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedSupplier ? selectedSupplier.customer_name : "거래처를 선택하세요"}
</button>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setSupplierScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.amber.buttonBg} ${COLOR_MAP.amber.buttonBgHover} shadow-[0_4px_12px_rgba(245,158,11,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedSupplier && (
<button
onClick={() => { selectSupplier(null); setSupplierSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Supplier dropdown removed — use modal instead */}
</div>
</div>
{/* Item search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-amber-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedSupplier ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 발주번호 검색..."
disabled={!selectedSupplier}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedSupplier
? "focus:border-amber-400 focus:ring-2 focus:ring-amber-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedSupplier}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.amber.buttonBg} ${COLOR_MAP.amber.buttonBgHover} ${
!selectedSupplier ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(245,158,11,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-[11px] text-gray-400">
{selectedSupplier ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedSupplier ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-amber-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedSupplier ? "해당 거래처의 반품 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-amber-500 rounded-lg hover:bg-amber-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-amber-300"
}`}
>
{/* Green left bar for in-cart items */}
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
{/* === Header row: item code + item name + inspection badge === */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
{/* === Body row: image + info + action === */}
<div className="flex gap-2.5">
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.purchase_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
{/* Action column: qty display + add/cancel button */}
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
{/* Qty display - clickable to open numpad */}
<button
onClick={() => {
if (!inCart) openNumpad(order);
}}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-amber-50 border-amber-200 hover:bg-amber-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-amber-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{/* Add / Cancel button */}
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all"
style={{
background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)",
}}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={supplierModalOpen}
onClose={() => setSupplierModalOpen(false)}
onSelect={(s) => selectSupplier(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
{/* Barcode scan modal for supplier */}
<BarcodeScanModal
open={supplierScanOpen}
onOpenChange={setSupplierScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setSupplierScanOpen(false);
// 스캔 결과로 거래처 검색 (거래처명 또는 코드 매칭)
const match = allSuppliers.find(
(s) =>
s.customer_code === barcode ||
s.customer_name.includes(barcode)
);
if (match) {
selectSupplier(match);
setSupplierSearchText("");
} else {
// 매칭 안 되면 검색 텍스트에 넣어서 드롭다운 표시
setSupplierSearchText(barcode);
setSupplierDropdownOpen(true);
}
}}
/>
{/* Barcode scan modal for item */}
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="반품 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
// 스캔 결과로 품목 필터
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,598 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface ReturnOrder {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
received_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
inspection_type: "self" | "request" | null;
/** Item image URL from item_info.image (may be null) */
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface ReturnInternalInboundProps {
/** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */
cart: import("../common/useCartSync").UseCartSyncReturn;
/** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */
onCartClick: () => void;
/** 카트 저장 중 상태 (버튼 스피너/비활성화용) */
saving: boolean;
/** 입고 유형 — 카트 품목에 기록됨 */
inboundType: string;
/** 소스 테이블명 — 카트 품목별 sourceTable */
sourceTable: string;
}
const STORAGE_KEY = "pop_supplier_return-internal";
export function ReturnInternalInbound({ cart, onCartClick, saving, inboundType, sourceTable }: ReturnInternalInboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
/* State */
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
const [orders, setOrders] = useState<ReturnOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
/* NumberPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<ReturnOrder | null>(null);
/* Barcode scan modal state */
const [supplierScanOpen, setSupplierScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
/* Inline supplier search state */
const [supplierSearchText, setSupplierSearchText] = useState("");
const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false);
const [allSuppliers, setAllSuppliers] = useState<Supplier[]>([]);
const supplierInputRef = useRef<HTMLInputElement>(null);
const supplierDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all suppliers for inline search
* TODO: API
*/
const fetchAllSuppliers = useCallback(async () => {
setAllSuppliers([]);
}, []);
useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedSupplier(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectSupplier = (s: Supplier | null) => {
setSelectedSupplier(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
/* Filtered suppliers for inline dropdown */
const filteredSuppliers = useMemo(() => {
if (!supplierSearchText.trim()) return [];
return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim()));
}, [allSuppliers, supplierSearchText]);
/* Close dropdown on outside click */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
supplierDropdownRef.current &&
!supplierDropdownRef.current.contains(e.target as Node) &&
supplierInputRef.current &&
!supplierInputRef.current.contains(e.target as Node)
) {
setSupplierDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch return orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
/* Initial load */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
/* Filter orders by selected supplier */
const filteredOrders = selectedSupplier
? orders.filter((o) =>
o.supplier_code === selectedSupplier.customer_code ||
o.supplier_name === selectedSupplier.customer_name
)
: orders;
/* Filter by keyword */
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.purchase_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
/* Open numpad for an order */
const openNumpad = (order: ReturnOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
/* Add to cart with numpad result */
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
// 공급사 검증: 카트에 이미 다른 공급사 품목이 있으면 차단
if (cart.cartItems.length > 0) {
const existingSupplier = String(cart.cartItems[0].row.supplier_code || "");
if (existingSupplier && existingSupplier !== order.supplier_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
supplier_code: order.supplier_code,
supplier_name: order.supplier_name,
purchase_no: order.purchase_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
inbound_type: inboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Search */
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/inbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* Cart button — 반납입고 라인, 발주품목 위. 테마 orange */}
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.orange.buttonBg} ${COLOR_MAP.orange.buttonBgHover} shadow-[0_4px_12px_rgba(234,88,12,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area (2 columns on tablet+) ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Supplier search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedSupplier && (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{selectedSupplier.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setSupplierModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedSupplier
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedSupplier ? selectedSupplier.customer_name : "거래처를 선택하세요"}
</button>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setSupplierScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.orange.buttonBg} ${COLOR_MAP.orange.buttonBgHover} shadow-[0_4px_12px_rgba(249,115,22,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedSupplier && (
<button
onClick={() => { selectSupplier(null); setSupplierSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Supplier dropdown removed — use modal instead */}
</div>
</div>
{/* Item search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-orange-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedSupplier ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 발주번호 검색..."
disabled={!selectedSupplier}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedSupplier
? "focus:border-orange-400 focus:ring-2 focus:ring-orange-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedSupplier}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.orange.buttonBg} ${COLOR_MAP.orange.buttonBgHover} ${
!selectedSupplier ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(249,115,22,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-[11px] text-gray-400">
{selectedSupplier ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedSupplier ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-orange-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedSupplier ? "해당 거래처의 반납 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-orange-500 rounded-lg hover:bg-orange-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-orange-300"
}`}
>
{/* Green left bar for in-cart items */}
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
{/* === Header row: item code + item name + inspection badge === */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 border border-orange-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
{/* === Body row: image + info + action === */}
<div className="flex gap-2.5">
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.purchase_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
{/* Action column: qty display + add/cancel button */}
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
{/* Qty display - clickable to open numpad */}
<button
onClick={() => {
if (!inCart) openNumpad(order);
}}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-orange-50 border-orange-200 hover:bg-orange-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-orange-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{/* Add / Cancel button */}
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all ${COLOR_MAP.orange.buttonBg} ${COLOR_MAP.orange.buttonBgHover}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={supplierModalOpen}
onClose={() => setSupplierModalOpen(false)}
onSelect={(s) => selectSupplier(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
{/* Barcode scan modal for supplier */}
<BarcodeScanModal
open={supplierScanOpen}
onOpenChange={setSupplierScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setSupplierScanOpen(false);
// 스캔 결과로 거래처 검색 (거래처명 또는 코드 매칭)
const match = allSuppliers.find(
(s) =>
s.customer_code === barcode ||
s.customer_name.includes(barcode)
);
if (match) {
selectSupplier(match);
setSupplierSearchText("");
} else {
// 매칭 안 되면 검색 텍스트에 넣어서 드롭다운 표시
setSupplierSearchText(barcode);
setSupplierDropdownOpen(true);
}
}}
/>
{/* Barcode scan modal for item */}
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="반납 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
// 스캔 결과로 품목 필터
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,598 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface SubcontractorOrder {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
received_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
inspection_type: "self" | "request" | null;
/** Item image URL from item_info.image (may be null) */
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface SubcontractorInboundProps {
/** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */
cart: import("../common/useCartSync").UseCartSyncReturn;
/** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */
onCartClick: () => void;
/** 카트 저장 중 상태 (버튼 스피너/비활성화용) */
saving: boolean;
/** 입고 유형 — 카트 품목에 기록됨 */
inboundType: string;
/** 소스 테이블명 — 카트 품목별 sourceTable */
sourceTable: string;
}
const STORAGE_KEY = "pop_supplier_subcontractor";
export function SubcontractorInbound({ cart, onCartClick, saving, inboundType, sourceTable }: SubcontractorInboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
/* State */
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
const [orders, setOrders] = useState<SubcontractorOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
/* NumberPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<SubcontractorOrder | null>(null);
/* Barcode scan modal state */
const [supplierScanOpen, setSupplierScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
/* Inline supplier search state */
const [supplierSearchText, setSupplierSearchText] = useState("");
const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false);
const [allSuppliers, setAllSuppliers] = useState<Supplier[]>([]);
const supplierInputRef = useRef<HTMLInputElement>(null);
const supplierDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all suppliers for inline search
* TODO: API
*/
const fetchAllSuppliers = useCallback(async () => {
setAllSuppliers([]);
}, []);
useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedSupplier(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectSupplier = (s: Supplier | null) => {
setSelectedSupplier(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
/* Filtered suppliers for inline dropdown */
const filteredSuppliers = useMemo(() => {
if (!supplierSearchText.trim()) return [];
return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim()));
}, [allSuppliers, supplierSearchText]);
/* Close dropdown on outside click */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
supplierDropdownRef.current &&
!supplierDropdownRef.current.contains(e.target as Node) &&
supplierInputRef.current &&
!supplierInputRef.current.contains(e.target as Node)
) {
setSupplierDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch return orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
/* Initial load */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
/* Filter orders by selected supplier */
const filteredOrders = selectedSupplier
? orders.filter((o) =>
o.supplier_code === selectedSupplier.customer_code ||
o.supplier_name === selectedSupplier.customer_name
)
: orders;
/* Filter by keyword */
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.purchase_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
/* Open numpad for an order */
const openNumpad = (order: SubcontractorOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
/* Add to cart with numpad result */
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
// 공급사 검증: 카트에 이미 다른 공급사 품목이 있으면 차단
if (cart.cartItems.length > 0) {
const existingSupplier = String(cart.cartItems[0].row.supplier_code || "");
if (existingSupplier && existingSupplier !== order.supplier_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
supplier_code: order.supplier_code,
supplier_name: order.supplier_name,
purchase_no: order.purchase_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
inbound_type: inboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Search */
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/inbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* Cart button — 외주입고 라인, 발주품목 위. 테마 purple */}
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.purple.buttonBg} ${COLOR_MAP.purple.buttonBgHover} shadow-[0_4px_12px_rgba(124,58,237,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area (2 columns on tablet+) ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Supplier search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedSupplier && (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{selectedSupplier.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setSupplierModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedSupplier
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedSupplier ? selectedSupplier.customer_name : "거래처를 선택하세요"}
</button>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setSupplierScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.purple.buttonBg} ${COLOR_MAP.purple.buttonBgHover} shadow-[0_4px_12px_rgba(139,92,246,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedSupplier && (
<button
onClick={() => { selectSupplier(null); setSupplierSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Supplier dropdown removed — use modal instead */}
</div>
</div>
{/* Item search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-purple-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedSupplier ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 발주번호 검색..."
disabled={!selectedSupplier}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedSupplier
? "focus:border-purple-400 focus:ring-2 focus:ring-purple-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedSupplier}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.purple.buttonBg} ${COLOR_MAP.purple.buttonBgHover} ${
!selectedSupplier ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(139,92,246,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-[11px] text-gray-400">
{selectedSupplier ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedSupplier ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-purple-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedSupplier ? "해당 거래처의 외주 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-purple-500 rounded-lg hover:bg-purple-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-purple-300"
}`}
>
{/* Green left bar for in-cart items */}
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
{/* === Header row: item code + item name + inspection badge === */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 border border-purple-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
{/* === Body row: image + info + action === */}
<div className="flex gap-2.5">
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.purchase_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
{/* Action column: qty display + add/cancel button */}
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
{/* Qty display - clickable to open numpad */}
<button
onClick={() => {
if (!inCart) openNumpad(order);
}}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-purple-50 border-purple-200 hover:bg-purple-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-purple-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{/* Add / Cancel button */}
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all ${COLOR_MAP.purple.buttonBg} ${COLOR_MAP.purple.buttonBgHover}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={supplierModalOpen}
onClose={() => setSupplierModalOpen(false)}
onSelect={(s) => selectSupplier(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
{/* Barcode scan modal for supplier */}
<BarcodeScanModal
open={supplierScanOpen}
onOpenChange={setSupplierScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setSupplierScanOpen(false);
// 스캔 결과로 거래처 검색 (거래처명 또는 코드 매칭)
const match = allSuppliers.find(
(s) =>
s.customer_code === barcode ||
s.customer_name.includes(barcode)
);
if (match) {
selectSupplier(match);
setSupplierSearchText("");
} else {
// 매칭 안 되면 검색 텍스트에 넣어서 드롭다운 표시
setSupplierSearchText(barcode);
setSupplierDropdownOpen(true);
}
}}
/>
{/* Barcode scan modal for item */}
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="외주 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
// 스캔 결과로 품목 필터
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,598 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface SuppliedOrder {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
received_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
inspection_type: "self" | "request" | null;
/** Item image URL from item_info.image (may be null) */
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface SuppliedInboundProps {
/** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */
cart: import("../common/useCartSync").UseCartSyncReturn;
/** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */
onCartClick: () => void;
/** 카트 저장 중 상태 (버튼 스피너/비활성화용) */
saving: boolean;
/** 입고 유형 — 카트 품목에 기록됨 */
inboundType: string;
/** 소스 테이블명 — 카트 품목별 sourceTable */
sourceTable: string;
}
const STORAGE_KEY = "pop_supplier_supplied";
export function SuppliedInbound({ cart, onCartClick, saving, inboundType, sourceTable }: SuppliedInboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
/* State */
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
const [orders, setOrders] = useState<SuppliedOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
/* NumberPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<SuppliedOrder | null>(null);
/* Barcode scan modal state */
const [supplierScanOpen, setSupplierScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
/* Inline supplier search state */
const [supplierSearchText, setSupplierSearchText] = useState("");
const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false);
const [allSuppliers, setAllSuppliers] = useState<Supplier[]>([]);
const supplierInputRef = useRef<HTMLInputElement>(null);
const supplierDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all suppliers for inline search
* TODO: API
*/
const fetchAllSuppliers = useCallback(async () => {
setAllSuppliers([]);
}, []);
useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedSupplier(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectSupplier = (s: Supplier | null) => {
setSelectedSupplier(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
/* Filtered suppliers for inline dropdown */
const filteredSuppliers = useMemo(() => {
if (!supplierSearchText.trim()) return [];
return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim()));
}, [allSuppliers, supplierSearchText]);
/* Close dropdown on outside click */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
supplierDropdownRef.current &&
!supplierDropdownRef.current.contains(e.target as Node) &&
supplierInputRef.current &&
!supplierInputRef.current.contains(e.target as Node)
) {
setSupplierDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch return orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
/* Initial load */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
/* Filter orders by selected supplier */
const filteredOrders = selectedSupplier
? orders.filter((o) =>
o.supplier_code === selectedSupplier.customer_code ||
o.supplier_name === selectedSupplier.customer_name
)
: orders;
/* Filter by keyword */
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.purchase_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
/* Open numpad for an order */
const openNumpad = (order: SuppliedOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
/* Add to cart with numpad result */
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
// 공급사 검증: 카트에 이미 다른 공급사 품목이 있으면 차단
if (cart.cartItems.length > 0) {
const existingSupplier = String(cart.cartItems[0].row.supplier_code || "");
if (existingSupplier && existingSupplier !== order.supplier_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
supplier_code: order.supplier_code,
supplier_name: order.supplier_name,
purchase_no: order.purchase_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
inbound_type: inboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Search */
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/inbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* Cart button — 사급입고 라인, 발주품목 위. 테마 cyan */}
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.cyan.buttonBg} ${COLOR_MAP.cyan.buttonBgHover} shadow-[0_4px_12px_rgba(8,145,178,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area (2 columns on tablet+) ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Supplier search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedSupplier && (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{selectedSupplier.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setSupplierModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedSupplier
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedSupplier ? selectedSupplier.customer_name : "거래처를 선택하세요"}
</button>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setSupplierScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.cyan.buttonBg} ${COLOR_MAP.cyan.buttonBgHover} shadow-[0_4px_12px_rgba(6,182,212,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedSupplier && (
<button
onClick={() => { selectSupplier(null); setSupplierSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Supplier dropdown removed — use modal instead */}
</div>
</div>
{/* Item search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-cyan-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedSupplier ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 발주번호 검색..."
disabled={!selectedSupplier}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedSupplier
? "focus:border-cyan-400 focus:ring-2 focus:ring-cyan-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
{/* QR/Barcode scan button - glossy v3 */}
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedSupplier}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.cyan.buttonBg} ${COLOR_MAP.cyan.buttonBgHover} ${
!selectedSupplier ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(6,182,212,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-[11px] text-gray-400">
{selectedSupplier ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedSupplier ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-cyan-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedSupplier ? "해당 거래처의 사급 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-cyan-500 rounded-lg hover:bg-cyan-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-cyan-300"
}`}
>
{/* Green left bar for in-cart items */}
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
{/* === Header row: item code + item name + inspection badge === */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-cyan-100 text-cyan-700 border border-cyan-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
{/* === Body row: image + info + action === */}
<div className="flex gap-2.5">
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.purchase_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
{/* Action column: qty display + add/cancel button */}
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
{/* Qty display - clickable to open numpad */}
<button
onClick={() => {
if (!inCart) openNumpad(order);
}}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-cyan-50 border-cyan-200 hover:bg-cyan-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-cyan-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{/* Add / Cancel button */}
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all ${COLOR_MAP.cyan.buttonBg} ${COLOR_MAP.cyan.buttonBgHover}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={supplierModalOpen}
onClose={() => setSupplierModalOpen(false)}
onSelect={(s) => selectSupplier(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
{/* Barcode scan modal for supplier */}
<BarcodeScanModal
open={supplierScanOpen}
onOpenChange={setSupplierScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setSupplierScanOpen(false);
// 스캔 결과로 거래처 검색 (거래처명 또는 코드 매칭)
const match = allSuppliers.find(
(s) =>
s.customer_code === barcode ||
s.customer_name.includes(barcode)
);
if (match) {
selectSupplier(match);
setSupplierSearchText("");
} else {
// 매칭 안 되면 검색 텍스트에 넣어서 드롭다운 표시
setSupplierSearchText(barcode);
setSupplierDropdownOpen(true);
}
}}
/>
{/* Barcode scan modal for item */}
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="사급 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
// 스캔 결과로 품목 필터
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,329 @@
"use client";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
/**
* .
* customer_name / customer_code ,
* (// ) .
*/
export interface Supplier {
id: string;
customer_name: string;
customer_code?: string;
business_number?: string;
phone?: string;
address?: string;
}
/**
* .
* ) supplier_mng + { code: "supplier_code", name: "supplier_name", ... }
* customer_mng + { code: "customer_code", name: "customer_name", ... }
*/
export interface PartnerSourceConfig {
/** 조회할 테이블명 (GET /api/data/:tableName 으로 조회됨) */
tableName: string;
/** 필드 매핑 */
fields: {
code: string;
name: string;
businessNumber?: string;
phone?: string;
address?: string;
};
}
interface SupplierModalProps {
open: boolean;
onClose: () => void;
onSelect: (supplier: Supplier) => void;
/** 테이블/필드 매핑. 미지정 시 supplier_mng 기본값 사용(하위호환) */
source?: PartnerSourceConfig;
/** 모달 타이틀 (기본: "거래처 선택") */
title?: string;
/** 검색 플레이스홀더 (기본: "거래처명 또는 코드 검색...") */
searchPlaceholder?: string;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const AVATAR_COLORS = [
"#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#ec4899", "#14b8a6", "#f97316", "#6366f1",
"#84cc16", "#e11d48", "#0ea5e9", "#a855f7", "#10b981",
];
function getAvatarColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
/** Get the Korean initial consonant (Chosung) for sorting */
export function getChosung(char: string): string {
const code = char.charCodeAt(0);
if (code < 0xAC00 || code > 0xD7A3) {
// Not Korean -- group by uppercase letter
const upper = char.toUpperCase();
if (/[A-Z]/.test(upper)) return upper;
return "#";
}
const chosungList = [
"ㄱ","ㄲ","ㄴ","ㄷ","ㄸ","ㄹ","ㅁ","ㅂ","ㅃ","ㅅ",
"ㅆ","ㅇ","ㅈ","ㅉ","ㅊ","ㅋ","ㅌ","ㅍ","ㅎ",
];
const idx = Math.floor((code - 0xAC00) / 588);
return chosungList[idx] ?? "#";
}
/** Check if a query (possibly chosung-only) matches a Korean string */
export function matchChosung(text: string, query: string): boolean {
if (!query) return true;
// Normal substring match first
if (text.toLowerCase().includes(query.toLowerCase())) return true;
// Check if query is all chosung characters
const chosungChars = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
const isChosungQuery = [...query].every((c) => chosungChars.includes(c));
if (!isChosungQuery) return false;
// Extract chosung from text (strip prefixes like (주))
const cleaned = text.replace(/^\(주\)|\(유\)|\(합\)/g, "").trim();
const textChosung = [...cleaned].map((c) => getChosung(c)).join("");
return textChosung.includes(query);
}
/* ------------------------------------------------------------------ */
/* Defaults (하위호환: source 미지정 시 supplier_mng 사용) */
/* ------------------------------------------------------------------ */
const DEFAULT_SOURCE: PartnerSourceConfig = {
tableName: "supplier_mng",
fields: {
code: "supplier_code",
name: "supplier_name",
businessNumber: "business_number",
phone: "contact_phone",
address: "address",
},
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function SupplierModal({
open,
onClose,
onSelect,
source,
title = "거래처 선택",
searchPlaceholder = "거래처명 또는 코드 검색...",
}: SupplierModalProps) {
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [search, setSearch] = useState("");
const [sortMode, setSortMode] = useState<"korean" | "abc">("korean");
const [loading, setLoading] = useState(false);
// 지정된 소스가 없으면 기본값(supplier_mng) 사용
const activeSource = source ?? DEFAULT_SOURCE;
// Fetch partners from the configured table
// 구매관리 > 공급업체관리 화면과 동일한 API 사용 (POST /table-management/tables/{table}/data, autoFilter 적용)
const fetchSuppliers = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post(
`/table-management/tables/${activeSource.tableName}/data`,
{
page: 1,
size: 500,
autoFilter: true,
sort: { columnName: activeSource.fields.code, order: "desc" },
},
);
const data =
res.data?.data?.data ?? res.data?.data?.rows ?? [];
const f = activeSource.fields;
const list: Supplier[] = (Array.isArray(data) ? data : []).map(
(r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
customer_name: String(r[f.name] ?? ""),
customer_code: String(r[f.code] ?? ""),
business_number: f.businessNumber
? String(r[f.businessNumber] ?? "")
: "",
phone: f.phone ? String(r[f.phone] ?? "") : "",
address: f.address ? String(r[f.address] ?? "") : "",
}),
);
setSuppliers(list);
} catch {
setSuppliers([]);
} finally {
setLoading(false);
}
}, [activeSource.tableName, activeSource.fields]);
useEffect(() => {
if (open) {
fetchSuppliers();
setSearch("");
}
}, [open, fetchSuppliers]);
/* Filtered + grouped */
const grouped = useMemo(() => {
const filtered = suppliers.filter((s) =>
s.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(s.customer_code ?? "").toLowerCase().includes(search.toLowerCase())
);
// Sort — 접두사 제거 후 비교 (그룹핑 기준과 일치)
const stripPrefix = (name: string) =>
name.replace(/^\(주\)|\(유\)|\(합\)/g, "").trim();
const sorted = [...filtered].sort((a, b) => {
const an = stripPrefix(a.customer_name);
const bn = stripPrefix(b.customer_name);
if (sortMode === "abc") return an.localeCompare(bn, "en");
return an.localeCompare(bn, "ko");
});
// Group by chosung
const groups: { letter: string; items: Supplier[] }[] = [];
const map = new Map<string, Supplier[]>();
for (const s of sorted) {
const first = s.customer_name.replace(/^\(주\)|\(유\)|\(합\)/g, "").trim().charAt(0);
const letter = getChosung(first);
if (!map.has(letter)) map.set(letter, []);
map.get(letter)!.push(s);
}
for (const [letter, items] of map) {
groups.push({ letter, items });
}
return groups;
}, [suppliers, search, sortMode]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Overlay */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* Modal */}
<div className="relative bg-white rounded-2xl w-[80vw] h-[80vh] flex flex-col shadow-2xl overflow-hidden z-10">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div className="flex items-center gap-3">
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
{/* Sort tabs */}
<div className="flex gap-1.5">
<button
onClick={() => setSortMode("korean")}
className={`px-3 py-1 rounded-full text-xs font-medium border transition-all ${
sortMode === "korean"
? "bg-gray-900 text-white border-gray-900"
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-50"
}`}
>
</button>
<button
onClick={() => setSortMode("abc")}
className={`px-3 py-1 rounded-full text-xs font-medium border transition-all ${
sortMode === "abc"
? "bg-gray-900 text-white border-gray-900"
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-50"
}`}
>
ABC
</button>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 hover:bg-gray-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Search */}
<div className="px-5 py-3">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition-all"
/>
</div>
</div>
{/* Supplier list */}
<div className="flex-1 overflow-y-auto px-5 pb-5">
{loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
...
</div>
) : grouped.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
{search ? "검색 결과가 없습니다" : "거래처가 없습니다"}
</div>
) : (
grouped.map((group) => (
<div key={group.letter} className="mb-2">
{/* Group header */}
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-bold text-blue-500 min-w-[20px]">{group.letter}</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
{/* Grid — 뷰포트 900px 미만 4개(25%), 이상 5개(20%), 셀 전체 클릭 가능 */}
<div className="grid grid-cols-4 min-[900px]:grid-cols-5 gap-x-2 gap-y-1">
{group.items.map((supplier) => {
const displayName = supplier.customer_name.replace(/^\(주\)|\(유\)|\(합\)/g, "").trim();
const initial = displayName.charAt(0);
const color = getAvatarColor(supplier.customer_name);
return (
<button
key={supplier.id}
onClick={() => { onSelect(supplier); onClose(); }}
className="flex flex-col items-center gap-1 py-1.5 px-3 w-full rounded-xl hover:bg-gray-50 active:scale-95 transition-all cursor-pointer border-none bg-transparent"
>
<div
className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl flex items-center justify-center text-white text-xl sm:text-2xl font-bold shadow-sm shrink-0"
style={{ background: color }}
>
{initial}
</div>
<span className="text-xs sm:text-sm font-medium text-gray-700 text-center leading-tight w-full truncate px-1">
{supplier.customer_name}
</span>
</button>
);
})}
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,564 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "../inbound/SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface OutboundOrder {
id: string;
reference_no: string;
order_date: string;
customer_code: string;
customer_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
shipped_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
inspection_type: "self" | "request" | null;
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface EtcOutboundProps {
cart: import("../common/useCartSync").UseCartSyncReturn;
onCartClick: () => void;
saving: boolean;
outboundType: string;
sourceTable: string;
}
const STORAGE_KEY = "pop_customer_etc";
export function EtcOutbound({ cart, onCartClick, saving, outboundType, sourceTable }: EtcOutboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
const [selectedCustomer, setSelectedCustomer] = useState<Supplier | null>(null);
const [customerModalOpen, setCustomerModalOpen] = useState(false);
const [orders, setOrders] = useState<OutboundOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
const [customerSearchText, setCustomerSearchText] = useState("");
const [customerDropdownOpen, setCustomerDropdownOpen] = useState(false);
const [allCustomers, setAllCustomers] = useState<Supplier[]>([]);
const customerInputRef = useRef<HTMLInputElement>(null);
const customerDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all customers
* TODO: API
*/
const fetchAllCustomers = useCallback(async () => {
setAllCustomers([]);
}, []);
useEffect(() => { fetchAllCustomers(); }, [fetchAllCustomers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedCustomer(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectCustomer = (s: Supplier | null) => {
setSelectedCustomer(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
const filteredCustomers = useMemo(() => {
if (!customerSearchText.trim()) return [];
return allCustomers.filter((s) => matchChosung(s.customer_name, customerSearchText.trim()));
}, [allCustomers, customerSearchText]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
customerDropdownRef.current &&
!customerDropdownRef.current.contains(e.target as Node) &&
customerInputRef.current &&
!customerInputRef.current.contains(e.target as Node)
) {
setCustomerDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch outbound orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
const filteredOrders = selectedCustomer
? orders.filter((o) =>
o.customer_code === selectedCustomer.customer_code ||
o.customer_name === selectedCustomer.customer_name
)
: orders;
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.reference_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
const openNumpad = (order: OutboundOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
if (cart.cartItems.length > 0) {
const existingCustomer = String(cart.cartItems[0].row.customer_code || "");
if (existingCustomer && existingCustomer !== order.customer_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
customer_code: order.customer_code,
customer_name: order.customer_name,
reference_no: order.reference_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
outbound_type: outboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/outbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
<button
onClick={onCartClick}
disabled={saving}
className="relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60"
style={{
background: "linear-gradient(to bottom, #475569, #1e293b)",
boxShadow: "0 4px 12px rgba(30,41,59,0.3)",
}}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedCustomer && (
<span className="text-[11px] font-medium text-gray-600 bg-gray-100 px-2 py-0.5 rounded-full">
{selectedCustomer.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setCustomerModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedCustomer
? "bg-gray-50/50 border-gray-300 text-gray-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedCustomer ? selectedCustomer.customer_name : "거래처를 선택하세요"}
</button>
<button
onClick={() => setCustomerScanOpen(true)}
className="min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0"
style={{
background: "linear-gradient(to bottom, #475569, #1e293b)",
boxShadow: "0 4px 12px rgba(30,41,59,0.3)",
}}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedCustomer && (
<button
onClick={() => { selectCustomer(null); setCustomerSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-gray-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedCustomer ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 주문번호 검색..."
disabled={!selectedCustomer}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedCustomer
? "focus:border-gray-400 focus:ring-2 focus:ring-gray-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedCustomer}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${
!selectedCustomer ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{
background: "linear-gradient(to bottom, #475569, #1e293b)",
boxShadow: selectedCustomer ? "0 4px 12px rgba(30,41,59,0.3)" : "none",
}}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] text-gray-400">
{selectedCustomer ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedCustomer ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-gray-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedCustomer ? "해당 거래처의 출하 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-gray-500 rounded-lg hover:bg-gray-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-gray-400"
}`}
>
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
<div className="flex gap-2.5">
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.reference_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
<button
onClick={() => { if (!inCart) openNumpad(order); }}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-gray-100 border-gray-300 hover:bg-gray-200 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-gray-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all"
style={{
background: "linear-gradient(135deg, #475569 0%, #1e293b 100%)",
}}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={customerModalOpen}
onClose={() => setCustomerModalOpen(false)}
onSelect={(s) => selectCustomer(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
<BarcodeScanModal
open={customerScanOpen}
onOpenChange={setCustomerScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setCustomerScanOpen(false);
const match = allCustomers.find(
(s) => s.customer_code === barcode || s.customer_name.includes(barcode)
);
if (match) {
selectCustomer(match);
setCustomerSearchText("");
} else {
setCustomerSearchText(barcode);
setCustomerDropdownOpen(true);
}
}}
/>
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="출하 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,876 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier } from "../inbound/SupplierModal";
import {
getOutboundList,
updateOutbound,
deleteOutbound,
getOutboundWarehouses,
type OutboundItem,
type WarehouseOption,
} from "@/lib/api/outbound";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface OutboundRecord extends OutboundItem {
detail_id?: string;
seq_no?: number;
detail_outbound_type?: string;
header_memo?: string;
item_number?: string;
}
const STATUS_OPTIONS = ["출고완료", "부분출고", "대기"];
const OUTBOUND_TYPE_OPTIONS = [
{ value: "all", label: "전체" },
{ value: "판매출고", label: "판매출고" },
{ value: "생산출고", label: "생산출고" },
{ value: "외주출고", label: "외주출고" },
{ value: "사급출고", label: "사급출고" },
{ value: "반품출고", label: "반품출고" },
{ value: "기타출고", label: "기타출고" },
{ value: "재고이동", label: "재고이동" },
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function OutboundManage() {
const router = useRouter();
const companyPath = usePopCompanyPath();
const today = new Date().toISOString().slice(0, 10);
/* ── Filters ── */
const [outboundDate, setOutboundDate] = useState(today);
const [outboundType, setOutboundType] = useState("all");
const [keyword, setKeyword] = useState("");
const [selectedCustomer, setSelectedCustomer] = useState<Supplier | null>(
null,
);
const [customerModalOpen, setCustomerModalOpen] = useState(false);
/* ── Data ── */
const [records, setRecords] = useState<OutboundRecord[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
/* ── Edit modal ── */
const [editRecord, setEditRecord] = useState<OutboundRecord | null>(null);
const [editForm, setEditForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
/* ── Helpers ── */
const getRowKey = (r: OutboundRecord) => r.detail_id || r.id;
/* ---------------------------------------------------------------- */
/* Fetch */
/* ---------------------------------------------------------------- */
const fetchRecords = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (outboundDate) {
params.date_from = outboundDate;
params.date_to = outboundDate;
}
if (outboundType !== "all") params.outbound_type = outboundType;
if (keyword.trim()) params.search_keyword = keyword.trim();
const res = await getOutboundList(params);
if (res.success) {
let data = res.data as unknown as OutboundRecord[];
if (selectedCustomer?.customer_code) {
data = data.filter(
(r) => r.customer_code === selectedCustomer.customer_code,
);
}
setRecords(data);
setSelectedIds(new Set());
}
} catch (e) {
console.error("출고 목록 조회 실패", e);
} finally {
setLoading(false);
}
}, [outboundDate, outboundType, keyword, selectedCustomer]);
useEffect(() => {
getOutboundWarehouses()
.then((res) => {
if (res.success) setWarehouses(res.data);
})
.catch(() => {});
}, []);
useEffect(() => {
fetchRecords();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ---------------------------------------------------------------- */
/* Selection */
/* ---------------------------------------------------------------- */
const toggleSelect = (key: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleSelectAll = () => {
if (selectedIds.size === records.length) setSelectedIds(new Set());
else setSelectedIds(new Set(records.map(getRowKey)));
};
/* ---------------------------------------------------------------- */
/* Delete */
/* ---------------------------------------------------------------- */
const handleDelete = async () => {
if (selectedIds.size === 0) return;
const headerIds = new Set<string>();
records.forEach((r) => {
if (selectedIds.has(getRowKey(r))) headerIds.add(r.id);
});
if (
!confirm(
`선택한 ${headerIds.size}건의 출고를 삭제하시겠습니까?\n(재고가 롤백됩니다)`,
)
)
return;
setDeleting(true);
try {
for (const hid of headerIds) {
await deleteOutbound(hid);
}
await fetchRecords();
} catch (e: any) {
alert(`삭제 실패: ${e?.message || "알 수 없는 오류"}`);
} finally {
setDeleting(false);
}
};
/* ---------------------------------------------------------------- */
/* Edit */
/* ---------------------------------------------------------------- */
const openEdit = (record: OutboundRecord) => {
setEditRecord(record);
setEditForm({
outbound_date: record.outbound_date?.slice(0, 10) || today,
outbound_qty: record.outbound_qty ?? 0,
unit_price: record.unit_price ?? 0,
total_amount: record.total_amount ?? 0,
lot_number: record.lot_number || "",
warehouse_code: record.warehouse_code || "",
location_code: record.location_code || "",
outbound_status: record.outbound_status || "출고완료",
manager_id: record.manager_id || "",
memo: record.memo || "",
});
};
const handleEditFromSelection = () => {
if (selectedIds.size !== 1) {
alert("수정할 항목을 1건만 선택해주세요.");
return;
}
const key = Array.from(selectedIds)[0];
const rec = records.find((r) => getRowKey(r) === key);
if (rec) openEdit(rec);
};
const updateField = (key: string, value: any) => {
setEditForm((prev) => {
const next = { ...prev, [key]: value };
if (key === "outbound_qty" || key === "unit_price") {
next.total_amount =
Math.round(
(Number(next.outbound_qty) || 0) *
(Number(next.unit_price) || 0) *
100,
) / 100;
}
return next;
});
};
const handleSave = async () => {
if (!editRecord) return;
setSaving(true);
try {
const payload: Record<string, any> = { ...editForm };
if (editRecord.detail_id) payload.detail_id = editRecord.detail_id;
await updateOutbound(editRecord.id, payload as Partial<OutboundItem>);
setEditRecord(null);
await fetchRecords();
} catch (e: any) {
alert(`수정 실패: ${e?.message || "알 수 없는 오류"}`);
} finally {
setSaving(false);
}
};
/* ---------------------------------------------------------------- */
/* Render */
/* ---------------------------------------------------------------- */
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/outbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">
</h1>
<p className="text-xs text-gray-400 mt-0.5">
, ,
</p>
</div>
</div>
{/* ===== Search / Filter ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
{/* 출고일 */}
<div>
<label className="text-xs font-semibold text-gray-500 mb-1 block">
</label>
<input
type="date"
value={outboundDate}
onChange={(e) => setOutboundDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
/>
</div>
{/* 출고유형 */}
<div>
<label className="text-xs font-semibold text-gray-500 mb-1 block">
</label>
<select
value={outboundType}
onChange={(e) => setOutboundType(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 bg-white"
>
{OUTBOUND_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 거래처 */}
<div>
<label className="text-xs font-semibold text-gray-500 mb-1 block">
</label>
<button
onClick={() => setCustomerModalOpen(true)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm text-left outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 bg-white flex items-center justify-between"
>
<span
className={
selectedCustomer
? "text-gray-900 truncate"
: "text-gray-400"
}
>
{selectedCustomer ? selectedCustomer.customer_name : "전체"}
</span>
{selectedCustomer ? (
<span
onClick={(e) => {
e.stopPropagation();
setSelectedCustomer(null);
}}
className="text-gray-400 hover:text-gray-600 ml-1 shrink-0"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</span>
) : (
<svg
className="w-4 h-4 text-gray-400 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
/>
</svg>
)}
</button>
</div>
{/* 검색어 + 검색버튼 */}
<div className="col-span-2">
<label className="text-xs font-semibold text-gray-500 mb-1 block">
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") fetchRecords();
}}
placeholder="출고번호, 품목명, 거래처명..."
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
/>
<button
onClick={fetchRecords}
disabled={loading}
className="min-w-[48px] min-h-[40px] rounded-lg flex items-center justify-center text-white active:scale-95 transition-all disabled:opacity-50"
style={{
background: "linear-gradient(to bottom, #34d399, #059669)",
}}
>
{loading ? (
<svg
className="w-5 h-5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
)}
</button>
</div>
</div>
</div>
</div>
{/* ===== Action buttons ===== */}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">
{selectedIds.size > 0
? `${selectedIds.size}건 선택`
: `${records.length}`}
</span>
<div className="flex items-center gap-2">
<button
disabled={selectedIds.size !== 1}
onClick={handleEditFromSelection}
className="px-4 py-2 rounded-lg text-sm font-semibold text-white active:scale-95 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: "linear-gradient(to bottom, #34d399, #059669)",
}}
>
</button>
<button
disabled={selectedIds.size === 0 || deleting}
onClick={handleDelete}
className="px-4 py-2 rounded-lg text-sm font-semibold text-white active:scale-95 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: "linear-gradient(to bottom, #f87171, #dc2626)",
}}
>
{deleting ? "삭제 중..." : "삭제"}
</button>
</div>
</div>
{/* ===== Record list ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={
records.length > 0 && selectedIds.size === records.length
}
onChange={toggleSelectAll}
className="w-4 h-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
/>
<span className="text-xs font-semibold text-gray-500">
</span>
</div>
<span className="text-[11px] text-gray-400">{records.length}</span>
</div>
{loading && records.length === 0 ? (
<div className="flex items-center justify-center py-16">
<svg
className="w-8 h-8 animate-spin text-emerald-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
</div>
) : records.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg
className="w-16 h-16 mb-4 opacity-20"
fill="none"
stroke="currentColor"
strokeWidth={1}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
<p className="text-sm font-medium text-gray-500 mb-1">
</p>
<p className="text-xs text-gray-400">
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{records.map((record) => {
const key = getRowKey(record);
return (
<div
key={key}
className={`relative rounded-xl border p-3 transition-all cursor-pointer ${
selectedIds.has(key)
? "ring-2 ring-emerald-500 border-emerald-300 bg-emerald-50/30"
: "border-gray-200 bg-white hover:border-emerald-300"
}`}
onClick={() => toggleSelect(key)}
>
{/* Card header */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<input
type="checkbox"
checked={selectedIds.has(key)}
onChange={() => toggleSelect(key)}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
/>
<span className="text-[11px] text-gray-400 font-medium">
{record.outbound_number}
</span>
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-emerald-50 text-emerald-600">
{record.detail_outbound_type || record.outbound_type}
</span>
<span
className={`ml-auto text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${
record.outbound_status === "출고완료"
? "bg-green-50 text-green-600"
: record.outbound_status === "부분출고"
? "bg-amber-50 text-amber-600"
: "bg-gray-50 text-gray-500"
}`}
>
{record.outbound_status || "출고"}
</span>
<button
onClick={(e) => {
e.stopPropagation();
openEdit(record);
}}
className="ml-1 w-7 h-7 rounded-lg bg-gray-50 hover:bg-emerald-50 flex items-center justify-center text-gray-400 hover:text-emerald-600 transition-colors"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</button>
</div>
{/* Card body */}
<div className="flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700 truncate">
{record.item_name || "-"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-500 truncate">
{record.item_code || record.item_number || "-"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700">
{record.customer_name || "-"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-bold text-emerald-700">
{Number(record.outbound_qty).toLocaleString()}{" "}
{record.unit || "EA"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700">
{record.outbound_date?.slice(0, 10) || "-"}
</span>
</div>
{(record as any).warehouse_name && (
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700">
{(record as any).warehouse_name}
</span>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Edit Modal ===== */}
{editRecord && (
<div
className="fixed inset-0 z-50 bg-black/40"
onClick={() => setEditRecord(null)}
>
<div
className="absolute inset-x-0 bottom-0 sm:inset-auto sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 bg-white w-full sm:max-w-lg max-h-[90vh] rounded-t-2xl sm:rounded-2xl overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Modal header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gradient-to-b from-emerald-50 to-white shrink-0">
<div className="min-w-0">
<h2 className="text-lg font-bold text-gray-900"> </h2>
<p className="text-[11px] text-gray-400 truncate">
{editRecord.outbound_number} | {editRecord.item_name}
</p>
</div>
<button
onClick={() => setEditRecord(null)}
className="w-9 h-9 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 ml-2"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Modal body */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-5">
{/* 기본 정보 */}
<FieldGroup title="기본 정보">
<FormField
label="출고일"
type="date"
value={editForm.outbound_date}
onChange={(v) => updateField("outbound_date", v)}
/>
<FormField
label="출고상태"
type="select"
value={editForm.outbound_status}
onChange={(v) => updateField("outbound_status", v)}
options={STATUS_OPTIONS}
/>
</FieldGroup>
{/* 수량/금액 */}
<FieldGroup title="수량/금액">
<FormField
label="수량"
type="number"
value={editForm.outbound_qty}
onChange={(v) => updateField("outbound_qty", v)}
/>
<FormField
label="단가"
type="number"
value={editForm.unit_price}
onChange={(v) => updateField("unit_price", v)}
/>
<FormField
label="금액"
type="number"
value={editForm.total_amount}
onChange={(v) => updateField("total_amount", v)}
readOnly
/>
</FieldGroup>
{/* 출고 상세 */}
<FieldGroup title="출고 상세">
<FormField
label="LOT번호"
value={editForm.lot_number}
onChange={(v) => updateField("lot_number", v)}
/>
<FormField
label="창고"
type="select"
value={editForm.warehouse_code}
onChange={(v) => updateField("warehouse_code", v)}
options={warehouses.map((w) => ({
value: w.warehouse_code,
label: w.warehouse_name,
}))}
emptyLabel="선택..."
/>
<FormField
label="위치"
value={editForm.location_code}
onChange={(v) => updateField("location_code", v)}
/>
<FormField
label="담당자"
value={editForm.manager_id}
onChange={(v) => updateField("manager_id", v)}
/>
</FieldGroup>
{/* 메모 */}
<div>
<h3 className="text-xs font-bold text-gray-500 mb-2 uppercase tracking-wider">
</h3>
<textarea
value={editForm.memo}
onChange={(e) => updateField("memo", e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 resize-none"
placeholder="메모 입력..."
/>
</div>
</div>
{/* Modal footer */}
<div className="flex items-center gap-3 px-4 py-3 border-t border-gray-100 bg-gray-50/50 shrink-0">
<button
onClick={() => setEditRecord(null)}
className="flex-1 py-3 rounded-xl border border-gray-200 text-sm font-semibold text-gray-600 active:scale-95 transition-all"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 rounded-xl text-sm font-semibold text-white active:scale-95 transition-all disabled:opacity-50"
style={{
background: "linear-gradient(to bottom, #34d399, #059669)",
}}
>
{saving ? "저장 중..." : "저장"}
</button>
</div>
</div>
</div>
)}
{/* ===== Customer Modal ===== */}
<SupplierModal
open={customerModalOpen}
onClose={() => setCustomerModalOpen(false)}
onSelect={(customer) => setSelectedCustomer(customer)}
/>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
function FieldGroup({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div>
<h3 className="text-xs font-bold text-gray-500 mb-2 uppercase tracking-wider">
{title}
</h3>
<div className="grid grid-cols-2 gap-3">{children}</div>
</div>
);
}
interface FormFieldProps {
label: string;
value: any;
onChange: (v: any) => void;
type?: "text" | "number" | "date" | "select";
options?: string[] | { value: string; label: string }[];
emptyLabel?: string;
readOnly?: boolean;
}
function FormField({
label,
value,
onChange,
type = "text",
options,
emptyLabel,
readOnly,
}: FormFieldProps) {
const baseClass =
"w-full px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100";
return (
<div>
<label className="text-[11px] font-semibold text-gray-400 mb-1 block">
{label}
</label>
{type === "select" && options ? (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${baseClass} bg-white`}
>
{emptyLabel && <option value="">{emptyLabel}</option>}
{options.map((opt) => {
const v = typeof opt === "string" ? opt : opt.value;
const l = typeof opt === "string" ? opt : opt.label;
return (
<option key={v} value={v}>
{l}
</option>
);
})}
</select>
) : (
<input
type={type}
value={value}
onChange={(e) =>
onChange(
type === "number" ? Number(e.target.value) : e.target.value,
)
}
readOnly={readOnly}
className={`${baseClass} ${readOnly ? "bg-gray-50 text-gray-500" : ""}`}
/>
)}
</div>
);
}
@@ -0,0 +1,550 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "../inbound/SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface OutboundOrder {
id: string;
reference_no: string;
order_date: string;
customer_code: string;
customer_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
shipped_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
inspection_type: "self" | "request" | null;
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface ProductionOutboundProps {
cart: import("../common/useCartSync").UseCartSyncReturn;
onCartClick: () => void;
saving: boolean;
outboundType: string;
sourceTable: string;
}
const STORAGE_KEY = "pop_customer_production";
export function ProductionOutbound({ cart, onCartClick, saving, outboundType, sourceTable }: ProductionOutboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
const [selectedCustomer, setSelectedCustomer] = useState<Supplier | null>(null);
const [customerModalOpen, setCustomerModalOpen] = useState(false);
const [orders, setOrders] = useState<OutboundOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
const [customerSearchText, setCustomerSearchText] = useState("");
const [customerDropdownOpen, setCustomerDropdownOpen] = useState(false);
const [allCustomers, setAllCustomers] = useState<Supplier[]>([]);
const customerInputRef = useRef<HTMLInputElement>(null);
const customerDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all customers
* TODO: API
*/
const fetchAllCustomers = useCallback(async () => {
setAllCustomers([]);
}, []);
useEffect(() => { fetchAllCustomers(); }, [fetchAllCustomers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedCustomer(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectCustomer = (s: Supplier | null) => {
setSelectedCustomer(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
const filteredCustomers = useMemo(() => {
if (!customerSearchText.trim()) return [];
return allCustomers.filter((s) => matchChosung(s.customer_name, customerSearchText.trim()));
}, [allCustomers, customerSearchText]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
customerDropdownRef.current &&
!customerDropdownRef.current.contains(e.target as Node) &&
customerInputRef.current &&
!customerInputRef.current.contains(e.target as Node)
) {
setCustomerDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch outbound orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
const filteredOrders = selectedCustomer
? orders.filter((o) =>
o.customer_code === selectedCustomer.customer_code ||
o.customer_name === selectedCustomer.customer_name
)
: orders;
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.reference_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
const openNumpad = (order: OutboundOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
if (cart.cartItems.length > 0) {
const existingCustomer = String(cart.cartItems[0].row.customer_code || "");
if (existingCustomer && existingCustomer !== order.customer_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
customer_code: order.customer_code,
customer_name: order.customer_name,
reference_no: order.reference_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
outbound_type: outboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/outbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.orange.buttonBg} ${COLOR_MAP.orange.buttonBgHover} shadow-[0_4px_12px_rgba(194,65,12,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedCustomer && (
<span className="text-[11px] font-medium text-orange-600 bg-orange-50 px-2 py-0.5 rounded-full">
{selectedCustomer.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setCustomerModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedCustomer
? "bg-orange-50/50 border-orange-200 text-orange-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedCustomer ? selectedCustomer.customer_name : "거래처를 선택하세요"}
</button>
<button
onClick={() => setCustomerScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.orange.buttonBg} ${COLOR_MAP.orange.buttonBgHover} shadow-[0_4px_12px_rgba(194,65,12,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedCustomer && (
<button
onClick={() => { selectCustomer(null); setCustomerSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-orange-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedCustomer ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 주문번호 검색..."
disabled={!selectedCustomer}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedCustomer
? "focus:border-orange-400 focus:ring-2 focus:ring-orange-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedCustomer}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.orange.buttonBg} ${COLOR_MAP.orange.buttonBgHover} ${
!selectedCustomer ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(194,65,12,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] text-gray-400">
{selectedCustomer ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedCustomer ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-orange-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedCustomer ? "해당 거래처의 출하 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-orange-500 rounded-lg hover:bg-orange-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-orange-300"
}`}
>
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
<div className="flex gap-2.5">
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.reference_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
<button
onClick={() => { if (!inCart) openNumpad(order); }}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-orange-50 border-orange-200 hover:bg-orange-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-orange-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all ${COLOR_MAP.orange.buttonBg} ${COLOR_MAP.orange.buttonBgHover}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={customerModalOpen}
onClose={() => setCustomerModalOpen(false)}
onSelect={(s) => selectCustomer(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
<BarcodeScanModal
open={customerScanOpen}
onOpenChange={setCustomerScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setCustomerScanOpen(false);
const match = allCustomers.find(
(s) => s.customer_code === barcode || s.customer_name.includes(barcode)
);
if (match) {
selectCustomer(match);
setCustomerSearchText("");
} else {
setCustomerSearchText(barcode);
setCustomerDropdownOpen(true);
}
}}
/>
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="출하 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,564 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "../inbound/SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface OutboundOrder {
id: string;
reference_no: string;
order_date: string;
customer_code: string;
customer_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
shipped_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
inspection_type: "self" | "request" | null;
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface ReturnOutboundProps {
cart: import("../common/useCartSync").UseCartSyncReturn;
onCartClick: () => void;
saving: boolean;
outboundType: string;
sourceTable: string;
}
const STORAGE_KEY = "pop_customer_return";
export function ReturnOutbound({ cart, onCartClick, saving, outboundType, sourceTable }: ReturnOutboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
const [selectedCustomer, setSelectedCustomer] = useState<Supplier | null>(null);
const [customerModalOpen, setCustomerModalOpen] = useState(false);
const [orders, setOrders] = useState<OutboundOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
const [customerSearchText, setCustomerSearchText] = useState("");
const [customerDropdownOpen, setCustomerDropdownOpen] = useState(false);
const [allCustomers, setAllCustomers] = useState<Supplier[]>([]);
const customerInputRef = useRef<HTMLInputElement>(null);
const customerDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all customers
* TODO: API
*/
const fetchAllCustomers = useCallback(async () => {
setAllCustomers([]);
}, []);
useEffect(() => { fetchAllCustomers(); }, [fetchAllCustomers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedCustomer(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectCustomer = (s: Supplier | null) => {
setSelectedCustomer(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
const filteredCustomers = useMemo(() => {
if (!customerSearchText.trim()) return [];
return allCustomers.filter((s) => matchChosung(s.customer_name, customerSearchText.trim()));
}, [allCustomers, customerSearchText]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
customerDropdownRef.current &&
!customerDropdownRef.current.contains(e.target as Node) &&
customerInputRef.current &&
!customerInputRef.current.contains(e.target as Node)
) {
setCustomerDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch outbound orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
const filteredOrders = selectedCustomer
? orders.filter((o) =>
o.customer_code === selectedCustomer.customer_code ||
o.customer_name === selectedCustomer.customer_name
)
: orders;
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.reference_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
const openNumpad = (order: OutboundOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
if (cart.cartItems.length > 0) {
const existingCustomer = String(cart.cartItems[0].row.customer_code || "");
if (existingCustomer && existingCustomer !== order.customer_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
customer_code: order.customer_code,
customer_name: order.customer_name,
reference_no: order.reference_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
outbound_type: outboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/outbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
<button
onClick={onCartClick}
disabled={saving}
className="relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60"
style={{
background: "linear-gradient(to bottom, #64748b, #334155)",
boxShadow: "0 4px 12px rgba(51,65,85,0.3)",
}}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedCustomer && (
<span className="text-[11px] font-medium text-slate-600 bg-slate-50 px-2 py-0.5 rounded-full">
{selectedCustomer.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setCustomerModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedCustomer
? "bg-slate-50/50 border-slate-200 text-slate-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedCustomer ? selectedCustomer.customer_name : "거래처를 선택하세요"}
</button>
<button
onClick={() => setCustomerScanOpen(true)}
className="min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0"
style={{
background: "linear-gradient(to bottom, #64748b, #334155)",
boxShadow: "0 4px 12px rgba(51,65,85,0.3)",
}}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedCustomer && (
<button
onClick={() => { selectCustomer(null); setCustomerSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-slate-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedCustomer ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 주문번호 검색..."
disabled={!selectedCustomer}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedCustomer
? "focus:border-slate-400 focus:ring-2 focus:ring-slate-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedCustomer}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${
!selectedCustomer ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{
background: "linear-gradient(to bottom, #64748b, #334155)",
boxShadow: selectedCustomer ? "0 4px 12px rgba(51,65,85,0.3)" : "none",
}}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] text-gray-400">
{selectedCustomer ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedCustomer ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-slate-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedCustomer ? "해당 거래처의 출하 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-slate-500 rounded-lg hover:bg-slate-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-slate-300"
}`}
>
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
<div className="flex gap-2.5">
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.reference_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
<button
onClick={() => { if (!inCart) openNumpad(order); }}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-slate-50 border-slate-200 hover:bg-slate-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-slate-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all"
style={{
background: "linear-gradient(135deg, #64748b 0%, #334155 100%)",
}}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={customerModalOpen}
onClose={() => setCustomerModalOpen(false)}
onSelect={(s) => selectCustomer(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
<BarcodeScanModal
open={customerScanOpen}
onOpenChange={setCustomerScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setCustomerScanOpen(false);
const match = allCustomers.find(
(s) => s.customer_code === barcode || s.customer_name.includes(barcode)
);
if (match) {
selectCustomer(match);
setCustomerSearchText("");
} else {
setCustomerSearchText(barcode);
setCustomerDropdownOpen(true);
}
}}
/>
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="출하 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,593 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "../inbound/SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface OutboundOrder {
id: string;
reference_no: string;
order_date: string;
customer_code: string;
customer_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
shipped_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
inspection_type: "self" | "request" | null;
/** Item image URL from item_info.image (may be null) */
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface SalesOutboundProps {
/** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */
cart: import("../common/useCartSync").UseCartSyncReturn;
/** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */
onCartClick: () => void;
/** 카트 저장 중 상태 (버튼 스피너/비활성화용) */
saving: boolean;
/** 출고 유형 — 카트 품목에 기록됨 */
outboundType: string;
/** 소스 테이블명 — 카트 품목별 sourceTable */
sourceTable: string;
}
const STORAGE_KEY = "pop_customer_sales";
export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceTable }: SalesOutboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
/* State */
const [selectedCustomer, setSelectedCustomer] = useState<Supplier | null>(null);
const [customerModalOpen, setCustomerModalOpen] = useState(false);
const [orders, setOrders] = useState<OutboundOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
/* NumberPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
/* Barcode scan modal state */
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
/* Inline customer search state */
const [customerSearchText, setCustomerSearchText] = useState("");
const [customerDropdownOpen, setCustomerDropdownOpen] = useState(false);
const [allCustomers, setAllCustomers] = useState<Supplier[]>([]);
const customerInputRef = useRef<HTMLInputElement>(null);
const customerDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all customers for inline search
* TODO: API
*/
const fetchAllCustomers = useCallback(async () => {
setAllCustomers([]);
}, []);
useEffect(() => { fetchAllCustomers(); }, [fetchAllCustomers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedCustomer(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectCustomer = (s: Supplier | null) => {
setSelectedCustomer(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
/* Filtered customers for inline dropdown */
const filteredCustomers = useMemo(() => {
if (!customerSearchText.trim()) return [];
return allCustomers.filter((s) => matchChosung(s.customer_name, customerSearchText.trim()));
}, [allCustomers, customerSearchText]);
/* Close dropdown on outside click */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
customerDropdownRef.current &&
!customerDropdownRef.current.contains(e.target as Node) &&
customerInputRef.current &&
!customerInputRef.current.contains(e.target as Node)
) {
setCustomerDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch outbound orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
/* Initial load */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
/* Filter orders by selected customer */
const filteredOrders = selectedCustomer
? orders.filter((o) =>
o.customer_code === selectedCustomer.customer_code ||
o.customer_name === selectedCustomer.customer_name
)
: orders;
/* Filter by keyword */
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.reference_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
/* Open numpad for an order */
const openNumpad = (order: OutboundOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
/* Add to cart with numpad result */
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
// 거래처 검증: 카트에 이미 다른 거래처 품목이 있으면 차단
if (cart.cartItems.length > 0) {
const existingCustomer = String(cart.cartItems[0].row.customer_code || "");
if (existingCustomer && existingCustomer !== order.customer_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
customer_code: order.customer_code,
customer_name: order.customer_name,
reference_no: order.reference_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
outbound_type: outboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
/* Search */
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/outbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* Cart button */}
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.green.buttonBg} ${COLOR_MAP.green.buttonBgHover} shadow-[0_4px_12px_rgba(21,128,61,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area (2 columns on tablet+) ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Customer search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedCustomer && (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{selectedCustomer.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setCustomerModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedCustomer
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedCustomer ? selectedCustomer.customer_name : "거래처를 선택하세요"}
</button>
{/* QR/Barcode scan button */}
<button
onClick={() => setCustomerScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.green.buttonBg} ${COLOR_MAP.green.buttonBgHover} shadow-[0_4px_12px_rgba(21,128,61,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedCustomer && (
<button
onClick={() => { selectCustomer(null); setCustomerSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{/* Item search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-green-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedCustomer ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 주문번호 검색..."
disabled={!selectedCustomer}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedCustomer
? "focus:border-green-400 focus:ring-2 focus:ring-green-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
{/* QR/Barcode scan button */}
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedCustomer}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.green.buttonBg} ${COLOR_MAP.green.buttonBgHover} ${
!selectedCustomer ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(21,128,61,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-[11px] text-gray-400">
{selectedCustomer ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedCustomer ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-green-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedCustomer ? "해당 거래처의 출하 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-green-500 rounded-lg hover:bg-green-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-green-300"
}`}
>
{/* Green left bar for in-cart items */}
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
{/* === Header row: item code + item name + inspection badge === */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
{/* === Body row: image + info + action === */}
<div className="flex gap-2.5">
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.reference_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
{/* Action column: qty display + add/cancel button */}
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
{/* Qty display - clickable to open numpad */}
<button
onClick={() => {
if (!inCart) openNumpad(order);
}}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-green-50 border-green-200 hover:bg-green-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-green-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{/* Add / Cancel button */}
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all ${COLOR_MAP.green.buttonBg} ${COLOR_MAP.green.buttonBgHover}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={customerModalOpen}
onClose={() => setCustomerModalOpen(false)}
onSelect={(s) => selectCustomer(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
{/* Barcode scan modal for customer */}
<BarcodeScanModal
open={customerScanOpen}
onOpenChange={setCustomerScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setCustomerScanOpen(false);
const match = allCustomers.find(
(s) =>
s.customer_code === barcode ||
s.customer_name.includes(barcode)
);
if (match) {
selectCustomer(match);
setCustomerSearchText("");
} else {
setCustomerSearchText(barcode);
setCustomerDropdownOpen(true);
}
}}
/>
{/* Barcode scan modal for item */}
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="출하 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,550 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "../inbound/SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface OutboundOrder {
id: string;
reference_no: string;
order_date: string;
customer_code: string;
customer_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
shipped_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
inspection_type: "self" | "request" | null;
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface SubcontractorOutboundProps {
cart: import("../common/useCartSync").UseCartSyncReturn;
onCartClick: () => void;
saving: boolean;
outboundType: string;
sourceTable: string;
}
const STORAGE_KEY = "pop_customer_subcontractor";
export function SubcontractorOutbound({ cart, onCartClick, saving, outboundType, sourceTable }: SubcontractorOutboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
const [selectedCustomer, setSelectedCustomer] = useState<Supplier | null>(null);
const [customerModalOpen, setCustomerModalOpen] = useState(false);
const [orders, setOrders] = useState<OutboundOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
const [customerSearchText, setCustomerSearchText] = useState("");
const [customerDropdownOpen, setCustomerDropdownOpen] = useState(false);
const [allCustomers, setAllCustomers] = useState<Supplier[]>([]);
const customerInputRef = useRef<HTMLInputElement>(null);
const customerDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all customers
* TODO: API
*/
const fetchAllCustomers = useCallback(async () => {
setAllCustomers([]);
}, []);
useEffect(() => { fetchAllCustomers(); }, [fetchAllCustomers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedCustomer(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectCustomer = (s: Supplier | null) => {
setSelectedCustomer(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
const filteredCustomers = useMemo(() => {
if (!customerSearchText.trim()) return [];
return allCustomers.filter((s) => matchChosung(s.customer_name, customerSearchText.trim()));
}, [allCustomers, customerSearchText]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
customerDropdownRef.current &&
!customerDropdownRef.current.contains(e.target as Node) &&
customerInputRef.current &&
!customerInputRef.current.contains(e.target as Node)
) {
setCustomerDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch outbound orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
const filteredOrders = selectedCustomer
? orders.filter((o) =>
o.customer_code === selectedCustomer.customer_code ||
o.customer_name === selectedCustomer.customer_name
)
: orders;
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.reference_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
const openNumpad = (order: OutboundOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
if (cart.cartItems.length > 0) {
const existingCustomer = String(cart.cartItems[0].row.customer_code || "");
if (existingCustomer && existingCustomer !== order.customer_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
customer_code: order.customer_code,
customer_name: order.customer_name,
reference_no: order.reference_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
outbound_type: outboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/outbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.purple.buttonBg} ${COLOR_MAP.purple.buttonBgHover} shadow-[0_4px_12px_rgba(109,40,217,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedCustomer && (
<span className="text-[11px] font-medium text-purple-600 bg-purple-50 px-2 py-0.5 rounded-full">
{selectedCustomer.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setCustomerModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedCustomer
? "bg-purple-50/50 border-purple-200 text-purple-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedCustomer ? selectedCustomer.customer_name : "거래처를 선택하세요"}
</button>
<button
onClick={() => setCustomerScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.purple.buttonBg} ${COLOR_MAP.purple.buttonBgHover} shadow-[0_4px_12px_rgba(109,40,217,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedCustomer && (
<button
onClick={() => { selectCustomer(null); setCustomerSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-purple-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedCustomer ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 주문번호 검색..."
disabled={!selectedCustomer}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedCustomer
? "focus:border-purple-400 focus:ring-2 focus:ring-purple-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedCustomer}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.purple.buttonBg} ${COLOR_MAP.purple.buttonBgHover} ${
!selectedCustomer ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(109,40,217,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] text-gray-400">
{selectedCustomer ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedCustomer ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-purple-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedCustomer ? "해당 거래처의 출하 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-purple-500 rounded-lg hover:bg-purple-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-purple-300"
}`}
>
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
<div className="flex gap-2.5">
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.reference_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
<button
onClick={() => { if (!inCart) openNumpad(order); }}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-purple-50 border-purple-200 hover:bg-purple-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-purple-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all ${COLOR_MAP.purple.buttonBg} ${COLOR_MAP.purple.buttonBgHover}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={customerModalOpen}
onClose={() => setCustomerModalOpen(false)}
onSelect={(s) => selectCustomer(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
<BarcodeScanModal
open={customerScanOpen}
onOpenChange={setCustomerScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setCustomerScanOpen(false);
const match = allCustomers.find(
(s) => s.customer_code === barcode || s.customer_name.includes(barcode)
);
if (match) {
selectCustomer(match);
setCustomerSearchText("");
} else {
setCustomerSearchText(barcode);
setCustomerDropdownOpen(true);
}
}}
/>
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="출하 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,550 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "../inbound/SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface OutboundOrder {
id: string;
reference_no: string;
order_date: string;
customer_code: string;
customer_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
shipped_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string;
source_table: string;
inspection_type: "self" | "request" | null;
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface SuppliedOutboundProps {
cart: import("../common/useCartSync").UseCartSyncReturn;
onCartClick: () => void;
saving: boolean;
outboundType: string;
sourceTable: string;
}
const STORAGE_KEY = "pop_customer_supplied";
export function SuppliedOutbound({ cart, onCartClick, saving, outboundType, sourceTable }: SuppliedOutboundProps) {
const router = useRouter();
const companyPath = usePopCompanyPath();
const [selectedCustomer, setSelectedCustomer] = useState<Supplier | null>(null);
const [customerModalOpen, setCustomerModalOpen] = useState(false);
const [orders, setOrders] = useState<OutboundOrder[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
const [customerSearchText, setCustomerSearchText] = useState("");
const [customerDropdownOpen, setCustomerDropdownOpen] = useState(false);
const [allCustomers, setAllCustomers] = useState<Supplier[]>([]);
const customerInputRef = useRef<HTMLInputElement>(null);
const customerDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all customers
* TODO: API
*/
const fetchAllCustomers = useCallback(async () => {
setAllCustomers([]);
}, []);
useEffect(() => { fetchAllCustomers(); }, [fetchAllCustomers]);
/* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setSelectedCustomer(parsed);
} catch {}
}
}, []);
/* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */
const selectCustomer = (s: Supplier | null) => {
setSelectedCustomer(s);
if (s) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
};
const filteredCustomers = useMemo(() => {
if (!customerSearchText.trim()) return [];
return allCustomers.filter((s) => matchChosung(s.customer_name, customerSearchText.trim()));
}, [allCustomers, customerSearchText]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
customerDropdownRef.current &&
!customerDropdownRef.current.contains(e.target as Node) &&
customerInputRef.current &&
!customerInputRef.current.contains(e.target as Node)
) {
setCustomerDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* Fetch outbound orders
* TODO: API
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
setLoading(true);
setFetchError(null);
try {
setOrders([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
const filteredOrders = selectedCustomer
? orders.filter((o) =>
o.customer_code === selectedCustomer.customer_code ||
o.customer_name === selectedCustomer.customer_name
)
: orders;
const displayOrders = keyword
? filteredOrders.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.reference_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredOrders;
const openNumpad = (order: OutboundOrder) => {
setNumpadTarget(order);
setNumpadOpen(true);
};
const handleNumpadConfirm = (qty: number) => {
if (!numpadTarget) return;
const order = numpadTarget;
if (cart.isItemInCart(order.id)) return;
if (cart.cartItems.length > 0) {
const existingCustomer = String(cart.cartItems[0].row.customer_code || "");
if (existingCustomer && existingCustomer !== order.customer_code) {
alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다.");
setNumpadTarget(null);
return;
}
}
const finalQty = Math.min(qty, order.remain_qty);
cart.addItem(
{
row: {
id: order.id,
item_code: order.item_code,
item_name: order.item_name,
customer_code: order.customer_code,
customer_name: order.customer_name,
reference_no: order.reference_no,
unit_price: order.unit_price || 0,
spec: order.spec || "",
material: order.material || "",
order_qty: order.order_qty,
remain_qty: order.remain_qty,
order_date: order.order_date || "",
inspection_type: order.inspection_type,
source_table: order.source_table,
image: order.image || null,
outbound_type: outboundType,
},
quantity: finalQty,
},
order.id,
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
};
const handleSearch = () => {
fetchOrders(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/outbound"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
<button
onClick={onCartClick}
disabled={saving}
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.cyan.buttonBg} ${COLOR_MAP.cyan.buttonBgHover} shadow-[0_4px_12px_rgba(14,116,144,0.3)]`}
>
{saving ? (
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
)}
<span></span>
{cart.cartCount > 0 && (
<span
className={`absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center ${
cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"
}`}
>
{cart.cartCount}
</span>
)}
</button>
</div>
{/* ===== Search area ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedCustomer && (
<span className="text-[11px] font-medium text-cyan-600 bg-cyan-50 px-2 py-0.5 rounded-full">
{selectedCustomer.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setCustomerModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedCustomer
? "bg-cyan-50/50 border-cyan-200 text-cyan-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedCustomer ? selectedCustomer.customer_name : "거래처를 선택하세요"}
</button>
<button
onClick={() => setCustomerScanOpen(true)}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.cyan.buttonBg} ${COLOR_MAP.cyan.buttonBgHover} shadow-[0_4px_12px_rgba(14,116,144,0.3)]`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedCustomer && (
<button
onClick={() => { selectCustomer(null); setCustomerSearchText(""); }}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-cyan-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedCustomer ? displayOrders.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 주문번호 검색..."
disabled={!selectedCustomer}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedCustomer
? "focus:border-cyan-400 focus:ring-2 focus:ring-cyan-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedCustomer}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.cyan.buttonBg} ${COLOR_MAP.cyan.buttonBgHover} ${
!selectedCustomer ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(14,116,144,0.3)]"
}`}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Order items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] text-gray-400">
{selectedCustomer ? `${displayOrders.length}` : "-"}
</span>
</div>
{!selectedCustomer ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-cyan-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-sm">
{fetchError ? fetchError : selectedCustomer ? "해당 거래처의 출하 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
</p>
{fetchError && (
<button
onClick={() => fetchOrders()}
className="mt-3 px-4 py-2 text-xs font-medium text-white bg-cyan-500 rounded-lg hover:bg-cyan-600 active:scale-95 transition-all"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayOrders.map((order) => {
const inCart = isInCart(order.id);
const cartItem = getCartItem(order.id);
return (
<div
key={order.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-cyan-300"
}`}
>
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-200 shrink-0 whitespace-nowrap">
</span>
)}
</div>
<div className="flex gap-2.5">
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{order.image ? (
<img src={order.image} alt={order.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{order.reference_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{order.order_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
<button
onClick={() => { if (!inCart) openNumpad(order); }}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-cyan-50 border-cyan-200 hover:bg-cyan-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-cyan-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
: order.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{inCart ? (
<button
onClick={() => handleRemoveFromCart(order.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(order)}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all ${COLOR_MAP.cyan.buttonBg} ${COLOR_MAP.cyan.buttonBgHover}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<SupplierModal
open={customerModalOpen}
onClose={() => setCustomerModalOpen(false)}
onSelect={(s) => selectCustomer(s)}
/>
<SimpleKeypadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
<BarcodeScanModal
open={customerScanOpen}
onOpenChange={setCustomerScanOpen}
targetField="거래처"
autoSubmit
onScanSuccess={(barcode) => {
setCustomerScanOpen(false);
const match = allCustomers.find(
(s) => s.customer_code === barcode || s.customer_name.includes(barcode)
);
if (match) {
selectCustomer(match);
setCustomerSearchText("");
} else {
setCustomerSearchText(barcode);
setCustomerDropdownOpen(true);
}
}}
/>
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="출하 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
setKeyword(barcode);
fetchOrders(barcode);
}}
/>
</div>
);
}
@@ -0,0 +1,184 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface AcceptProcessModalProps {
open: boolean;
onClose: () => void;
onConfirm: (qty: number) => void;
maxQty: number;
processName: string;
seqNo: number;
loading?: boolean;
}
/* ------------------------------------------------------------------ */
/* Numpad Keys */
/* ------------------------------------------------------------------ */
const KEYS = [
{ label: "7", action: "7" },
{ label: "8", action: "8" },
{ label: "9", action: "9" },
{ label: "\u2190", action: "backspace" },
{ label: "4", action: "4" },
{ label: "5", action: "5" },
{ label: "6", action: "6" },
{ label: "C", action: "clear" },
{ label: "1", action: "1" },
{ label: "2", action: "2" },
{ label: "3", action: "3" },
{ label: "MAX", action: "max" },
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function AcceptProcessModal({
open,
onClose,
onConfirm,
maxQty,
processName,
seqNo,
loading = false,
}: AcceptProcessModalProps) {
const [qty, setQty] = useState("0");
useEffect(() => {
if (open) {
setQty("0");
}
}, [open]);
const qtyNum = parseInt(qty, 10) || 0;
const isOverMax = qtyNum > maxQty;
const handleKey = useCallback(
(key: string) => {
setQty((prev) => {
switch (key) {
case "backspace":
return prev.length <= 1 ? "0" : prev.slice(0, -1);
case "clear":
return "0";
case "max":
return String(maxQty);
default: {
const next = prev === "0" ? key : prev + key;
const num = parseInt(next, 10);
if (isNaN(num)) return prev;
return next;
}
}
});
},
[maxQty]
);
const handleConfirm = () => {
const finalQty = Math.min(qtyNum, maxQty);
if (finalQty <= 0) return;
onConfirm(finalQty);
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Overlay */}
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
{/* Panel */}
<div className="relative bg-white w-[90%] max-w-[360px] rounded-2xl shadow-2xl z-10 overflow-hidden">
{/* Header */}
<div
className="px-4 py-3"
style={{ background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)" }}
>
<div className="flex items-center justify-between">
<span className="text-white font-bold text-base"> </span>
<span className="text-white/80 text-xs bg-white/20 px-2.5 py-1 rounded-full">
{maxQty.toLocaleString()} EA
</span>
</div>
<p className="text-white/80 text-xs mt-1">
{seqNo}: {processName}
</p>
</div>
{/* Body */}
<div className="p-4">
<p className="text-center text-sm font-semibold text-gray-700 mb-1">
</p>
<p className="text-center text-xs text-gray-400 mb-3">
(EA)
</p>
{/* Display */}
<input
type="text"
readOnly
value={qtyNum.toLocaleString()}
className={`w-full px-4 py-3 text-right text-3xl font-bold border-2 rounded-xl bg-gray-50 mb-3 ${
isOverMax ? "border-red-300 text-red-500" : "border-gray-200 text-gray-900"
}`}
style={{ fontVariantNumeric: "tabular-nums" }}
/>
{isOverMax && (
<p className="text-xs text-red-500 text-center mb-2 font-medium">
({maxQty.toLocaleString()})
</p>
)}
{/* Numpad */}
<div className="grid grid-cols-4 gap-2.5">
{KEYS.map((key) => (
<button
key={key.action}
onClick={() => handleKey(key.action)}
className={`h-14 rounded-xl text-lg font-semibold active:scale-95 transition-all ${
key.action === "backspace" || key.action === "clear"
? "bg-amber-100 text-amber-700 hover:bg-amber-200"
: key.action === "max"
? "bg-blue-100 text-blue-700 hover:bg-blue-200 text-sm"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
}`}
>
{key.label}
</button>
))}
{/* Bottom row */}
<button
onClick={() => handleKey("0")}
className="col-span-2 h-14 rounded-xl text-lg font-semibold bg-gray-100 text-gray-900 hover:bg-gray-200 active:scale-95 transition-all"
>
0
</button>
<button
onClick={handleConfirm}
disabled={qtyNum <= 0 || loading}
className="col-span-2 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{
background:
qtyNum <= 0 || loading
? "#9ca3af"
: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)",
}}
>
{loading ? "처리중..." : "접수"}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,365 @@
"use client";
import React, { useState, useEffect } from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface DefectType {
id: string;
defect_code: string;
defect_name: string;
defect_type: string;
severity: string;
}
export interface DefectEntry {
defect_code: string;
defect_name: string;
qty: string;
disposition: "scrap" | "rework" | "accept";
}
interface DefectTypeModalProps {
open: boolean;
onClose: () => void;
onConfirm: (entries: DefectEntry[]) => void;
defectTypes: DefectType[];
maxQty: number;
initialEntries?: DefectEntry[];
}
/* ------------------------------------------------------------------ */
/* Simple Number Input with Stepper */
/* ------------------------------------------------------------------ */
function QtyInput({
value,
onChange,
max,
}: {
value: number;
onChange: (v: number) => void;
max: number;
}) {
const [padOpen, setPadOpen] = useState(false);
const [padValue, setPadValue] = useState(String(value));
const handlePadOpen = () => {
setPadValue("");
setPadOpen(true);
};
const handlePadKey = (key: string) => {
if (key === "backspace") {
setPadValue((prev) => prev.length > 1 ? prev.slice(0, -1) : "0");
} else if (key === "clear") {
setPadValue("0");
} else if (key === "max") {
setPadValue(String(max));
} else {
setPadValue((prev) => prev === "0" ? key : prev + key);
}
};
const handlePadConfirm = () => {
const num = Math.min(Math.max(0, parseInt(padValue, 10) || 0), max);
onChange(num);
setPadOpen(false);
};
return (
<>
<div className="flex items-center gap-2">
<button
onClick={() => onChange(Math.max(0, value - 1))}
className="w-10 h-10 rounded-lg bg-gray-100 text-gray-600 text-lg font-bold flex items-center justify-center active:scale-95 transition-all hover:bg-gray-200"
>
-
</button>
<button
onClick={handlePadOpen}
className="w-16 h-10 text-center text-lg font-bold text-gray-900 bg-gray-50 rounded-lg border-2 border-gray-200 hover:border-red-300 active:scale-95 transition-all"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{value}
</button>
<button
onClick={() => onChange(Math.min(max, value + 1))}
className="w-10 h-10 rounded-lg bg-gray-100 text-gray-600 text-lg font-bold flex items-center justify-center active:scale-95 transition-all hover:bg-gray-200"
>
+
</button>
</div>
{/* 숫자 키패드 모달 */}
{padOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={() => setPadOpen(false)} />
<div className="relative bg-white rounded-2xl shadow-2xl p-4 w-[280px] z-10">
<div className="text-center mb-3">
<p className="text-sm text-gray-500"> ( {max})</p>
<p className="text-3xl font-bold text-gray-900 mt-1" style={{ fontVariantNumeric: "tabular-nums" }}>{padValue || "0"}</p>
</div>
<div className="grid grid-cols-3 gap-2 mb-3">
{["1","2","3","4","5","6","7","8","9"].map((k) => (
<button key={k} onClick={() => handlePadKey(k)}
className="h-12 rounded-xl bg-gray-100 text-lg font-bold text-gray-800 active:scale-95 active:bg-gray-200 transition-all">{k}</button>
))}
<button onClick={() => handlePadKey("clear")}
className="h-12 rounded-xl bg-gray-200 text-sm font-bold text-gray-600 active:scale-95 transition-all">C</button>
<button onClick={() => handlePadKey("0")}
className="h-12 rounded-xl bg-gray-100 text-lg font-bold text-gray-800 active:scale-95 transition-all">0</button>
<button onClick={() => handlePadKey("backspace")}
className="h-12 rounded-xl bg-gray-200 text-sm font-bold text-gray-600 active:scale-95 transition-all"></button>
</div>
<button onClick={() => handlePadKey("max")}
className="w-full h-10 rounded-xl bg-red-50 text-red-600 text-sm font-bold mb-2 active:scale-95 transition-all">MAX ({max})</button>
<div className="flex gap-2">
<button onClick={() => setPadOpen(false)}
className="flex-1 h-11 rounded-xl bg-gray-100 text-gray-700 font-semibold active:scale-95 transition-all"></button>
<button onClick={handlePadConfirm}
className="flex-1 h-11 rounded-xl bg-red-500 text-white font-bold active:scale-95 transition-all"></button>
</div>
</div>
</div>
)}
</>
);
}
/* ------------------------------------------------------------------ */
/* Disposition labels */
/* ------------------------------------------------------------------ */
const DISPOSITIONS: { value: DefectEntry["disposition"]; label: string; color: string }[] = [
{ value: "scrap", label: "폐기", color: "bg-red-100 text-red-700 border-red-200" },
{ value: "rework", label: "재작업", color: "bg-amber-100 text-amber-700 border-amber-200" },
{ value: "accept", label: "특채", color: "bg-blue-100 text-blue-700 border-blue-200" },
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function DefectTypeModal({
open,
onClose,
onConfirm,
defectTypes,
maxQty,
initialEntries,
}: DefectTypeModalProps) {
const [entries, setEntries] = useState<
Array<{ defect_code: string; defect_name: string; qty: number; disposition: DefectEntry["disposition"] }>
>([]);
useEffect(() => {
if (open) {
if (initialEntries && initialEntries.length > 0) {
setEntries(
initialEntries.map((e) => ({
...e,
qty: parseInt(e.qty, 10) || 0,
}))
);
} else {
setEntries([]);
}
}
}, [open, initialEntries]);
const totalDefectQty = entries.reduce((sum, e) => sum + e.qty, 0);
const addEntry = (dt: DefectType) => {
// If already exists, just increment
const existing = entries.find((e) => e.defect_code === dt.defect_code);
if (existing) {
setEntries((prev) =>
prev.map((e) =>
e.defect_code === dt.defect_code ? { ...e, qty: Math.min(e.qty + 1, maxQty) } : e
)
);
} else {
setEntries((prev) => [
...prev,
{
defect_code: dt.defect_code,
defect_name: dt.defect_name,
qty: 1,
disposition: "scrap" as const,
},
]);
}
};
const removeEntry = (code: string) => {
setEntries((prev) => prev.filter((e) => e.defect_code !== code));
};
const updateQty = (code: string, qty: number) => {
setEntries((prev) =>
prev.map((e) => (e.defect_code === code ? { ...e, qty } : e))
);
};
const updateDisposition = (code: string, disposition: DefectEntry["disposition"]) => {
setEntries((prev) =>
prev.map((e) => (e.defect_code === code ? { ...e, disposition } : e))
);
};
const handleConfirm = () => {
const validEntries = entries.filter((e) => e.qty > 0);
onConfirm(
validEntries.map((e) => ({
defect_code: e.defect_code,
defect_name: e.defect_name,
qty: String(e.qty),
disposition: e.disposition,
}))
);
onClose();
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
{/* Overlay */}
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
{/* Panel */}
<div className="relative bg-white w-full sm:w-[90%] sm:max-w-[480px] rounded-t-2xl sm:rounded-2xl shadow-2xl z-10 overflow-hidden max-h-[85dvh] flex flex-col">
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 shrink-0"
style={{ background: "linear-gradient(135deg, #ef4444 0%, #b91c1c 100%)" }}
>
<span className="text-white font-bold text-base"> </span>
<div className="flex items-center gap-3">
<span className="text-white/80 text-xs bg-white/20 px-2.5 py-1 rounded-full">
{totalDefectQty}
</span>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white hover:bg-white/30 active:scale-95 transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4">
{/* Defect type selection */}
<p className="text-sm font-semibold text-gray-700 mb-3"> </p>
<div className="grid grid-cols-2 gap-2 mb-4">
{defectTypes.map((dt) => {
const isSelected = entries.some((e) => e.defect_code === dt.defect_code);
return (
<button
key={dt.id}
onClick={() => addEntry(dt)}
className={`px-3 py-3 rounded-xl text-sm font-medium border-2 active:scale-95 transition-all text-left ${
isSelected
? "border-red-400 bg-red-50 text-red-700"
: "border-gray-200 bg-white text-gray-700 hover:border-red-300 hover:bg-red-50"
}`}
>
<span className="block font-bold">{dt.defect_name}</span>
<span className="block text-xs text-gray-400 mt-0.5">{dt.defect_code}</span>
</button>
);
})}
{defectTypes.length === 0 && (
<div className="col-span-2 text-center py-6 text-gray-400 text-sm">
</div>
)}
</div>
{/* Selected entries */}
{entries.length > 0 && (
<>
<p className="text-sm font-semibold text-gray-700 mb-3"> </p>
<div className="flex flex-col gap-3">
{entries.map((entry) => (
<div
key={entry.defect_code}
className="border-2 border-red-100 rounded-xl p-3 bg-red-50/50"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-gray-900">{entry.defect_name}</span>
<button
onClick={() => removeEntry(entry.defect_code)}
className="w-7 h-7 rounded-lg bg-red-100 text-red-500 flex items-center justify-center hover:bg-red-200 active:scale-95 transition-all"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Qty */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500"></span>
<QtyInput
value={entry.qty}
onChange={(v) => updateQty(entry.defect_code, v)}
max={maxQty}
/>
</div>
{/* Disposition */}
<div className="flex gap-2">
{DISPOSITIONS.map((d) => (
<button
key={d.value}
onClick={() => updateDisposition(entry.defect_code, d.value)}
className={`flex-1 py-2 rounded-lg text-xs font-semibold border active:scale-95 transition-all ${
entry.disposition === d.value
? d.color
: "bg-white text-gray-400 border-gray-200"
}`}
>
{d.label}
</button>
))}
</div>
</div>
))}
</div>
</>
)}
</div>
{/* Footer */}
<div className="shrink-0 border-t border-gray-200 p-4 flex gap-3">
<button
onClick={onClose}
className="flex-1 h-12 rounded-xl text-base font-semibold bg-gray-100 text-gray-700 hover:bg-gray-200 active:scale-95 transition-all"
>
</button>
<button
onClick={handleConfirm}
disabled={entries.length === 0 || totalDefectQty <= 0}
className="flex-1 h-12 rounded-xl text-base font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{
background:
entries.length === 0 || totalDefectQty <= 0
? "#9ca3af"
: "linear-gradient(135deg, #ef4444 0%, #b91c1c 100%)",
}}
>
({totalDefectQty})
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,230 @@
"use client";
import React from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface ProcessStep {
no: number;
name: string;
code: string;
status: "completed" | "in_progress" | "waiting" | "acceptable";
inputQty: number;
goodQty: number;
defectQty: number;
planQty: number;
availableQty: number;
}
interface ProcessDetailModalProps {
open: boolean;
onClose: () => void;
workInstructionNo: string;
totalQty: number;
steps: ProcessStep[];
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function ProcessDetailModal({
open,
onClose,
workInstructionNo,
totalQty,
steps,
}: ProcessDetailModalProps) {
if (!open) return null;
const completedCount = steps.filter((s) => s.status === "completed").length;
const overallPct = steps.length > 0 ? Math.round((completedCount / steps.length) * 100) : 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-gray-900"> </h3>
<p className="text-xs text-gray-400 mt-0.5">{workInstructionNo}</p>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 hover:bg-gray-200 active:scale-95 transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Summary bar */}
<div className="px-5 py-4">
<div className="mb-4 p-4 bg-gray-50 rounded-xl">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-gray-900"> </span>
<span className="text-xl font-extrabold text-gray-900">
{totalQty.toLocaleString()} EA
</span>
</div>
<div className="flex items-center gap-2">
<div className="h-3 flex-1 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${overallPct}%` }}
/>
</div>
<span className="text-sm font-bold text-blue-600">
{completedCount}/{steps.length}
</span>
</div>
</div>
</div>
{/* Steps */}
<div className="px-5 pb-4 max-h-[400px] overflow-y-auto -mt-2">
{steps.map((s) => {
const borderColor =
s.status === "completed"
? "border-green-400 bg-green-50"
: s.status === "in_progress"
? "border-blue-400 bg-blue-50"
: s.status === "acceptable"
? "border-amber-400 bg-amber-50"
: "border-gray-200 bg-gray-50";
const dotColor =
s.status === "completed"
? "bg-green-500"
: s.status === "in_progress"
? "bg-blue-500"
: s.status === "acceptable"
? "bg-amber-500"
: "bg-gray-400";
const badge =
s.status === "completed" ? (
<span className="text-xs font-bold px-3 py-1 rounded-full bg-green-100 text-green-700"></span>
) : s.status === "in_progress" ? (
<span className="text-xs font-bold px-3 py-1 rounded-full bg-blue-100 text-blue-700"></span>
) : s.status === "acceptable" ? (
<span className="text-xs font-bold px-3 py-1 rounded-full bg-amber-100 text-amber-700"></span>
) : (
<span className="text-xs font-bold px-3 py-1 rounded-full bg-gray-100 text-gray-500"></span>
);
const barColor =
s.status === "completed"
? "bg-green-500"
: s.status === "in_progress"
? "bg-blue-500"
: s.status === "acceptable"
? "bg-amber-500"
: "bg-gray-300";
const barTextColor =
s.status === "completed"
? "text-green-600"
: s.status === "in_progress"
? "text-blue-600"
: s.status === "acceptable"
? "text-amber-600"
: "text-gray-400";
const pct = s.planQty > 0 ? Math.round((s.inputQty / s.planQty) * 100) : 0;
const unaccept = s.planQty - s.inputQty;
return (
<div key={s.code + "-" + s.no} className={`rounded-xl border-2 ${borderColor} mb-3 overflow-hidden`}>
{/* Header */}
<div className="flex items-center gap-3 p-4 pb-2">
<div
className={`w-10 h-10 rounded-full ${dotColor} text-white flex items-center justify-center text-base font-bold shrink-0`}
>
{s.no}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-base font-bold text-gray-900 truncate">{s.name}</span>
{badge}
</div>
<span className="text-xs text-gray-400">{s.code}</span>
</div>
</div>
{/* Progress bar */}
<div className="px-4 pb-2">
<div className="flex items-center gap-2">
<div className="h-2.5 flex-1 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${barColor} rounded-full transition-all`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<span className={`text-xs font-bold ${barTextColor}`}>{pct}%</span>
</div>
</div>
{/* Qty grid */}
<div className="grid grid-cols-4 gap-1 px-4 pb-3">
<div className="text-center py-1.5">
<div className="text-[10px] text-gray-400"></div>
<div className="text-base font-extrabold text-gray-900">{s.planQty.toLocaleString()}</div>
</div>
<div className="text-center py-1.5">
<div className="text-[10px] text-blue-500"></div>
<div className="text-base font-extrabold text-blue-700">{s.inputQty.toLocaleString()}</div>
</div>
<div className="text-center py-1.5">
<div className="text-[10px] text-emerald-500"></div>
<div className="text-base font-extrabold text-emerald-600">{s.goodQty.toLocaleString()}</div>
</div>
{s.status === "completed" || s.status === "in_progress" ? (
<div className="text-center py-1.5">
<div className="text-[10px] text-red-500"></div>
<div className="text-base font-extrabold text-red-600">{s.defectQty.toLocaleString()}</div>
</div>
) : (
<div className="text-center py-1.5">
<div className="text-[10px] text-gray-400"></div>
<div className="text-base font-extrabold text-gray-500">{Math.max(0, unaccept).toLocaleString()}</div>
</div>
)}
</div>
{/* Additional accept qty */}
{s.status === "in_progress" && s.availableQty > 0 && (
<div className="px-4 pb-3 text-right">
<span className="text-xs text-violet-600 font-semibold">
{s.availableQty.toLocaleString()}
</span>
</div>
)}
{s.status === "acceptable" && s.availableQty > 0 && (
<div className="px-4 pb-3 text-right">
<span className="text-xs text-amber-600 font-semibold">
{s.availableQty.toLocaleString()}
</span>
</div>
)}
</div>
);
})}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-gray-100">
<button
onClick={onClose}
className="w-full py-3 rounded-xl text-sm font-bold text-white bg-blue-500 active:scale-[0.98] transition-all"
>
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,243 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type TimerStatus = "idle" | "running" | "paused" | "completed";
interface ProcessTimerProps {
status: TimerStatus;
/** ISO string or epoch — when the timer was first started */
startedAt: string | null;
/** ISO string or epoch — when paused (null if not paused) */
pausedAt: string | null;
/** Total paused seconds accumulated before current pause */
totalPausedSeconds: number;
/** Completed at timestamp */
completedAt: string | null;
/** Actual work time in seconds (from server, used when completed) */
actualWorkTime: number | null;
onStart: () => void;
onPause: () => void;
onResume: () => void;
onComplete: () => void;
disabled?: boolean;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function formatTime(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function ProcessTimer({
status,
startedAt,
pausedAt,
totalPausedSeconds,
completedAt,
actualWorkTime,
onStart,
onPause,
onResume,
onComplete,
disabled = false,
}: ProcessTimerProps) {
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const calcElapsed = useCallback(() => {
if (!startedAt) return 0;
const start = new Date(startedAt).getTime();
const now = Date.now();
let pausedMs = totalPausedSeconds * 1000;
// If currently paused, add time since pause started
if (pausedAt) {
const pauseStart = new Date(pausedAt).getTime();
pausedMs += now - pauseStart;
}
return Math.max(0, Math.floor((now - start - pausedMs) / 1000));
}, [startedAt, pausedAt, totalPausedSeconds]);
useEffect(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (status === "completed" && actualWorkTime !== null) {
setElapsed(actualWorkTime);
return;
}
if (status === "running") {
setElapsed(calcElapsed());
intervalRef.current = setInterval(() => {
setElapsed(calcElapsed());
}, 1000);
} else if (status === "paused") {
setElapsed(calcElapsed());
} else if (status === "idle") {
setElapsed(0);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [status, startedAt, pausedAt, totalPausedSeconds, actualWorkTime, calcElapsed]);
/* Color by status */
const colorMap: Record<TimerStatus, { bg: string; text: string; border: string; ring: string }> = {
idle: { bg: "bg-gray-50", text: "text-gray-400", border: "border-gray-200", ring: "ring-gray-200" },
running: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200", ring: "ring-blue-300" },
paused: { bg: "bg-amber-50", text: "text-amber-600", border: "border-amber-200", ring: "ring-amber-300" },
completed: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200", ring: "ring-green-300" },
};
const colors = colorMap[status];
const statusLabels: Record<TimerStatus, string> = {
idle: "대기",
running: "진행중",
paused: "일시정지",
completed: "완료",
};
return (
<div className={`rounded-2xl border-2 ${colors.border} ${colors.bg} p-5 sm:p-6`}>
{/* Status badge */}
<div className="flex items-center justify-between mb-4">
<span className={`text-xs font-bold px-3 py-1 rounded-full ${colors.bg} ${colors.text} border ${colors.border}`}>
{statusLabels[status]}
</span>
{status === "running" && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
<span className="text-xs text-blue-500 font-medium"></span>
</span>
)}
</div>
{/* Timer display */}
<div className="text-center mb-5">
<p
className={`text-5xl sm:text-6xl font-black tracking-wider ${colors.text}`}
style={{ fontVariantNumeric: "tabular-nums", fontFamily: "monospace" }}
>
{formatTime(elapsed)}
</p>
{startedAt && (
<p className="text-xs text-gray-400 mt-2">
: {new Date(startedAt).toLocaleTimeString("ko-KR")}
{completedAt && ` | 종료: ${new Date(completedAt).toLocaleTimeString("ko-KR")}`}
</p>
)}
</div>
{/* Buttons */}
<div className="flex gap-3">
{status === "idle" && (
<button
onClick={onStart}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</span>
</button>
)}
{status === "running" && (
<>
<button
onClick={onPause}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</span>
</button>
<button
onClick={onComplete}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z" />
</svg>
</span>
</button>
</>
)}
{status === "paused" && (
<>
<button
onClick={onResume}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</span>
</button>
<button
onClick={onComplete}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z" />
</svg>
</span>
</button>
</>
)}
{status === "completed" && (
<div className="flex-1 h-14 rounded-xl bg-green-100 border-2 border-green-300 flex items-center justify-center gap-2 text-green-700 font-bold text-lg">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,370 @@
"use client";
import React, { useEffect, useState } from "react";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
/* ================================================================== */
/* Material Qty Keypad (±20% 범위 안내 포함) */
/* ================================================================== */
function MaterialQtyKeypad({
open,
onClose,
onConfirm,
initialValue,
unit,
requiredQty,
itemName,
}: {
open: boolean;
onClose: () => void;
onConfirm: (val: string) => void;
initialValue: string;
unit: string;
requiredQty: number;
itemName: string;
}) {
const [val, setVal] = useState(initialValue || "0");
useEffect(() => {
if (open) setVal(initialValue || "0");
}, [open, initialValue]);
const press = (k: string) => {
setVal((prev) => {
if (k === "backspace") return prev.length <= 1 ? "0" : prev.slice(0, -1);
if (k === "clear") return "0";
if (k === "dot") return prev.includes(".") ? prev : prev + ".";
if (k === "ref") return String(requiredQty);
if (prev === "0" && k !== ".") return k;
return prev + k;
});
};
if (!open) return null;
const numVal = parseFloat(val);
const lower = requiredQty * 0.8;
const upper = requiredQty * 1.2;
const outOfRange = !isNaN(numVal) && (numVal < lower || numVal > upper);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white rounded-2xl shadow-2xl p-4 w-[320px] z-10">
<div className="text-center mb-3">
<p className="text-sm text-gray-500 truncate">{itemName}</p>
<p className="text-xs text-blue-500 mt-0.5">
{requiredQty} {unit} (±20%)
</p>
<p
className={`text-3xl font-bold mt-2 ${outOfRange ? "text-amber-600" : "text-gray-900"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{val} <span className="text-base text-gray-400">{unit}</span>
</p>
</div>
<div className="grid grid-cols-3 gap-2 mb-3">
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((k) => (
<button
key={k}
onClick={() => press(k)}
className="h-14 rounded-xl bg-gray-100 text-xl font-bold text-gray-800 active:scale-95 active:bg-gray-200 transition-all"
>
{k}
</button>
))}
<button
onClick={() => press("dot")}
className="h-14 rounded-xl bg-gray-100 text-xl font-bold text-gray-800 active:scale-95 transition-all"
>
.
</button>
<button
onClick={() => press("0")}
className="h-14 rounded-xl bg-gray-100 text-xl font-bold text-gray-800 active:scale-95 transition-all"
>
0
</button>
<button
onClick={() => press("backspace")}
className="h-14 rounded-xl bg-gray-200 text-base font-bold text-gray-600 active:scale-95 transition-all"
>
</button>
</div>
<div className="flex gap-2 mb-2">
<button
onClick={() => press("clear")}
className="flex-1 h-10 rounded-xl bg-gray-100 text-gray-600 text-sm font-bold active:scale-95"
>
</button>
<button
onClick={() => press("ref")}
className="flex-1 h-10 rounded-xl bg-blue-50 text-blue-600 text-sm font-bold active:scale-95"
>
({requiredQty})
</button>
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="flex-1 h-12 rounded-xl bg-gray-100 text-gray-700 font-semibold active:scale-95"
>
</button>
<button
onClick={() => {
onConfirm(val);
onClose();
}}
className="flex-1 h-12 rounded-xl text-white font-bold active:scale-95"
style={{
background: outOfRange
? "linear-gradient(135deg, #f59e0b, #d97706)"
: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
}}
>
{outOfRange ? "확인 (범위 외)" : "확인"}
</button>
</div>
</div>
</div>
);
}
/* ================================================================== */
/* Material Qty Input Row (키패드 트리거 버튼) */
/* ================================================================== */
function MaterialQtyInputRow({
material,
value,
onChange,
}: {
material: {
id: string;
child_item_name: string;
required_qty: number;
unit: string;
};
value: string;
onChange: (v: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="flex items-center shrink-0">
<button
type="button"
onClick={() => setOpen(true)}
className="px-8 py-4 rounded-xl border-2 border-blue-300 text-xl font-bold text-blue-700 hover:border-blue-500 active:scale-[0.96] transition-all bg-blue-50 min-w-[120px] text-center"
>
{value || <span className="text-blue-300 font-semibold"></span>}
</button>
<MaterialQtyKeypad
open={open}
onClose={() => setOpen(false)}
onConfirm={onChange}
initialValue={value}
unit={material.unit}
requiredQty={material.required_qty}
itemName={material.child_item_name}
/>
</div>
);
}
/* ================================================================== */
/* Material Input Section */
/* ================================================================== */
/**
* Phase B-1: ProcessWork.tsx L2810-2993 .
* BOM / processId props ( ).
* 그대로: 자체 state + API , peSettings.materialInput (ProcessWork) .
*
* 구조: processId = wop_result.id ( id).
* /pop/production/bom-materials/:id, /material-inputs/:id,
* /material-input (body.work_order_process_id) wop_result.id .
*/
export function MaterialInputSection({ processId }: { processId: string }) {
const [bomMaterials, setBomMaterials] = useState<
Array<{
id: string;
child_item_id: string;
child_item_code: string;
child_item_name: string;
bom_qty: number;
unit: string;
required_qty: number;
input_qty: number;
}>
>([]);
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [inputted, setInputted] = useState<
Array<{
id: string;
item_code: string;
item_name: string;
input_qty: string;
unit: string;
recorded_at: string;
}>
>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [defaultWarehouseCode, setDefaultWarehouseCode] = useState<string>("");
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [bomRes, inputRes, whRes] = await Promise.all([
apiClient.get(`/pop/production/bom-materials/${processId}`),
apiClient.get(`/pop/production/material-inputs/${processId}`),
apiClient.get("/pop/production/warehouses"),
]);
setBomMaterials(bomRes.data?.data?.materials || []);
setInputted(inputRes.data?.data || []);
const wh = whRes.data?.data?.[0];
if (wh?.warehouse_code) setDefaultWarehouseCode(wh.warehouse_code);
} catch {
/* non-critical */
}
setLoading(false);
};
fetchData();
}, [processId]);
const handleSave = async () => {
const inputs = bomMaterials
.filter((m) => {
const val = parseFloat(inputValues[m.id] || "0");
return val > 0;
})
.map((m) => ({
child_item_id: m.child_item_id,
child_item_code: m.child_item_code,
child_item_name: m.child_item_name,
input_qty: parseFloat(inputValues[m.id] || "0"),
unit: m.unit,
bom_detail_id: m.id,
required_qty: m.required_qty,
warehouse_code: defaultWarehouseCode || undefined,
location_code: defaultWarehouseCode || undefined,
}));
if (inputs.length === 0) {
toast.warning("투입 수량을 입력해주세요.");
return;
}
setSaving(true);
try {
const res = await apiClient.post("/pop/production/material-input", {
work_order_process_id: processId,
inputs,
});
if (res.data?.success) {
toast.success(res.data.message || "투입 완료");
setInputValues({});
const inputRes = await apiClient.get(
`/pop/production/material-inputs/${processId}`,
);
setInputted(inputRes.data?.data || []);
} else {
toast.error(res.data?.message || "투입 실패");
}
} catch {
toast.error("투입 중 오류");
}
setSaving(false);
};
if (loading) {
return (
<div className="text-center py-8 text-gray-400"> ...</div>
);
}
return (
<div className="space-y-3">
{/* BOM 기준 자재 목록 — 컴팩트 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-bold text-gray-900">BOM </h3>
<span className="text-xs text-gray-400">{bomMaterials.length}</span>
</div>
{bomMaterials.length === 0 ? (
<p className="text-sm text-gray-400 py-4 text-center">
BOM
</p>
) : (
<div>
<div className="divide-y divide-gray-200">
{bomMaterials.map((m) => (
<div key={m.id} className="flex items-center gap-2 py-3">
<div className="flex-1 min-w-0">
<span className="text-base font-bold text-gray-900">
{m.child_item_name}
</span>
<span className="text-sm text-gray-400 ml-1">
({m.child_item_code})
</span>
<span className="text-base font-bold text-blue-600 ml-3">
{m.required_qty}
</span>
</div>
<MaterialQtyInputRow
material={m}
value={inputValues[m.id] || ""}
onChange={(v) =>
setInputValues((prev) => ({ ...prev, [m.id]: v }))
}
/>
<span className="text-base font-semibold text-gray-500 shrink-0 w-8">
{m.unit}
</span>
</div>
))}
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full mt-4 py-4 rounded-xl text-lg font-bold text-white active:scale-[0.98] transition-all disabled:opacity-40"
style={{
background: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
}}
>
{saving ? "저장중..." : "자재 투입 확정"}
</button>
</div>
)}
</div>
{/* 투입 이력 */}
{inputted.length > 0 && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<h3 className="text-base font-bold text-gray-900 mb-3"> </h3>
{inputted.map((item) => (
<div
key={item.id}
className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0"
>
<div>
<p className="text-sm font-semibold text-gray-900">
{item.item_name}
</p>
<p className="text-xs text-gray-400">{item.item_code}</p>
</div>
<p className="text-base font-bold text-green-600">
{item.input_qty} {item.unit}
</p>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,234 @@
/**
* POP / (Phase D)
*
* :
* - stringly-typed ( string, is_rework "Y"/"true"/"1")
* UI number/boolean으로
* - normalize는 useProcessData 1 , View만
*/
/** 서버 응답 raw row (일부 string + Phase C number 혼재) */
export interface WorkOrderProcessRaw {
id: string;
wo_id: string;
seq_no: string | number;
process_code: string;
process_name: string;
status: string;
result_status?: string;
plan_qty?: string | number | null;
input_qty?: string | number | null;
good_qty?: string | number | null;
defect_qty?: string | number | null;
concession_qty?: string | number | null;
total_production_qty?: string | number | null;
parent_process_id?: string | null;
is_rework?: string | boolean | null;
rework_source_id?: string | null;
started_at?: string | null;
completed_at?: string | null;
accepted_by?: string | null;
accepted_at?: string | null;
created_date?: string | null;
batch_id?: string | null;
equipment_code?: string | null;
// Phase C 서버 계산 필드
available_qty?: string | number | null;
prev_good_qty?: string | number | null;
my_input_qty?: string | number | null;
rework_available_qty?: string | number | null;
split_no?: string | number | null;
split_total?: string | number | null;
batch_count?: string | number | null;
batch_list?: string[] | null;
batch_index?: number | null;
/** 신 구조 — 이 wop 에 속한 접수 카드 배열 (wop_result rows) */
accepted_results?: Array<{
id: string; // wop_result.id
seq: string | number;
status: string;
result_status?: string;
input_qty?: string | number | null;
good_qty?: string | number | null;
defect_qty?: string | number | null;
concession_qty?: string | number | null;
total_production_qty?: string | number | null;
is_rework?: string | boolean | null;
rework_source_id?: string | null;
accepted_by?: string | null;
accepted_at?: string | null;
started_at?: string | null;
completed_at?: string | null;
equipment_code?: string | null;
batch_id?: string | null;
}> | null;
}
/** UI 컴포넌트가 사용하는 정규화 View — 수량 number, 플래그 boolean */
export interface WorkOrderProcessView {
id: string;
wo_id: string;
seq_no: number;
process_code: string;
process_name: string;
status: "acceptable" | "waiting" | "in_progress" | "completed";
result_status: string;
// 수량 (모두 number, null 폴백 0)
plan_qty: number;
input_qty: number;
good_qty: number;
defect_qty: number;
concession_qty: number;
total_production_qty: number;
// 리워크
is_rework: boolean;
rework_source_id: string | null;
// 계층 / 배치
parent_process_id: string | null;
batch_id: string | null;
// 타이밍
started_at: string | null;
completed_at: string | null;
accepted_at: string | null;
accepted_by: string | null;
created_date: string | null;
// 설비
equipment_code: string | null;
// Phase C 서버 계산 필드
available_qty: number;
prev_good_qty: number | null;
my_input_qty: number;
rework_available_qty: number;
split_no: number | null;
split_total: number | null;
batch_count: number;
batch_list: string[] | null;
batch_index: number | null;
/** 신 구조 — 이 wop 에 속한 접수 카드 배열 (정규화됨) */
accepted_results: Array<{
id: string; // wop_result.id — 접수/실적 단위 식별자
seq: number;
status: "acceptable" | "waiting" | "in_progress" | "completed";
result_status: string;
input_qty: number;
good_qty: number;
defect_qty: number;
concession_qty: number;
total_production_qty: number;
is_rework: boolean;
rework_source_id: string | null;
accepted_by: string | null;
accepted_at: string | null;
started_at: string | null;
completed_at: string | null;
equipment_code: string | null;
batch_id: string | null;
}>;
}
/** 범용 Yes 판정 — "Y"/"true"/"1" (대소문자 유연) 또는 boolean true */
export function isYes(v: unknown): boolean {
if (v === true) return true;
if (typeof v !== "string") return false;
const s = v.trim().toLowerCase();
return s === "y" || s === "true" || s === "1";
}
/** 리워크 행 여부 — is_rework만 보는 래퍼 (Phase D 집약 대상) */
export function isReworkProcess(
row: { is_rework?: string | boolean | null } | null | undefined,
): boolean {
if (!row) return false;
return isYes(row.is_rework);
}
/** 수량 정규화 — string/null → number, NaN 방지 */
function toInt(v: unknown): number {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (v == null) return 0;
const n = parseInt(String(v), 10);
return Number.isFinite(n) ? n : 0;
}
/** nullable 숫자 정규화 — null 유지 */
function toNullableInt(v: unknown): number | null {
if (v == null) return null;
if (typeof v === "number") return Number.isFinite(v) ? v : null;
const n = parseInt(String(v), 10);
return Number.isFinite(n) ? n : null;
}
/**
* raw row View
* - number (NaN )
* - is_rework boolean
* - seq_no number
* - Phase C number
*/
export function normalizeWorkOrderProcess(
raw: WorkOrderProcessRaw,
): WorkOrderProcessView {
return {
id: String(raw.id || ""),
wo_id: String(raw.wo_id || ""),
seq_no: toInt(raw.seq_no),
process_code: String(raw.process_code || ""),
process_name: String(raw.process_name || ""),
status: (raw.status as WorkOrderProcessView["status"]) || "waiting",
result_status: String(raw.result_status || ""),
plan_qty: toInt(raw.plan_qty),
input_qty: toInt(raw.input_qty),
good_qty: toInt(raw.good_qty),
defect_qty: toInt(raw.defect_qty),
concession_qty: toInt(raw.concession_qty),
total_production_qty: toInt(raw.total_production_qty),
is_rework: isYes(raw.is_rework),
rework_source_id: raw.rework_source_id ?? null,
parent_process_id: raw.parent_process_id ?? null,
batch_id: raw.batch_id ?? null,
started_at: raw.started_at ?? null,
completed_at: raw.completed_at ?? null,
accepted_at: raw.accepted_at ?? null,
accepted_by: raw.accepted_by ?? null,
created_date: raw.created_date ?? null,
equipment_code: raw.equipment_code ?? null,
// Phase C
available_qty: toInt(raw.available_qty),
prev_good_qty: toNullableInt(raw.prev_good_qty),
my_input_qty: toInt(raw.my_input_qty),
rework_available_qty: toInt(raw.rework_available_qty),
split_no: toNullableInt(raw.split_no),
split_total: toNullableInt(raw.split_total),
batch_count: toInt(raw.batch_count),
batch_list: Array.isArray(raw.batch_list) ? raw.batch_list : null,
batch_index: raw.batch_index ?? null,
accepted_results: Array.isArray(raw.accepted_results)
? raw.accepted_results.map((ar) => ({
id: String(ar.id || ""),
seq: toInt(ar.seq),
status:
(ar.status as WorkOrderProcessView["status"]) || "in_progress",
result_status: String(ar.result_status || ""),
input_qty: toInt(ar.input_qty),
good_qty: toInt(ar.good_qty),
defect_qty: toInt(ar.defect_qty),
concession_qty: toInt(ar.concession_qty),
total_production_qty: toInt(ar.total_production_qty),
is_rework: isYes(ar.is_rework),
rework_source_id: ar.rework_source_id ?? null,
accepted_by: ar.accepted_by ?? null,
accepted_at: ar.accepted_at ?? null,
started_at: ar.started_at ?? null,
completed_at: ar.completed_at ?? null,
equipment_code: ar.equipment_code ?? null,
batch_id: ar.batch_id ?? null,
}))
: [],
};
}
@@ -0,0 +1,229 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import { dataApi } from "@/lib/api/data";
import {
normalizeWorkOrderProcess,
type WorkOrderProcessRaw,
type WorkOrderProcessView,
} from "./types";
/* ------------------------------------------------------------------ */
/* Types (WorkOrderList.tsx와 동일 스키마) */
/* ------------------------------------------------------------------ */
export interface WorkInstruction {
id: string;
work_instruction_no: string;
item_id: string;
item_name: string;
item_code: string;
item_number: string;
qty: number;
completed_qty: number;
status: string;
progress_status: string;
routing: string | null;
start_date: string;
end_date: string;
equipment_id: string;
work_team: string;
worker: string;
}
/** Phase D: 정규화 View re-export (기존 타입명 유지 호환) */
export type WorkOrderProcess = WorkOrderProcessView;
export interface ProcessMng {
id: string;
process_code: string;
process_name: string;
}
export interface EquipmentMng {
id: string;
equipment_code: string;
equipment_name: string;
}
const REFRESH_COOLDOWN_MS = 3000;
/**
*
*
* - 1 sync-work-instructions POST +
* - refresh(): sync , 3 ( )
* - refetch(): mutation sync
* - sonner toast (silent catch )
*/
export function useProcessData() {
const [instructions, setInstructions] = useState<WorkInstruction[]>([]);
const [allProcesses, setAllProcesses] = useState<WorkOrderProcess[]>([]);
const [processList, setProcessList] = useState<ProcessMng[]>([]);
const [equipmentList, setEquipmentList] = useState<EquipmentMng[]>([]);
const [itemNameMap, setItemNameMap] = useState<Record<string, string>>({});
const [itemTypeMap, setItemTypeMap] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const lastRefreshAt = useRef<number>(0);
const inFlight = useRef<Promise<void> | null>(null);
const fetchData = useCallback(
async (opts?: { withSync?: boolean }): Promise<void> => {
// 동시 호출 방지 (같은 fetch가 이미 진행 중이면 그 Promise를 공유)
if (inFlight.current) return inFlight.current;
const task = (async () => {
setLoading(true);
if (opts?.withSync) setSyncing(true);
try {
if (opts?.withSync) {
try {
await apiClient.post("/pop/production/sync-work-instructions");
} catch {
toast.warning(
"동기화 실패 — 최신 작업지시가 반영되지 않을 수 있습니다",
);
}
}
const [wiRes, procRes, pmRes, eqRes] = await Promise.all([
apiClient.get("/work-instruction/list"),
apiClient.get("/pop/production/processes"),
dataApi.getTableData("process_mng", { size: 500 }),
dataApi.getTableData("equipment_mng", { size: 500 }),
]);
// work-instruction: header+detail 조인이라 id 중복 → 첫 행만 취함
let wiRaw: Record<string, unknown>[] = [];
if (wiRes.data?.data) {
wiRaw = Array.isArray(wiRes.data.data)
? wiRes.data.data
: wiRes.data.data.rows || [];
} else if (Array.isArray(wiRes.data)) {
wiRaw = wiRes.data;
}
const seen = new Set<string>();
const wiData: WorkInstruction[] = [];
const newItemNameMap: Record<string, string> = {};
const newItemTypeMap: Record<string, string> = {};
for (const raw of wiRaw) {
const wiId = String(raw.wi_id || raw.id || "");
const rawItemNumber = String(raw.item_number || "");
const rawItemName = String(raw.item_name || "");
const rawItemType = String(raw.item_type || "");
if (rawItemNumber && rawItemName) {
newItemNameMap[rawItemNumber] = rawItemName;
}
if (rawItemNumber && rawItemType) {
newItemTypeMap[rawItemNumber] = rawItemType;
}
if (!wiId || seen.has(wiId)) continue;
seen.add(wiId);
wiData.push({
...raw,
id: wiId,
item_name: rawItemName,
item_code: String(raw.item_code || ""),
item_number: rawItemNumber,
qty: parseInt(String(raw.total_qty || raw.qty || 0), 10),
} as unknown as WorkInstruction);
}
setInstructions(wiData);
setItemNameMap(newItemNameMap);
setItemTypeMap(newItemTypeMap);
const rawRows: WorkOrderProcessRaw[] = procRes.data?.data ?? [];
// 마스터(wop) + accepted_results 평탄화: 신 구조 접수 카드 = wop_result 항목을
// 기존 UI 의 "분할 카드(parent_process_id 있음)" 형태로 펼쳐서 기존 필터 로직과
// 호환 유지. virtual row 의 id = wop_result.id (라우팅/API body 식별자)
const flat: WorkOrderProcessView[] = [];
for (const raw of rawRows) {
const master = normalizeWorkOrderProcess(raw);
flat.push(master);
for (const ar of master.accepted_results) {
flat.push({
...master,
// 접수 카드 → virtual split row
id: ar.id, // wop_result.id
parent_process_id: master.id, // 마스터(wop.id) 참조
status: ar.status,
result_status: ar.result_status,
input_qty: ar.input_qty,
good_qty: ar.good_qty,
defect_qty: ar.defect_qty,
concession_qty: ar.concession_qty,
total_production_qty: ar.total_production_qty,
is_rework: ar.is_rework,
rework_source_id: ar.rework_source_id,
accepted_by: ar.accepted_by,
accepted_at: ar.accepted_at,
started_at: ar.started_at,
completed_at: ar.completed_at,
equipment_code: ar.equipment_code,
batch_id: ar.batch_id ?? master.batch_id,
// split 표시용 (접수 #n)
split_no: ar.seq,
split_total: master.accepted_results.length,
// 분할 카드 자체의 accepted_results 는 의미 없음 (재귀 금지)
accepted_results: [],
});
}
}
setAllProcesses(flat);
setProcessList((pmRes.data ?? []) as ProcessMng[]);
setEquipmentList((eqRes.data ?? []) as EquipmentMng[]);
} catch {
toast.error("데이터 조회 실패");
} finally {
setLoading(false);
setSyncing(false);
}
})();
inFlight.current = task;
try {
await task;
} finally {
inFlight.current = null;
}
},
[],
);
// 진입 시 1회만 sync + 로드
useEffect(() => {
fetchData({ withSync: true });
// fetchData는 stable — 의존성에 넣으면 useCallback deps 변화 없어도 무관하나,
// 명시적으로 빈 배열로 두어 "마운트 시 정확히 1회"를 강제
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** 수동 새로고침 — sync 포함, 3초 쿨다운 */
const refresh = useCallback(() => {
const now = Date.now();
if (now - lastRefreshAt.current < REFRESH_COOLDOWN_MS) return;
lastRefreshAt.current = now;
fetchData({ withSync: true });
}, [fetchData]);
/** Mutation(접수/실적/취소) 직후 — sync 없이 데이터만 재조회 */
const refetch = useCallback(() => {
fetchData({ withSync: false });
}, [fetchData]);
return {
instructions,
allProcesses,
processList,
equipmentList,
itemNameMap,
itemTypeMap,
loading,
syncing,
refresh,
refetch,
};
}
@@ -0,0 +1,28 @@
"use client";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { InboundCartPage } from "../../_components/inbound/InboundCartPage";
function InboundCartContent() {
const searchParams = useSearchParams();
const companyPath = usePopCompanyPath();
const backUrl = searchParams.get("backUrl") || companyPath("/pop/inbound");
return <InboundCartPage backUrl={backUrl} />;
}
export default function InboundCartRoute() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
...
</div>
}
>
<InboundCartContent />
</Suspense>
);
}
@@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { ChangeInbound } from "../../_components/inbound/ChangeInbound";
import { useCartSync } from "../../_components/common/useCartSync";
export default function ChangeInboundPage() {
const router = useRouter();
const companyPath = usePopCompanyPath();
const cart = useCartSync("inbound");
const [saving, setSaving] = useState(false);
const handleCartClick = async () => {
if (cart.isDirty) {
setSaving(true);
const ok = await cart.saveToDb();
setSaving(false);
if (!ok) return;
}
router.push(`${companyPath("/pop/inbound/cart")}?backUrl=${companyPath("/pop/inbound/change")}`);
};
return (
<ChangeInbound
cart={cart}
onCartClick={handleCartClick}
saving={saving}
inboundType="교환입고"
sourceTable="change_detail"
/>
);
}
@@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { ErrorInbound } from "../../_components/inbound/ErrorInbound";
import { useCartSync } from "../../_components/common/useCartSync";
export default function ErrorInboundPage() {
const router = useRouter();
const companyPath = usePopCompanyPath();
const cart = useCartSync("inbound");
const [saving, setSaving] = useState(false);
const handleCartClick = async () => {
if (cart.isDirty) {
setSaving(true);
const ok = await cart.saveToDb();
setSaving(false);
if (!ok) return;
}
router.push(`${companyPath("/pop/inbound/cart")}?backUrl=${companyPath("/pop/inbound/error")}`);
};
return (
<ErrorInbound
cart={cart}
onCartClick={handleCartClick}
saving={saving}
inboundType="불량입고"
sourceTable="error_detail"
/>
);
}
@@ -0,0 +1,7 @@
"use client";
import { InboundManage } from "../../_components/inbound/InboundManage";
export default function InboundManagePage() {
return <InboundManage />;
}
@@ -0,0 +1,440 @@
"use client";
import React, { useState, useRef, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface InboundMenuItem {
id: string;
title: string;
gradient: string;
shadowColor: string;
icon: React.ReactNode;
href: string;
}
interface RecentInboundItem {
id: string;
time: string;
type: string;
itemName: string;
qty: string;
supplier: string;
statusColor: string;
statusLabel: string;
}
/* ------------------------------------------------------------------ */
/* Static Data (UI만) */
/* ------------------------------------------------------------------ */
const EXTERNAL_ITEMS: InboundMenuItem[] = [
{
id: "purchase",
title: "구매입고",
gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)",
shadowColor: "rgba(59,130,246,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
href: "/pop/inbound/purchase",
},
{
id: "outsourcing",
title: "외주입고",
gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
shadowColor: "rgba(139,92,246,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
),
href: "/pop/inbound/subcontractor",
},
{
id: "return",
title: "반품입고",
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
shadowColor: "rgba(245,158,11,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" />
</svg>
),
href: "/pop/inbound/return-external",
},
{
id: "supplied-material",
title: "사급자재",
gradient: "linear-gradient(135deg,#06b6d4,#0e7490)",
shadowColor: "rgba(6,182,212,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
),
href: "/pop/inbound/supplied",
},
{
id: "defect",
title: "불량입고",
gradient: "linear-gradient(135deg,#ef4444,#b91c1c)",
shadowColor: "rgba(239,68,68,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
),
href: "/pop/inbound/error",
},
{
id: "outsource-return",
title: "외주자재회수",
gradient: "linear-gradient(135deg,#ec4899,#be185d)",
shadowColor: "rgba(236,72,153,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />
</svg>
),
href: "/pop/inbound/recovery",
},
{
id: "exchange",
title: "교환입고",
gradient: "linear-gradient(135deg,#14b8a6,#0f766e)",
shadowColor: "rgba(20,184,166,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
),
href: "/pop/inbound/change",
},
];
const INTERNAL_ITEMS: InboundMenuItem[] = [
{
id: "production",
title: "생산입고",
gradient: "linear-gradient(135deg,#22c55e,#15803d)",
shadowColor: "rgba(34,197,94,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
href: "/pop/inbound/production",
},
{
id: "return-internal",
title: "반납입고",
gradient: "linear-gradient(135deg,#f97316,#c2410c)",
shadowColor: "rgba(249,115,22,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
</svg>
),
href: "/pop/inbound/return-internal",
},
{
id: "transfer",
title: "재고이동",
gradient: "linear-gradient(135deg,#64748b,#334155)",
shadowColor: "rgba(100,116,139,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 7.5h-.75A2.25 2.25 0 004.5 9.75v7.5a2.25 2.25 0 002.25 2.25h7.5a2.25 2.25 0 002.25-2.25v-7.5a2.25 2.25 0 00-2.25-2.25h-.75m0-3l-3-3m0 0l-3 3m3-3v11.25m6-2.25h.75a2.25 2.25 0 012.25 2.25v7.5a2.25 2.25 0 01-2.25 2.25h-7.5a2.25 2.25 0 01-2.25-2.25v-7.5a2.25 2.25 0 012.25-2.25H12m-3 0V5.25" />
</svg>
),
href: "#",
},
{
id: "inbound-manage",
title: "입고관리",
gradient: "linear-gradient(135deg,#3b82f6,#1e40af)",
shadowColor: "rgba(59,130,246,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
),
href: "/pop/inbound/inbound-manage",
},
];
/* 최근 입고 정적 목업 데이터 */
const RECENT_ITEMS: RecentInboundItem[] = [
{
id: "1",
time: "10:14",
type: "구매입고",
itemName: "데모제일 20L (3공정 시연용)",
qty: "20 EA",
supplier: "거래처테스트(발주처)",
statusColor: "text-green-600 bg-green-50",
statusLabel: "완료",
},
{
id: "2",
time: "09:52",
type: "구매입고",
itemName: "용주N_CTG28_반투명",
qty: "10 EA",
supplier: "거래처테스트(발주처)",
statusColor: "text-gray-600 bg-gray-50",
statusLabel: "대기",
},
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
function InboundTypeSelect() {
const router = useRouter();
const companyPath = usePopCompanyPath();
/* KPI carousel */
const [kpiIdx, setKpiIdx] = useState(1);
const kpiTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startKpiAuto = useCallback(() => {
if (kpiTimerRef.current) clearInterval(kpiTimerRef.current);
kpiTimerRef.current = setInterval(() => setKpiIdx((p) => (p + 1) % 3), 4000);
}, []);
useEffect(() => {
startKpiAuto();
return () => {
if (kpiTimerRef.current) clearInterval(kpiTimerRef.current);
};
}, [startKpiAuto]);
const handleMenuClick = (item: InboundMenuItem) => {
if (item.href === "#") {
alert(`${item.title} 화면은 준비 중입니다.`);
} else {
router.push(companyPath(item.href));
}
};
return (
<div className="flex flex-col gap-5">
{/* ===== Back + Title ===== */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push(companyPath("/pop/main"))}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* ===== KPI Carousel ===== */}
<div className="relative overflow-hidden">
<div
className="flex select-none"
style={{
transform: `translateX(-${kpiIdx * 100}%)`,
transition: "transform 0.4s cubic-bezier(.25,.46,.45,.94)",
}}
>
{/* Slide 1 — 금일 입고 현황 */}
<div className="min-w-full shrink-0">
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
<div className="grid grid-cols-3 gap-0">
<KpiCell value="0" label="금일 입고" color="text-blue-600" />
<KpiCell value="0" label="검수 대기" color="text-amber-600" />
<KpiCell value="0" label="완료" color="text-green-600" />
</div>
</div>
</div>
{/* Slide 2 — 유형별 건수 */}
<div className="min-w-full shrink-0">
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
<div className="grid grid-cols-3 gap-0">
<KpiCell value="0" label="완료" color="text-blue-600" />
<KpiCell value="0" label="구매입고" color="text-purple-600" />
<KpiCell value="0" label="외주입고" color="text-gray-600" />
</div>
</div>
</div>
{/* Slide 3 — 수량/품질 */}
<div className="min-w-full shrink-0">
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
<div className="grid grid-cols-3 gap-0">
<KpiCell value="0" label="금일 수량" color="text-blue-600" unit="EA" />
<KpiCell value="0" label="합격률" color="text-green-600" unit="%" />
<KpiCell value="0" label="불량" color="text-red-600" unit="건" />
</div>
</div>
</div>
</div>
{/* Dots */}
<div className="flex justify-center gap-2 mt-3">
{[0, 1, 2].map((idx) => (
<button
key={idx}
onClick={() => {
setKpiIdx(idx);
startKpiAuto();
}}
className="border-none p-0 transition-all duration-300 cursor-pointer"
style={{
width: kpiIdx === idx ? 24 : 8,
height: 8,
borderRadius: kpiIdx === idx ? 4 : "50%",
background: kpiIdx === idx ? "#3b82f6" : "#D1D5DB",
}}
aria-label={`KPI 슬라이드 ${idx + 1}`}
/>
))}
</div>
</div>
{/* ===== External Inbound ===== */}
<section>
<div className="flex items-center gap-2 mb-3">
<div className="w-1 h-5 rounded-full bg-blue-500" />
<h2 className="text-base sm:text-lg font-bold text-gray-900"> </h2>
</div>
<div className="flex flex-wrap justify-start gap-x-5 gap-y-4 sm:gap-x-6 sm:gap-y-5">
{EXTERNAL_ITEMS.map((item) => (
<MenuIcon key={item.id} item={item} onClick={handleMenuClick} />
))}
</div>
</section>
{/* ===== Internal Inbound ===== */}
<section>
<div className="flex items-center gap-2 mb-3">
<div className="w-1 h-5 rounded-full bg-green-500" />
<h2 className="text-base sm:text-lg font-bold text-gray-900"> </h2>
</div>
<div className="flex flex-wrap justify-start gap-x-5 gap-y-4 sm:gap-x-6 sm:gap-y-5">
{INTERNAL_ITEMS.map((item) => (
<MenuIcon key={item.id} item={item} onClick={handleMenuClick} />
))}
</div>
</section>
{/* ===== Recent Inbound ===== */}
<section>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-100">
<h3 className="text-base sm:text-lg font-bold text-gray-900"> </h3>
<span className="text-xs text-gray-400"> 5</span>
</div>
<div className="flex flex-col gap-2">
{RECENT_ITEMS.map((item) => (
<div
key={item.id}
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors"
>
<span
className="text-xs font-semibold text-gray-400 min-w-[44px] text-right"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{item.time}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900 truncate">{item.itemName}</span>
<span
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full shrink-0 ${item.statusColor}`}
>
{item.statusLabel}
</span>
</div>
<div className="text-xs text-gray-400 mt-0.5 truncate">
{item.type} | {item.supplier} | {item.qty}
</div>
</div>
</div>
))}
</div>
</div>
</section>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
function KpiCell({
value,
label,
color,
unit,
}: {
value: string;
label: string;
color: string;
unit?: string;
}) {
return (
<div className="flex flex-col items-center py-2">
<div className="flex items-end gap-0.5">
<span
className={`text-2xl sm:text-3xl font-extrabold leading-none ${color}`}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.02em" }}
>
{value}
</span>
{unit && <span className="text-xs text-gray-400 mb-0.5">{unit}</span>}
</div>
<span className="text-[11px] font-medium text-gray-400 mt-1">{label}</span>
</div>
);
}
function MenuIcon({
item,
onClick,
}: {
item: InboundMenuItem;
onClick: (item: InboundMenuItem) => void;
}) {
return (
<div
className="flex flex-col items-center gap-2 w-16 sm:w-[72px] cursor-pointer group"
style={{ WebkitTapHighlightColor: "transparent" }}
onClick={() => onClick(item)}
>
<div
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center transition-transform duration-150 group-hover:scale-105 group-active:scale-[0.93]"
style={{ background: item.gradient, boxShadow: `0 4px 12px ${item.shadowColor}` }}
>
{item.icon}
</div>
<span className="text-[11px] sm:text-xs font-semibold text-gray-700 text-center leading-tight">
{item.title}
</span>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Page */
/* ------------------------------------------------------------------ */
export default function InboundPage() {
return <InboundTypeSelect />;
}
@@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { ProductionInbound } from "../../_components/inbound/ProductionInbound";
import { useCartSync } from "../../_components/common/useCartSync";
export default function ProductionInboundPage() {
const router = useRouter();
const companyPath = usePopCompanyPath();
const cart = useCartSync("inbound");
const [saving, setSaving] = useState(false);
const handleCartClick = async () => {
if (cart.isDirty) {
setSaving(true);
const ok = await cart.saveToDb();
setSaving(false);
if (!ok) return;
}
router.push(`${companyPath("/pop/inbound/cart")}?backUrl=${companyPath("/pop/inbound/production")}`);
};
return (
<ProductionInbound
cart={cart}
onCartClick={handleCartClick}
saving={saving}
inboundType="생산입고"
sourceTable="production_detail"
/>
);
}

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