Compare commits

...

90 Commits

Author SHA1 Message Date
kjs 9cbf0c6868 Merge pull request 'feat: COMPANY_29 하드코딩 페이지 추가' (#431) from jskim-node into main 2026-03-29 13:42:00 +09:00
kjs 3e935792d4 feat: COMPANY_29 하드코딩 페이지 추가 (설계/영업/기준정보/생산 27파일) 2026-03-29 13:41:46 +09:00
kjs de440f8d42 Merge pull request 'feat: COMPANY_9 수주관리 페이지 추가 및 생산계획/공정 개선' (#430) from jskim-node into main 2026-03-27 22:35:16 +09:00
kjs 2e9b67a509 feat: COMPANY_9 수주관리 페이지 추가 및 생산계획/공정 개선
- COMPANY_9 수주관리(sales/order) 하드코딩 페이지 추가
- 생산계획 서비스 로직 정리 및 공정 컨트롤러 수정
- COMPANY_7 생산계획관리 페이지 개선
- AdminPageRenderer 기능 확장
2026-03-27 22:32:18 +09:00
SeongHyun Kim 6fe7bfbefc feat: pop-card-list 업그레이드 — cart-outbound 삭제 + 프리셋 + 검사 연동 + UI 수정
- pop-cart-outbound 컴포넌트 완전 삭제 (4개 화면 pop-card-list로 교체 완료)
- 레지스트리/팔레트/타입에서 cart-outbound 참조 제거
- PopRenderer 뷰모드 label prop 제거 (컴포넌트 라벨 노출 버그 수정)
- dataFetcher SUM/AVG varchar CAST 처리 (Dashboard 500 수정)
- PopCardListConfig 장바구니 목록모드 섹션 프리셋 사용 시 숨김
- types.ts에 CardListPresetMode, CardListInspectionConfig 타입 추가
2026-03-27 18:11:08 +09:00
SeongHyun Kim eacfe60f89 WIP: preset + inspection (임시, 나중에 squash) 2026-03-27 17:05:36 +09:00
kjs f32861df8b feat: add new design management pages and session files
- Introduced multiple new pages for design management, including change management, design requests, my work, project management, and task management.
- Added session files to track design sessions with relevant details such as session ID, end time, and reason.
- Enhanced the overall structure and organization of the design management features, improving user experience and functionality.

This commit expands the design management capabilities within the application, allowing for better tracking and handling of design-related tasks.
2026-03-27 14:48:15 +09:00
SeongHyun Kim f10946ae5b fix: screen_layouts_pop 이중 감싸기(double-wrap) 자동 해제
getLayoutPop/saveLayoutPop에서 layout_data가 이중으로 감싸진 경우
(layout_data.layout_data.components) 자동 감지하여 내부 데이터를 추출.
13개 신규 POP 화면이 빈 화면으로 표시되던 문제 해결.
2026-03-27 13:44:45 +09:00
SeongHyun Kim 0aef19578a Merge remote-tracking branch 'origin/main' 2026-03-27 10:56:31 +09:00
SeongHyun Kim 590ae8fbb7 fix: pop-work-detail 버튼/입력 크기 원복 (design-v2-final)
- DESIGN.button.height: 56 → 60 원복
- DESIGN.input.height: 56 → 52 원복
- GlossyButton default minHeight: 56 → 60, fontSize: 20 → 18
- 타이머 버튼(시작/일시정지/재개/완료): minHeight 56 → 48, fontSize 20 → 16 원복
- GroupCompleteButton: minHeight 56 → 64, fontSize 20 → 18 원복
- InspectNumericInput 저장버튼: minHeight 56 → 48, fontSize 20 → 16
- InspectNumericInput 입력필드: height 56 → 48
- InspectSelectInput 버튼: minHeight 56 → 52, fontSize 20 → 16
- InspectTextInput 입력/버튼: height 56 → 48, fontSize 20 → 15
- MaterialInputOnly 입력/버튼: height 56 → 48, fontSize 20 → 15
- ResultInputOnly 입력/버튼: height 56 → 44, fontSize 20 → 15
- CheckInputOnly 체크박스: h-10 w-10 → h-8 w-8
- ProcedureInputOnly 체크박스: h-10 w-10 → h-5 w-5
- InputOnlyItem 입력필드: height 56 → 48
- O/X 버튼(InspectOXInput): 56px, 20px 유지 (변경 없음)
2026-03-26 18:09:43 +09:00
SeongHyun Kim 5cad4ed7fd feat(pop-work-detail): 모든 그룹 작업완료 버튼 항상 표시 + 타이머 완료 버튼 추가
- GroupCompleteButton: !isGroupCompleted 조건 제거 → 모든 그룹에서 항상 표시
  - 이미 완료된 그룹에서는 "작업완료됨" 완료 표시 렌더링
  - isGroupCompleted prop 추가
- GroupTimerHeader: isGroupStarted 상태(진행 중/일시정지)에 완료 버튼 추가
  - GlossyButton green variant, onTimerAction("complete") 호출
2026-03-26 17:43:15 +09:00
SeongHyun Kim f471ce245a feat(pop-work-detail): 작업완료 후 다음 그룹 자동 이동 + 사이드바 숫자 구조 변경
- GroupCompleteButton onComplete: 그룹 완료 처리 후 다음 그룹(같은 phase 또는 다음 phase)으로 자동 이동
- 사이드바 phase 라벨 옆 숫자: 완료/전체 체크리스트 수 → 그룹 개수 (n) 형태로 변경
- 사이드바 각 그룹 항목 옆에 해당 그룹의 체크리스트 완료/전체 수 표시 (g.completed/g.total)
2026-03-26 17:39:42 +09:00
SeongHyun Kim a29691c31e design: 체크리스트 버튼 우측 정렬 + 타이머 최상단 고정 및 크기 확대
- ChecklistRowItem 우측 입력 영역을 justify-center → justify-end로 변경
- 리스트 모드에서 GroupTimerHeader를 스크롤 컨테이너 바깥(최상단)으로 이동
- 타이머 숫자 폰트 26px → 38px, 경과 시간 폰트 14px → 16px
- GroupTimerHeader의 sticky 제거 (외부 배치로 자연스럽게 상단 고정)
2026-03-26 17:33:07 +09:00
SeongHyun Kim 3249611cfc pop-work-detail: 디자인 v2 전면 개편
- 글로시/입체감 버튼 스타일 (GlossyButton 컴포넌트 추가)
- 체크리스트 좌정보/우입력 분할 레이아웃 (여백 최소화)
- 타이머 sticky 고정 + 시작/일시정지/재개 전환 토글
- 풋터 3버튼 제거 → 각 그룹 하단에 작업완료 버튼 배치
- 필수 항목 미체크 시 다음 공정 탭 전환 차단
- 전체 글자 크기 확대 (버튼 18px+, 항목명 15px, 타이머 26px)
- 배경 흰색 유지
2026-03-26 17:25:57 +09:00
SeongHyun Kim 1128a4c278 fix: 대시보드 KPI 글자 크기 반응형 + globals.css !important 예외 처리
ResizeObserver로 컨테이너 크기에 비례하는 동적 폰트 크기 적용.
globals.css의 font-size: 16px !important에서 kpi-dynamic-font 클래스 제외.
2026-03-26 17:05:11 +09:00
kjs 5da134f016 feat: add web crawling management functionality
- Introduced a new crawling management feature allowing users to configure, execute, and log web crawls.
- Added CRUD operations for crawl configurations, including URL analysis and preview capabilities.
- Implemented a new service for handling crawling logic and scheduling tasks.
- Integrated cheerio for HTML parsing and axios for HTTP requests.
- Created a sample HTML page for testing crawling functionality.

This commit enhances the application's data collection capabilities from external websites.
2026-03-26 16:30:53 +09:00
SeongHyun Kim cda7e7bbfe feat: KPI 카드 글자 크기를 컨테이너 크기에 비례하도록 개선
ResizeObserver로 실제 컨테이너 픽셀 크기를 감지하여
숫자·라벨·단위 폰트 크기를 동적으로 계산한다.
기존 고정 @container Tailwind 브레이크포인트 방식 대체.
- 숫자: 컨테이너 높이의 42~62% (표시 요소 수에 따라 조정)
- 너비 기준 35% 캡으로 가로로 매우 넓은 셀도 적절히 제한
- 라벨: 높이의 13%, 단위: 숫자의 40%, 추세: 높이의 9%
- valueFontSize(xs/sm/base/lg/xl)는 전체 배율로 계속 동작
2026-03-26 15:03:47 +09:00
SeongHyun Kim 761100a176 feat(pop-dashboard): KPI 카드 글자 크기 제어 및 단위 표시 개선
- ItemStyleConfig에 valueFontSize 옵션 추가 (xs/sm/base/lg/xl)
- KpiCard: valueFontSize에 따라 숫자/단위/라벨 크기 일괄 조정
- 단위 표시: kpiConfig.unit 우선, item.unit fallback 지원 (레거시 호환)
- DashboardItem 타입에 unit, subLabel 최상위 필드 추가 (기존 데이터 호환)
- 4479 화면 config 업데이트: valueFontSize=xl, kpiConfig.unit 설정
2026-03-26 14:43:47 +09:00
SeongHyun Kim bf42f27440 chore: pop-icon.tsx 디버그 console.log 2줄 제거 2026-03-26 14:06:47 +09:00
kjs 07777e314b fix: 분할패널 V2 설정에서 컬럼 선택 기능 복원
- 추가 탭에 PanelColumnSection 컬럼 선택 기능 추가
- 우측 패널 custom 모드에서도 컬럼 설정 표시되도록 조건 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:04:51 +09:00
kjs 86f9040e40 Merge pull request '배포: v2-split-panel-layout 컴포넌트 개선' (#429) from jskim-node into main 2026-03-26 12:13:20 +09:00
kjs 0a6922edeb feat: enhance v2-split-panel-layout component
SplitPanelLayoutComponent, ConfigPanel, types 개선

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:12:56 +09:00
kjs f9e243d439 Merge pull request 'jskim-node' (#428) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/428
2026-03-26 09:31:19 +09:00
kjs 8c80c854cc Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-26 09:30:58 +09:00
kjs 1bf91bf043 Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-26 09:30:17 +09:00
kjs 348da95823 Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-25 18:48:37 +09:00
kjs 6de31eb55a Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-25 18:47:51 +09:00
kjs 70e040db39 Enhance user management and token invalidation features
- Added comprehensive validation for user data during registration and updates, including email format, company code existence, user type validation, and password length checks.
- Implemented JWT token invalidation for users when their status changes or when roles are updated, ensuring security and compliance with the latest policies.
- Introduced a new TokenInvalidationService to manage token versioning and invalidation processes efficiently.
- Updated the admin controller to provide detailed error messages and success responses for user status changes and validations.
- Enhanced the authentication middleware to check token versions against the database, ensuring that invalidated tokens cannot be used.

This commit improves the overall security and user management experience within the application.
2026-03-25 18:47:50 +09:00
kmh 02ac36c94f Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node 2026-03-25 18:39:07 +09:00
SeongHyun Kim 8db6b4984b feat: pop-work-detail에 PLC 데이터 섹션 추가
- types.ts: PlcDataConfig 인터페이스 추가, ResultSectionConfig에 plcConfig 필드 추가
- PopWorkDetailConfig.tsx: PLC 데이터 설정 패널 구현
  - 외부 DB 연결 선택 -> 테이블 -> 컬럼 순차 선택
  - 디바이스/태그 유니크값 자동 조회
  - 표시 설정 (라벨, 단위, 갱신주기, 표시방식)
  - 매핑 저장 설정 (대상 테이블/컬럼, 모드)
- PopWorkDetailComponent.tsx: PLC 데이터 런타임 표시 구현
  - 외부 DB에서 값 주기적 폴링
  - 큰 숫자/게이지 표시 + PLC 자동 배지
  - 수동 입력 fallback 제공
  - 매핑 저장 ON 시 값 변경 시 대상 테이블에 저장
2026-03-25 17:26:51 +09:00
SeongHyun Kim 49da393f17 fix: PopFieldComponent preview fetch에 json_extract_lookup 처리 추가
컴포넌트 마운트 시 cart_items.row_data에서 코드값을 추출한 후
참조 테이블에서 표시값을 조회하는 json_extract_lookup을
preview fetch에도 추가한다.
2026-03-25 16:08:32 +09:00
SeongHyun Kim dd3b226917 feat(pop-profile): 앱 모드(풀스크린) 메뉴 항목 추가
POP 프로필 팝오버에 "앱 모드" 메뉴를 추가한다.
Fullscreen API로 브라우저를 전체화면 전환하여 앱처럼 사용 가능.
- showAppMode 설정 옵션 추가 (기본 활성화)
- 풀스크린 진입: document.documentElement.requestFullscreen()
- 풀스크린 해제: document.exitFullscreen()
- 상태에 따라 아이콘/텍스트 전환 (Maximize2/Minimize2, 앱 모드/앱 모드 해제)
- fullscreenchange 이벤트 리스너로 상태 동기화
- 디자이너 설정 패널에 "앱 모드 (풀스크린)" 토글 추가
2026-03-25 15:54:26 +09:00
kjs 0852361e92 Merge pull request 'jskim-node' (#427) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/427
2026-03-25 15:19:14 +09:00
kjs 8fdbbb7f41 Merge branch 'main' into jskim-node 2026-03-25 15:19:05 +09:00
kjs 782ebb1b33 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-25 15:18:40 +09:00
kjs df6c479589 Update project memory and enhance table settings functionality
- Updated project memory configuration to reflect recent access counts and timestamps for various components.
- Modified SQL queries in the processInfoController to utilize the correct equipment management table for improved data retrieval.
- Enhanced the TableManagementService to automatically fill display columns for entity types during both creation and update processes.
- Introduced new TableSettingsModal components across multiple pages for better user control over table configurations.
- Improved the DynamicSearchFilter component to accept external filter configurations, enhancing the filtering capabilities for various data grids.
2026-03-25 15:18:38 +09:00
SeongHyun Kim 6262ddb76b feat(pop-work-detail): combined-final 디자인 시안 적용 — 사이드바 레이아웃
확정된 combined-final.html 시안을 기반으로 PopWorkDetailComponent 전면 리디자인:
레이아웃 구조 변경:
- 탭 바 → 좌측 사이드바(w-52) + 우측 콘텐츠 영역 구조로 전환
- 모달 헤더: "작업 상세" + 작업지시번호 + 닫기(X) 버튼
- 정보바: #1a1a2e 다크 배경, gap-8 간격, label(white/40) + value(white)
- 사이드바: 페이즈별 그룹 (아이콘 + 상태색 + 진행률), 실적 그룹 포함
- KPI 카드: 콘텐츠와 함께 스크롤 (44px/800weight, tabular-nums, divider)
- 풋터: 48px 높이 3버튼 (border-2 + rounded-xl)
스크롤 구조:
- 사이드바: overflow-y-auto (자체 스크롤, thin scrollbar)
- 콘텐츠: overflow-y-auto (KPI + 체크리스트 + 이력 함께 스크롤)
- 정보바 + 풋터: flex-shrink-0 (고정)
스타일 변경:
- DESIGN 토큰: kpi.valueSize 40→44, weight 700→800, section.titleSize 16→13
- 섹션 제목: text-xs uppercase tracking-widest text-gray-400
- 실적 입력: native input + rounded-xl, 2xl font, color-coded borders
- 불량 유형: 카드형 UI (rounded-xl border, severity badge)
- 등록 이력: table 형태 (차수/생산수량/양품/불량/누적/시각)
- GroupTimerHeader: px-8 간격, 미니멀 스타일
기능 유지:
- step 모드 + list 모드 모두 사이드바 구조 안에서 정상 동작
- 체크리스트 입력/저장, 그룹 타이머, 실적 등록/이력 모두 보존
- 2단계 확인 (작업완료), 불량등록 연동 유지
2026-03-25 15:01:53 +09:00
kmh e67e43cd7d feat: update shipping-plan page and FieldDetailSettingsModal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:36:57 +09:00
kmh 48af85c713 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node 2026-03-25 14:36:47 +09:00
kjs d674d88d1e Merge pull request 'jskim-node' (#426) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/426
2026-03-25 11:45:36 +09:00
kjs bc186111ef Merge branch 'main' into jskim-node 2026-03-25 11:45:27 +09:00
kjs 69c5a78753 Enhance packaging and department management features
- Updated SQL queries in the packaging controller to include item details and packaging unit information through LEFT JOINs for improved data retrieval.
- Enhanced the packaging page with a new search input for real-time item matching, allowing users to search by item code or name dynamically.
- Refactored department management to use a more consistent state management approach, including renaming state variables for clarity and updating the user modal functionality for better user experience.
- Improved the overall layout and responsiveness of the packaging and department pages, ensuring a more user-friendly interface.
2026-03-25 11:44:49 +09:00
SeongHyun Kim bb6e17ec28 refactor(pop-work-detail): 사이드바→탭 전환, KPI 카드, 풋터 액션바 도입
리서치 반영 UX 구조 대폭 변경:
- 좌측 사이드바(220px) → 상단 탭 바(48px)로 전환 (작업 전|작업 중|작업 후|실적)
- 탭마다 진행률 표시 (예: "작업 전 2/5")
- 고정 헤더: 진한 배경(#263238) + 흰색 텍스트로 작업 정보 표시
- KPI 카드 상시 표시: 접수량/작업완료/잔여/불량 (40px bold, 색상 구분)
- 고정 풋터 액션바: 일시정지(황)/불량등록(적)/작업완료(녹) 3버튼
- 작업완료 2단계 확인 (클릭 → "정말 완료?" 확인)
- 배경색 #F5F5F5 (공장 조명 눈부심 방지)
- 체크리스트 행: 좌측 상태 바 (완료=녹, 필수=적, 기본=회)
- ChecklistRowItem 래퍼로 행 전체 터치 영역 + 시각 피드백
- DESIGN 토큰 확장: tab, footer, header, kpi, bg 추가
- COLORS 확장: kpiInput, kpiComplete, kpiRemaining, kpiDefect 추가
- step 모드와 list 모드 모두 탭 구조 안에서 정상 동작
2026-03-25 11:30:54 +09:00
kjs d5650c5797 Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-25 11:03:41 +09:00
kjs 0775a45606 Enhance packaging page with popover and command components
- Integrated Popover and Command components for improved item selection in the packaging page.
- Replaced the previous search input with a more dynamic and user-friendly popover interface for selecting packaging items.
- Updated state management to handle popover visibility and item selection more effectively.
- Enhanced user experience by providing real-time search capabilities within the popover for packaging items.
2026-03-25 11:03:11 +09:00
SeongHyun Kim 525237d42d style(pop-work-detail): ISA-101 디자인 토큰 도입 및 크기 수정
- DESIGN 상수 도입 (button, input, stat, section, sidebar, nav, infoBar, defectRow)
- COLORS 상수 도입 (good, defect, complete, warning, info)
- 버튼 높이: 44px → 56px (ISA-101 장갑 터치 기준)
- 핵심 숫자(수량/실적): 18px → 36px (2-3m 가독성)
- 섹션 제목/라벨: 14px → 16px (1m 가독성)
- 입력 필드 높이: 44px → 56px
- 불량 유형 행 높이: 40px → 56px
- 사이드바 너비: 208px → 220px, 항목 패딩 확대
- 기능 변경 없음 (디자인만 수정)
2026-03-25 10:57:29 +09:00
wace 86e64492cf Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node 2026-03-25 10:49:50 +09:00
kjs e2f18b19bc Implement outbound management features with new routes and controller
- Added outbound management routes for listing, creating, updating, and deleting outbound records.
- Introduced a new outbound controller to handle business logic for outbound operations, including inventory updates and source data retrieval.
- Enhanced the application by integrating outbound management functionalities into the existing logistics module.
- Improved user experience with responsive design and real-time data handling for outbound operations.
2026-03-25 10:48:47 +09:00
wace 7c5e9b0c46 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node 2026-03-25 10:08:37 +09:00
wace 0fd0a43370 feat: enhance v2 components and entity join functionality
- Add entity join controller/routes enhancements
- Improve table management and category value services
- Update v2 table list, card display, search widget components
- Improve split panel layout responsiveness

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:54:18 +09:00
SeongHyun Kim b677840952 chore: Playwright 테스트 파일을 관제탑(My-agent)으로 이관
테스트 파일은 ERP-node 프로젝트가 아닌 My-agent에서 관리한다.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:23:55 +09:00
SeongHyun Kim 2da1532e65 chore: .gitignore에 Playwright 테스트 파일 제외
테스트 파일은 My-agent 관제탑에서 별도 관리한다.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:23:14 +09:00
SeongHyun Kim e8fe077369 chore: .gitignore에 test-results, .cursor 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:16:24 +09:00
SeongHyun Kim bda77ef844 chore: Playwright 테스트 설정 및 E2E 테스트 추가
pop-cart-outbound 브라우저 테스트 시나리오 10개 포함.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:16:24 +09:00
SeongHyun Kim a8bb72050f Merge branch 'feature/fix-4579-customer-name' into ksh-cart-outbound-test 2026-03-24 19:14:42 +09:00
SeongHyun Kim 9afe98ec60 feat: pop-field에 json_extract_lookup valueSource 추가
cart_items.row_data에서 코드값(partner_id)을 추출한 후
참조 테이블(customer_mng)에서 표시값(customer_name)을 조회하는
json_extract_lookup 기능을 추가한다.
화면 4579(출고 확정)에서 거래처명이 표시되지 않는 문제를 해결한다.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:14:34 +09:00
SeongHyun Kim 4c113f2b8e feat: pop-cart-outbound 출고 전용 장바구니 카드 컴포넌트 추가
출고(판매/기타/외주) 전용 카드 리스트 컴포넌트를 신규 생성한다.
- 세로형 카드 레이아웃: 헤더 + 스탯 그리드 + 수량 입력 + 담기/취소
- ISA-101 산업 현장 디자인 토큰 (56px 버튼, 36px 숫자)
- useCartSync 훅 연동, 이벤트 버스 filter/collect/save 지원
- 디자이너 설정 패널 3탭 (데이터/카드/장바구니)
- React key prop fallback 패턴 적용 (sf.id || idx)
- PopComponentType, ComponentPalette, PopRenderer 레지스트리 등록
- @playwright/test devDependency 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 18:34:05 +09:00
SeongHyun Kim 8ee6d75b3d fix: pop-button showCartBadge 속성으로 장바구니 모드 활성화
cart-save 태스크 없이 배지만 표시하는 경우에도 isCartMode가
활성화되도록 showCartBadge 속성 조건을 추가한다.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 18:33:53 +09:00
SeongHyun Kim 7e54940963 fix: 가나다 필터 검색어 입력 시 전체 범위 검색으로 수정
검색어 입력 시 activeFilterTab(초성/알파벳) 필터가 동시 적용되어
검색 결과가 현재 탭 내로 제한되던 문제를 수정한다.
searchText가 있으면 필터 탭을 무시하고 전체 데이터에서 검색한다.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 18:33:47 +09:00
kjs 5d4cf8d462 Add equipment and department management pages with comprehensive features
- Introduced a new equipment information page that displays a list of equipment alongside inspection items and consumables.
- Implemented dynamic loading of categories for equipment types and inspection methods to enhance data representation.
- Added functionality for equipment editing, inspection item management, and consumable tracking.
- Introduced a department management page that allows for department registration and user assignment.
- Enhanced user experience with responsive design, modals for data entry, and real-time data updates.
2026-03-24 18:25:46 +09:00
kjs f1ebcf7dee Add sales item page with comprehensive features for item and customer management
- Introduced a new sales item page that displays a list of items alongside customer information.
- Implemented dynamic loading of item categories and customer mappings for enhanced data representation.
- Integrated various UI components such as DataGrid, Dialogs, and Selects for improved user interaction.
- Added functionality for item editing, customer selection, and Excel upload capabilities for batch processing.
- Enhanced the overall user experience with responsive design and real-time data updates.
2026-03-24 16:12:32 +09:00
kjs 1c562fa854 Update item info and sales order pages with new components and functionality
- Updated the item information page to include category code to label conversion for better data representation.
- Enhanced the sales order page by integrating a fullscreen dialog for improved user experience during order registration and editing.
- Added dynamic loading of delivery options based on selected customers to streamline the order process.
- Introduced a new FullscreenDialog component for consistent fullscreen behavior across modals.
- Implemented validation utilities for form fields to ensure data integrity during user input.
2026-03-24 15:32:56 +09:00
SeongHyun Kim cbe3242f3a fix: Docker 환경 health check package.json 경로 오류 수정
require("../../package.json")이 Docker 컨테이너(/app/src/)에서
모듈을 찾지 못해 서버가 크래시하는 문제를 try-catch로 해결한다.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:59:45 +09:00
SeongHyun Kim 29640063a8 merge: health-check-version (자동 파이프라인) 2026-03-24 11:50:31 +09:00
SeongHyun Kim d061240498 feat: add version info to health check endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:37:54 +09:00
kjs ec7308bf43 Update project memory configuration and add new components for shipping plans
- Updated project memory settings, including last scanned timestamp and project root path.
- Added new session files to track session data and state.
- Introduced ConfirmDialog and DataGrid components for improved user interaction and data management.
- Implemented ShippingPlanBatchModal and ShippingPlanModal components for batch processing and management of shipping plans.
- Enhanced sales order page with dynamic data handling and multi-select deletion functionality.
2026-03-24 11:36:00 +09:00
kjs 3f8204e662 Add item information and sales order pages with dynamic search filter component
- Introduced new pages for item information and sales orders, enhancing the master data management capabilities.
- Implemented a dynamic search filter component to allow users to customize their search criteria, including text, select, and date filters.
- Integrated category loading for filter options and ensured real-time filtering functionality.
- Enhanced user experience with modals for item registration and editing, along with Excel upload capabilities for batch processing.
2026-03-23 22:32:49 +09:00
kjs 074626426b Enhance production plan service by adding lead time handling. Implemented checks for lead time column in item_info and adjusted scheduling logic accordingly. Updated frontend to reflect lead time in production plan management and shipping order pages, including Excel upload functionality for batch processing. 2026-03-23 20:39:07 +09:00
kjs cab0342081 Refactor AlertDialog and Dialog components to improve tab management and visibility handling. Updated effectiveOpen logic and adjusted display styles based on tab activity. 2026-03-23 15:22:16 +09:00
kjs aa48d40048 Implement production plan listing feature with API and frontend integration 2026-03-23 11:11:44 +09:00
SeongHyun Kim c54364312a chore: frontend package-lock.json 동기화
npm install로 @tanstack/react-virtual 등 누락된 의존성을 설치한다.
2026-03-23 10:42:17 +09:00
SeongHyun Kim b5e48f0b12 fix: PopCardListV2Component nullish coalescing 연산자 괄호 추가
SWC 빌더에서 ?? 와 || 혼용 시 명시적 괄호를 요구하는 문법 에러를 수정한다.
2026-03-23 10:41:57 +09:00
SeongHyun Kim 678e5fa368 Merge branch 'ksh-v2-work' into main
POP MES 전용 카드 + 디자인 감사 + 장바구니 분리 + 체크리스트/실적 관리 기능을 main에 통합한다.
주요 병합 내용:
- mes-process-card 전용 카드 + batch_done 워크플로우 + 실적 관리 강화
- MES 불량 처분 체계(disposition 3종) + 공정 흐름 스트립 필/칩 UI 개편
- MES 다중작업자 분할접수 4대 연쇄 버그 근본 해결
- MES 체크리스트 자동 복사 + 구조적 버그 3건 수정
- MES 카드 산업용 태블릿 UI 리디자인 (10인치 터치 최적화)
- MES 상태 탭 카드 내장 + pop-search 레거시 정리 + 하위 테이블 자동 판단
- pop-card-list-v2 설정 패널 MES 간소화 + 내 작업 표시 3모드
- BLOCK DETAIL Phase 4: 그룹별 타이머, 터치 최적화 UI, DB 저장 버그 수정
- POP 디자인 감사 + WYSIWYG 정렬 + MES/장바구니 분리
- POP 장바구니 입고 워크플로우 수정 및 접근성/UX 개선
- POP 뷰어 헤더 제거 + 디자이너-뷰어 그리드 칸 수 불일치 수정
- PopFieldConfig JsonKeySelect 개선
충돌 해결: 1건 (.gitignore - 양쪽 추가 항목 통합)
2026-03-23 10:31:45 +09:00
SeongHyun Kim da9bce2301 fix: POP 장바구니 입고 워크플로우 수정 및 접근성/UX 개선
장바구니(cart) 기반 구매입고 흐름에서 버튼 동작, 검색 접근성,
필터 안내 메시지 기능을 수정하여 실제 운영 시나리오에서의
안정성을 확보한다.
[pop-button 장바구니 모드 판단 수정]
- isCartMode: v1 preset뿐 아니라 v2 tasks에 cart-save가 포함된
  경우에도 장바구니 모드로 인식하도록 개선
- resolvedCartScreenId: v2 tasks의 cartScreenId도 참조
- 장바구니 모드 분기를 v2 tasks 처리보다 먼저 실행하여
  cart-save 버튼이 정상 동작하도록 순서 변경
[pop-search 아이콘 카드 접근성]
- IconView의 div 카드에 role="button", tabIndex={0},
  onKeyDown(Enter/Space) 추가
- 브라우저 자동화 및 키보드 사용자가 아이콘 카드를
  인터랙티브 요소로 인식 가능
[필터 필수 안내 메시지 기능]
- pop-card-list(장바구니 목록): requireFilter, requireFilterMessage
  설정 추가, 필터 미선택 시 커스텀 안내 문구 표시
- pop-card-list-v2(MES 공정흐름): hideUntilFilteredMessage
  설정 추가, 기존 필터 전 숨김에 커스텀 문구 지원
- 양쪽 설정 패널에 안내 문구 입력 UI 추가
[원본 화면 선택 Combobox 전환]
- PopCardListConfig 장바구니 모드의 원본 화면 선택을
  Select에서 검색 가능한 Combobox로 변경
- 로그인 계정의 companyCode로 화면 목록 필터링 적용
2026-03-23 10:26:06 +09:00
kjs ca2af56aad Merge pull request 'chpark-sync' (#425) from chpark-sync into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/425
2026-03-23 09:36:34 +09:00
SeongHyun Kim 461ff6dbf7 refactor: POP 디자인 감사 + WYSIWYG 정렬 + MES/장바구니 분리 + 장바구니 코드 제거
POP 컴포넌트 전반의 디자인 일관성을 확보하고, 디자이너-뷰어 간 WYSIWYG를
달성하며, MES 공정흐름 컴포넌트에서 장바구니 코드를 완전 분리한다.
[POP 디자인 종합 감사]
- PopRenderer 고스트 보더 제거 (border-2 bg-white -> 투명 래퍼)
- 하드코딩 배경색 -> CSS 변수 hsl(var(--background))
- 빈 상태 메시지 border-dashed bg-muted/30 제거
- 콘텐츠 컴포넌트만 선택적 보더 (rounded-lg border-border/40 bg-card)
[뷰어-디자이너 WYSIWYG 통일]
- PopRenderer 행 높이: 디자이너/뷰어 모두 고정 24px (minmax 제거)
- page.tsx mx-auto + maxWidth 제거 -> 뷰어 전체 폭 채움
- pop-icon 셀 내 스케일링 (maxWidth/maxHeight 100% + aspectRatio)
- pop-icon 라벨 표시 + 배경 투명/아이콘 색상 설정 UI 추가
- pop-profile 반응형, pop-button 오버플로 클리핑
[마키(흐르는 텍스트) 추가]
- pop-text에 marquee 옵션 (marqueeSpeed, marqueeIcon 설정)
- CSS animation + paddingRight 100vw 연속 스크롤
[컴포넌트 이름 명확화]
- pop-card-list-v2: "카드 목록 V2" -> "MES 공정흐름"
- pop-card-list: "카드 목록" -> "장바구니 목록"
[MES 공정흐름에서 장바구니 코드 완전 제거]
- PopCardListV2Component: useCartSync, parseCartRow, isCartListMode,
  selectedKeys, cartRef, cart 이벤트 3개, fetchCartData,
  handleDeleteItem/UpdateQuantity/CartAdd/Cancel/Delete 제거 (~250줄)
- cell-renderers: CartButtonCell, DynamicLucideIcon, ShoppingCart 제거 (~50줄)
- PopCardListV2Config: cart-button 셀 편집 UI 제거
- index.tsx: cart_updated/cart_save_completed/selected_items/
  cart_save_trigger/confirm_trigger 이벤트 메타 제거
- migrate.ts: cartAction/cartListMode 마이그레이션 제거
- types.ts: PopCardListV2Config cartAction/cartListMode 필드,
  CardCellDefinitionV2 cart 4필드, CardCellType "cart-button" 제거
- pop-card-list(장바구니 목록)의 타입/훅은 그대로 유지
- 매 Phase마다 tsc --noEmit 검증, 신규 에러 0건
2026-03-20 16:31:25 +09:00
SeongHyun Kim 73674385be feat: MES 카드 산업용 태블릿 UI 리디자인 (10인치 터치 최적화)
산업현장 10인치 태블릿에서의 가독성과 터치 조작성을 대폭 개선한다.
HTML 시안(pop-design-v2)을 기반으로 기존 기능을 모두 보존하면서 디자인만 변경.
[카드 헤더 재구성]
- 품목명(item_name) 20px bold 강조 (기존: 미표시)
- WO번호+공정명 14px 보조 정보로 전환 (기존: WO번호 13px 주인공)
- 상태 배지 14px rounded-full pill (기존: 10px)
- 재작업 배지 12px (기존: 9px)
- 좌측 컬러바 4px (기존: 3px)
[메트릭 3단 통일 구조]
- 접수가능: 컨텍스트 + 파랑 박스(접수가능 N EA)
- 진행중: 컨텍스트 + 주황 박스(생산 N / N EA) + 칩(양품/불량/잔여)
- 완료: 컨텍스트+수율pill + 초록 박스(최종양품 N EA) + 칩
- 재작업: 컨텍스트 + 주황 박스(재작업 수량 N EA)
- 핵심 수량 36px extrabold, 컨텍스트 14px (기존: 18px/10px)
[카드 높이 일관성]
- 메트릭 영역 flex-1 적용: 같은 행의 카드들이 동일 높이로 정렬
- 공정 흐름/버튼이 항상 카드 하단 고정
[공정 흐름 노드 기반 디자인]
- 텍스트 칩 -> 40x40px 노드 박스 + 라벨 (기존: 10px 칩)
- 커넥터 라인으로 공정 연결 시각화
[액션 버튼 대형화]
- h-14(56px) full-width rounded-xl 17px bold (기존: h-7 28px 11px)
[MES 카드 래퍼 패딩]
- MES 카드 전용 p-1 제거 (카드 내부 충분한 패딩 px-5 보유)
2026-03-20 10:51:26 +09:00
SeongHyun Kim 17fb815513 feat: MES 상태 탭을 카드 컴포넌트에 내장
별도 pop-status-bar 컴포넌트 배치/연결 없이 카드 상단에
MES 고정 상태 탭(전체/대기/접수가능/진행/완료)을 내장한다.
[내장 상태 탭 UI]
- MES_STATUS_TABS 상수: waiting/acceptable/in_progress/completed 고정
- 카드 그리드 상단에 pill 형태 탭 렌더링
- 상태별 카운트 + "전체"는 클론 제외 originalCount 표시
- showStatusTabs && hasProcessFlow 조건부 표시
[자체 카운트/필터]
- statusCounts useMemo: filteredRows 기준 VIRTUAL_SUB_STATUS 집계
- statusFilteredRows useMemo: 선택 탭으로 내부 필터
- displayCards/hasMoreCards/totalPages가 statusFilteredRows 기준으로 동작
- 탭 전환 시 currentPage=1 리셋
[외부 status-bar 하위 호환]
- effectiveExternalFilters: showStatusTabs일 때 _source="status-bar"
  필터 무시 (내장 탭으로 대체, 충돌 방지)
- all_rows 이벤트 발행 유지 (기존 pop-status-bar 연결 안 깨짐)
- rowsForStatusCount: showStatusTabs일 때 filteredRows 직접 반환
[설정 패널]
- 고급 설정에 "상태 탭 내장" Switch 토글 추가
- showStatusTabs?: boolean 타입 추가 (기본 false)
2026-03-19 18:01:57 +09:00
SeongHyun Kim 1d85de8bf6 refactor: pop-search 레거시 정리 + 하위 테이블 자동 판단 + 초기값 프로필 세팅
MES 고정 구조에 맞게 검색/연결/필터 컴포넌트를 간소화하고,
하위 테이블 필터를 수동 설정에서 자동 판단으로 전환한다.
[pop-search 레거시 정리]
- LegacySearchInputType, StatusChipConfig, StatusChipStyle,
  SelectDataSource 등 미사용 타입 제거
- status-chip, multi-select, combo 입력 타입 제거
  (DB 호환: normalizeInputType에서 text로 정규화)
- 설정 패널에서 status-chip 관련 UI/안내문 제거
- SEARCH_INPUT_TYPE_LABELS 간소화 (7종 -> 5종)
[하위 테이블 자동 판단]
- PopCardListV2Component: subTableKeys useMemo 추가
  (processFlow rawData 키셋에서 하위 테이블 컬럼 자동 추출)
- isSubTableColumn useCallback: filterConfig.isSubTable 하위 호환 +
  subTableKeys 기반 자동 판단으로 메인/하위 필터 분류
- ConnectionEditor: "하위 테이블 기준으로 필터" 체크박스 UI 제거,
  isSubTable 상태 및 setIsSubTable 전부 제거
- 컬럼 선택 드롭다운: 메인+하위 테이블 컬럼 통합 표시
- 기존 연결 배지 "하위 테이블" -> "자동 판단"으로 변경
[초기값 프로필 세팅]
- PopSearchConfig.initialValueSource 타입 추가
  ({ type: "user_profile", column: string })
- PopSearchComponent: useAuth + useEffect로 사용자 프로필 값
  자동 필터 발행 (userId, deptCode, positionCode 등)
- 설정 패널: "초기값 자동 세팅" Select 드롭다운 추가
  (사용 안 함 / 사용자ID / 부서코드 / 직급 등 7개 옵션)
2026-03-19 17:14:22 +09:00
SeongHyun Kim d001f82565 feat: pop-card-list-v2 설정 패널 MES 간소화 + Core Binding + 내 작업 표시 모드
MES 고정 구조에 맞게 설정 패널을 간소화하고, 작업상세 내장 모달과
작업자 기반 카드 필터링 기능을 추가한다.
[설정 패널 간소화]
- 3탭(데이터/디자인/동작) -> 2탭(정보/동작)으로 축소
- "정보" 탭: 데이터 소스, 카드 구성, 클릭 동작을 읽기 전용 요약으로 표시
- "동작" 탭: cardClickAction 선택(none/modal-open/built-in-work-detail)
  + 내 작업 표시 + 고급 설정(필터 전 숨김, 기본 표시 수) 유지
- PopWorkDetailConfigPanel을 카드 설정에서 분리
  (작업상세 컴포넌트 자체 설정 패널에서 관리)
[Core Binding 내장 모달]
- cardClickAction="built-in-work-detail" 시 내부 Dialog로
  PopWorkDetail 직접 렌더링, parentRow를 prop으로 전달
- LazyPopWorkDetail dynamic import로 성능 최적화
- in_progress 상태 카드만 상세 모달 열림
[카드 열 수 선택]
- 정보 탭 상단에 1열/2열/3열/4열 버튼 UI 추가
- gridColumns 설정 즉시 반영
[내 작업 표시 3모드]
- 전체 보기: 모든 카드 동등 표시
- 우선 표시: 내 카드 상단 + 다른 카드 비활성화(기존 동작)
- 내 작업만: worker 컬럼 기준 내 카드만 표시, 나머지 숨김
- ownerFilterMode("priority"|"only") 타입 추가, 컬럼 선택 드롭다운
  제거하고 worker 고정 토글로 단순화
2026-03-19 16:09:11 +09:00
SeongHyun Kim 5d12bef5e5 fix: MES 체크리스트 자동 복사 + 구조적 버그 3건 수정
분할 접수/재작업 카드 생성 시 체크리스트가 복사되지 않던 문제를 해결하고,
공정 흐름 모달과 카드 클릭 이벤트 격리 버그를 수정한다.
[체크리스트 자동 복사]
- copyChecklistToSplit 공통 헬퍼 함수 추출
  (createWorkProcesses/acceptProcess/saveResult 3곳에서 통합 사용)
- routing_detail_id 존재 시: process_work_item 템플릿에서 복사
- routing_detail_id 부재 시: 마스터 process_work_result에서 구조만 복사
[ChecklistItem 렌더링 수정]
- 레거시 detail_type(inspect_numeric/inspect_ox 등)을 정규화하여
  detail_type=inspect + input_type 자동 파싱으로 InspectRouter 라우팅
- info 타입 렌더러 추가
[공정 흐름 모달 이벤트 격리]
- Dialog 래퍼에 onClick/onPointerDown stopPropagation 추가
  (모달 닫기 시 부모 카드 onClick 전파 차단)
[카드 클릭 진행 탭 제한]
- handleCardSelect에 VIRTUAL_SUB_STATUS 코드 가드 추가
  (in_progress가 아닌 카드는 작업상세 모달 열림 차단)
- 재작업 카드 포함 접수가능 탭에서 상세 진입 방지
2026-03-18 18:26:54 +09:00
SeongHyun Kim 9d164d08af feat: BLOCK MES-HARDEN Phase 0~3 + 공정 흐름 스트립 필/칩 UI 개편
MES 불량 처분 체계(disposition 3종)를 구현하고, 공정 카드의 흐름
스트립을 현재 공정 중심 필/칩 윈도우로 전면 재설계한다.
[Phase 0: 기반 안정화]
- confirmResult: SUM 버그 수정 + 마스터 캐스케이드 완료 판정
- checkAndCompleteWorkInstruction: 헬퍼 함수 추출
  (saveResult/confirmResult 양쪽에서 work_instruction 상태 갱신)
- saveResult: 초과 생산 에러 -> 경고 로그로 변경
[Phase 1: UI 정리]
- DISPOSITION_OPTIONS: 5종 -> 3종(폐기/재작업/특채)
- 카드 수동 완료 버튼: in_progress + 생산 있음 + 미완료 시 표시
  (__manualComplete -> confirmResult 호출)
[Phase 2: 양품 계산 서버화]
- concession_qty/is_rework/rework_source_id DB 컬럼 추가
- saveResult: defect_detail disposition별 서버 양품 계산
  (addGood = addProduction - addDefect, addConcession 분리)
- prevGoodQty 5곳: SUM(good_qty) + SUM(concession_qty) 통일
- 프론트 특채 표시: MesInProgressMetrics/MesCompletedMetrics
[Phase 3: 재작업 카드]
- saveResult: disposition=rework 시 동일 공정에 분할행 자동 INSERT
  (is_rework='Y', rework_source_id 연결, status='acceptable')
- 프론트: amber "재작업" 배지 + MesAcceptableMetrics 재작업 전용 UI
- 재작업 카드 접수가능 수량 버그 수정 (마스터 qty -> input_qty)
[공정 흐름 스트립 UI 개편]
- ProcessFlowStrip: 바 형태 -> 필/칩 5슬롯 윈도우
  (+N/이전/현재/다음/+N, 현재 공정 항상 중앙)
- 색상: 지나온=emerald(완료)/slate, 현재=primary,
  완료=emerald, 대기=muted, 남은=amber
2026-03-18 16:38:22 +09:00
kmh 08ad2abdd1 refactor: improve SplitPanelLayoutComponent for better responsiveness and layout handling
- Removed unnecessary div wrappers to streamline the layout structure.
- Adjusted table minimum width calculations to ensure better responsiveness across different screen sizes.
- Enhanced column width handling to provide a more consistent and flexible user experience.

These changes aim to refine the overall layout and usability of the SplitPanelLayoutComponent, ensuring it adapts more effectively to varying content and screen dimensions.
2026-03-18 16:25:13 +09:00
kmh c8226b0ba6 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node 2026-03-18 14:49:53 +09:00
kmh 33dfa6b475 fix: update ItemRoutingComponent dialog width for better responsiveness
- Changed the maximum width of the add item dialog to be more flexible, allowing it to utilize the full width of the viewport while respecting the configured maximum width.
- This adjustment aims to enhance the user experience by ensuring the dialog is appropriately sized across different screen sizes.
2026-03-18 14:27:19 +09:00
SeongHyun Kim fba5390f5a fix: MES 다중작업자 분할접수 4대 연쇄 버그 근본 해결
분할 카드 접수량 오표시(800), 모달 접수량 0, 실적등록 미반영,
접수취소 실패의 근본 원인을 분석하고 4건의 수정을 적용한다.
근본 원인: acceptProcess에서 마스터를 in_progress로 변경 ->
프론트 isCurrent가 코팅(acceptable)을 잡음 -> 분할 카드 미생성
-> 마스터ID로 API 호출하여 모든 후속 동작이 실패하는 연쇄 구조.
[Fix A] acceptProcess: 마스터 상태를 in_progress로 변경하는 로직 제거
  - 마스터는 항상 acceptable 유지, completed로만 전환
[Fix B] buildProcessSteps: isCurrent 결정 시 활성 분할 행 우선
  - in_progress 등 비완료 분할이 있는 공정을 최우선 선택
  - 분할 카드(__splitProcessId)가 정상 생성되도록 보장
[Fix C] applySubFilterAndDuplicate: 분할 카드 데이터 보호
  - __splitProcessId가 있는 카드의 __process_* 필드를
    마스터 SUM 집계값으로 덮어쓰지 않음
[Fix D] saveResult: 마스터 행 직접 실적등록 차단
  - 분할 행이 존재하는 마스터에 실적 등록 시 400 에러 반환
2026-03-18 13:57:14 +09:00
SeongHyun Kim 20fbe85c74 feat: BLOCK MES-REWORK - mes-process-card 전용 카드 + batch_done 워크플로우 + 실적 관리 강화
MES 카드를 CSS Grid 다중 셀 방식에서 Flexbox 기반 단일 전용 카드(mes-process-card)로
전환하고, batch_done 상태를 도입하여 부분 확정 후 추가접수 워크플로우를 구현한다.
[mes-process-card 전용 카드]
- CardCellType "mes-process-card" 신규: 상태별 좌측 보더+배경, 공정 흐름 스트립, 클릭 모달
- MesAcceptableMetrics / MesInProgressMetrics / MesCompletedMetrics 서브 컴포넌트
- 워크플로우 기반 activeBtn 결정 로직 (상태+잔여량 조합으로 버튼 1개만 표시)
- 하드코딩 취소 버튼 제거, DB 설정 "접수취소" 라벨 인터셉트로 통합
[batch_done 워크플로우]
- confirmResult API: 부분 확정 시 status = 'batch_done' (진행 탭 숨김 + 접수가능 탭 유지)
- acceptProcess API: batch_done -> in_progress 복귀 (추가접수)
- 카드 복제 로직에 batch_done 포함 (잔여량 있으면 접수가능 탭에 클론 카드)
- status 매핑에 batch_done 추가 (semantic: active)
[실적 관리 강화]
- PopWorkDetailComponent: 실적 입력 UI 전면 구현 (차수별 등록, 누적 실적, 이력 표시)
- 모든 실적 저장 시 process_completed 이벤트 발행 (카드 리스트 즉시 갱신)
- 전량접수+전량생산 시 자동 완료 (status=completed, result_status=confirmed)
[버그 수정]
- 서브 필터 변경 시 __process_* 필드 미갱신 -> processFields 재주입
- cancelAccept SQL inconsistent types -> boolean 파라미터 분리
- 접수취소 라벨 매핑 누락 -> taskPreset 조건 확장
2026-03-17 21:36:43 +09:00
SeongHyun Kim 06c52b422f feat: BLOCK DETAIL Phase 4 + 안정화 - 그룹별 타이머, 터치 최적화 UI, DB 저장 버그 수정
pop-work-detail 컴포넌트에 그룹별 타이머 시스템과 터치 최적화 UI를 추가하고,
체크리스트 결과가 DB에 저장되지 않던 버그를 수정하여 안정화를 완료한다.
[그룹별 타이머]
- group-timer API 신규: start/pause/resume/complete 액션 (popProductionController)
- process_work_result에 group_started_at/paused_at/total_paused_time/completed_at 활용
- GroupTimerHeader UI: 순수 작업시간 + 경과시간 이중 표시
- 첫 그룹 "시작" 시 work_order_process.started_at 자동 기록 (공정 시작 자동 감지)
- 공정 완료 시 actual_work_time을 그룹 타이머 합산으로 백엔드 자동 계산
[터치 최적화 UI]
- 12개 영역 전면 스케일업: 버튼 h-11~h-12, 입력 h-11, 체크박스 h-6 w-6
- 사이드바 w-[180px], InfoBar text-sm, 최소 터치 영역 40~44px 확보
- 산업 현장 태블릿 터치 사용 최적화
[DB 저장 버그 수정]
- saveResultValue/handleQuantityRegister: execute-action task 형식 수정
  (fixedValue + lookupMode:"manual" + manualItemField/manualPkColumn:"id")
- 원인: 백엔드가 __cart_row_key를 찾는데 프론트에서 id만 전송하여 lookup 실패
[디자이너 설정 확장]
- displayMode: list/step 전환 설정 추가
- PopWorkDetailConfig: 표시 모드 Select 드롭다운
- types.ts: PopWorkDetailConfig 인터페이스 displayMode 추가
- PopCardListV2Component: parentRow.__processFlow__ 전달 보강
2026-03-17 09:32:59 +09:00
SeongHyun Kim 230d35b03a fix: PopFieldConfig JsonKeySelect - 데이터 없을 때도 Combobox UI 유지
테이블에 데이터가 0건일 때 JsonKeySelect가 plain Input으로 폴백되어
설계 단계에서 Select 박스가 표시되지 않는 문제를 수정한다.
[JsonKeySelect 개선]
- 항상 Combobox(Popover + Command) UI로 렌더링
- keys 있을 때: 기존과 동일한 자동완성 목록 + 검색
- keys 없을 때: "테이블에 데이터가 없습니다" 안내 + Enter로 직접 입력 확정
- 검색 결과 없을 때도 Enter로 자유 입력 가능
[updateSaveMapping 경합 조건 수정]
- onUpdateConfig 두 번 연속 호출 시 React batching으로 첫 번째 호출이
  덮어쓰여지는 문제 수정
- syncAndUpdateSaveMappings에 extraPartial 파라미터 추가하여
  한 번의 onUpdateConfig 호출로 병합
2026-03-16 16:24:27 +09:00
SeongHyun Kim 8ee10e411e fix: POP 뷰어 헤더 제거 + 디자이너-뷰어 그리드 칸 수 불일치 수정
POP 화면 상단 네비게이션 바(POP 대시보드/PC 모드 전환)를 제거하고,
디자이너와 뷰어의 그리드 칸 수가 달라 컴포넌트 배치가 어긋나는 문제를 수정한다.
[헤더 제거]
- "POP 대시보드 | 화면이름 | PC 모드" 바 삭제 (pop-profile 컴포넌트로 대체)
- 미사용 import 정리 (LayoutGrid, Monitor, GAP_PRESETS, GRID_BREAKPOINTS)
[그리드 불일치 수정]
- 문제: 디자이너 태블릿 가로=1024px(38칸), 뷰어 window.innerWidth=1366px(52칸)
- 수정: 뷰어에서 모드별 기준 너비 고정 (tablet_landscape=1024, tablet_portrait=820,
  mobile_landscape=600, mobile_portrait=375)
- rawWidth는 모드 감지 용도로만 사용, viewportWidth는 디자이너와 동일한 기준 너비
2026-03-16 12:09:37 +09:00
189 changed files with 58336 additions and 3329 deletions

13
.gitignore vendored
View File

@ -193,7 +193,9 @@ scripts/browser-test-*.js
# 개인 작업 문서 # 개인 작업 문서
popdocs/ popdocs/
kshdocs/
.cursor/rules/popdocs-safety.mdc .cursor/rules/popdocs-safety.mdc
.cursor/rules/overtime-registration.mdc
# 멀티 에이전트 MCP 태스크 큐 # 멀티 에이전트 MCP 태스크 큐
mcp-task-queue/ mcp-task-queue/
@ -209,3 +211,14 @@ docs/retrospectives/
mes-architecture-guide.md mes-architecture-guide.md
# MES Reference Documents # MES Reference Documents
docs/mes-reference/ docs/mes-reference/
# 테스트 결과물
frontend/test-results/
# Cursor 설정
.cursor/
# Playwright 테스트 (관제탑에서 관리)
frontend/playwright.config.ts
frontend/tests/
frontend/test-results/

View File

@ -1,7 +1,7 @@
{ {
"version": "1.0.0", "version": "1.0.0",
"lastScanned": 1772609393905, "lastScanned": 1774313213052,
"projectRoot": "/Users/johngreen/Dev/vexplor", "projectRoot": "/Users/kimjuseok/ERP-node",
"techStack": { "techStack": {
"languages": [ "languages": [
{ {
@ -13,7 +13,13 @@
] ]
} }
], ],
"frameworks": [], "frameworks": [
{
"name": "playwright",
"version": "1.58.2",
"category": "testing"
}
],
"packageManager": "npm", "packageManager": "npm",
"runtime": null "runtime": null
}, },
@ -28,16 +34,14 @@
"namingStyle": null, "namingStyle": null,
"importStyle": null, "importStyle": null,
"testPattern": null, "testPattern": null,
"fileOrganization": "type-based" "fileOrganization": null
}, },
"structure": { "structure": {
"isMonorepo": false, "isMonorepo": false,
"workspaces": [], "workspaces": [],
"mainDirectories": [ "mainDirectories": [
"docs", "docs",
"lib", "scripts"
"scripts",
"src"
], ],
"gitBranches": { "gitBranches": {
"defaultBranch": "main", "defaultBranch": "main",
@ -46,37 +50,39 @@
}, },
"customNotes": [], "customNotes": [],
"directoryMap": { "directoryMap": {
"WebContent": { "_local": {
"path": "WebContent", "path": "_local",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1774313213033,
"keyFiles": [
"pipeline-progress.json"
]
},
"ai-assistant": {
"path": "ai-assistant",
"purpose": null, "purpose": null,
"fileCount": 5, "fileCount": 5,
"lastAccessed": 1772609393856, "lastAccessed": 1774313213036,
"keyFiles": [ "keyFiles": [
"init.jsp", "Dockerfile.win",
"init_jqGrid.jsp", "README.md",
"init_no_login.jsp", "package-lock.json",
"init_toastGrid.jsp", "package.json"
"viewImage.jsp"
] ]
}, },
"backend": { "backend": {
"path": "backend", "path": "backend",
"purpose": null, "purpose": null,
"fileCount": 6, "fileCount": 0,
"lastAccessed": 1772609393857, "lastAccessed": 1774313213038,
"keyFiles": [ "keyFiles": []
"Dockerfile",
"Dockerfile.mac",
"build.gradle",
"gradlew",
"gradlew.bat"
]
}, },
"backend-node": { "backend-node": {
"path": "backend-node", "path": "backend-node",
"purpose": null, "purpose": null,
"fileCount": 14, "fileCount": 17,
"lastAccessed": 1772609393872, "lastAccessed": 1774313213039,
"keyFiles": [ "keyFiles": [
"API_연동_가이드.md", "API_연동_가이드.md",
"API_키_정리.md", "API_키_정리.md",
@ -85,70 +91,88 @@
"README.md" "README.md"
] ]
}, },
"backup": {
"path": "backup",
"purpose": null,
"fileCount": 6,
"lastAccessed": 1774313213040,
"keyFiles": [
"Dockerfile",
"README.md",
"backup.py",
"docker-compose.backup.yml"
]
},
"db": { "db": {
"path": "db", "path": "db",
"purpose": null, "purpose": null,
"fileCount": 2, "fileCount": 14,
"lastAccessed": 1772609393873, "lastAccessed": 1774313213041,
"keyFiles": [ "keyFiles": [
"00-create-roles.sh", "00-create-roles.sh",
"migrate_company13_export.sh" "check_category_values.sql",
"check_numbering_rules.sql",
"cleanup_duplicate_screens_daejin.sql",
"company7_screen_backup.sql"
] ]
}, },
"deploy": { "deploy": {
"path": "deploy", "path": "deploy",
"purpose": null, "purpose": null,
"fileCount": 0, "fileCount": 0,
"lastAccessed": 1772609393873, "lastAccessed": 1774313213041,
"keyFiles": [] "keyFiles": []
}, },
"digitalTwin": {
"path": "digitalTwin",
"purpose": null,
"fileCount": 4,
"lastAccessed": 1774313213041,
"keyFiles": [
"architecture-v4.md",
"fleet-management-plan.md",
"디지털트윈 아키텍쳐_v3.png",
"디지털트윈 아키텍쳐_v4.png"
]
},
"docker": { "docker": {
"path": "docker", "path": "docker",
"purpose": null, "purpose": null,
"fileCount": 0, "fileCount": 0,
"lastAccessed": 1772609393873, "lastAccessed": 1774313213042,
"keyFiles": [] "keyFiles": []
}, },
"docs": { "docs": {
"path": "docs", "path": "docs",
"purpose": "Documentation", "purpose": "Documentation",
"fileCount": 23, "fileCount": 35,
"lastAccessed": 1772609393873, "lastAccessed": 1774313213042,
"keyFiles": [ "keyFiles": [
"AI_화면생성_시스템_설계서.md", "AI_화면생성_시스템_설계서.md",
"BOM_개발_현황.md",
"DB_ARCHITECTURE_ANALYSIS.md", "DB_ARCHITECTURE_ANALYSIS.md",
"DB_STRUCTURE_DIAGRAM.html", "DB_STRUCTURE_DIAGRAM.html",
"DB_WORKFLOW_ANALYSIS.md", "DB_WORKFLOW_ANALYSIS.md"
"KUBERNETES_DEPLOYMENT_GUIDE.md"
] ]
}, },
"frontend": { "frontend": {
"path": "frontend", "path": "frontend",
"purpose": null, "purpose": null,
"fileCount": 14, "fileCount": 17,
"lastAccessed": 1772609393873, "lastAccessed": 1774313213043,
"keyFiles": [ "keyFiles": [
"MODAL_REPEATER_TABLE_DEBUG.md", "MODAL_REPEATER_TABLE_DEBUG.md",
"README.md", "README.md",
"approval-box-result.png",
"components.json", "components.json",
"eslint.config.mjs", "eslint.config.mjs"
"middleware.ts"
]
},
"hooks": {
"path": "hooks",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1772609393879,
"keyFiles": [
"useScreenStandards.ts"
] ]
}, },
"k8s": { "k8s": {
"path": "k8s", "path": "k8s",
"purpose": null, "purpose": null,
"fileCount": 7, "fileCount": 7,
"lastAccessed": 1772609393882, "lastAccessed": 1774313213043,
"keyFiles": [ "keyFiles": [
"local-path-provisioner.yaml", "local-path-provisioner.yaml",
"namespace.yaml", "namespace.yaml",
@ -157,18 +181,11 @@
"vexplor-frontend-deployment.yaml" "vexplor-frontend-deployment.yaml"
] ]
}, },
"lib": {
"path": "lib",
"purpose": "Library code",
"fileCount": 0,
"lastAccessed": 1772609393883,
"keyFiles": []
},
"mcp-agent-orchestrator": { "mcp-agent-orchestrator": {
"path": "mcp-agent-orchestrator", "path": "mcp-agent-orchestrator",
"purpose": null, "purpose": null,
"fileCount": 4, "fileCount": 4,
"lastAccessed": 1772609393883, "lastAccessed": 1774313213043,
"keyFiles": [ "keyFiles": [
"README.md", "README.md",
"package-lock.json", "package-lock.json",
@ -176,91 +193,68 @@
"tsconfig.json" "tsconfig.json"
] ]
}, },
"popdocs": { "mcp-task-queue": {
"path": "popdocs", "path": "mcp-task-queue",
"purpose": null, "purpose": null,
"fileCount": 12, "fileCount": 4,
"lastAccessed": 1772609393884, "lastAccessed": 1774313213043,
"keyFiles": [ "keyFiles": [
"ARCHITECTURE.md", "package-lock.json",
"CHANGELOG.md", "package.json",
"FILES.md", "tsconfig.json"
"INDEX.md",
"PLAN.md"
] ]
}, },
"mcp-task-server": {
"path": "mcp-task-server",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313213043,
"keyFiles": []
},
"scripts": { "scripts": {
"path": "scripts", "path": "scripts",
"purpose": "Build/utility scripts", "purpose": "Build/utility scripts",
"fileCount": 2, "fileCount": 11,
"lastAccessed": 1772609393884, "lastAccessed": 1774313213044,
"keyFiles": [ "keyFiles": [
"add-modal-ids.py", "add-modal-ids.py",
"remove-logs.js" "analyze-company-info-layout.js",
"browser-test-admin-switch-button.js",
"browser-test-customer-crud.js",
"browser-test-customer-via-menu.js"
] ]
}, },
"src": { "test-output": {
"path": "src", "path": "test-output",
"purpose": "Source code", "purpose": null,
"fileCount": 0, "fileCount": 2,
"lastAccessed": 1772609393884, "lastAccessed": 1774313213044,
"keyFiles": [] "keyFiles": [
"screen-149-field-type-verification-guide.md",
"unified-field-type-config-panel-test-guide.md"
]
}, },
"tomcat-conf": { "test-results": {
"path": "tomcat-conf", "path": "test-results",
"purpose": null, "purpose": null,
"fileCount": 1, "fileCount": 1,
"lastAccessed": 1772609393884, "lastAccessed": 1774313213044,
"keyFiles": [
"context.xml"
]
},
"backend/build": {
"path": "backend/build",
"purpose": "Build output",
"fileCount": 0,
"lastAccessed": 1772609393884,
"keyFiles": [] "keyFiles": []
}, },
"backend/src": { "ai-assistant/src": {
"path": "backend/src", "path": "ai-assistant/src",
"purpose": "Source code", "purpose": "Source code",
"fileCount": 0,
"lastAccessed": 1772609393884,
"keyFiles": []
},
"backend-node/data": {
"path": "backend-node/data",
"purpose": "Data files",
"fileCount": 0,
"lastAccessed": 1772609393884,
"keyFiles": []
},
"db/migrations": {
"path": "db/migrations",
"purpose": "Database migrations",
"fileCount": 16,
"lastAccessed": 1772609393884,
"keyFiles": [
"046_MIGRATION_FIX.md",
"046_QUICK_FIX.md",
"README_1003.md"
]
},
"db/scripts": {
"path": "db/scripts",
"purpose": "Build/utility scripts",
"fileCount": 1, "fileCount": 1,
"lastAccessed": 1772609393884, "lastAccessed": 1774313213045,
"keyFiles": [ "keyFiles": [
"README_cleanup.md" "app.js"
] ]
}, },
"frontend/app": { "frontend/app": {
"path": "frontend/app", "path": "frontend/app",
"purpose": "Application code", "purpose": "Application code",
"fileCount": 5, "fileCount": 5,
"lastAccessed": 1772609393885, "lastAccessed": 1774313213046,
"keyFiles": [ "keyFiles": [
"favicon.ico", "favicon.ico",
"globals.css", "globals.css",
@ -271,7 +265,7 @@
"path": "frontend/components", "path": "frontend/components",
"purpose": "UI components", "purpose": "UI components",
"fileCount": 1, "fileCount": 1,
"lastAccessed": 1772609393885, "lastAccessed": 1774313213046,
"keyFiles": [ "keyFiles": [
"GlobalFileViewer.tsx" "GlobalFileViewer.tsx"
] ]
@ -280,49 +274,174 @@
"path": "mcp-agent-orchestrator/src", "path": "mcp-agent-orchestrator/src",
"purpose": "Source code", "purpose": "Source code",
"fileCount": 1, "fileCount": 1,
"lastAccessed": 1772609393885, "lastAccessed": 1774313213047,
"keyFiles": [ "keyFiles": [
"index.ts" "index.ts"
] ]
}, },
"src/controllers": { "mcp-task-queue/data": {
"path": "src/controllers", "path": "mcp-task-queue/data",
"purpose": "Controllers", "purpose": "Data files",
"fileCount": 1,
"lastAccessed": 1772609393885,
"keyFiles": [
"dataflowDiagramController.ts"
]
},
"src/routes": {
"path": "src/routes",
"purpose": "Route handlers",
"fileCount": 1,
"lastAccessed": 1772609393885,
"keyFiles": [
"dataflowDiagramRoutes.ts"
]
},
"src/services": {
"path": "src/services",
"purpose": "Business logic services",
"fileCount": 1,
"lastAccessed": 1772609393885,
"keyFiles": [
"dataflowDiagramService.ts"
]
},
"src/utils": {
"path": "src/utils",
"purpose": "Utility functions",
"fileCount": 2, "fileCount": 2,
"lastAccessed": 1772609393885, "lastAccessed": 1774313213047,
"keyFiles": [ "keyFiles": [
"databaseValidator.ts", "knowledge.json",
"queryBuilder.ts" "tasks.json"
] ]
},
"mcp-task-queue/dist": {
"path": "mcp-task-queue/dist",
"purpose": "Distribution/build output",
"fileCount": 28,
"lastAccessed": 1774313213048,
"keyFiles": [
"agent-runner.d.ts",
"agent-runner.d.ts.map",
"agent-runner.js"
]
},
"mcp-task-queue/node_modules": {
"path": "mcp-task-queue/node_modules",
"purpose": "Dependencies",
"fileCount": 1,
"lastAccessed": 1774313213049,
"keyFiles": []
},
"mcp-task-queue/src": {
"path": "mcp-task-queue/src",
"purpose": "Source code",
"fileCount": 7,
"lastAccessed": 1774313213049,
"keyFiles": [
"agent-runner.ts",
"index.ts",
"knowledge-store.ts"
]
},
"mcp-task-server/data": {
"path": "mcp-task-server/data",
"purpose": "Data files",
"fileCount": 0,
"lastAccessed": 1774313213049,
"keyFiles": []
},
"mcp-task-server/dist": {
"path": "mcp-task-server/dist",
"purpose": "Distribution/build output",
"fileCount": 6,
"lastAccessed": 1774313213050,
"keyFiles": [
"index.d.ts",
"index.js",
"taskStore.d.ts"
]
},
"mcp-task-server/node_modules": {
"path": "mcp-task-server/node_modules",
"purpose": "Dependencies",
"fileCount": 1,
"lastAccessed": 1774313213050,
"keyFiles": []
},
"mcp-task-server/src": {
"path": "mcp-task-server/src",
"purpose": "Source code",
"fileCount": 0,
"lastAccessed": 1774313213052,
"keyFiles": []
} }
}, },
"hotPaths": [], "hotPaths": [
{
"path": "frontend/app/(main)/sales/order/page.tsx",
"accessCount": 19,
"lastAccessed": 1774408850812,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/shipping-plan/page.tsx",
"accessCount": 4,
"lastAccessed": 1774313720455,
"type": "file"
},
{
"path": "frontend/components/common/DataGrid.tsx",
"accessCount": 4,
"lastAccessed": 1774408732451,
"type": "file"
},
{
"path": "frontend/components/common/DynamicSearchFilter.tsx",
"accessCount": 3,
"lastAccessed": 1774408732309,
"type": "file"
},
{
"path": "frontend/app/(main)/production/plan-management/page.tsx",
"accessCount": 2,
"lastAccessed": 1774313461313,
"type": "file"
},
{
"path": "frontend/app/(main)",
"accessCount": 2,
"lastAccessed": 1774313529384,
"type": "directory"
},
{
"path": "frontend/lib/api/shipping.ts",
"accessCount": 2,
"lastAccessed": 1774313725308,
"type": "file"
},
{
"path": ".claude/plans/lively-wishing-yeti.md",
"accessCount": 2,
"lastAccessed": 1774313824670,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/shipping-order/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313447495,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/claim/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313450420,
"type": "file"
},
{
"path": "frontend/app/(main)/production/process-info/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313450623,
"type": "file"
},
{
"path": "frontend/components/common/ExcelUploadModal.tsx",
"accessCount": 1,
"lastAccessed": 1774313454238,
"type": "file"
},
{
"path": "frontend/app/(main)/master-data/item-info/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313528166,
"type": "file"
},
{
"path": "frontend/components/common/ShippingPlanModal.tsx",
"accessCount": 1,
"lastAccessed": 1774313925751,
"type": "file"
},
{
"path": "frontend/components/common/TableSettingsModal.tsx",
"accessCount": 1,
"lastAccessed": 1774409034693,
"type": "file"
}
],
"userDirectives": [] "userDirectives": []
} }

View File

@ -0,0 +1,8 @@
{
"session_id": "037169c7-72ba-4843-8e9a-417ca1423715",
"ended_at": "2026-03-26T08:24:13.261Z",
"reason": "other",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}

View File

@ -0,0 +1,8 @@
{
"session_id": "8145031e-d7ea-4aa3-94d7-ddaa69383b8a",
"ended_at": "2026-03-26T09:35:10.082Z",
"reason": "other",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}

View File

@ -0,0 +1,8 @@
{
"session_id": "d2bc3862-569e-4904-a3f9-6b20e3f14c43",
"ended_at": "2026-03-24T01:15:06.127Z",
"reason": "other",
"agents_spawned": 1,
"agents_completed": 1,
"modes_used": []
}

View File

@ -0,0 +1,8 @@
{
"session_id": "d6a10e69-4ebc-48f9-b451-c1d0587badc8",
"ended_at": "2026-03-24T01:15:07.644Z",
"reason": "other",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}

View File

@ -1,6 +0,0 @@
{
"timestamp": "2026-03-04T07:29:57.315Z",
"backgroundTasks": [],
"sessionStartTimestamp": "2026-03-04T07:29:53.176Z",
"sessionId": "591d357c-df9d-4bbc-8dfa-1b98a9184e23"
}

View File

@ -1 +0,0 @@
{"session_id":"591d357c-df9d-4bbc-8dfa-1b98a9184e23","transcript_path":"/Users/johngreen/.claude/projects/-Users-johngreen-Dev-vexplor/591d357c-df9d-4bbc-8dfa-1b98a9184e23.jsonl","cwd":"/Users/johngreen/Dev/vexplor","model":{"id":"claude-opus-4-6","display_name":"Opus 4.6"},"workspace":{"current_dir":"/Users/johngreen/Dev/vexplor","project_dir":"/Users/johngreen/Dev/vexplor","added_dirs":[]},"version":"2.1.66","output_style":{"name":"default"},"cost":{"total_cost_usd":0.516748,"total_duration_ms":65256,"total_api_duration_ms":28107,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":604,"total_output_tokens":838,"context_window_size":200000,"current_usage":{"input_tokens":1,"output_tokens":277,"cache_creation_input_tokens":1836,"cache_read_input_tokens":55498},"used_percentage":29,"remaining_percentage":71},"exceeds_200k_tokens":false}

View File

@ -1,3 +1,3 @@
{ {
"lastSentAt": "2026-03-04T07:30:30.883Z" "lastSentAt": "2026-03-24T02:36:44.477Z"
} }

View File

@ -0,0 +1,53 @@
{
"updatedAt": "2026-03-24T00:51:37.962Z",
"missions": [
{
"id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none",
"source": "session",
"name": "none",
"objective": "Session mission",
"createdAt": "2026-03-24T00:50:40.568Z",
"updatedAt": "2026-03-24T00:51:37.962Z",
"status": "done",
"workerCount": 1,
"taskCounts": {
"total": 1,
"pending": 0,
"blocked": 0,
"inProgress": 0,
"completed": 1,
"failed": 0
},
"agents": [
{
"name": "Explore:a9237b1",
"role": "Explore",
"ownership": "a9237b1b6af985371",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-24T00:51:37.962Z"
}
],
"timeline": [
{
"id": "session-start:a9237b1b6af985371:2026-03-24T00:50:40.568Z",
"at": "2026-03-24T00:50:40.568Z",
"kind": "update",
"agent": "Explore:a9237b1",
"detail": "started Explore:a9237b1",
"sourceKey": "session-start:a9237b1b6af985371"
},
{
"id": "session-stop:a9237b1b6af985371:2026-03-24T00:51:37.962Z",
"at": "2026-03-24T00:51:37.962Z",
"kind": "completion",
"agent": "Explore:a9237b1",
"detail": "completed",
"sourceKey": "session-stop:a9237b1b6af985371"
}
]
}
]
}

View File

@ -13,6 +13,7 @@
"axios": "^1.11.0", "axios": "^1.11.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bwip-js": "^4.8.0", "bwip-js": "^4.8.0",
"cheerio": "^1.2.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"docx": "^9.5.1", "docx": "^9.5.1",
@ -36,6 +37,7 @@
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"oracledb": "^6.9.0", "oracledb": "^6.9.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"playwright": "^1.58.2",
"quill": "^2.0.3", "quill": "^2.0.3",
"react-quill": "^2.0.0", "react-quill": "^2.0.0",
"redis": "^4.6.10", "redis": "^4.6.10",
@ -4408,6 +4410,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bowser": { "node_modules/bowser": {
"version": "2.12.1", "version": "2.12.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz",
@ -4704,6 +4712,79 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cheerio/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/cheerio/node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -5091,6 +5172,34 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -5539,6 +5648,31 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ent": { "node_modules/ent": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
@ -9020,6 +9154,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -9254,6 +9400,55 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseley": { "node_modules/parseley": {
"version": "0.12.1", "version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
@ -9525,6 +9720,50 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postgres-array": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@ -11146,6 +11385,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
"integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@ -11310,6 +11558,40 @@
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View File

@ -27,6 +27,7 @@
"axios": "^1.11.0", "axios": "^1.11.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bwip-js": "^4.8.0", "bwip-js": "^4.8.0",
"cheerio": "^1.2.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"docx": "^9.5.1", "docx": "^9.5.1",
@ -50,6 +51,7 @@
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"oracledb": "^6.9.0", "oracledb": "^6.9.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"playwright": "^1.58.2",
"quill": "^2.0.3", "quill": "^2.0.3",
"react-quill": "^2.0.0", "react-quill": "^2.0.0",
"redis": "^4.6.10", "redis": "^4.6.10",

View File

@ -115,6 +115,7 @@ import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리 import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리 import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
@ -127,6 +128,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머) import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
@ -151,6 +153,7 @@ import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황 import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리 import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
@ -253,12 +256,26 @@ app.use("/api/", limiter);
app.use("/api/", refreshTokenIfNeeded); app.use("/api/", refreshTokenIfNeeded);
// 헬스 체크 엔드포인트 // 헬스 체크 엔드포인트
let appVersion = "unknown";
try {
// 로컬: ../../package.json, Docker(/app/src/): ../package.json
// eslint-disable-next-line @typescript-eslint/no-var-requires
appVersion = require("../../package.json").version;
} catch {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
appVersion = require("../package.json").version;
} catch {
/* version stays "unknown" */
}
}
app.get("/health", (req, res) => { app.get("/health", (req, res) => {
res.status(200).json({ res.status(200).json({
status: "OK", status: "OK",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime: process.uptime(), uptime: process.uptime(),
environment: config.nodeEnv, environment: config.nodeEnv,
version: appVersion,
}); });
}); });
@ -272,6 +289,7 @@ app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/pop", popActionRoutes); // POP 액션 실행 app.use("/api/pop", popActionRoutes); // POP 액션 실행
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리 app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
app.use("/api/pop/inspection-result", inspectionResultRoutes); // POP 검사 결과 관리
app.use("/api/common-codes", commonCodeRoutes); app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes); app.use("/api/files", fileRoutes);
@ -324,6 +342,7 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/production", productionRoutes); // 생산계획 관리 app.use("/api/production", productionRoutes); // 생산계획 관리
app.use("/api/crawl", crawlRoutes); // 웹 크롤링
app.use("/api/material-status", materialStatusRoutes); // 자재현황 app.use("/api/material-status", materialStatusRoutes); // 자재현황
app.use("/api/process-info", processInfoRoutes); // 공정정보관리 app.use("/api/process-info", processInfoRoutes); // 공정정보관리
app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/roles", roleRoutes); // 권한 그룹 관리
@ -353,6 +372,7 @@ app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형) app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
app.use("/api/design", designRoutes); // 설계 모듈 app.use("/api/design", designRoutes); // 설계 모듈
app.use("/api/receiving", receivingRoutes); // 입고관리 app.use("/api/receiving", receivingRoutes); // 입고관리
app.use("/api/outbound", outboundRoutes); // 출고관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
@ -413,6 +433,11 @@ async function initializeServices() {
try { try {
await BatchSchedulerService.initializeScheduler(); await BatchSchedulerService.initializeScheduler();
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`); logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
// 크롤링 스케줄러 초기화
const { CrawlService } = await import("./services/crawlService");
await CrawlService.initializeScheduler();
logger.info(`🕷️ 크롤링 스케줄러가 시작되었습니다.`);
} catch (error) { } catch (error) {
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error); logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
} }

View File

@ -2504,7 +2504,9 @@ export const changeUserStatus = async (
// 필수 파라미터 검증 // 필수 파라미터 검증
if (!userId || !status) { if (!userId || !status) {
res.status(400).json({ res.status(400).json({
success: false,
result: false, result: false,
message: "사용자 ID와 상태는 필수입니다.",
msg: "사용자 ID와 상태는 필수입니다.", msg: "사용자 ID와 상태는 필수입니다.",
}); });
return; return;
@ -2513,7 +2515,9 @@ export const changeUserStatus = async (
// 상태 값 검증 // 상태 값 검증
if (!["active", "inactive"].includes(status)) { if (!["active", "inactive"].includes(status)) {
res.status(400).json({ res.status(400).json({
success: false,
result: false, result: false,
message: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)", msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
}); });
return; return;
@ -2528,7 +2532,9 @@ export const changeUserStatus = async (
if (!currentUser) { if (!currentUser) {
res.status(404).json({ res.status(404).json({
success: false,
result: false, result: false,
message: "사용자를 찾을 수 없습니다.",
msg: "사용자를 찾을 수 없습니다.", msg: "사용자를 찾을 수 없습니다.",
}); });
return; return;
@ -2549,6 +2555,12 @@ export const changeUserStatus = async (
if (updateResult.length > 0) { if (updateResult.length > 0) {
// 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략 // 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
// inactive로 변경 시 기존 JWT 토큰 무효화
if (status === "inactive") {
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateUserTokens(userId);
}
logger.info("사용자 상태 변경 성공", { logger.info("사용자 상태 변경 성공", {
userId, userId,
oldStatus: currentUser.status, oldStatus: currentUser.status,
@ -2571,12 +2583,16 @@ export const changeUserStatus = async (
}); });
res.json({ res.json({
success: true,
result: true, result: true,
message: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`, msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
}); });
} else { } else {
res.status(400).json({ res.status(400).json({
success: false,
result: false, result: false,
message: "사용자 상태 변경에 실패했습니다.",
msg: "사용자 상태 변경에 실패했습니다.", msg: "사용자 상태 변경에 실패했습니다.",
}); });
} }
@ -2587,7 +2603,9 @@ export const changeUserStatus = async (
status: req.body.status, status: req.body.status,
}); });
res.status(500).json({ res.status(500).json({
success: false,
result: false, result: false,
message: "시스템 오류가 발생했습니다.",
msg: "시스템 오류가 발생했습니다.", msg: "시스템 오류가 발생했습니다.",
}); });
} }
@ -2627,12 +2645,214 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
} }
} }
// 추가 유효성 검증
// 1. email 형식 검증 (값이 있는 경우만)
if (userData.email && userData.email.trim() !== "") {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(userData.email.trim())) {
res.status(400).json({
success: false,
message: "이메일 형식이 올바르지 않습니다.",
error: {
code: "INVALID_EMAIL_FORMAT",
details: `Invalid email format: ${userData.email}`,
},
});
return;
}
}
// 2. companyCode 존재 확인 (값이 있는 경우만)
if (userData.companyCode && userData.companyCode.trim() !== "") {
const companyExists = await queryOne<{ company_code: string }>(
`SELECT company_code FROM company_mng WHERE company_code = $1`,
[userData.companyCode.trim()]
);
if (!companyExists) {
res.status(400).json({
success: false,
message: `존재하지 않는 회사 코드입니다: ${userData.companyCode}`,
error: {
code: "INVALID_COMPANY_CODE",
details: `Company code not found: ${userData.companyCode}`,
},
});
return;
}
}
// 3. userType 유효값 검증 (값이 있는 경우만)
if (userData.userType && userData.userType.trim() !== "") {
const validUserTypes = ["SUPER_ADMIN", "COMPANY_ADMIN", "USER", "GUEST", "PARTNER"];
if (!validUserTypes.includes(userData.userType.trim())) {
res.status(400).json({
success: false,
message: `유효하지 않은 사용자 유형입니다: ${userData.userType}. 허용값: ${validUserTypes.join(", ")}`,
error: {
code: "INVALID_USER_TYPE",
details: `Invalid userType: ${userData.userType}. Allowed: ${validUserTypes.join(", ")}`,
},
});
return;
}
}
// 4. 비밀번호 최소 길이 검증 (신규 등록 시)
if (!isUpdate && userData.userPassword && userData.userPassword.length < 4) {
res.status(400).json({
success: false,
message: "비밀번호는 최소 4자 이상이어야 합니다.",
error: {
code: "PASSWORD_TOO_SHORT",
details: "Password must be at least 4 characters long",
},
});
return;
}
// 비밀번호 암호화 (비밀번호가 제공된 경우에만) // 비밀번호 암호화 (비밀번호가 제공된 경우에만)
let encryptedPassword = null; let encryptedPassword = null;
if (userData.userPassword) { if (userData.userPassword) {
encryptedPassword = await EncryptUtil.encrypt(userData.userPassword); encryptedPassword = await EncryptUtil.encrypt(userData.userPassword);
} }
// PUT(수정) 요청 시 company_code / dept_code 변경 감지
if (isUpdate) {
const existingUser = await queryOne<{ company_code: string; dept_code: string }>(
`SELECT company_code, dept_code FROM user_info WHERE user_id = $1`,
[userData.userId]
);
// company_code 변경 감지 → 이전 회사 권한 그룹 제거
if (
userData.companyCode &&
existingUser &&
existingUser.company_code &&
existingUser.company_code !== userData.companyCode
) {
const oldCompanyCode = existingUser.company_code;
logger.info("사용자 회사 코드 변경 감지 - 이전 회사 권한 그룹 제거", {
userId: userData.userId,
oldCompanyCode,
newCompanyCode: userData.companyCode,
});
// 이전 회사의 권한 그룹에서 해당 사용자 제거
await query(
`DELETE FROM authority_sub_user
WHERE user_id = $1
AND master_objid IN (
SELECT objid FROM authority_master WHERE company_code = $2
)`,
[userData.userId, oldCompanyCode]
);
}
// dept_code 변경 감지 → 결재 템플릿/진행중 결재라인 경고 로그
const newDeptCode = userData.deptCode || null;
const oldDeptCode = existingUser?.dept_code || null;
if (existingUser && oldDeptCode && newDeptCode && oldDeptCode !== newDeptCode) {
logger.warn("사용자 부서 변경 감지 - 결재라인 영향 확인 시작", {
userId: userData.userId,
userName: userData.userName,
oldDeptCode,
newDeptCode,
});
try {
// 1) 결재선 템플릿 스텝에서 해당 사용자가 결재자로 등록된 건 조회
const templateSteps = await query<{
template_id: number;
step_order: number;
approver_label: string | null;
approver_dept_code: string | null;
}>(
`SELECT s.template_id, s.step_order, s.approver_label, s.approver_dept_code
FROM approval_line_template_steps s
WHERE s.approver_user_id = $1`,
[userData.userId]
);
if (templateSteps && templateSteps.length > 0) {
logger.warn(
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})가 결재선 템플릿 ${templateSteps.length}건에 결재자로 등록되어 있습니다. 수동 확인이 필요합니다.`,
{
userId: userData.userId,
oldDeptCode,
newDeptCode,
affectedTemplates: templateSteps.map((s) => ({
templateId: s.template_id,
stepOrder: s.step_order,
label: s.approver_label,
currentDeptInStep: s.approver_dept_code,
})),
}
);
}
// 2) 진행중인 결재 요청에서 해당 사용자가 대기중 결재자인 건 조회
const pendingLines = await query<{
request_id: number;
step_order: number;
approver_dept: string | null;
status: string;
}>(
`SELECT l.request_id, l.step_order, l.approver_dept, l.status
FROM approval_lines l
JOIN approval_requests r ON r.request_id = l.request_id
WHERE l.approver_id = $1
AND l.status = 'pending'
AND r.status IN ('in_progress', 'pending')`,
[userData.userId]
);
if (pendingLines && pendingLines.length > 0) {
logger.warn(
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})에게 대기중인 결재 ${pendingLines.length}건이 있습니다. 수동 확인이 필요합니다.`,
{
userId: userData.userId,
oldDeptCode,
newDeptCode,
pendingApprovals: pendingLines.map((l) => ({
requestId: l.request_id,
stepOrder: l.step_order,
currentDeptInLine: l.approver_dept,
})),
}
);
}
// 감사 로그 기록
auditLogService.log({
companyCode: userData.companyCode || req.user?.companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: "DEPT_CHANGE_WARNING",
resourceType: "USER",
resourceId: userData.userId,
resourceName: userData.userName,
summary: `사용자 "${userData.userName}"의 부서 변경 (${oldDeptCode}${newDeptCode}). 결재 템플릿 ${templateSteps?.length || 0}건, 대기중 결재 ${pendingLines?.length || 0}건 영향 가능`,
changes: {
before: { deptCode: oldDeptCode },
after: {
deptCode: newDeptCode,
affectedTemplateCount: templateSteps?.length || 0,
pendingApprovalCount: pendingLines?.length || 0,
},
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
} catch (approvalCheckError) {
// 결재 테이블이 없는 환경에서도 사용자 저장은 계속 진행
logger.warn("결재라인 영향 확인 중 오류 (사용자 저장은 계속 진행)", {
error: approvalCheckError instanceof Error ? approvalCheckError.message : approvalCheckError,
});
}
}
}
// Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT) // Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT)
const updatePasswordClause = encryptedPassword ? "user_password = $4," : ""; const updatePasswordClause = encryptedPassword ? "user_password = $4," : "";
@ -2688,6 +2908,12 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
savedUser.regdate && savedUser.regdate &&
new Date(savedUser.regdate).getTime() < Date.now() - 1000; new Date(savedUser.regdate).getTime() < Date.now() - 1000;
// 기존 사용자의 비밀번호 변경 시 JWT 토큰 무효화
if (encryptedPassword && isExistingUser) {
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateUserTokens(userData.userId);
}
logger.info( logger.info(
isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료",
{ {
@ -3534,6 +3760,10 @@ export const resetUserPassword = async (
if (updateResult.length > 0) { if (updateResult.length > 0) {
// 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략 // 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
// 비밀번호 변경 후 기존 JWT 토큰 무효화
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateUserTokens(userId);
logger.info("비밀번호 초기화 성공", { logger.info("비밀번호 초기화 성공", {
userId, userId,
updatedBy: req.user?.userId, updatedBy: req.user?.userId,
@ -4153,6 +4383,140 @@ export const saveUserWithDept = async (
* GET /api/admin/users/:userId/with-dept * GET /api/admin/users/:userId/with-dept
* + API ( ) * + API ( )
*/ */
/**
* DELETE /api/admin/users/:userId
* API (soft delete)
* status = 'deleted', end_date = now()
* authority_sub_user , JWT
*/
export const deleteUser = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { userId } = req.params;
// 1. userId 파라미터 검증
if (!userId) {
res.status(400).json({
success: false,
result: false,
message: "사용자 ID는 필수입니다.",
});
return;
}
// 2. 자기 자신 삭제 방지
if (req.user?.userId === userId) {
res.status(400).json({
success: false,
result: false,
message: "자기 자신은 삭제할 수 없습니다.",
});
return;
}
// 3. 사용자 존재 여부 확인
const currentUser = await queryOne<any>(
`SELECT user_id, user_name, status, company_code FROM user_info WHERE user_id = $1`,
[userId]
);
if (!currentUser) {
res.status(404).json({
success: false,
result: false,
message: "사용자를 찾을 수 없습니다.",
});
return;
}
// 이미 삭제된 사용자 체크
if (currentUser.status === "deleted") {
res.status(400).json({
success: false,
result: false,
message: "이미 삭제된 사용자입니다.",
});
return;
}
// 4. soft delete: status = 'deleted', end_date = now()
const updateResult = await query<any>(
`UPDATE user_info
SET status = 'deleted', end_date = NOW()
WHERE user_id = $1
RETURNING *`,
[userId]
);
if (updateResult.length === 0) {
res.status(500).json({
success: false,
result: false,
message: "사용자 삭제에 실패했습니다.",
});
return;
}
// 5. authority_sub_user에서 해당 사용자 멤버십 제거
await query(
`DELETE FROM authority_sub_user WHERE user_id = $1`,
[userId]
);
// 6. JWT 토큰 무효화
try {
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateUserTokens(userId);
} catch (tokenError) {
logger.warn("토큰 무효화 중 오류 (삭제는 정상 처리됨)", { userId, error: tokenError });
}
logger.info("사용자 삭제(soft delete) 성공", {
userId,
userName: currentUser.user_name,
deletedBy: req.user?.userId,
});
// 7. 감사 로그 기록
auditLogService.log({
companyCode: currentUser.company_code || req.user?.companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: "DELETE",
resourceType: "USER",
resourceId: userId,
resourceName: currentUser.user_name,
summary: `사용자 "${currentUser.user_name}" (${userId}) 삭제 처리`,
changes: {
before: { status: currentUser.status },
after: { status: "deleted" },
fields: ["status", "end_date"],
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
// 8. 응답
res.json({
success: true,
result: true,
message: `사용자 "${currentUser.user_name}" (${userId})이(가) 삭제되었습니다.`,
});
} catch (error: any) {
logger.error("사용자 삭제 중 오류 발생", {
error: error.message,
userId: req.params.userId,
});
res.status(500).json({
success: false,
result: false,
message: "시스템 오류가 발생했습니다.",
});
}
};
export const getUserWithDept = async ( export const getUserWithDept = async (
req: AuthenticatedRequest, req: AuthenticatedRequest,
res: Response res: Response

View File

@ -0,0 +1,124 @@
import { Request, Response } from "express";
import { CrawlService } from "../services/crawlService";
import { logger } from "../utils/logger";
interface AuthenticatedRequest extends Request {
user?: { companyCode: string; userId: string };
}
// 설정 목록 조회
export async function getCrawlConfigs(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode || "*";
const configs = await CrawlService.getConfigs(companyCode);
return res.json({ success: true, data: configs });
} catch (error: any) {
logger.error("크롤링 설정 조회 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
}
// 설정 상세 조회
export async function getCrawlConfig(req: AuthenticatedRequest, res: Response) {
try {
const config = await CrawlService.getConfigById(req.params.id);
if (!config) return res.status(404).json({ success: false, message: "설정을 찾을 수 없습니다." });
return res.json({ success: true, data: config });
} catch (error: any) {
logger.error("크롤링 설정 상세 조회 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
}
// 설정 생성
export async function createCrawlConfig(req: AuthenticatedRequest, res: Response) {
try {
const data = {
...req.body,
company_code: req.user?.companyCode || req.body.company_code,
writer: req.user?.userId,
};
const config = await CrawlService.createConfig(data);
return res.json({ success: true, data: config });
} catch (error: any) {
logger.error("크롤링 설정 생성 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
}
// 설정 수정
export async function updateCrawlConfig(req: AuthenticatedRequest, res: Response) {
try {
const config = await CrawlService.updateConfig(req.params.id, req.body);
if (!config) return res.status(404).json({ success: false, message: "설정을 찾을 수 없습니다." });
return res.json({ success: true, data: config });
} catch (error: any) {
logger.error("크롤링 설정 수정 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
}
// 설정 삭제
export async function deleteCrawlConfig(req: AuthenticatedRequest, res: Response) {
try {
await CrawlService.deleteConfig(req.params.id);
return res.json({ success: true });
} catch (error: any) {
logger.error("크롤링 설정 삭제 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
}
// 미리보기
export async function previewCrawl(req: AuthenticatedRequest, res: Response) {
try {
const { url, row_selector, column_mappings, method, headers, request_body } = req.body;
if (!url) return res.status(400).json({ success: false, message: "URL은 필수입니다." });
const result = await CrawlService.preview(url, row_selector, column_mappings || [], method, headers, request_body);
return res.json({ success: true, data: result });
} catch (error: any) {
logger.error("크롤링 미리보기 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
}
// URL 자동 분석 — 페이지의 테이블/리스트 구조를 감지
export async function analyzeUrl(req: AuthenticatedRequest, res: Response) {
try {
const { url } = req.body;
if (!url) return res.status(400).json({ success: false, message: "URL은 필수입니다." });
const result = await CrawlService.analyzeUrl(url);
return res.json({ success: true, data: result });
} catch (error: any) {
logger.error("URL 분석 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
}
// 수동 실행
export async function executeCrawl(req: AuthenticatedRequest, res: Response) {
try {
const config = await CrawlService.getConfigById(req.params.id);
if (!config) return res.status(404).json({ success: false, message: "설정을 찾을 수 없습니다." });
const result = await CrawlService.executeCrawl(config);
return res.json({ success: true, data: result });
} catch (error: any) {
logger.error("크롤링 수동 실행 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
}
// 실행 로그 조회
export async function getCrawlLogs(req: AuthenticatedRequest, res: Response) {
try {
const limit = parseInt(req.query.limit as string) || 20;
const logs = await CrawlService.getLogs(req.params.id, limit);
return res.json({ success: true, data: logs });
} catch (error: any) {
logger.error("크롤링 로그 조회 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -561,6 +561,34 @@ export class EntityJoinController {
}); });
} }
} }
/**
* ( )
* GET /api/table-management/tables/:tableName/column-values/:columnName
*/
async getColumnUniqueValues(req: Request, res: Response): Promise<void> {
try {
const { tableName, columnName } = req.params;
const companyCode = (req as any).user?.companyCode;
const data = await tableManagementService.getColumnDistinctValues(
tableName,
columnName,
companyCode
);
res.status(200).json({
success: true,
data,
});
} catch (error) {
logger.error(`컬럼 고유값 조회 실패: ${req.params.tableName}.${req.params.columnName}`, error);
res.status(500).json({
success: false,
message: "컬럼 고유값 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
} }
export const entityJoinController = new EntityJoinController(); export const entityJoinController = new EntityJoinController();

View File

@ -191,18 +191,30 @@ export const getLangKeys = async (
): Promise<void> => { ): Promise<void> => {
try { try {
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query; const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 키 목록 조회 요청", { logger.info("다국어 키 목록 조회 요청", {
query: req.query, query: req.query,
user: req.user, user: req.user,
}); });
// company_code 필터링: 비관리자는 자기 회사 + 공통(*) 키만 조회 가능
let effectiveCompanyCode = companyCode as string;
if (userCompanyCode !== "*") {
// 비관리자가 다른 회사의 데이터를 요청하면 자기 회사로 제한
if (companyCode && companyCode !== userCompanyCode && companyCode !== "*") {
effectiveCompanyCode = userCompanyCode || "";
}
}
const multiLangService = new MultiLangService(); const multiLangService = new MultiLangService();
const langKeys = await multiLangService.getLangKeys({ const langKeys = await multiLangService.getLangKeys({
companyCode: companyCode as string, companyCode: effectiveCompanyCode,
menuCode: menuCode as string, menuCode: menuCode as string,
keyType: keyType as string, keyType: keyType as string,
searchText: searchText as string, searchText: searchText as string,
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined, categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
userCompanyCode: userCompanyCode,
}); });
const response: ApiResponse<any[]> = { const response: ApiResponse<any[]> = {
@ -235,9 +247,24 @@ export const getLangTexts = async (
): Promise<void> => { ): Promise<void> => {
try { try {
const { keyId } = req.params; const { keyId } = req.params;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user }); logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user });
const multiLangService = new MultiLangService(); const multiLangService = new MultiLangService();
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
if (userCompanyCode !== "*") {
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
if (keyOwner && keyOwner !== "*" && keyOwner !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 다국어 키에 접근할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
}
const langTexts = await multiLangService.getLangTexts(parseInt(keyId)); const langTexts = await multiLangService.getLangTexts(parseInt(keyId));
const response: ApiResponse<any[]> = { const response: ApiResponse<any[]> = {
@ -270,6 +297,7 @@ export const createLangKey = async (
): Promise<void> => { ): Promise<void> => {
try { try {
const keyData: CreateLangKeyRequest = req.body; const keyData: CreateLangKeyRequest = req.body;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 키 생성 요청", { keyData, user: req.user }); logger.info("다국어 키 생성 요청", { keyData, user: req.user });
// 필수 입력값 검증 // 필수 입력값 검증
@ -285,6 +313,26 @@ export const createLangKey = async (
return; return;
} }
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
if (keyData.companyCode === "*" && userCompanyCode !== "*") {
res.status(403).json({
success: false,
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
// 비관리자: 자기 회사 키만 생성 가능
if (userCompanyCode !== "*" && keyData.companyCode !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 키를 생성할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
const multiLangService = new MultiLangService(); const multiLangService = new MultiLangService();
const keyId = await multiLangService.createLangKey({ const keyId = await multiLangService.createLangKey({
...keyData, ...keyData,
@ -323,10 +371,33 @@ export const updateLangKey = async (
try { try {
const { keyId } = req.params; const { keyId } = req.params;
const keyData: UpdateLangKeyRequest = req.body; const keyData: UpdateLangKeyRequest = req.body;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user }); logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user });
const multiLangService = new MultiLangService(); const multiLangService = new MultiLangService();
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키는 수정 불가)
if (userCompanyCode !== "*") {
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
if (!keyOwner) {
res.status(404).json({
success: false,
message: "다국어 키를 찾을 수 없습니다.",
error: { code: "KEY_NOT_FOUND" },
});
return;
}
if (keyOwner !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 다국어 키를 수정할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
}
await multiLangService.updateLangKey(parseInt(keyId), { await multiLangService.updateLangKey(parseInt(keyId), {
...keyData, ...keyData,
updatedBy: req.user?.userId || "system", updatedBy: req.user?.userId || "system",
@ -362,9 +433,32 @@ export const deleteLangKey = async (
): Promise<void> => { ): Promise<void> => {
try { try {
const { keyId } = req.params; const { keyId } = req.params;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 키 삭제 요청", { keyId, user: req.user }); logger.info("다국어 키 삭제 요청", { keyId, user: req.user });
const multiLangService = new MultiLangService(); const multiLangService = new MultiLangService();
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키 삭제 불가)
if (userCompanyCode !== "*") {
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
if (!keyOwner) {
res.status(404).json({
success: false,
message: "다국어 키를 찾을 수 없습니다.",
error: { code: "KEY_NOT_FOUND" },
});
return;
}
if (keyOwner !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 다국어 키를 삭제할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
}
await multiLangService.deleteLangKey(parseInt(keyId)); await multiLangService.deleteLangKey(parseInt(keyId));
const response: ApiResponse<string> = { const response: ApiResponse<string> = {
@ -397,9 +491,32 @@ export const toggleLangKey = async (
): Promise<void> => { ): Promise<void> => {
try { try {
const { keyId } = req.params; const { keyId } = req.params;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user }); logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user });
const multiLangService = new MultiLangService(); const multiLangService = new MultiLangService();
// 비관리자: 해당 키가 자기 회사 키인지 검증
if (userCompanyCode !== "*") {
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
if (!keyOwner) {
res.status(404).json({
success: false,
message: "다국어 키를 찾을 수 없습니다.",
error: { code: "KEY_NOT_FOUND" },
});
return;
}
if (keyOwner !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 다국어 키를 변경할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
}
const result = await multiLangService.toggleLangKey(parseInt(keyId)); const result = await multiLangService.toggleLangKey(parseInt(keyId));
const response: ApiResponse<string> = { const response: ApiResponse<string> = {
@ -433,6 +550,7 @@ export const saveLangTexts = async (
try { try {
const { keyId } = req.params; const { keyId } = req.params;
const textData: SaveLangTextsRequest = req.body; const textData: SaveLangTextsRequest = req.body;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user }); logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user });
@ -454,6 +572,28 @@ export const saveLangTexts = async (
} }
const multiLangService = new MultiLangService(); const multiLangService = new MultiLangService();
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
if (userCompanyCode !== "*") {
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
if (!keyOwner) {
res.status(404).json({
success: false,
message: "다국어 키를 찾을 수 없습니다.",
error: { code: "KEY_NOT_FOUND" },
});
return;
}
if (keyOwner !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 다국어 텍스트를 수정할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
}
await multiLangService.saveLangTexts(parseInt(keyId), { await multiLangService.saveLangTexts(parseInt(keyId), {
texts: textData.texts.map((text) => ({ texts: textData.texts.map((text) => ({
...text, ...text,

View File

@ -0,0 +1,509 @@
/**
*
*
* :
* - shipment_instruction + shipment_instruction_detail ()
* - purchase_order_mng (/)
* - item_info ()
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// 출고 목록 조회
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const {
outbound_type,
outbound_status,
search_keyword,
date_from,
date_to,
} = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`om.company_code = $${paramIdx}`);
params.push(companyCode);
paramIdx++;
}
if (outbound_type && outbound_type !== "all") {
conditions.push(`om.outbound_type = $${paramIdx}`);
params.push(outbound_type);
paramIdx++;
}
if (outbound_status && outbound_status !== "all") {
conditions.push(`om.outbound_status = $${paramIdx}`);
params.push(outbound_status);
paramIdx++;
}
if (search_keyword) {
conditions.push(
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})`
);
params.push(`%${search_keyword}%`);
paramIdx++;
}
if (date_from) {
conditions.push(`om.outbound_date >= $${paramIdx}`);
params.push(date_from);
paramIdx++;
}
if (date_to) {
conditions.push(`om.outbound_date <= $${paramIdx}`);
params.push(date_to);
paramIdx++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
om.*,
wh.warehouse_name
FROM outbound_mng om
LEFT JOIN warehouse_info wh
ON om.warehouse_code = wh.warehouse_code
AND om.company_code = wh.company_code
${whereClause}
ORDER BY om.created_date DESC
`;
const pool = getPool();
const result = await pool.query(query, params);
logger.info("출고 목록 조회", {
companyCode,
rowCount: result.rowCount,
});
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 출고 등록 (다건)
export async function create(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { items, outbound_number, outbound_date, warehouse_code, location_code, manager_id, memo } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
}
await client.query("BEGIN");
const insertedRows: any[] = [];
for (const item of items) {
const result = await client.query(
`INSERT INTO outbound_mng (
company_code, outbound_number, outbound_type, outbound_date,
reference_number, customer_code, customer_name,
item_code, item_name, specification, material, unit,
outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, manager_id, memo,
source_type, sales_order_id, shipment_plan_id, item_info_id,
destination_code, delivery_destination, delivery_address,
created_date, created_by, writer, status
) VALUES (
$1, $2, $3, $4,
$5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20, $21,
$22, $23, $24, $25,
$26, $27, $28,
NOW(), $29, $29, '출고'
) RETURNING *`,
[
companyCode,
outbound_number || item.outbound_number,
item.outbound_type,
outbound_date || item.outbound_date,
item.reference_number || null,
item.customer_code || null,
item.customer_name || null,
item.item_code || item.item_number || null,
item.item_name || null,
item.spec || item.specification || null,
item.material || null,
item.unit || "EA",
item.outbound_qty || 0,
item.unit_price || 0,
item.total_amount || 0,
item.lot_number || null,
warehouse_code || item.warehouse_code || null,
location_code || item.location_code || null,
item.outbound_status || "대기",
manager_id || item.manager_id || null,
memo || item.memo || null,
item.source_type || null,
item.sales_order_id || null,
item.shipment_plan_id || null,
item.item_info_id || null,
item.destination_code || null,
item.delivery_destination || null,
item.delivery_address || null,
userId,
]
);
insertedRows.push(result.rows[0]);
// 재고 업데이트 (inventory_stock): 출고 수량 차감
const itemCode = item.item_code || item.item_number || null;
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
const outQty = Number(item.outbound_qty) || 0;
if (itemCode && outQty > 0) {
const existingStock = await client.query(
`SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
if (existingStock.rows.length > 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text),
last_out_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[outQty, existingStock.rows[0].id]
);
} else {
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
await client.query(
`INSERT INTO inventory_stock (
company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_out_date,
created_date, updated_date, writer
) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
[companyCode, itemCode, whCode, locCode, userId]
);
}
}
// 판매출고인 경우 출하지시의 ship_qty 업데이트
if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") {
await client.query(
`UPDATE shipment_instruction_detail
SET ship_qty = COALESCE(ship_qty, 0) + $1,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.outbound_qty || 0, item.source_id, companyCode]
);
}
}
await client.query("COMMIT");
logger.info("출고 등록 완료", {
companyCode,
userId,
count: insertedRows.length,
outbound_number,
});
return res.json({
success: true,
data: insertedRows,
message: `${insertedRows.length}건 출고 등록 완료`,
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("출고 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// 출고 수정
export async function update(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const {
outbound_date, outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, manager_id: mgr, memo,
} = req.body;
const pool = getPool();
const result = await pool.query(
`UPDATE outbound_mng SET
outbound_date = COALESCE($1, outbound_date),
outbound_qty = COALESCE($2, outbound_qty),
unit_price = COALESCE($3, unit_price),
total_amount = COALESCE($4, total_amount),
lot_number = COALESCE($5, lot_number),
warehouse_code = COALESCE($6, warehouse_code),
location_code = COALESCE($7, location_code),
outbound_status = COALESCE($8, outbound_status),
manager_id = COALESCE($9, manager_id),
memo = COALESCE($10, memo),
updated_date = NOW(),
updated_by = $11
WHERE id = $12 AND company_code = $13
RETURNING *`,
[
outbound_date, outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, mgr, memo,
userId, id, companyCode,
]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
}
logger.info("출고 수정", { companyCode, userId, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("출고 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 출고 삭제
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
logger.info("출고 삭제", { companyCode, id });
return res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
logger.error("출고 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 판매출고용: 출하지시 데이터 조회
export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["si.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
sid.id AS detail_id,
si.id AS instruction_id,
si.instruction_no,
si.instruction_date,
si.partner_id,
si.status AS instruction_status,
sid.item_code,
sid.item_name,
sid.spec,
sid.material,
COALESCE(sid.plan_qty, 0) AS plan_qty,
COALESCE(sid.ship_qty, 0) AS ship_qty,
COALESCE(sid.order_qty, 0) AS order_qty,
GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_qty,
sid.source_type
FROM shipment_instruction si
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id
AND si.company_code = sid.company_code
WHERE ${conditions.join(" AND ")}
AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0)
ORDER BY si.instruction_date DESC, si.instruction_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출하지시 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 반품출고용: 발주(입고) 데이터 조회
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
// 입고된 것만 (반품 대상)
conditions.push(
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`
);
if (keyword) {
conditions.push(
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, purchase_no, order_date, supplier_code, supplier_name,
item_code, item_name, spec, material,
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
status, due_date
FROM purchase_order_mng
WHERE ${conditions.join(" AND ")}
ORDER BY order_date DESC, purchase_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("발주 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 기타출고용: 품목 데이터 조회
export async function getItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, item_number, item_name, size AS spec, material, unit,
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
params
);
return res.json({ success: true, data: result.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 {
const companyCode = req.user!.companyCode;
const pool = getPool();
const today = new Date();
const yyyy = today.getFullYear();
const prefix = `OUT-${yyyy}-`;
const result = await pool.query(
`SELECT outbound_number FROM outbound_mng
WHERE company_code = $1 AND outbound_number LIKE $2
ORDER BY outbound_number DESC LIMIT 1`,
[companyCode, `${prefix}%`]
);
let seq = 1;
if (result.rows.length > 0) {
const lastNo = result.rows[0].outbound_number;
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
if (!isNaN(lastSeq)) seq = lastSeq + 1;
}
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
return res.json({ success: true, data: newNumber });
} catch (error: any) {
logger.error("출고번호 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 창고 목록 조회
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const result = await pool.query(
`SELECT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1 AND status != '삭제'
ORDER BY warehouse_name`,
[companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("창고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -173,7 +173,11 @@ export async function getPkgUnitItems(
const pool = getPool(); const pool = getPool();
const result = await pool.query( const result = await pool.query(
`SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`, `SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit
FROM pkg_unit_item pui
LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code
WHERE pui.pkg_code=$1 AND pui.company_code=$2
ORDER BY pui.created_date DESC`,
[pkgCode, companyCode] [pkgCode, companyCode]
); );
@ -410,7 +414,11 @@ export async function getLoadingUnitPkgs(
const pool = getPool(); const pool = getPool();
const result = await pool.query( const result = await pool.query(
`SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`, `SELECT lup.*, pu.pkg_name, pu.pkg_type
FROM loading_unit_pkg lup
LEFT JOIN pkg_unit pu ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code
WHERE lup.loading_code=$1 AND lup.company_code=$2
ORDER BY lup.created_date DESC`,
[loadingCode, companyCode] [loadingCode, companyCode]
); );
@ -476,3 +484,112 @@ export async function deleteLoadingUnitPkg(
res.status(500).json({ success: false, message: error.message }); res.status(500).json({ success: false, message: error.message });
} }
} }
// ──────────────────────────────────────────────
// 품목정보 연동 (division별 item_info 조회)
// ──────────────────────────────────────────────
export async function getItemsByDivision(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { divisionLabel } = req.params;
const { keyword } = req.query;
const pool = getPool();
// division 카테고리에서 해당 라벨의 코드 찾기
const catResult = await pool.query(
`SELECT value_code FROM category_values
WHERE table_name = 'item_info' AND column_name = 'division'
AND value_label = $1 AND company_code = $2
LIMIT 1`,
[divisionLabel, companyCode]
);
if (catResult.rows.length === 0) {
res.json({ success: true, data: [] });
return;
}
const divisionCode = catResult.rows[0].value_code;
const conditions: string[] = ["company_code = $1", `$2 = ANY(string_to_array(division, ','))`];
const params: any[] = [companyCode, divisionCode];
let paramIdx = 3;
if (keyword) {
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
params.push(`%${keyword}%`);
paramIdx++;
}
const result = await pool.query(
`SELECT id, item_number, item_name, size, material, unit, division
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
params
);
logger.info(`품목 조회 (division=${divisionLabel})`, { companyCode, count: result.rowCount });
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// 일반 품목 조회 (포장재/적재함 제외, 매칭용)
export async function getGeneralItems(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const pool = getPool();
// 포장재/적재함 division 코드 조회
const catResult = await pool.query(
`SELECT value_code FROM category_values
WHERE table_name = 'item_info' AND column_name = 'division'
AND value_label IN ('포장재', '적재함') AND company_code = $1`,
[companyCode]
);
const excludeCodes = catResult.rows.map((r: any) => r.value_code);
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (excludeCodes.length > 0) {
// 다중 값(콤마 구분) 지원: 포장재/적재함 코드가 포함된 품목 제외
const excludeConditions = excludeCodes.map((_: any, i: number) => `$${paramIdx + i} = ANY(string_to_array(division, ','))`);
conditions.push(`(division IS NULL OR division = '' OR NOT (${excludeConditions.join(" OR ")}))`);
params.push(...excludeCodes);
paramIdx += excludeCodes.length;
}
if (keyword) {
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
params.push(`%${keyword}%`);
paramIdx++;
}
const result = await pool.query(
`SELECT id, item_number, item_name, size AS spec, material, unit, division
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name
LIMIT 200`,
params
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("일반 품목 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}

File diff suppressed because it is too large Load Diff

View File

@ -148,9 +148,9 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo
const { processCode } = req.params; const { processCode } = req.params;
const result = await pool.query( const result = await pool.query(
`SELECT pe.*, ei.equipment_name `SELECT pe.*, em.equipment_name
FROM process_equipment pe FROM process_equipment pe
LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code
WHERE pe.process_code = $1 AND pe.company_code = $2 WHERE pe.process_code = $1 AND pe.company_code = $2
ORDER BY pe.equipment_code`, ORDER BY pe.equipment_code`,
[processCode, companyCode] [processCode, companyCode]
@ -214,7 +214,7 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response)
const params = companyCode === "*" ? [] : [companyCode]; const params = companyCode === "*" ? [] : [companyCode];
const result = await pool.query( const result = await pool.query(
`SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`, `SELECT id, equipment_code, equipment_name FROM equipment_mng ${condition} ORDER BY equipment_code`,
params params
); );

View File

@ -40,6 +40,28 @@ export async function getStockShortage(req: AuthenticatedRequest, res: Response)
} }
} }
// ─── 생산계획 목록 조회 ───
export async function getPlans(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { productType, status, startDate, endDate, itemCode } = req.query;
const data = await productionService.getPlans(companyCode, {
productType: productType as string,
status: status as string,
startDate: startDate as string,
endDate: endDate as string,
itemCode: itemCode as string,
});
return res.json({ success: true, data });
} catch (error: any) {
logger.error("생산계획 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 생산계획 상세 조회 ─── // ─── 생산계획 상세 조회 ───
export async function getPlanById(req: AuthenticatedRequest, res: Response) { export async function getPlanById(req: AuthenticatedRequest, res: Response) {

View File

@ -170,6 +170,42 @@ export async function create(req: AuthenticatedRequest, res: Response) {
insertedRows.push(result.rows[0]); insertedRows.push(result.rows[0]);
// 재고 업데이트 (inventory_stock): 입고 수량 증가
const itemCode = item.item_number || null;
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
const inQty = Number(item.inbound_qty) || 0;
if (itemCode && inQty > 0) {
const existingStock = await client.query(
`SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
if (existingStock.rows.length > 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
last_in_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[inQty, existingStock.rows[0].id]
);
} else {
await client.query(
`INSERT INTO inventory_stock (
company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date,
created_date, updated_date, writer
) VALUES ($1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`,
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
);
}
}
// 구매입고인 경우 발주의 received_qty 업데이트 // 구매입고인 경우 발주의 received_qty 업데이트
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") { if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
await client.query( await client.query(

View File

@ -472,6 +472,10 @@ export const addRoleMembers = async (
req.user?.userId || "SYSTEM" req.user?.userId || "SYSTEM"
); );
// 권한 변경된 사용자들의 JWT 토큰 무효화
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
const response: ApiResponse<null> = { const response: ApiResponse<null> = {
success: true, success: true,
message: "권한 그룹 멤버 추가 성공", message: "권한 그룹 멤버 추가 성공",
@ -568,6 +572,13 @@ export const updateRoleMembers = async (
); );
} }
// 권한 변경된 사용자들의 JWT 토큰 무효화
const allAffectedUsers = [...new Set([...toAdd, ...toRemove])];
if (allAffectedUsers.length > 0) {
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateMultipleUserTokens(allAffectedUsers);
}
logger.info("권한 그룹 멤버 일괄 업데이트 성공", { logger.info("권한 그룹 멤버 일괄 업데이트 성공", {
masterObjid, masterObjid,
added: toAdd.length, added: toAdd.length,
@ -646,6 +657,10 @@ export const removeRoleMembers = async (
req.user?.userId || "SYSTEM" req.user?.userId || "SYSTEM"
); );
// 권한 변경된 사용자들의 JWT 토큰 무효화
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
const response: ApiResponse<null> = { const response: ApiResponse<null> = {
success: true, success: true,
message: "권한 그룹 멤버 제거 성공", message: "권한 그룹 멤버 제거 성공",
@ -777,6 +792,18 @@ export const setMenuPermissions = async (
req.user?.userId || "SYSTEM" req.user?.userId || "SYSTEM"
); );
// 해당 권한 그룹의 모든 멤버 JWT 토큰 무효화
try {
const members = await RoleService.getRoleMembers(authObjid);
const memberIds = members.map((m: any) => m.userId);
if (memberIds.length > 0) {
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateMultipleUserTokens(memberIds);
}
} catch (invalidateError) {
logger.warn("메뉴 권한 변경 후 토큰 무효화 실패 (권한 설정은 성공)", { invalidateError });
}
const response: ApiResponse<null> = { const response: ApiResponse<null> = {
success: true, success: true,
message: "메뉴 권한 설정 성공", message: "메뉴 권한 설정 성공",

View File

@ -5,6 +5,7 @@ import { Request, Response, NextFunction } from "express";
import { JwtUtils } from "../utils/jwtUtils"; import { JwtUtils } from "../utils/jwtUtils";
import { AuthenticatedRequest, PersonBean } from "../types/auth"; import { AuthenticatedRequest, PersonBean } from "../types/auth";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { TokenInvalidationService } from "../services/tokenInvalidationService";
// AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export // AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export
export { AuthenticatedRequest } from "../types/auth"; export { AuthenticatedRequest } from "../types/auth";
@ -22,11 +23,11 @@ declare global {
* JWT * JWT
* *
*/ */
export const authenticateToken = ( export const authenticateToken = async (
req: AuthenticatedRequest, req: AuthenticatedRequest,
res: Response, res: Response,
next: NextFunction next: NextFunction
): void => { ): Promise<void> => {
try { try {
// Authorization 헤더에서 토큰 추출 // Authorization 헤더에서 토큰 추출
const authHeader = req.get("Authorization"); const authHeader = req.get("Authorization");
@ -46,6 +47,25 @@ export const authenticateToken = (
// JWT 토큰 검증 및 사용자 정보 추출 // JWT 토큰 검증 및 사용자 정보 추출
const userInfo: PersonBean = JwtUtils.verifyToken(token); const userInfo: PersonBean = JwtUtils.verifyToken(token);
// token_version 검증 (JWT payload vs DB)
const decoded = JwtUtils.decodeToken(token);
const tokenVersion = decoded?.tokenVersion;
// tokenVersion이 undefined면 구버전 토큰이므로 통과 (하위 호환)
if (tokenVersion !== undefined) {
const dbVersion = await TokenInvalidationService.getUserTokenVersion(userInfo.userId);
if (tokenVersion !== dbVersion) {
res.status(401).json({
success: false,
error: {
code: "TOKEN_INVALIDATED",
details: "보안 정책에 의해 재로그인이 필요합니다.",
},
});
return;
}
}
// 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일) // 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일)
req.user = userInfo; req.user = userInfo;
@ -173,11 +193,11 @@ export const requireUserOrAdmin = (targetUserId: string) => {
* *
* *
*/ */
export const refreshTokenIfNeeded = ( export const refreshTokenIfNeeded = async (
req: AuthenticatedRequest, req: AuthenticatedRequest,
res: Response, res: Response,
next: NextFunction next: NextFunction
): void => { ): Promise<void> => {
try { try {
const authHeader = req.get("Authorization"); const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1]; const token = authHeader && authHeader.split(" ")[1];
@ -191,6 +211,16 @@ export const refreshTokenIfNeeded = (
// 1시간(3600초) 이내에 만료되는 경우 갱신 // 1시간(3600초) 이내에 만료되는 경우 갱신
if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) { if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) {
// 갱신 전 token_version 검증
if (decoded.tokenVersion !== undefined) {
const dbVersion = await TokenInvalidationService.getUserTokenVersion(decoded.userId);
if (decoded.tokenVersion !== dbVersion) {
// 무효화된 토큰은 갱신하지 않음
next();
return;
}
}
const newToken = JwtUtils.refreshToken(token); const newToken = JwtUtils.refreshToken(token);
// 새로운 토큰을 응답 헤더에 포함 // 새로운 토큰을 응답 헤더에 포함

View File

@ -21,6 +21,7 @@ import {
saveUser, // 사용자 등록/수정 saveUser, // 사용자 등록/수정
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!) saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
getUserWithDept, // 사원 + 부서 조회 (NEW!) getUserWithDept, // 사원 + 부서 조회 (NEW!)
deleteUser, // 사용자 삭제 (soft delete)
getCompanyList, getCompanyList,
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
getCompanyByCode, // 회사 단건 조회 getCompanyByCode, // 회사 단건 조회
@ -62,6 +63,7 @@ router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
router.put("/profile", updateProfile); // 프로필 수정 router.put("/profile", updateProfile); // 프로필 수정
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화 router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화
router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete)
// 부서 관리 API // 부서 관리 API
router.get("/departments", getDepartmentList); // 부서 목록 조회 router.get("/departments", getDepartmentList); // 부서 목록 조회

View File

@ -0,0 +1,32 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getCrawlConfigs,
getCrawlConfig,
createCrawlConfig,
updateCrawlConfig,
deleteCrawlConfig,
previewCrawl,
analyzeUrl,
executeCrawl,
getCrawlLogs,
} from "../controllers/crawlController";
const router = Router();
// 설정 CRUD
router.get("/configs", authenticateToken, getCrawlConfigs);
router.get("/configs/:id", authenticateToken, getCrawlConfig);
router.post("/configs", authenticateToken, createCrawlConfig);
router.put("/configs/:id", authenticateToken, updateCrawlConfig);
router.delete("/configs/:id", authenticateToken, deleteCrawlConfig);
// 분석 & 미리보기 & 실행
router.post("/analyze", authenticateToken, analyzeUrl);
router.post("/preview", authenticateToken, previewCrawl);
router.post("/execute/:id", authenticateToken, executeCrawl);
// 실행 로그
router.get("/configs/:id/logs", authenticateToken, getCrawlLogs);
export default router;

View File

@ -55,6 +55,15 @@ router.get(
entityJoinController.getTableDataWithJoins.bind(entityJoinController) entityJoinController.getTableDataWithJoins.bind(entityJoinController)
); );
/**
* ( )
* GET /api/table-management/tables/:tableName/column-values/:columnName
*/
router.get(
"/tables/:tableName/column-values/:columnName",
entityJoinController.getColumnUniqueValues.bind(entityJoinController)
);
// ======================================== // ========================================
// 🎯 Entity 조인 설정 관리 // 🎯 Entity 조인 설정 관리
// ======================================== // ========================================

View File

@ -0,0 +1,191 @@
import { Router, Request, Response } from "express";
import { getPool } from "../database/db";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
router.use(authenticateToken);
// ---- 검사 기준 조회 (item_inspection_info) ----
// GET /api/pop/inspection-result/info?itemCode=ITEM-001&inspectionType=입고검사
router.get("/info", async (req: Request, res: Response) => {
const pool = getPool();
const companyCode = (req as any).user?.companyCode;
const { itemCode, itemId, inspectionType } = req.query;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보 없음" });
}
const conditions: string[] = ["company_code = $1", "is_active = 'Y'"];
const params: unknown[] = [companyCode];
let idx = 2;
if (itemCode) {
conditions.push(`item_code = $${idx++}`);
params.push(itemCode);
}
if (itemId) {
conditions.push(`item_id = $${idx++}`);
params.push(itemId);
}
if (inspectionType) {
conditions.push(`inspection_type = $${idx++}`);
params.push(inspectionType);
}
const sql = `
SELECT id, item_id, item_code, item_name,
inspection_type, inspection_item_name, inspection_standard,
inspection_method, pass_criteria, is_required, sort_order, memo
FROM item_inspection_info
WHERE ${conditions.join(" AND ")}
ORDER BY sort_order, inspection_item_name
`;
try {
const result = await pool.query(sql, params);
return res.json({ success: true, data: result.rows });
} catch (err: any) {
return res.status(500).json({ success: false, message: err.message });
}
});
// ---- 검사 결과 조회 ----
// GET /api/pop/inspection-result?referenceId=xxx&referenceTable=yyy&screenId=zzz
router.get("/", async (req: Request, res: Response) => {
const pool = getPool();
const companyCode = (req as any).user?.companyCode;
const { referenceId, referenceTable, screenId } = req.query;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보 없음" });
}
const conditions: string[] = ["company_code = $1"];
const params: unknown[] = [companyCode];
let idx = 2;
if (referenceId) {
conditions.push(`reference_id = $${idx++}`);
params.push(referenceId);
}
if (referenceTable) {
conditions.push(`reference_table = $${idx++}`);
params.push(referenceTable);
}
if (screenId) {
conditions.push(`screen_id = $${idx++}`);
params.push(screenId);
}
const sql = `
SELECT *
FROM inspection_result
WHERE ${conditions.join(" AND ")}
ORDER BY created_date DESC
`;
try {
const result = await pool.query(sql, params);
return res.json({ success: true, data: result.rows });
} catch (err: any) {
return res.status(500).json({ success: false, message: err.message });
}
});
// ---- 검사 결과 저장 (INSERT or UPDATE) ----
// POST /api/pop/inspection-result
router.post("/", async (req: Request, res: Response) => {
const pool = getPool();
const companyCode = (req as any).user?.companyCode;
const writer = (req as any).user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보 없음" });
}
const {
referenceTable,
referenceId,
screenId,
itemId,
itemCode,
itemName,
inspectionType,
items, // 검사 항목별 결과 배열
overallJudgment,
memo,
isCompleted,
} = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "검사 항목이 없습니다" });
}
const client = await pool.connect();
try {
await client.query("BEGIN");
// 기존 결과 삭제 (동일 referenceId + referenceTable 기준 덮어쓰기)
if (referenceId && referenceTable) {
await client.query(
`DELETE FROM inspection_result
WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3`,
[companyCode, referenceId, referenceTable]
);
}
const insertedIds: string[] = [];
for (const item of items) {
const completedFlag = isCompleted ? "Y" : "N";
const completedDate = isCompleted ? new Date() : null;
const insertSql = `
INSERT INTO inspection_result (
company_code, writer,
reference_table, reference_id, screen_id,
inspection_info_id, item_id, item_code, item_name,
inspection_type, inspection_item_name, inspection_standard, pass_criteria, is_required,
measured_value, judgment, overall_judgment, memo,
is_completed, completed_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20
)
RETURNING id
`;
const result = await client.query(insertSql, [
companyCode,
writer,
referenceTable || null,
referenceId || null,
screenId || null,
item.inspectionInfoId || null,
itemId || item.itemId || null,
itemCode || item.itemCode || null,
itemName || item.itemName || null,
inspectionType || item.inspectionType || null,
item.inspectionItemName || null,
item.inspectionStandard || null,
item.passCriteria || null,
item.isRequired || "Y",
item.measuredValue || null,
item.judgment || null,
overallJudgment || null,
memo || null,
completedFlag,
completedDate,
]);
insertedIds.push(result.rows[0].id);
}
await client.query("COMMIT");
return res.json({ success: true, data: { ids: insertedIds } });
} catch (err: any) {
await client.query("ROLLBACK");
return res.status(500).json({ success: false, message: err.message });
} finally {
client.release();
}
});
export default router;

View File

@ -0,0 +1,40 @@
/**
*
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as outboundController from "../controllers/outboundController";
const router = Router();
router.use(authenticateToken);
// 출고 목록 조회
router.get("/list", outboundController.getList);
// 출고번호 자동생성
router.get("/generate-number", outboundController.generateNumber);
// 창고 목록 조회
router.get("/warehouses", outboundController.getWarehouses);
// 소스 데이터: 출하지시 (판매출고)
router.get("/source/shipment-instructions", outboundController.getShipmentInstructions);
// 소스 데이터: 발주 (반품출고)
router.get("/source/purchase-orders", outboundController.getPurchaseOrders);
// 소스 데이터: 품목 (기타출고)
router.get("/source/items", outboundController.getItems);
// 출고 등록
router.post("/", outboundController.create);
// 출고 수정
router.put("/:id", outboundController.update);
// 출고 삭제
router.delete("/:id", outboundController.deleteOutbound);
export default router;

View File

@ -5,6 +5,7 @@ import {
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem, getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit, getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg, getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
} from "../controllers/packagingController"; } from "../controllers/packagingController";
const router = Router(); const router = Router();
@ -33,4 +34,8 @@ router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
router.post("/loading-unit-pkgs", createLoadingUnitPkg); router.post("/loading-unit-pkgs", createLoadingUnitPkg);
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg); router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
// 품목정보 연동 (division별)
router.get("/items/general", getGeneralItems);
router.get("/items/:divisionLabel", getItemsByDivision);
export default router; export default router;

View File

@ -3,6 +3,14 @@ import { authenticateToken } from "../middleware/authMiddleware";
import { import {
createWorkProcesses, createWorkProcesses,
controlTimer, controlTimer,
controlGroupTimer,
getDefectTypes,
saveResult,
confirmResult,
getResultHistory,
getAvailableQty,
acceptProcess,
cancelAccept,
} from "../controllers/popProductionController"; } from "../controllers/popProductionController";
const router = Router(); const router = Router();
@ -11,5 +19,13 @@ router.use(authenticateToken);
router.post("/create-work-processes", createWorkProcesses); router.post("/create-work-processes", createWorkProcesses);
router.post("/timer", controlTimer); router.post("/timer", controlTimer);
router.post("/group-timer", controlGroupTimer);
router.get("/defect-types", getDefectTypes);
router.post("/save-result", saveResult);
router.post("/confirm-result", confirmResult);
router.get("/result-history", getResultHistory);
router.get("/available-qty", getAvailableQty);
router.post("/accept-process", acceptProcess);
router.post("/cancel-accept", cancelAccept);
export default router; export default router;

View File

@ -16,6 +16,9 @@ router.get("/order-summary", productionController.getOrderSummary);
// 안전재고 부족분 조회 // 안전재고 부족분 조회
router.get("/stock-shortage", productionController.getStockShortage); router.get("/stock-shortage", productionController.getStockShortage);
// 생산계획 목록 조회
router.get("/plans", productionController.getPlans);
// 생산계획 CRUD // 생산계획 CRUD
router.get("/plan/:id", productionController.getPlanById); router.get("/plan/:id", productionController.getPlanById);
router.put("/plan/:id", productionController.updatePlan); router.put("/plan/:id", productionController.updatePlan);

View File

@ -24,7 +24,8 @@ export type AuditAction =
| "STATUS_CHANGE" | "STATUS_CHANGE"
| "BATCH_CREATE" | "BATCH_CREATE"
| "BATCH_UPDATE" | "BATCH_UPDATE"
| "BATCH_DELETE"; | "BATCH_DELETE"
| "DEPT_CHANGE_WARNING";
export type AuditResourceType = export type AuditResourceType =
| "MENU" | "MENU"

View File

@ -134,12 +134,14 @@ export class AuthService {
company_code: string | null; company_code: string | null;
locale: string | null; locale: string | null;
photo: Buffer | null; photo: Buffer | null;
token_version: number | null;
}>( }>(
`SELECT `SELECT
sabun, user_id, user_name, user_name_eng, user_name_cn, sabun, user_id, user_name, user_name_eng, user_name_cn,
dept_code, dept_name, position_code, position_name, dept_code, dept_name, position_code, position_name,
email, tel, cell_phone, user_type, user_type_name, email, tel, cell_phone, user_type, user_type_name,
partner_objid, company_code, locale, photo partner_objid, company_code, locale, photo,
COALESCE(token_version, 0) as token_version
FROM user_info FROM user_info
WHERE user_id = $1`, WHERE user_id = $1`,
[userId] [userId]
@ -210,6 +212,7 @@ export class AuthService {
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}` ? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
: undefined, : undefined,
locale: userInfo.locale || "KR", locale: userInfo.locale || "KR",
tokenVersion: userInfo.token_version ?? 0,
// 권한 레벨 정보 추가 (3단계 체계) // 권한 레벨 정보 추가 (3단계 체계)
isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN", isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN",
isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*", isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*",

View File

@ -0,0 +1,489 @@
import * as cheerio from "cheerio";
import axios from "axios";
import cron, { ScheduledTask } from "node-cron";
import { query } from "../database/db";
import { logger } from "../utils/logger";
export interface CrawlConfig {
id: string;
company_code: string;
name: string;
url: string;
method: string;
headers: Record<string, string>;
request_body?: string;
selector_type: string;
row_selector: string;
column_mappings: Array<{
selector: string;
column: string;
type: "text" | "number" | "date";
attribute?: string; // href, src 등 속성값 추출
}>;
target_table: string;
upsert_key?: string;
cron_schedule?: string;
is_active: string;
writer?: string;
}
export interface CrawlResult {
collected: number;
saved: number;
errors: string[];
}
const DEFAULT_HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
};
export class CrawlService {
private static scheduledTasks: Map<string, ScheduledTask> = new Map();
// ─── 스케줄러 ───
static async initializeScheduler() {
try {
const configs = await query<CrawlConfig>(
`SELECT * FROM crawl_configs WHERE is_active = 'Y' AND cron_schedule IS NOT NULL AND cron_schedule != ''`
);
logger.info(`크롤링 스케줄러: ${configs.length}개 설정 등록`);
for (const config of configs) {
this.scheduleConfig(config);
}
} catch (error) {
logger.error("크롤링 스케줄러 초기화 실패:", error);
}
}
static scheduleConfig(config: CrawlConfig) {
if (!config.cron_schedule || !cron.validate(config.cron_schedule)) {
logger.warn(`크롤링 [${config.name}]: 유효하지 않은 cron 표현식 - ${config.cron_schedule}`);
return;
}
// 기존 스케줄 제거
if (this.scheduledTasks.has(config.id)) {
this.scheduledTasks.get(config.id)!.stop();
this.scheduledTasks.delete(config.id);
}
const task = cron.schedule(
config.cron_schedule,
async () => {
logger.info(`크롤링 [${config.name}] 스케줄 실행 시작`);
await this.executeCrawl(config);
},
{ timezone: "Asia/Seoul" }
);
this.scheduledTasks.set(config.id, task);
logger.info(`크롤링 [${config.name}] 스케줄 등록: ${config.cron_schedule}`);
}
static unscheduleConfig(configId: string) {
if (this.scheduledTasks.has(configId)) {
this.scheduledTasks.get(configId)!.stop();
this.scheduledTasks.delete(configId);
}
}
// ─── CRUD ───
static async getConfigs(companyCode: string) {
const condition = companyCode === "*" ? "" : "WHERE company_code = $1";
const params = companyCode === "*" ? [] : [companyCode];
return query<CrawlConfig>(`SELECT * FROM crawl_configs ${condition} ORDER BY created_date DESC`, params);
}
static async getConfigById(id: string) {
const rows = await query<CrawlConfig>(`SELECT * FROM crawl_configs WHERE id = $1`, [id]);
return rows[0] || null;
}
static async createConfig(data: Partial<CrawlConfig>) {
const result = await query<CrawlConfig>(
`INSERT INTO crawl_configs (company_code, name, url, method, headers, request_body, selector_type, row_selector, column_mappings, target_table, upsert_key, cron_schedule, is_active, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`,
[
data.company_code,
data.name,
data.url,
data.method || "GET",
JSON.stringify(data.headers || {}),
data.request_body || null,
data.selector_type || "css",
data.row_selector || null,
JSON.stringify(data.column_mappings || []),
data.target_table,
data.upsert_key || null,
data.cron_schedule || null,
data.is_active || "Y",
data.writer || null,
]
);
const config = result[0];
if (config.is_active === "Y" && config.cron_schedule) {
this.scheduleConfig(config);
}
return config;
}
static async updateConfig(id: string, data: Partial<CrawlConfig>) {
const result = await query<CrawlConfig>(
`UPDATE crawl_configs SET
name = COALESCE($2, name),
url = COALESCE($3, url),
method = COALESCE($4, method),
headers = COALESCE($5, headers),
request_body = $6,
selector_type = COALESCE($7, selector_type),
row_selector = $8,
column_mappings = COALESCE($9, column_mappings),
target_table = COALESCE($10, target_table),
upsert_key = $11,
cron_schedule = $12,
is_active = COALESCE($13, is_active),
updated_date = now()
WHERE id = $1 RETURNING *`,
[
id,
data.name,
data.url,
data.method,
data.headers ? JSON.stringify(data.headers) : null,
data.request_body ?? null,
data.selector_type,
data.row_selector ?? null,
data.column_mappings ? JSON.stringify(data.column_mappings) : null,
data.target_table,
data.upsert_key ?? null,
data.cron_schedule ?? null,
data.is_active,
]
);
const config = result[0];
if (config) {
this.unscheduleConfig(id);
if (config.is_active === "Y" && config.cron_schedule) {
this.scheduleConfig(config);
}
}
return config;
}
static async deleteConfig(id: string) {
this.unscheduleConfig(id);
await query(`DELETE FROM crawl_configs WHERE id = $1`, [id]);
}
// ─── 크롤링 실행 ───
static async executeCrawl(config: CrawlConfig): Promise<CrawlResult> {
const logId = await this.createLog(config.id, config.company_code);
const errors: string[] = [];
let collected = 0;
let saved = 0;
try {
// 1. HTTP 요청
const headers = { ...DEFAULT_HEADERS, ...(typeof config.headers === "string" ? JSON.parse(config.headers) : config.headers || {}) };
const response = await axios({
method: (config.method || "GET") as any,
url: config.url,
headers,
data: config.request_body || undefined,
timeout: 30000,
responseType: "text",
});
const html = response.data;
const htmlPreview = typeof html === "string" ? html.substring(0, 2000) : "";
// 2. DOM 파싱
const $ = cheerio.load(html);
const mappings = typeof config.column_mappings === "string"
? JSON.parse(config.column_mappings)
: config.column_mappings || [];
// 3. 행 추출
const rows: Record<string, any>[] = [];
if (config.row_selector) {
$(config.row_selector).each((_, el) => {
const row: Record<string, any> = {};
for (const mapping of mappings) {
const $el = $(el).find(mapping.selector);
const raw = mapping.attribute ? $el.attr(mapping.attribute) || "" : $el.text().trim();
row[mapping.column] = this.castValue(raw, mapping.type);
}
rows.push(row);
});
} else {
// row_selector 없으면 column_mappings의 selector로 직접 추출 (단일 행)
const row: Record<string, any> = {};
for (const mapping of mappings) {
const $el = $(mapping.selector);
const raw = mapping.attribute ? $el.attr(mapping.attribute) || "" : $el.text().trim();
row[mapping.column] = this.castValue(raw, mapping.type);
}
rows.push(row);
}
collected = rows.length;
// 4. DB 저장
for (const row of rows) {
try {
row.company_code = config.company_code;
if (config.upsert_key) {
await this.upsertRow(config.target_table, row, config.upsert_key, config.company_code);
} else {
await this.insertRow(config.target_table, row);
}
saved++;
} catch (err: any) {
errors.push(`행 저장 실패: ${err.message}`);
}
}
// 5. 상태 업데이트
await this.updateLog(logId, "success", collected, saved, null, htmlPreview);
await query(
`UPDATE crawl_configs SET last_executed_at = now(), last_status = 'success', last_error = null WHERE id = $1`,
[config.id]
);
logger.info(`크롤링 [${config.name}] 완료: ${collected}건 수집, ${saved}건 저장`);
} catch (error: any) {
const errMsg = error.message || "Unknown error";
errors.push(errMsg);
await this.updateLog(logId, "fail", collected, saved, errMsg, null);
await query(
`UPDATE crawl_configs SET last_executed_at = now(), last_status = 'fail', last_error = $2 WHERE id = $1`,
[config.id, errMsg]
);
logger.error(`크롤링 [${config.name}] 실패:`, error);
}
return { collected, saved, errors };
}
// ─── URL 자동 분석 ───
static async analyzeUrl(url: string) {
const response = await axios({
method: "GET",
url,
headers: DEFAULT_HEADERS,
timeout: 15000,
responseType: "text",
});
const $ = cheerio.load(response.data);
const tables: Array<{
index: number;
selector: string;
caption: string;
headers: string[];
rowCount: number;
sampleRows: string[][];
}> = [];
// HTML <table> 자동 감지
$("table").each((i, tableEl) => {
const $table = $(tableEl);
// 헤더 추출
const headers: string[] = [];
$table.find("thead th, thead td, tr:first-child th").each((_, th) => {
headers.push($(th).text().trim());
});
// 헤더가 없으면 첫 행에서 추출 시도
if (headers.length === 0) {
$table.find("tr:first-child td").each((_, td) => {
headers.push($(td).text().trim());
});
}
// 데이터 행 수
const bodyRows = $table.find("tbody tr");
const allRows = bodyRows.length > 0 ? bodyRows : $table.find("tr").slice(headers.length > 0 ? 1 : 0);
const rowCount = allRows.length;
// 샘플 (최대 3행)
const sampleRows: string[][] = [];
allRows.slice(0, 3).each((_, tr) => {
const cells: string[] = [];
$(tr).find("td, th").each((_, td) => {
cells.push($(td).text().trim());
});
sampleRows.push(cells);
});
if (headers.length > 0 || rowCount > 0) {
// 선택자 생성
let selector = "table";
const id = $table.attr("id");
const cls = $table.attr("class");
if (id) selector = `table#${id}`;
else if (cls) selector = `table.${cls.split(/\s+/)[0]}`;
else if (i > 0) selector = `table:nth-of-type(${i + 1})`;
const caption = $table.find("caption").text().trim() || $table.attr("summary") || "";
tables.push({
index: i,
selector,
caption,
headers,
rowCount,
sampleRows,
});
}
});
return {
title: $("title").text().trim(),
tableCount: tables.length,
tables,
htmlLength: response.data.length,
};
}
// ─── 미리보기 ───
static async preview(
url: string,
rowSelector: string,
columnMappings: CrawlConfig["column_mappings"],
method = "GET",
headers: Record<string, string> = {},
requestBody?: string
) {
const mergedHeaders = { ...DEFAULT_HEADERS, ...headers };
const response = await axios({
method: method as any,
url,
headers: mergedHeaders,
data: requestBody || undefined,
timeout: 15000,
responseType: "text",
});
const $ = cheerio.load(response.data);
const rows: Record<string, any>[] = [];
if (rowSelector) {
$(rowSelector)
.slice(0, 10) // 미리보기는 10행까지
.each((_, el) => {
const row: Record<string, any> = {};
for (const mapping of columnMappings) {
const $el = $(el).find(mapping.selector);
const raw = mapping.attribute ? $el.attr(mapping.attribute) || "" : $el.text().trim();
row[mapping.column] = this.castValue(raw, mapping.type);
}
rows.push(row);
});
}
return {
totalElements: rowSelector ? $(rowSelector).length : 0,
previewRows: rows,
htmlLength: response.data.length,
};
}
// ─── 유틸 ───
private static castValue(raw: string, type: string): any {
if (!raw) return null;
switch (type) {
case "number": {
const cleaned = raw.replace(/[^0-9.\-]/g, "");
const num = parseFloat(cleaned);
return isNaN(num) ? null : num;
}
case "date":
return raw;
default:
return raw;
}
}
private static async insertRow(tableName: string, row: Record<string, any>) {
const cols = Object.keys(row);
const vals = Object.values(row);
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
const colNames = cols.map((c) => `"${c}"`).join(", ");
await query(`INSERT INTO "${tableName}" (${colNames}) VALUES (${placeholders})`, vals);
}
private static async upsertRow(tableName: string, row: Record<string, any>, upsertKey: string, companyCode: string) {
const existing = await query(
`SELECT 1 FROM "${tableName}" WHERE "${upsertKey}" = $1 AND company_code = $2 LIMIT 1`,
[row[upsertKey], companyCode]
);
if (existing.length > 0) {
const setClauses: string[] = [];
const vals: any[] = [];
let idx = 1;
for (const [k, v] of Object.entries(row)) {
if (k === upsertKey || k === "company_code") continue;
setClauses.push(`"${k}" = $${idx}`);
vals.push(v);
idx++;
}
if (setClauses.length > 0) {
vals.push(row[upsertKey], companyCode);
await query(
`UPDATE "${tableName}" SET ${setClauses.join(", ")}, updated_date = now() WHERE "${upsertKey}" = $${idx} AND company_code = $${idx + 1}`,
vals
);
}
} else {
await this.insertRow(tableName, row);
}
}
private static async createLog(configId: string, companyCode: string): Promise<string> {
const result = await query<any>(
`INSERT INTO crawl_execution_logs (config_id, company_code, status) VALUES ($1, $2, 'running') RETURNING id`,
[configId, companyCode]
);
return result[0].id;
}
private static async updateLog(
logId: string,
status: string,
collected: number,
saved: number,
errorMessage: string | null,
htmlPreview: string | null
) {
await query(
`UPDATE crawl_execution_logs SET status = $2, rows_collected = $3, rows_saved = $4, error_message = $5, response_html_preview = $6, finished_at = now() WHERE id = $1`,
[logId, status, collected, saved, errorMessage, htmlPreview]
);
}
// ─── 로그 조회 ───
static async getLogs(configId: string, limit = 20) {
return query(
`SELECT * FROM crawl_execution_logs WHERE config_id = $1 ORDER BY started_at DESC LIMIT $2`,
[configId, limit]
);
}
}

View File

@ -673,6 +673,22 @@ export class MultiLangService {
} }
} }
/**
* ( )
*/
async getKeyCompanyCode(keyId: number): Promise<string | null> {
try {
const result = await queryOne<{ company_code: string }>(
`SELECT company_code FROM multi_lang_key_master WHERE key_id = $1`,
[keyId]
);
return result?.company_code || null;
} catch (error) {
logger.error("키 소유 회사 코드 조회 실패:", error);
return null;
}
}
/** /**
* *
*/ */
@ -688,6 +704,10 @@ export class MultiLangService {
if (params.companyCode) { if (params.companyCode) {
whereConditions.push(`company_code = $${paramIndex++}`); whereConditions.push(`company_code = $${paramIndex++}`);
values.push(params.companyCode); values.push(params.companyCode);
} else if (params.userCompanyCode && params.userCompanyCode !== "*") {
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
whereConditions.push(`company_code IN ($${paramIndex++}, '*')`);
values.push(params.userCompanyCode);
} }
// 메뉴 코드 필터 // 메뉴 코드 필터

View File

@ -35,6 +35,33 @@ export async function getOrderSummary(
const whereClause = conditions.join(" AND "); const whereClause = conditions.join(" AND ");
// item_info에 lead_time 컬럼이 존재하는지 확인
const leadTimeColCheck = await pool.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true;
const itemLeadTimeCte = hasLeadTime
? `item_lead_time AS (
SELECT
item_number,
id AS item_id,
COALESCE(lead_time::int, 0) AS lead_time
FROM item_info
WHERE company_code = $1
),`
: `item_lead_time AS (
SELECT
item_number,
id AS item_id,
0 AS lead_time
FROM item_info
WHERE company_code = $1
),`;
const query = ` const query = `
WITH order_summary AS ( WITH order_summary AS (
SELECT SELECT
@ -49,6 +76,7 @@ export async function getOrderSummary(
WHERE ${whereClause} WHERE ${whereClause}
GROUP BY so.part_code, so.part_name GROUP BY so.part_code, so.part_name
), ),
${itemLeadTimeCte}
stock_info AS ( stock_info AS (
SELECT SELECT
item_code, item_code,
@ -85,10 +113,12 @@ export async function getOrderSummary(
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0) os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0), - COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
0 0
) AS required_plan_qty ) AS required_plan_qty,
COALESCE(ilt.lead_time, 0) AS lead_time
FROM order_summary os FROM order_summary os
LEFT JOIN stock_info si ON os.item_code = si.item_code LEFT JOIN stock_info si ON os.item_code = si.item_code
LEFT JOIN plan_info pi ON os.item_code = pi.item_code LEFT JOIN plan_info pi ON os.item_code = pi.item_code
LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id)
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""} ${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
ORDER BY os.item_code; ORDER BY os.item_code;
`; `;
@ -155,6 +185,80 @@ export async function getStockShortage(companyCode: string) {
return result.rows; return result.rows;
} }
// ─── 생산계획 목록 조회 ───
export async function getPlans(
companyCode: string,
options?: {
productType?: string;
status?: string;
startDate?: string;
endDate?: string;
itemCode?: string;
}
) {
const pool = getPool();
const conditions: string[] = ["p.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (companyCode !== "*") {
// 일반 회사: 자사 데이터만
} else {
// 최고관리자: 전체 데이터 (company_code 조건 제거)
conditions.length = 0;
}
if (options?.productType) {
conditions.push(`COALESCE(p.product_type, '완제품') = $${paramIdx}`);
params.push(options.productType);
paramIdx++;
}
if (options?.status && options.status !== "all") {
conditions.push(`p.status = $${paramIdx}`);
params.push(options.status);
paramIdx++;
}
if (options?.startDate) {
conditions.push(`p.end_date >= $${paramIdx}::date`);
params.push(options.startDate);
paramIdx++;
}
if (options?.endDate) {
conditions.push(`p.start_date <= $${paramIdx}::date`);
params.push(options.endDate);
paramIdx++;
}
if (options?.itemCode) {
conditions.push(`(p.item_code ILIKE $${paramIdx} OR p.item_name ILIKE $${paramIdx})`);
params.push(`%${options.itemCode}%`);
paramIdx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
p.id, p.company_code, p.plan_no, p.plan_date,
p.item_code, p.item_name, p.product_type,
p.plan_qty, p.completed_qty, p.progress_rate,
p.start_date, p.end_date, p.due_date,
p.equipment_id, p.equipment_code, p.equipment_name,
p.status, p.priority, p.work_shift,
p.work_order_no, p.manager_name,
p.order_no, p.parent_plan_id, p.remarks,
p.hourly_capacity, p.daily_capacity, p.lead_time,
p.created_date, p.updated_date
FROM production_plan_mng p
${whereClause}
ORDER BY p.start_date ASC, p.item_code ASC
`;
const result = await pool.query(query, params);
logger.info("생산계획 목록 조회", { companyCode, count: result.rowCount });
return result.rows;
}
// ─── 생산계획 CRUD ─── // ─── 생산계획 CRUD ───
export async function getPlanById(companyCode: string, planId: number) { export async function getPlanById(companyCode: string, planId: number) {
@ -267,49 +371,68 @@ export async function previewSchedule(
const deletedSchedules: any[] = []; const deletedSchedules: any[] = [];
const keptSchedules: any[] = []; const keptSchedules: any[] = [];
for (const item of items) { // 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행
if (options.recalculate_unstarted) { if (options.recalculate_unstarted) {
// 삭제 대상(planned) 상세 조회 const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
for (const itemCode of uniqueItemCodes) {
const deleteResult = await pool.query( const deleteResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status `SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
FROM production_plan_mng FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2 WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3 AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`, AND status = 'planned'`,
[companyCode, item.item_code, productType] [companyCode, itemCode, productType]
); );
deletedSchedules.push(...deleteResult.rows); deletedSchedules.push(...deleteResult.rows);
// 유지 대상(진행중 등) 상세 조회
const keptResult = await pool.query( const keptResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty `SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
FROM production_plan_mng FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2 WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3 AND COALESCE(product_type, '완제품') = $3
AND status NOT IN ('planned', 'completed', 'cancelled')`, AND status NOT IN ('planned', 'completed', 'cancelled')`,
[companyCode, item.item_code, productType] [companyCode, itemCode, productType]
); );
keptSchedules.push(...keptResult.rows); keptSchedules.push(...keptResult.rows);
} }
}
for (const item of items) {
const dailyCapacity = item.daily_capacity || 800; const dailyCapacity = item.daily_capacity || 800;
const itemLeadTime = item.lead_time || 0;
// 프론트에서 이미 전체 잔량 기준으로 계산하여 보내므로 그대로 사용
// (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨)
const requiredQty = item.required_qty; const requiredQty = item.required_qty;
if (requiredQty <= 0) continue; if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity); // 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
const dueDate = new Date(item.earliest_due_date); const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate); let startDate: Date;
endDate.setDate(endDate.getDate() - safetyLeadTime); let endDate: Date;
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays); if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
if (startDate < today) { if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime()); startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime()); endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays); endDate.setDate(endDate.getDate() + duration);
} }
// 해당 품목의 수주 건수 확인 // 해당 품목의 수주 건수 확인
@ -326,10 +449,11 @@ export async function previewSchedule(
required_qty: requiredQty, required_qty: requiredQty,
daily_capacity: dailyCapacity, daily_capacity: dailyCapacity,
hourly_capacity: item.hourly_capacity || 100, hourly_capacity: item.hourly_capacity || 100,
production_days: productionDays, production_days: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity),
start_date: startDate.toISOString().split("T")[0], start_date: startDate.toISOString().split("T")[0],
end_date: endDate.toISOString().split("T")[0], end_date: endDate.toISOString().split("T")[0],
due_date: item.earliest_due_date, due_date: item.earliest_due_date,
lead_time: itemLeadTime,
order_count: orderCount, order_count: orderCount,
status: "planned", status: "planned",
}); });
@ -343,7 +467,7 @@ export async function previewSchedule(
}; };
logger.info("자동 스케줄 미리보기", { companyCode, summary }); logger.info("자동 스케줄 미리보기", { companyCode, summary });
return { summary, previews, deletedSchedules, keptSchedules }; return { summary, schedules: previews, deletedSchedules, keptSchedules };
} }
export async function generateSchedule( export async function generateSchedule(
@ -363,10 +487,22 @@ export async function generateSchedule(
let deletedCount = 0; let deletedCount = 0;
let keptCount = 0; let keptCount = 0;
const newSchedules: any[] = []; const newSchedules: any[] = [];
const deletedQtyByItem = new Map<string, number>();
// 같은 item_code에 대한 삭제는 한 번만 수행
if (options.recalculate_unstarted) {
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
for (const itemCode of uniqueItemCodes) {
const deletedQtyResult = await client.query(
`SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, itemCode, productType]
);
deletedQtyByItem.set(itemCode, parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0);
for (const item of items) {
// 기존 미진행(planned) 스케줄 처리
if (options.recalculate_unstarted) {
const deleteResult = await client.query( const deleteResult = await client.query(
`DELETE FROM production_plan_mng `DELETE FROM production_plan_mng
WHERE company_code = $1 WHERE company_code = $1
@ -374,7 +510,7 @@ export async function generateSchedule(
AND COALESCE(product_type, '완제품') = $3 AND COALESCE(product_type, '완제품') = $3
AND status = 'planned' AND status = 'planned'
RETURNING id`, RETURNING id`,
[companyCode, item.item_code, productType] [companyCode, itemCode, productType]
); );
deletedCount += deleteResult.rowCount || 0; deletedCount += deleteResult.rowCount || 0;
@ -384,32 +520,48 @@ export async function generateSchedule(
AND item_code = $2 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3 AND COALESCE(product_type, '완제품') = $3
AND status NOT IN ('planned', 'completed', 'cancelled')`, AND status NOT IN ('planned', 'completed', 'cancelled')`,
[companyCode, item.item_code, productType] [companyCode, itemCode, productType]
); );
keptCount += parseInt(keptResult.rows[0].cnt, 10); keptCount += parseInt(keptResult.rows[0].cnt, 10);
} }
}
// 생산일수 계산 for (const item of items) {
// 필요 수량 계산 (삭제된 planned 수량을 비율로 분배)
const dailyCapacity = item.daily_capacity || 800; const dailyCapacity = item.daily_capacity || 800;
const itemLeadTime = item.lead_time || 0;
// 프론트에서 이미 전체 잔량 기준으로 계산하여 보내므로 그대로 사용
// (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨)
const requiredQty = item.required_qty; const requiredQty = item.required_qty;
if (requiredQty <= 0) continue; if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity); // 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
// 시작일 = 납기일 - 생산일수 - 안전리드타임
const dueDate = new Date(item.earliest_due_date); const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate); let startDate: Date;
endDate.setDate(endDate.getDate() - safetyLeadTime); let endDate: Date;
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays); if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
// 시작일이 오늘보다 이전이면 오늘로 조정 // 시작일이 오늘보다 이전이면 오늘로 조정
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
if (startDate < today) { if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime()); startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime()); endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays); endDate.setDate(endDate.getDate() + duration);
} }
// 계획번호 생성 (YYYYMMDD-NNNN 형식) // 계획번호 생성 (YYYYMMDD-NNNN 형식)
@ -576,13 +728,24 @@ async function getBomChildItems(
companyCode: string, companyCode: string,
itemCode: string itemCode: string
) { ) {
// item_info에 lead_time 컬럼 존재 여부 확인
const colCheck = await client.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = colCheck.rows[0]?.has_lead_time === true;
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time::int, 0)" : "0";
const bomQuery = ` const bomQuery = `
SELECT SELECT
bd.child_item_id, bd.child_item_id,
ii.item_name AS child_item_name, ii.item_name AS child_item_name,
ii.item_number AS child_item_code, ii.item_number AS child_item_code,
bd.quantity AS bom_qty, bd.quantity AS bom_qty,
bd.unit bd.unit,
${leadTimeCol} AS child_lead_time
FROM bom b FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
@ -641,9 +804,12 @@ export async function previewSemiSchedule(
if (requiredQty <= 0) continue; if (requiredQty <= 0) continue;
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date; const semiDueDate = plan.start_date;
const semiEndDate = new Date(plan.start_date);
const semiStartDate = new Date(plan.start_date); const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
previews.push({ previews.push({
parent_plan_id: plan.id, parent_plan_id: plan.id,
@ -653,13 +819,14 @@ export async function previewSemiSchedule(
item_name: bomItem.child_item_name || bomItem.child_item_id, item_name: bomItem.child_item_name || bomItem.child_item_id,
plan_qty: requiredQty, plan_qty: requiredQty,
bom_qty: parseFloat(bomItem.bom_qty) || 1, bom_qty: parseFloat(bomItem.bom_qty) || 1,
lead_time: childLeadTime,
start_date: semiStartDate.toISOString().split("T")[0], start_date: semiStartDate.toISOString().split("T")[0],
end_date: typeof semiDueDate === "string" end_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0] ? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0], : semiEndDate.toISOString().split("T")[0],
due_date: typeof semiDueDate === "string" due_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0] ? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0], : semiEndDate.toISOString().split("T")[0],
product_type: "반제품", product_type: "반제품",
status: "planned", status: "planned",
}); });
@ -683,7 +850,7 @@ export async function previewSemiSchedule(
parent_count: plansResult.rowCount, parent_count: plansResult.rowCount,
}; };
return { summary, previews, deletedSchedules, keptSchedules }; return { summary, schedules: previews, deletedSchedules, keptSchedules };
} }
// ─── 반제품 계획 자동 생성 ─── // ─── 반제품 계획 자동 생성 ───
@ -740,10 +907,12 @@ export async function generateSemiSchedule(
if (requiredQty <= 0) continue; if (requiredQty <= 0) continue;
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date; const semiDueDate = plan.start_date;
const semiEndDate = plan.start_date; const semiEndDate = plan.start_date;
const semiStartDate = new Date(plan.start_date); const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품) // plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
const planNoResult = await client.query( const planNoResult = await client.query(

View File

@ -1,4 +1,4 @@
import { query } from "../database/db"; import { query, transaction } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
/** /**
@ -145,10 +145,19 @@ export class RoleService {
writer: string; writer: string;
}): Promise<RoleGroup> { }): Promise<RoleGroup> {
try { try {
// 동일 회사 내 같은 이름의 권한 그룹 중복 체크
const dupCheck = await query<{ count: string }>(
`SELECT COUNT(*) AS count FROM authority_master WHERE company_code = $1 AND auth_name = $2`,
[data.companyCode, data.authName]
);
if (dupCheck.length > 0 && parseInt(dupCheck[0].count, 10) > 0) {
throw new Error(`동일 회사 내에 이미 같은 이름의 권한 그룹이 존재합니다: ${data.authName}`);
}
const sql = ` const sql = `
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate) INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW()) VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW())
RETURNING objid, auth_name AS "authName", auth_code AS "authCode", RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
company_code AS "companyCode", status, writer, regdate company_code AS "companyCode", status, writer, regdate
`; `;
@ -460,35 +469,37 @@ export class RoleService {
writer: string writer: string
): Promise<void> { ): Promise<void> {
try { try {
// 기존 권한 삭제 await transaction(async (client) => {
await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [ // 기존 권한 삭제
authObjid, await client.query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
]); authObjid,
// 새로운 권한 삽입
if (permissions.length > 0) {
const values = permissions
.map(
(_, index) =>
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
)
.join(", ");
const params = permissions.flatMap((p) => [
p.menuObjid,
p.createYn,
p.readYn,
p.updateYn,
p.deleteYn,
]); ]);
const sql = ` // 새로운 권한 삽입
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate) if (permissions.length > 0) {
VALUES ${values} const values = permissions
`; .map(
(_, index) =>
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
)
.join(", ");
await query(sql, [authObjid, ...params, writer]); const params = permissions.flatMap((p) => [
} p.menuObjid,
p.createYn,
p.readYn,
p.updateYn,
p.deleteYn,
]);
const sql = `
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
VALUES ${values}
`;
await client.query(sql, [authObjid, ...params, writer]);
}
});
logger.info("메뉴 권한 설정 성공", { logger.info("메뉴 권한 설정 성공", {
authObjid, authObjid,

View File

@ -5957,7 +5957,21 @@ export class ScreenManagementService {
return null; return null;
} }
const layoutData = layout.layout_data; let layoutData = layout.layout_data;
// 이중 래핑 감지 및 자동 언래핑
// layout_data 컬럼에 { version, layout_data: { components, ... } } 형태로 저장된 경우
// 실제 레이아웃은 내부 layout_data에 있으므로 언래핑한다
if (
layoutData &&
layoutData.layout_data &&
typeof layoutData.layout_data === "object" &&
!layoutData.components &&
layoutData.layout_data.components
) {
console.log(`POP 레이아웃 이중 래핑 감지 (screen_id=${screenId}), 자동 언래핑`);
layoutData = layoutData.layout_data;
}
// v1 → v2 자동 마이그레이션 // v1 → v2 자동 마이그레이션
if (layoutData && layoutData.version === "pop-1.0") { if (layoutData && layoutData.version === "pop-1.0") {
@ -5994,10 +6008,22 @@ export class ScreenManagementService {
console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`); console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
// 이중 래핑 방지: { version, layout_data: { components, ... } } 형태로 전달된 경우 언래핑
if (
layoutData &&
layoutData.layout_data &&
typeof layoutData.layout_data === "object" &&
!layoutData.components &&
layoutData.layout_data.components
) {
console.log(`저장 시 이중 래핑 감지 (screen_id=${screenId}), 자동 언래핑`);
layoutData = layoutData.layout_data;
}
// v5 그리드 레이아웃만 지원 // v5 그리드 레이아웃만 지원
const componentCount = Object.keys(layoutData.components || {}).length; const componentCount = Object.keys(layoutData.components || {}).length;
console.log(`컴포넌트: ${componentCount}`); console.log(`컴포넌트: ${componentCount}`);
// v5 형식 검증 // v5 형식 검증
if (layoutData.version && layoutData.version !== "pop-5.0") { if (layoutData.version && layoutData.version !== "pop-5.0") {
console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`); console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`);

View File

@ -211,7 +211,8 @@ class TableCategoryValueService {
created_at AS "createdAt", created_at AS "createdAt",
updated_at AS "updatedAt", updated_at AS "updatedAt",
created_by AS "createdBy", created_by AS "createdBy",
updated_by AS "updatedBy" updated_by AS "updatedBy",
path
FROM category_values FROM category_values
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
@ -1441,7 +1442,7 @@ class TableCategoryValueService {
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
if (companyCode === "*") { if (companyCode === "*") {
query = ` query = `
SELECT DISTINCT value_code, value_label SELECT DISTINCT value_code, value_label, path
FROM category_values FROM category_values
WHERE value_code IN (${placeholders1}) WHERE value_code IN (${placeholders1})
`; `;
@ -1449,7 +1450,7 @@ class TableCategoryValueService {
} else { } else {
const companyIdx = n + 1; const companyIdx = n + 1;
query = ` query = `
SELECT DISTINCT value_code, value_label SELECT DISTINCT value_code, value_label, path
FROM category_values FROM category_values
WHERE value_code IN (${placeholders1}) WHERE value_code IN (${placeholders1})
AND (company_code = $${companyIdx} OR company_code = '*') AND (company_code = $${companyIdx} OR company_code = '*')
@ -1460,10 +1461,15 @@ class TableCategoryValueService {
const result = await pool.query(query, params); const result = await pool.query(query, params);
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선) // { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
// path가 있고 '/'를 포함하면(depth>1) 전체 경로를 ' > ' 구분자로 표시
const labels: Record<string, string> = {}; const labels: Record<string, string> = {};
for (const row of result.rows) { for (const row of result.rows) {
if (!labels[row.value_code]) { if (!labels[row.value_code]) {
labels[row.value_code] = row.value_label; if (row.path && row.path.includes('/')) {
labels[row.value_code] = row.path.replace(/\//g, ' > ');
} else {
labels[row.value_code] = row.value_label;
}
} }
} }

View File

@ -1575,7 +1575,7 @@ export class TableManagementService {
switch (operator) { switch (operator) {
case "equals": case "equals":
return { return {
whereClause: `${columnName}::text = $${paramIndex}`, whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
values: [actualValue], values: [actualValue],
paramCount: 1, paramCount: 1,
}; };
@ -1859,10 +1859,10 @@ export class TableManagementService {
}; };
} }
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행 // select 필터(equals)인 경우 — 다중 값(콤마 구분) 지원
if (operator === "equals") { if (operator === "equals") {
return { return {
whereClause: `${columnName}::text = $${paramIndex}`, whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
values: [String(value)], values: [String(value)],
paramCount: 1, paramCount: 1,
}; };
@ -2717,6 +2717,43 @@ export class TableManagementService {
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`); logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
} }
// entity 컬럼의 display_column 자동 채우기 (예: supplier_code → supplier_name)
try {
const companyCode = data.company_code || "*";
const entityColsResult = await query<any>(
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'entity'
AND reference_table IS NOT NULL AND reference_table != ''
AND display_column IS NOT NULL AND display_column != ''
AND company_code IN ($2, '*')
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
[tableName, companyCode]
);
for (const ec of entityColsResult) {
const srcVal = data[ec.column_name];
const displayCol = ec.display_column;
// display_column이 테이블에 존재하고, 값이 비어있거나 없으면 자동 조회
if (srcVal && columnTypeMap.has(displayCol) && (!data[displayCol] || data[displayCol] === "")) {
try {
const refResult = await query<any>(
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
[srcVal, companyCode]
);
if (refResult.length > 0 && refResult[0][displayCol]) {
data[displayCol] = refResult[0][displayCol];
logger.info(`Entity auto-fill: ${tableName}.${displayCol} = ${data[displayCol]} (from ${ec.reference_table})`);
}
} catch (refErr: any) {
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
}
}
}
} catch (entityErr: any) {
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
}
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시) // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
const skippedColumns: string[] = []; const skippedColumns: string[] = [];
const existingColumns = Object.keys(data).filter((col) => { const existingColumns = Object.keys(data).filter((col) => {
@ -2868,6 +2905,42 @@ export class TableManagementService {
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`); logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
} }
// entity 컬럼의 display_column 자동 채우기 (수정 시)
try {
const companyCode = updatedData.company_code || originalData.company_code || "*";
const entityColsResult = await query<any>(
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'entity'
AND reference_table IS NOT NULL AND reference_table != ''
AND display_column IS NOT NULL AND display_column != ''
AND company_code IN ($2, '*')
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
[tableName, companyCode]
);
for (const ec of entityColsResult) {
const srcVal = updatedData[ec.column_name];
const displayCol = ec.display_column;
if (srcVal && columnTypeMap.has(displayCol) && (!updatedData[displayCol] || updatedData[displayCol] === "")) {
try {
const refResult = await query<any>(
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
[srcVal, companyCode]
);
if (refResult.length > 0 && refResult[0][displayCol]) {
updatedData[displayCol] = refResult[0][displayCol];
logger.info(`Entity auto-fill (edit): ${tableName}.${displayCol} = ${updatedData[displayCol]}`);
}
} catch (refErr: any) {
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
}
}
}
} catch (entityErr: any) {
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
}
// SET 절 생성 (수정할 데이터) - 먼저 생성 // SET 절 생성 (수정할 데이터) - 먼저 생성
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외) // 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
const setConditions: string[] = []; const setConditions: string[] = [];
@ -3357,16 +3430,20 @@ export class TableManagementService {
const safeColumn = `main."${columnName}"`; const safeColumn = `main."${columnName}"`;
switch (operator) { switch (operator) {
case "equals": case "equals": {
const safeVal = String(value).replace(/'/g, "''");
filterConditions.push( filterConditions.push(
`${safeColumn} = '${String(value).replace(/'/g, "''")}'` `('${safeVal}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal}')`
); );
break; break;
case "not_equals": }
case "not_equals": {
const safeVal2 = String(value).replace(/'/g, "''");
filterConditions.push( filterConditions.push(
`${safeColumn} != '${String(value).replace(/'/g, "''")}'` `NOT ('${safeVal2}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal2}')`
); );
break; break;
}
case "in": { case "in": {
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (inArr.length > 0) { if (inArr.length > 0) {
@ -3408,6 +3485,31 @@ export class TableManagementService {
case "is_not_null": case "is_not_null":
filterConditions.push(`${safeColumn} IS NOT NULL`); filterConditions.push(`${safeColumn} IS NOT NULL`);
break; break;
case "not_contains":
filterConditions.push(
`${safeColumn}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`
);
break;
case "greater_than":
filterConditions.push(
`(${safeColumn})::numeric > ${parseFloat(String(value))}`
);
break;
case "less_than":
filterConditions.push(
`(${safeColumn})::numeric < ${parseFloat(String(value))}`
);
break;
case "greater_or_equal":
filterConditions.push(
`(${safeColumn})::numeric >= ${parseFloat(String(value))}`
);
break;
case "less_or_equal":
filterConditions.push(
`(${safeColumn})::numeric <= ${parseFloat(String(value))}`
);
break;
} }
} }
@ -3424,6 +3526,89 @@ export class TableManagementService {
} }
} }
// 🆕 filterGroups 처리 (런타임 필터 빌더 - 그룹별 AND/OR 지원)
if (
options.dataFilter &&
options.dataFilter.filterGroups &&
options.dataFilter.filterGroups.length > 0
) {
const groupConditions: string[] = [];
for (const group of options.dataFilter.filterGroups) {
if (!group.conditions || group.conditions.length === 0) continue;
const conditions: string[] = [];
for (const condition of group.conditions) {
const { columnName, operator, value } = condition;
if (!columnName) continue;
const safeCol = `main."${columnName}"`;
switch (operator) {
case "equals":
conditions.push(`${safeCol}::text = '${String(value).replace(/'/g, "''")}'`);
break;
case "not_equals":
conditions.push(`${safeCol}::text != '${String(value).replace(/'/g, "''")}'`);
break;
case "contains":
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}%'`);
break;
case "not_contains":
conditions.push(`${safeCol}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`);
break;
case "starts_with":
conditions.push(`${safeCol}::text LIKE '${String(value).replace(/'/g, "''")}%'`);
break;
case "ends_with":
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}'`);
break;
case "greater_than":
conditions.push(`(${safeCol})::numeric > ${parseFloat(String(value))}`);
break;
case "less_than":
conditions.push(`(${safeCol})::numeric < ${parseFloat(String(value))}`);
break;
case "greater_or_equal":
conditions.push(`(${safeCol})::numeric >= ${parseFloat(String(value))}`);
break;
case "less_or_equal":
conditions.push(`(${safeCol})::numeric <= ${parseFloat(String(value))}`);
break;
case "is_null":
conditions.push(`(${safeCol} IS NULL OR ${safeCol}::text = '')`);
break;
case "is_not_null":
conditions.push(`(${safeCol} IS NOT NULL AND ${safeCol}::text != '')`);
break;
case "in": {
const inArr = Array.isArray(value) ? value : [String(value)];
if (inArr.length > 0) {
const vals = inArr.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(", ");
conditions.push(`${safeCol}::text IN (${vals})`);
}
break;
}
}
}
if (conditions.length > 0) {
const logic = group.logic === "OR" ? " OR " : " AND ";
groupConditions.push(`(${conditions.join(logic)})`);
}
}
if (groupConditions.length > 0) {
const groupWhere = groupConditions.join(" AND ");
whereClause = whereClause
? `${whereClause} AND ${groupWhere}`
: groupWhere;
logger.info(`🔍 필터 그룹 적용 (Entity 조인): ${groupWhere}`);
}
}
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외) // 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
if (options.excludeFilter && options.excludeFilter.enabled) { if (options.excludeFilter && options.excludeFilter.enabled) {
const { const {
@ -5387,4 +5572,40 @@ export class TableManagementService {
return []; return [];
} }
} }
/**
* ( )
*/
async getColumnDistinctValues(
tableName: string,
columnName: string,
companyCode?: string
): Promise<{ value: string; label: string }[]> {
try {
// 테이블명/컬럼명 안전성 검증 (영문, 숫자, 언더스코어만 허용)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
logger.warn(`잘못된 테이블/컬럼명: ${tableName}.${columnName}`);
return [];
}
let sql = `SELECT DISTINCT "${columnName}"::text as value FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND "${columnName}"::text != ''`;
const params: any[] = [];
if (companyCode) {
params.push(companyCode);
sql += ` AND "company_code" = $${params.length}`;
}
sql += ` ORDER BY value LIMIT 500`;
const rows = await query<{ value: string }>(sql, params);
return rows.map((row) => ({
value: row.value,
label: row.value,
}));
} catch (error) {
logger.error(`컬럼 고유값 조회 실패: ${tableName}.${columnName}`, error);
return [];
}
}
} }

View File

@ -0,0 +1,75 @@
// JWT 토큰 무효화 서비스
// user_info.token_version 기반으로 기존 JWT 토큰을 무효화
import { query } from "../database/db";
import { cache } from "../utils/cache";
import { logger } from "../utils/logger";
const TOKEN_VERSION_CACHE_TTL = 2 * 60 * 1000; // 2분 캐시
export class TokenInvalidationService {
/**
*
*/
static cacheKey(userId: string): string {
return `token_version:${userId}`;
}
/**
* (token_version +1)
*/
static async invalidateUserTokens(userId: string): Promise<void> {
try {
await query(
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id = $1`,
[userId]
);
cache.delete(this.cacheKey(userId));
logger.info(`토큰 무효화: ${userId}`);
} catch (error) {
logger.error(`토큰 무효화 실패: ${userId}`, { error });
}
}
/**
*
*/
static async invalidateMultipleUserTokens(userIds: string[]): Promise<void> {
if (userIds.length === 0) return;
try {
const placeholders = userIds.map((_, i) => `$${i + 1}`).join(", ");
await query(
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id IN (${placeholders})`,
userIds
);
userIds.forEach((id) => cache.delete(this.cacheKey(id)));
logger.info(`토큰 일괄 무효화: ${userIds.length}`);
} catch (error) {
logger.error(`토큰 일괄 무효화 실패`, { error, userIds });
}
}
/**
* token_version ( )
*/
static async getUserTokenVersion(userId: string): Promise<number> {
const cacheKey = this.cacheKey(userId);
const cached = cache.get<number>(cacheKey);
if (cached !== null) {
return cached;
}
try {
const result = await query<{ token_version: number | null }>(
`SELECT token_version FROM user_info WHERE user_id = $1`,
[userId]
);
const version = result.length > 0 ? (result[0].token_version ?? 0) : 0;
cache.set(cacheKey, version, TOKEN_VERSION_CACHE_TTL);
return version;
} catch (error) {
logger.error(`token_version 조회 실패: ${userId}`, { error });
return 0;
}
}
}

View File

@ -64,6 +64,7 @@ export interface PersonBean {
companyName?: string; // 회사명 추가 companyName?: string; // 회사명 추가
photo?: string; photo?: string;
locale?: string; locale?: string;
tokenVersion?: number; // JWT 토큰 무효화용 버전
// 권한 레벨 정보 (3단계 체계) // 권한 레벨 정보 (3단계 체계)
isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN') isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN')
isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN') isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN')
@ -98,6 +99,7 @@ export interface JwtPayload {
companyName?: string; // 회사명 추가 companyName?: string; // 회사명 추가
userType?: string; userType?: string;
userTypeName?: string; userTypeName?: string;
tokenVersion?: number; // JWT 토큰 무효화용 버전
iat?: number; iat?: number;
exp?: number; exp?: number;
aud?: string; aud?: string;

View File

@ -140,6 +140,7 @@ export interface GetLangKeysParams {
includeOverrides?: boolean; includeOverrides?: boolean;
page?: number; page?: number;
limit?: number; limit?: number;
userCompanyCode?: string; // 요청 사용자의 회사 코드 (비관리자 필터링용)
} }
export interface GetUserTextParams { export interface GetUserTextParams {

View File

@ -20,6 +20,7 @@ export class JwtUtils {
companyName: userInfo.companyName, // 회사명 추가 companyName: userInfo.companyName, // 회사명 추가
userType: userInfo.userType, userType: userInfo.userType,
userTypeName: userInfo.userTypeName, userTypeName: userInfo.userTypeName,
tokenVersion: userInfo.tokenVersion ?? 0,
}; };
return jwt.sign(payload, config.jwt.secret, { return jwt.sign(payload, config.jwt.secret, {

View File

@ -0,0 +1,763 @@
# MES 구조 및 PC-POP 연동 가이드
> 작성일: 2026-03-20
> 대상: PC 화면 개발자 / POP 연동 담당자
> 목적: PC에서 작업지시를 등록할 때, POP(생산실적관리)에 공정이 자동 연동되는 전체 구조를 설명
---
## 1. 전체 구조 개요
### 1.1 시스템 구성
```
[PC 영역 - 브라우저] [POP 영역 - 태블릿]
작업지시 등록 화면 생산실적관리 화면 (4480)
(screen 4155, 4493) 카드 리스트 + 상세 모달
| |
| (1) work_instruction INSERT | (3) 카드 리스트 조회
| (2) create-work-processes 호출 | (4) 접수/실적/확정
v v
=================================================================
[백엔드 - Express + PostgreSQL]
/api/data/work_instruction (범용 CRUD)
/api/pop/production/* (MES 전용 API 10개)
=================================================================
```
### 1.2 데이터 흐름 요약
```
PC 등록 서버 자동 처리 POP 표시
--------- ---------------- ----------
1. 품목 선택 3. 라우팅 공정 조회 5. 카드 목록
2. 작업지시 INSERT ---> 4. work_order_process 6. 접수
+ create-work-processes N건 일괄 INSERT ---> 7. 실적 입력
API 호출 + process_work_result 8. 완료 확정
체크리스트 복사
```
---
## 2. DB 테이블 상세 구조
### 2.1 테이블 관계도
```
[마스터 데이터 - PC에서 사전 등록]
item_info process_mng defect_standard_mng
(품목 마스터) (공정 마스터) (불량 유형 마스터)
| |
v v
item_routing_version -----> item_routing_detail
(품목별 라우팅 버전) (공정 순서 정의)
|
v
process_work_item -----> process_work_item_detail
(공정별 작업항목) (체크리스트 상세)
[트랜잭션 데이터 - PC 등록 + POP 실행]
work_instruction ─── 1:N ──> work_order_process ─── 1:N ──> process_work_result
(작업지시 마스터) (공정별 작업 단위) (체크리스트 결과)
|
parent_process_id (자기참조)
|
work_order_process (분할 행)
(접수/재작업 분할 카드)
|
work_order_process_log
(변경 이력 - 트리거 자동)
```
### 2.2 work_instruction (작업지시 마스터) - 19컬럼
> PC에서 등록하는 핵심 테이블. POP에서는 읽기 전용으로 참조.
| 컬럼 | 타입 | 기본값 | 역할 | 비고 |
|------|------|--------|------|------|
| `id` | varchar | gen_random_uuid() | PK | UUID 자동 생성 |
| `work_instruction_no` | varchar | - | 작업지시번호 | 사용자 채번 (예: WI-20260320-001) |
| `item_id` | varchar | - | 품목 FK | -> item_info.id |
| `status` | varchar | - | 작업지시 상태 | waiting / in_progress / completed / cancelled |
| `progress_status` | varchar | - | 진행상태 | POP에서 완료 시 'completed'로 자동 갱신 |
| `qty` | varchar | - | 지시수량 | 핵심. POP 접수 상한의 기준 |
| `completed_qty` | varchar | '0' | 완성수량 | 마지막 공정 양품 합계로 자동 갱신 |
| `routing` | varchar | - | 라우팅 참조 | 현재 미사용 (비어있음) |
| `worker` | varchar | - | 작업자 | |
| `work_team` | varchar | - | 작업팀 | |
| `equipment_id` | varchar | - | 설비 FK | |
| `start_date` | varchar | - | 시작일 | |
| `end_date` | varchar | - | 종료일(납기) | |
| `reason` | varchar | - | 사유 | |
| `remark` | text | - | 비고 | |
| `company_code` | varchar | - | 멀티테넌시 | 필수 |
| `created_date` | timestamp | now() | 생성일 | |
| `updated_date` | timestamp | now() | 수정일 | |
| `writer` | varchar | - | 작성자 | |
### 2.3 work_order_process (공정별 작업 단위) - 37컬럼
> create-work-processes API 호출 시 자동 생성. POP에서 접수/실적/완료를 처리하는 핵심 테이블.
| 컬럼 그룹 | 컬럼 | 타입 | 기본값 | 역할 |
|-----------|------|------|--------|------|
| **연결** | `wo_id` | varchar | - | -> work_instruction.id (작업지시 FK) |
| | `seq_no` | varchar | - | 공정 순서 (1, 2, 3...) |
| | `routing_detail_id` | varchar | - | -> item_routing_detail.id (라우팅 스냅샷) |
| | `parent_process_id` | varchar | NULL | 분할 시 마스터 행 참조 (NULL = 마스터) |
| **공정정보** | `process_code` | varchar | - | 공정코드 (예: P002) |
| | `process_name` | varchar | - | 공정명 (예: 가공) |
| | `is_required` | varchar | - | 필수 여부 |
| | `is_fixed_order` | varchar | - | 순서 고정 여부 |
| | `standard_time` | varchar | - | 표준시간 |
| | `equipment_code` | varchar | - | 사용 설비 |
| **수량** | `plan_qty` | varchar | - | 계획수량 |
| | `input_qty` | varchar | - | 접수량 (접수 시 설정) |
| | `good_qty` | varchar | - | 양품수량 (누적) |
| | `defect_qty` | varchar | - | 불량수량 (누적) |
| | `total_production_qty` | varchar | - | 총 생산수량 (누적) |
| | `concession_qty` | varchar | '0' | 특채수량 (양품에 합산 + 별도 추적) |
| **상태** | `status` | varchar | - | waiting / acceptable / in_progress / completed |
| | `result_status` | varchar | 'draft' | draft / confirmed |
| **타이머** | `started_at` | varchar | - | 작업 시작 시각 |
| | `paused_at` | varchar | - | 일시정지 시각 |
| | `total_paused_time` | varchar | 0 | 누적 일시정지 시간(초) |
| | `completed_at` | varchar | - | 완료 시각 |
| | `actual_work_time` | varchar | NULL | 실 작업시간(초) |
| **작업자** | `accepted_by` | varchar | - | 접수자 |
| | `accepted_at` | varchar | - | 접수 시각 |
| | `completed_by` | varchar | NULL | 완료 처리자 |
| **실적** | `defect_detail` | varchar | - | 불량 상세 JSON (코드/수량/처분) |
| | `result_note` | varchar | - | 실적 메모 |
| | `attachments` | varchar | - | 첨부파일 |
| **재작업** | `is_rework` | varchar | 'N' | 재작업 카드 여부 (Y/N) |
| | `rework_source_id` | varchar | NULL | 재작업 원본 행 참조 |
| **표준** | `company_code`, `created_date`, `updated_date`, `writer`, `remark` | - | - | 표준 컬럼 |
### 2.4 process_work_result (체크리스트/검사 결과) - 35컬럼
> 공정별 체크리스트. create-work-processes 시 마스터 템플릿에서 복사.
| 컬럼 그룹 | 컬럼 | 역할 |
|-----------|------|------|
| **연결** | `work_order_process_id` | -> work_order_process.id |
| | `source_work_item_id` | -> process_work_item.id (템플릿 원본) |
| | `source_detail_id` | -> process_work_item_detail.id (템플릿 상세) |
| **작업항목** | `work_phase` | 작업 단계 (PRE/IN/POST) |
| | `item_title` | 작업항목 제목 |
| | `item_sort_order` | 항목 정렬 순서 |
| **검사 상세** | `detail_content` | 검사 내용 |
| | `detail_type` | 상세 유형 |
| | `detail_sort_order` | 상세 정렬 순서 |
| | `is_required` | 필수 여부 |
| **검사 기준** | `inspection_code` | 검사 코드 |
| | `inspection_method` | 검사 방법 |
| | `unit` | 단위 |
| | `lower_limit` / `upper_limit` | 하한/상한 |
| **입력** | `input_type` | 입력 유형 |
| | `lookup_target` | 조회 대상 |
| | `display_fields` | 표시 필드 |
| | `duration_minutes` | 소요시간(분) |
| **결과** | `result_value` | 입력 결과값 |
| | `is_passed` | 합격 여부 |
| | `status` | pending / completed |
| | `recorded_by` | 기록자 |
| | `recorded_at` | 기록 시각 |
| **그룹 타이머** | `group_started_at`, `group_paused_at` | 그룹 시작/정지 |
| | `group_total_paused_time`, `group_completed_at` | 누적 정지/완료 |
### 2.5 마스터 데이터 테이블
#### item_info (품목 마스터)
| 주요 컬럼 | 역할 |
|-----------|------|
| `id` | PK (UUID) |
| `item_number` | 품목코드 (라우팅 연결 키) |
| `item_name` | 품목명 |
| `type` | 품목 유형 |
| `division` | 구분 |
#### process_mng (공정 마스터)
| 주요 컬럼 | 역할 |
|-----------|------|
| `process_code` | 공정코드 (PK 역할) |
| `process_name` | 공정명 (예: 가공, 검사, 포장) |
| `process_type` | 공정 유형 |
| `use_yn` | 사용 여부 |
#### item_routing_version (품목별 라우팅 버전)
| 주요 컬럼 | 역할 |
|-----------|------|
| `id` | PK (UUID) |
| `item_code` | -> item_info.item_number |
| `version_name` | 버전명 (예: 기본 라우팅, v1) |
| `is_default` | 기본 버전 여부 (boolean) |
#### item_routing_detail (공정 순서 정의)
| 주요 컬럼 | 역할 |
|-----------|------|
| `id` | PK (UUID) |
| `routing_version_id` | -> item_routing_version.id |
| `seq_no` | 공정 순서 (1, 2, 3...) |
| `process_code` | -> process_mng.process_code |
| `is_required` | 필수 여부 |
| `is_fixed_order` | 순서 고정 여부 |
| `standard_time` | 표준시간 |
---
## 3. PC에서 작업지시 등록 -> POP 연동 흐름
### 3.1 전체 시퀀스 다이어그램
```
[PC 사용자] [프론트엔드] [백엔드] [DB]
| | | |
| 1. 품목 선택 | | |
|------------------>| | |
| | 2. 라우팅 버전 조회 | |
| |--- GET /api/data/ | |
| | item_routing_ |--- SELECT |
| | version | item_routing_ |
| | ?item_code=XXX ->| version -->|
| |<-------------------|<----------------------|
| | | |
| 3. 정보 입력 | | |
| (수량/납기/etc) | | |
|------------------>| | |
| | | |
| 4. "등록" 클릭 | | |
|------------------>| | |
| | 5. 작업지시 INSERT | |
| |--- POST /api/data/ | |
| | work_instruction |--- INSERT |
| | --->| work_instruction -->|
| |<-------------------|<-- RETURNING id ------|
| | | |
| | 6. 공정 일괄 생성 | |
| |--- POST /api/pop/ | |
| | production/ | |
| | create-work- | |
| | processes -->| |
| | |-- SELECT item_routing |
| | | _detail + process_mng|
| | |<----------------------|
| | | |
| | |-- FOR EACH 공정: |
| | | INSERT work_order_ |
| | | process -->|
| | | INSERT process_work_ |
| | | result (체크리스트)->|
| | | |
| |<-- 성공 응답 ------| |
|<-- 등록 완료 -----| | |
| | | |
| | [이 시점부터 POP에서 조회 가능] |
```
### 3.2 Step 1: 작업지시 INSERT (필수)
**API**: `POST /api/data/work_instruction`
**필수 데이터**:
```json
{
"item_id": "품목 UUID (item_info.id)",
"qty": "지시수량 (예: 500)",
"status": "waiting",
"work_instruction_no": "작업지시번호 (예: WI-20260320-001)"
}
```
**선택 데이터**:
```json
{
"worker": "작업자",
"work_team": "작업팀",
"equipment_id": "설비 UUID",
"start_date": "시작일",
"end_date": "종료일(납기)",
"remark": "비고"
}
```
**응답 예시**:
```json
{
"success": true,
"data": {
"id": "a1b2c3d4-...",
"work_instruction_no": "WI-20260320-001",
"status": "waiting"
}
}
```
> 이 시점에서는 work_instruction 행만 생성되고, POP에서 공정 카드가 표시되지 않는다.
### 3.3 Step 2: 공정 일괄 생성 (핵심 - 반드시 호출해야 POP 연동됨)
**API**: `POST /api/pop/production/create-work-processes`
**인증**: JWT 토큰 필수 (Authorization 헤더)
**필수 파라미터**:
| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `work_instruction_id` | string | Step 1에서 받은 작업지시 ID |
| `routing_version_id` | string | 선택한 라우팅 버전 UUID |
**선택 파라미터**:
| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `item_code` | string | 품목코드 (참고용) |
| `plan_qty` | string | 계획수량 (work_order_process.plan_qty에 저장) |
**요청 예시**:
```json
{
"work_instruction_id": "a1b2c3d4-...작업지시ID",
"routing_version_id": "e5f6g7h8-...라우팅버전ID",
"plan_qty": "500"
}
```
**서버 내부 동작**:
1. **중복 방지**: 해당 work_instruction_id로 이미 공정이 있으면 409 에러
2. **라우팅 조회**: `item_routing_detail` + `process_mng` JOIN으로 공정 목록 취득
3. **공정별 INSERT**: seq_no 순서대로 work_order_process 행 생성
- 1공정: `status = 'acceptable'` (POP에서 즉시 접수 가능)
- 2~N공정: `status = 'waiting'` (앞공정 완료 대기)
4. **체크리스트 복사**: 각 공정의 `routing_detail_id`에 연결된 `process_work_item` + `process_work_item_detail``process_work_result`로 복사
**응답 예시**:
```json
{
"success": true,
"data": {
"processes": [
{ "id": "uuid-1", "seq_no": "1", "process_name": "가공", "checklist_count": 3 },
{ "id": "uuid-2", "seq_no": "2", "process_name": "검사", "checklist_count": 5 },
{ "id": "uuid-3", "seq_no": "3", "process_name": "포장", "checklist_count": 2 }
],
"total_processes": 3,
"total_checklists": 10
}
}
```
**에러 케이스**:
| HTTP | 상황 | 메시지 |
|------|------|--------|
| 400 | 필수 파라미터 누락 | "work_instruction_id와 routing_version_id는 필수입니다." |
| 409 | 이미 공정이 존재 | "이미 공정이 생성된 작업지시입니다." |
| 404 | 라우팅에 공정 없음 | "라우팅 버전에 등록된 공정이 없습니다." |
### 3.4 Step 1 + Step 2를 하나의 트랜잭션으로 묶는 방법
> PC 화면에서 "등록" 버튼 1회 클릭으로 두 API를 순차 호출해야 한다.
```javascript
// PC 프론트엔드 예시 코드
async function registerWorkInstruction(formData) {
// Step 1: 작업지시 INSERT
const wiResponse = await apiClient.post("/api/data/work_instruction", {
item_id: formData.itemId,
qty: formData.qty,
status: "waiting",
work_instruction_no: formData.wiNo,
worker: formData.worker,
start_date: formData.startDate,
end_date: formData.endDate,
});
if (!wiResponse.data.success) {
throw new Error("작업지시 등록 실패: " + wiResponse.data.message);
}
const workInstructionId = wiResponse.data.data.id;
// Step 2: 공정 일괄 생성 (이것이 POP 연동의 핵심)
const processResponse = await apiClient.post(
"/api/pop/production/create-work-processes",
{
work_instruction_id: workInstructionId,
routing_version_id: formData.routingVersionId,
plan_qty: formData.qty,
}
);
if (!processResponse.data.success) {
// 실패 시 작업지시도 삭제 또는 상태 변경 필요
throw new Error("공정 생성 실패: " + processResponse.data.message);
}
return {
workInstruction: wiResponse.data.data,
processes: processResponse.data.data.processes,
};
}
```
### 3.5 PC 화면에서 필요한 사전 데이터 조회
#### 3.5.1 품목 목록 조회
```
GET /api/data/item_info
```
#### 3.5.2 선택한 품목의 라우팅 버전 목록 조회
```
GET /api/data/item_routing_version?item_code={선택한 품목의 item_number}
```
> `is_default = true`인 버전을 자동 선택하면 UX가 좋다.
#### 3.5.3 라우팅 버전의 공정 목록 미리보기 (선택사항)
```
GET /api/data/item_routing_detail?routing_version_id={선택한 버전 ID}
```
> 등록 전에 사용자에게 "이 라우팅에 포함된 공정 목록"을 보여줄 수 있다.
---
## 4. POP 워크플로우 상세
### 4.1 공정 상태 전이 다이어그램
```
[PC 등록]
|
v
waiting ---------> acceptable ---------> in_progress ---------> completed
(대기중) (접수가능) (진행중) (완료)
| | |
| (접수 취소) | | (실적 저장 -> 자동완료)
|<--------------------+ |
| | (수동 확정)
| +----> completed + confirmed
|
| (앞공정에서 양품 발생 시 자동 전환)
|
waiting ---> acceptable
```
**상태별 의미**:
| 상태 | 의미 | POP 탭 | 전환 조건 |
|------|------|--------|----------|
| `waiting` | 앞공정 미완료, 접수 불가 | 대기 탭 | 앞공정 양품 발생 시 -> acceptable |
| `acceptable` | 접수 가능, 작업자 대기 | 접수가능 탭 | 작업자가 접수 시 -> in_progress (분할 행) |
| `in_progress` | 작업 진행 중 | 진행 탭 | 실적 전량 생산 시 -> completed (자동) |
| `completed` | 작업 완료 | 완료 탭 | 수동 확정 또는 자동 완료 |
### 4.2 수량 계산 공식
```
접수가능량 = 앞공정.양품합계 - 내공정.접수합계
= (앞공정 SUM(good_qty + concession_qty)) - (내공정 분할행 SUM(input_qty))
(1공정은 앞공정.양품합계 = work_instruction.qty)
양품 = 생산수량 - 불량수량
= production_qty - defect_qty
(서버에서 계산, 클라이언트 값은 참고만)
특채(concession) = 불량 중 disposition='accept'인 항목 합계
양품에는 포함되지 않으나, 다음 공정 전달량에 합산
자동완료 조건:
- 분할 행: total_production_qty >= input_qty
- 마스터 행: 모든 분할 행 completed + 잔여 접수가능 <= 0
- 작업지시: 마지막 seq_no의 모든 행 completed
```
### 4.3 접수(Accept) 상세
```
[접수가능 카드 클릭] -> [수량 입력 모달] -> [접수 확인]
|
v
POST /api/pop/production/accept-process
body: { work_order_process_id: 마스터행ID,
accept_qty: 접수수량 }
|
v
[분할 행 INSERT]
- parent_process_id = 마스터행ID
- input_qty = 접수수량
- status = 'in_progress'
- accepted_by = 로그인 사용자
- 체크리스트 복사
|
v
[POP 카드 갱신]
- 새 분할 카드가 "진행중" 탭에 표시
- 잔여 접수가능량이 남으면 마스터 카드도 "접수가능"에 유지
```
### 4.4 실적 저장(Save Result) 상세
```
[진행중 카드 클릭] -> [상세 모달 열림] -> [실적 입력]
|
생산수량 + 불량상세(코드/수량/처분) 입력
|
v
POST /api/pop/production/save-result
body: { work_order_process_id: 분할행ID,
production_qty: 생산수량,
defect_detail: [
{ defect_code, defect_name, qty, disposition }
] }
|
v
[서버 처리]
1. 양품/불량/특채 서버 계산
2. 기존 수량에 누적 (total_production_qty += production_qty)
3. 불량 상세 JSON 병합
4. disposition='rework' -> 재작업 카드 자동 생성
5. 양품 발생 -> 다음 공정 마스터 acceptable로 전환
6. 접수분 전량 생산 -> 분할 행 자동 completed
7. 모든 분할 행 완료 + 잔여 0 -> 마스터 자동 completed
8. 마지막 공정 전부 완료 -> work_instruction 완료
```
**불량 처분(disposition) 3종**:
| 처분 | 의미 | 수량 영향 |
|------|------|----------|
| `scrap` (폐기) | 폐기 처리 | 불량 집계에 포함, 양품에서 차감 |
| `rework` (재작업) | 같은 공정에서 재작업 | 재작업 카드 자동 생성, 양품에서 차감 |
| `accept` (특채) | 조건부 합격 | concession_qty에 기록, 다음 공정 전달량에 합산 |
### 4.5 확정(Confirm Result) 상세
```
수동 확정 = 접수분 전량 미생산이지만 강제로 완료 처리
(예: 생산 중단, 일부만 완료 등)
POST /api/pop/production/confirm-result
body: { work_order_process_id: 분할행ID }
결과:
- status = 'completed'
- result_status = 'confirmed'
- 양품 있으면 다음 공정 활성화
- 마스터/작업지시 캐스케이드 완료 판정
```
---
## 5. MES 전용 API 엔드포인트 정리
**베이스 URL**: `/api/pop/production`
**인증**: 모든 엔드포인트에 JWT 토큰 필수
| 순서 | 메서드 | URL | 용도 | 호출 주체 |
|------|--------|-----|------|----------|
| 1 | POST | `/create-work-processes` | 작업지시에 공정 일괄 생성 | PC |
| 2 | POST | `/accept-process` | 공정 접수 (분할 행 생성) | POP |
| 3 | POST | `/cancel-accept` | 접수 취소 | POP |
| 4 | POST | `/save-result` | 실적 저장 (누적) | POP |
| 5 | POST | `/confirm-result` | 실적 확정 (수동 완료) | POP |
| 6 | GET | `/available-qty` | 접수가능량 조회 | POP |
| 7 | GET | `/result-history` | 차수별 실적 이력 | POP |
| 8 | GET | `/defect-types` | 불량 유형 목록 | POP |
| 9 | POST | `/timer` | 공정 타이머 (시작/정지/완료) | POP |
| 10 | POST | `/group-timer` | 그룹 타이머 (체크리스트별) | POP |
---
## 6. PC 화면 현황 (COMPANY_7 탑씰)
### 6.1 등록된 화면 목록
| 화면 ID | 이름 | 메인 테이블 | 용도 |
|---------|------|------------|------|
| 4155 | 작업지시 목록 | work_instruction | 목록 조회 |
| 4493 | 작업지시 등록화면 | work_instruction | 신규 등록 |
| 4156 | 수주 선택 | sales_order_detail | 모달 (수주 참조) |
| 4157 | 적용 확인 | work_instruction | 모달 (등록 확인) |
### 6.2 메뉴 구조
```
생산관리 (COMPANY_7)
├── 생산옵션설정 (/screens/1606)
├── 생산계획 (/screens/3985)
├── 작업지시 (/production/work-instruction) <-- React 페이지 미구현
├── 공정정보관리 (/production/process-info)
├── 생산실적 (하위 없음)
└── 생산리포트 (/admin/report/production)
```
### 6.3 현재 미구현 사항 (PC 개발 필요)
1. **라우팅 버전 선택 UI**: 품목 선택 시 해당 품목의 라우팅 버전 목록을 표시하고 선택하는 기능
2. **create-work-processes 호출 연동**: 작업지시 등록 시 자동으로 공정 생성 API를 호출하는 로직
3. **work_instruction.routing 컬럼 활용**: 현재 비어있음. routing_version_id를 저장하면 추적 가능
4. **작업지시 상태 관리**: 등록/수정/취소 워크플로우
---
## 7. 마스터 데이터 사전 등록 요건
> 작업지시 등록 전에 다음 마스터 데이터가 반드시 등록되어 있어야 한다.
### 7.1 필수 사전 등록 순서
```
1. process_mng (공정 마스터) 등록
예: P002=가공, P003=검사, P009=포장
2. item_info (품목 마스터) 등록
예: item_number=R_FREE3_002, item_name=원제_AK1000
3. item_routing_version (라우팅 버전) 등록
item_code = item_info.item_number
is_default = true (기본 버전)
4. item_routing_detail (공정 순서) 등록
routing_version_id = 위에서 만든 버전 ID
seq_no = 1, process_code = P002 (1공정: 가공)
seq_no = 2, process_code = P003 (2공정: 검사)
seq_no = 3, process_code = P009 (3공정: 포장)
5. (선택) process_work_item + detail (체크리스트 템플릿)
routing_detail_id = 위의 item_routing_detail.id
```
### 7.2 현재 COMPANY_7 데이터 현황
**등록된 공정 (15개)**:
| 공정코드 | 공정명 | 유형 |
|---------|--------|------|
| P002 | 가공 | PT001 |
| P003 | 검사 | PT003 |
| P005 | 치수검사 | PT004 |
| P006 | 테스트 | PT001 |
| P007 | 인쇄 | PT006 |
| P008 | 조립 | PT002 |
| P009 | 포장 | PT004 |
| PRC-001 ~ PRC-006 | 검수/가공/조립/검사/포장 | 다양 |
| PROC-001 | 확인 | 세척 |
**등록된 라우팅 버전 (24건)**: 다양한 품목에 대해 1~3개 공정 조합
---
## 8. 데이터 예시: 전체 흐름 시뮬레이션
### 시나리오: 품목 R_FREE3_002를 500개 생산
#### Step 1: 작업지시 등록 (PC)
```sql
-- 자동 생성되는 행
INSERT INTO work_instruction (work_instruction_no, item_id, qty, status)
VALUES ('WI-20260320-001', 'a4e492a0-...', '500', 'waiting');
-- id = 'wi-new-001' (UUID 자동생성)
```
#### Step 2: 공정 생성 API 호출 (PC -> 서버)
```
POST /api/pop/production/create-work-processes
{ work_instruction_id: 'wi-new-001',
routing_version_id: '5cff0c1e-...' }
```
```sql
-- 서버가 자동 생성하는 행 (item_routing_detail 기반)
INSERT INTO work_order_process (wo_id, seq_no, process_code, process_name, status, plan_qty)
VALUES
('wi-new-001', '2', 'P002', '가공', 'acceptable', '500'), -- 1공정: 접수가능
('wi-new-001', '30', 'P006', '테스트', 'waiting', '500'); -- 2공정: 대기
```
#### Step 3: POP에서 작업자가 1공정 접수 (300개)
```sql
-- accept-process가 생성하는 분할 행
INSERT INTO work_order_process (wo_id, seq_no, process_code, status, input_qty,
parent_process_id, accepted_by)
VALUES ('wi-new-001', '2', 'P002', 'in_progress', '300',
'마스터행ID', '작업자A');
```
#### Step 4: POP에서 실적 저장 (생산 300개, 불량 10개)
```sql
-- save-result가 UPDATE하는 행
UPDATE work_order_process
SET total_production_qty = '300',
good_qty = '290', -- 서버 계산: 300 - 10
defect_qty = '10',
status = 'completed' -- 자동완료: 300 >= 300(input_qty)
WHERE id = '분할행ID';
-- 다음 공정 자동 활성화
UPDATE work_order_process
SET status = 'acceptable' -- waiting -> acceptable
WHERE wo_id = 'wi-new-001' AND seq_no = '30'
AND parent_process_id IS NULL;
```
#### Step 5: 2공정도 접수 -> 실적 -> 완료하면
```sql
-- 작업지시 자동 완료
UPDATE work_instruction
SET status = 'completed',
progress_status = 'completed',
completed_qty = '280' -- 마지막 공정 양품 합계
WHERE id = 'wi-new-001';
```
---
## 9. 주의사항 및 제약
### 9.1 필수 규칙
1. **create-work-processes는 1회만 호출 가능**: 같은 작업지시에 대해 2번 호출하면 409 에러
2. **routing_version_id는 필수**: 라우팅 없이는 공정을 생성할 수 없음
3. **1공정만 즉시 접수가능**: 나머지는 앞공정 양품 발생 후 자동 전환
4. **수량은 모두 VARCHAR**: 정수 변환 시 parseInt 필수
5. **멀티테넌시**: 모든 쿼리에 company_code 필터 필수
6. **분할 행 구조**: 접수 시 마스터 행에서 분할 행을 INSERT하는 방식. 마스터 행에는 직접 실적 등록 불가
### 9.2 현재 미활용 컬럼
| 컬럼 | 테이블 | 상태 |
|------|--------|------|
| `routing` | work_instruction | 비어있음 (routing_version_id 저장 권장) |
| `equipment_id` | work_instruction | 등록 가능하나 POP 연동 미구현 |
| `item_id` | work_instruction | 일부 테스트 데이터에서 비어있음 |
### 9.3 자동 완료 판정 주의
- 재작업 카드가 있으면 해당 카드가 완료될 때까지 마스터 행이 완료되지 않음
- 특채(concession_qty)는 양품에 포함되지 않으나 다음 공정 전달량에는 합산됨
- 초과 생산은 경고만 하고 차단하지 않음 (현장 유연성)

View File

@ -0,0 +1,603 @@
# POP 화면 배포서버 마이그레이션 가이드
> **작성일**: 2026-03-23
> **목적**: 로컬(탑씰 COMPANY_7) POP 화면 5종을 배포서버 COMPANY_21(테스트회사)로 복사
> **대상 화면**: 4173, 4479, 4480, 4576, 4577
> **주의**: DB 작업 전 반드시 백업 후 진행
---
## 0. 개념 정리 (먼저 읽기)
### PopDeployModal이란?
POP 관리 화면 내 내장된 **화면 배포 도구**입니다.
- **접근**: POP 디자이너 > 카테고리 트리 > 그룹 우클릭 또는 배포 버튼
- **하는 일**:
1. 선택한 화면들을 다른 회사 계정으로 복사 (`screen_definitions` 새 행 생성)
2. POP 레이아웃 JSON 복사 (`screen_layouts_pop`)
3. **layout_json 내 화면 ID 참조 자동 리매핑** (screenId, cartScreenId, sourceScreenId, targetScreenId)
4. 카테고리 그룹 구조 생성 (`screen_groups`, `screen_group_screens`)
5. numberingRuleId 자동 제거 (회사별 고유값이므로)
- **제약**: 같은 서버 안에서만 동작 (로컬 → 배포 서버 간 복사 불가)
- **권장 사용 시점**: 배포 DB COMPANY_7에 화면이 먼저 세팅된 후, 같은 배포 서버 내 테스트 계정으로 복사할 때
### 마이그레이션 전체 흐름
```
[로컬 DB / COMPANY_7] ──── SQL 직접 복사 ────→ [배포 DB / COMPANY_7]
PopDeployModal
(배포서버 내)
[배포 DB / COMPANY_21]
(테스트 환경)
```
---
## 1. 현황 요약
### 1-1. 환경 정보
| 구분 | 로컬 DB | 배포 DB |
|------|---------|---------|
| Host | 39.117.244.52:11132 | 211.115.91.141:11134 |
| Database | plm | plm |
| 소스 회사 | COMPANY_7 (탑씰) | - |
| 1차 타겟 | - | COMPANY_7 (탑씰, SQL 직접 삽입) |
| 2차 타겟 | - | COMPANY_21 (테스트회사, PopDeployModal) |
### 1-2. 복사 대상 화면
| 화면 ID | screen_code | screen_name | 역할 |
|---------|-------------|-------------|------|
| 4479 | COMPANY_7_179 | 홈 | POP 메인 홈 화면 |
| 4576 | COMPANY_7_194 | 입고메뉴 | 입고 카테고리 메뉴 |
| 4173 | COMPANY_7_169 | 구매입고 담기 | 구매입고 항목 선택/담기 |
| 4577 | COMPANY_7_195 | 구매입고 장바구니 | 장바구니 확인/입고 확정 |
| 4480 | COMPANY_7_180 | MES공정 | MES 생산실적 관리 |
### 1-3. POP 카테고리 구조 (screen_groups)
```
탑씰 (id:3134, code:TOPSSEAL)
├── 홈 #4479
├── 입고관리 (id:3216, code:INBOUND_MENU)
│ ├── 입고메뉴 #4576
│ └── 구매입고 (id:3221, code:PURCHASE RECEIPT)
│ ├── 구매입고 담기 #4173
│ └── 구매입고 장바구니 #4577
└── 생산실적 (id:3220, code:PRODUCTION RESULTS)
└── MES공정 #4480
```
### 1-4. 화면 간 상호참조 (layout_json 내부)
| 출발 화면 | 참조 방식 | 대상 화면 | JSON 키 |
|----------|----------|----------|---------|
| 4479 홈 | navigate | 4480 MES공정 | `screenId: "4480"` |
| 4479 홈 | navigate | 4576 입고메뉴 | `screenId: "4576"` |
| 4576 입고메뉴 | navigate | 4173 구매입고 | `screenId: "4173"` |
| 4173 구매입고 | cart-save | 4577 장바구니 | `cartScreenId: "4577"` |
| 4577 장바구니 | source | 4173 구매입고 | `sourceScreenId: 4173` (숫자) |
| 4577 장바구니 | navigate | 4173 구매입고 | `targetScreenId: "4173"` |
> PopDeployModal을 사용하면 이 참조들이 모두 자동 리매핑됩니다.
---
## 2. 배포 DB 현황 점검 결과
### 2-1. 테이블 누락 상태
| 테이블 | 로컬 | 배포 | MES/기능 의존도 |
|--------|------|------|----------------|
| work_order_process | O (37컬럼) | **없음** | MES공정 화면 전체 동작 불가 |
| process_work_result | O (35컬럼) | **없음** | MES 체크리스트 기능 불가 |
| work_order_process_log | O (12컬럼+트리거) | **없음** | 공정 변경 이력 로깅 불가 |
| cart_items | O (16컬럼) | **없음** | 구매입고 장바구니 전체 불가 |
### 2-2. 컬럼 누락 상태
| 테이블 | 누락 컬럼 | 영향 |
|--------|----------|------|
| work_instruction | `reason`, `completed_qty` | MES 완료수량 업데이트 실패 |
### 2-3. COMPANY_21 (테스트회사) 현황
| 항목 | 상태 |
|------|------|
| 회사 존재 | O (active) |
| 기존 POP 레이아웃 | 없음 (깨끗한 상태) |
| 로그인 가능 계정 | **0개 (계정 없음!)** |
| 기존 화면 수 | 23개 (일반 ERP 화면들) |
> **중요**: COMPANY_21에는 현재 등록된 사용자가 없습니다.
> 테스트 전에 사용자 계정을 먼저 생성해야 로그인 및 POP 테스트가 가능합니다.
### 2-4. COMPANY_7 (탑씰) 배포 서버 현황
- 기존 POP 레이아웃: `screen_id 4114` (테스트용 1개만 존재)
- 로그인 계정: `topseal_admin`, `topseal_admin2`, `topseal_user`, `test1`, `test2`
- 5개 화면(4173, 4479, 4480, 4576, 4577) 모두 배포 DB에 없음 → 안전하게 삽입 가능
---
## 3. 누락 테이블/컬럼 추가 방법
### 3-1. Vexplor DDL 시스템으로 가능한 작업
Vexplor에는 **관리자 DDL 실행 시스템**이 내장되어 있습니다.
- **접근**: 관리자 > 시스템관리 > 테이블관리 (`/admin/systemMng/tableMngList`)
- **가능한 작업**:
- 테이블 생성 (`POST /api/ddl/tables`)
- 컬럼 추가 (`POST /api/ddl/tables/:tableName/columns`)
- 생성 시 `table_type_columns` 메타데이터 자동 등록
- **권한**: 슈퍼 어드민 계정 (`company_code = '*'`)만 사용 가능
- **불가능한 것**: 트리거 함수 생성 (이건 psql 직접 실행 필요)
### 3-2. 작업 분류
| 작업 | 방법 | 비고 |
|------|------|------|
| work_order_process 생성 | **psql 직접 실행** | 컬럼 37개 + 인덱스 7개, UI 입력보다 SQL이 효율적 |
| process_work_result 생성 | **psql 직접 실행** | 컬럼 35개 |
| cart_items 생성 | **psql 직접 실행** | 컬럼 16개 + 인덱스 5개 |
| work_order_process_log 생성 | **psql 직접 실행** | 트리거 함수 포함 필수 |
| work_instruction 컬럼 추가 | DDL UI 또는 psql | 컬럼 2개, 어느 방법이든 가능 |
| table_type_columns 메타데이터 | psql COPY 명령 | 로컬에서 추출 후 배포에 삽입 |
> **결론**: DDL UI는 컬럼 추가(`work_instruction`) 정도에 활용하고,
> 테이블 생성은 모두 psql SQL 직접 실행이 현실적입니다.
---
## 4. 실행 절차 (단계별)
### STEP 0: 배포 DB 백업
```sql
-- 배포 DB에서 실행
CREATE TABLE backup_20260323_screen_definitions AS
SELECT * FROM screen_definitions WHERE company_code = 'COMPANY_7';
CREATE TABLE backup_20260323_screen_layouts_pop AS
SELECT * FROM screen_layouts_pop WHERE company_code = 'COMPANY_7';
CREATE TABLE backup_20260323_screen_groups AS
SELECT * FROM screen_groups WHERE company_code = 'COMPANY_7';
CREATE TABLE backup_20260323_screen_group_screens AS
SELECT * FROM screen_group_screens WHERE company_code = 'COMPANY_7';
```
---
### STEP 1: 누락 테이블 생성 (배포 DB에서 psql 실행)
```bash
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm
```
#### 1-1. work_order_process
```sql
CREATE TABLE work_order_process (
id character varying(500) NOT NULL DEFAULT (gen_random_uuid())::text,
created_date timestamp without time zone DEFAULT now(),
updated_date timestamp without time zone DEFAULT now(),
writer character varying(255),
company_code character varying(255),
wo_id character varying(500),
seq_no character varying(255),
process_code character varying(255),
process_name character varying(255),
is_required character varying(255),
is_fixed_order character varying(255),
standard_time character varying(255),
status character varying(255),
accepted_by character varying(255),
accepted_at character varying(255),
started_at character varying(255),
completed_at character varying(255),
plan_qty character varying(255),
input_qty character varying(255),
good_qty character varying(255),
defect_qty character varying(255),
equipment_code character varying(255),
remark character varying(500),
paused_at character varying(500),
total_paused_time character varying(500) DEFAULT '0',
routing_detail_id character varying(500),
actual_work_time character varying(500) DEFAULT NULL::character varying,
completed_by character varying(500) DEFAULT NULL::character varying,
total_production_qty character varying(500),
defect_detail character varying(500),
result_note character varying(500),
result_status character varying(500) DEFAULT 'draft'::character varying,
attachments character varying(500),
parent_process_id character varying(500) DEFAULT NULL::character varying,
concession_qty character varying(500) DEFAULT '0'::character varying,
is_rework character varying(500) DEFAULT 'N'::character varying,
rework_source_id character varying(500) DEFAULT NULL::character varying,
CONSTRAINT work_order_process_pkey PRIMARY KEY (id)
);
CREATE INDEX idx_wop_company ON work_order_process (company_code);
CREATE INDEX idx_wop_wo_id ON work_order_process (wo_id);
CREATE INDEX idx_wop_wo_id_seq_no ON work_order_process (wo_id, seq_no);
CREATE INDEX idx_wop_process ON work_order_process (company_code, process_code);
CREATE INDEX idx_wop_status ON work_order_process (company_code, process_code, status);
CREATE INDEX idx_wop_parent_process_id ON work_order_process (parent_process_id);
```
#### 1-2. process_work_result
```sql
CREATE TABLE process_work_result (
id character varying(500) NOT NULL DEFAULT (gen_random_uuid())::text,
created_date timestamp without time zone DEFAULT now(),
updated_date timestamp without time zone DEFAULT now(),
writer character varying(500) DEFAULT NULL::character varying,
company_code character varying(500),
work_order_process_id character varying(500),
source_work_item_id character varying(500),
source_detail_id character varying(500),
work_phase character varying(500),
item_title character varying(500),
item_sort_order character varying(500),
detail_content character varying(500),
detail_type character varying(500),
detail_sort_order character varying(500),
is_required character varying(500),
inspection_code character varying(500),
inspection_method character varying(500),
unit character varying(500),
lower_limit character varying(500),
upper_limit character varying(500),
input_type character varying(500),
lookup_target character varying(500),
display_fields character varying(500),
duration_minutes character varying(500),
status character varying(500),
result_value character varying(500),
is_passed character varying(500),
remark character varying(500),
recorded_by character varying(500),
recorded_at character varying(500),
started_at character varying(500) DEFAULT NULL::character varying,
group_started_at character varying(500) DEFAULT NULL::character varying,
group_paused_at character varying(500) DEFAULT NULL::character varying,
group_total_paused_time character varying(500) DEFAULT NULL::character varying,
group_completed_at character varying(500) DEFAULT NULL::character varying,
CONSTRAINT process_work_result_pkey PRIMARY KEY (id)
);
```
#### 1-3. work_order_process_log + 트리거
```sql
-- 로그 테이블
CREATE TABLE work_order_process_log (
log_id SERIAL PRIMARY KEY,
operation_type character varying(10) NOT NULL,
original_id character varying(100),
changed_column character varying(100),
old_value text,
new_value text,
changed_by character varying(50),
changed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
ip_address character varying(50),
user_agent text,
full_row_before jsonb,
full_row_after jsonb
);
-- 트리거 함수 (로컬 DB에서 정확한 정의 먼저 추출)
-- 로컬에서: SELECT pg_get_functiondef(oid) FROM pg_proc WHERE proname = 'work_order_process_log_trigger_func';
-- 추출한 함수 정의를 아래에 붙여넣기
-- 트리거 등록 (함수 생성 후 실행)
CREATE TRIGGER work_order_process_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON work_order_process
FOR EACH ROW EXECUTE FUNCTION work_order_process_log_trigger_func();
```
#### 1-4. cart_items
```sql
CREATE TABLE cart_items (
id character varying(255) NOT NULL DEFAULT (gen_random_uuid())::text,
created_date timestamp without time zone DEFAULT now(),
updated_date timestamp without time zone DEFAULT now(),
company_code character varying(20),
cart_type character varying(255),
screen_id character varying(255),
user_id character varying(255),
source_table character varying(255),
row_key text,
row_data text,
quantity character varying(255),
unit character varying(255),
package_unit character varying(255),
package_entries text,
status character varying(255),
memo text,
CONSTRAINT cart_items_pkey PRIMARY KEY (id)
);
CREATE INDEX idx_cart_items_company ON cart_items (company_code);
CREATE INDEX idx_cart_items_screen_user ON cart_items (screen_id, user_id);
CREATE INDEX idx_cart_items_type ON cart_items (cart_type);
CREATE INDEX idx_cart_items_status ON cart_items (status);
```
---
### STEP 2: 기존 테이블 컬럼 추가
#### 방법 A: DDL UI (관리자 화면)
1. 슈퍼 어드민 계정으로 배포서버 접속
2. 관리자 > 시스템관리 > 테이블관리
3. `work_instruction` 테이블 선택
4. 컬럼 추가 버튼 → `reason` (varchar 500) 추가
5. 컬럼 추가 버튼 → `completed_qty` (varchar 500, 기본값: '0') 추가
#### 방법 B: SQL 직접
```sql
ALTER TABLE work_instruction
ADD COLUMN IF NOT EXISTS reason character varying(500),
ADD COLUMN IF NOT EXISTS completed_qty character varying(500) DEFAULT '0'::character varying;
```
---
### STEP 3: table_type_columns 메타데이터 복사 (로컬 → 배포)
```bash
# 로컬 DB에서 추출
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
COPY (
SELECT * FROM table_type_columns
WHERE table_name IN ('work_order_process', 'cart_items', 'process_work_result')
) TO STDOUT WITH CSV HEADER" > /tmp/ttc_export.csv
# 배포 DB에 삽입 (충돌 시 무시)
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
COPY table_type_columns FROM STDIN WITH CSV HEADER
ON CONFLICT DO NOTHING" < /tmp/ttc_export.csv
```
---
### STEP 4: 화면 5종 → 배포 COMPANY_7 복사 (SQL)
> 화면 ID 4173~4577은 배포 DB에 없으므로 동일 ID로 안전하게 삽입 가능
```bash
# 로컬에서 screen_definitions 추출
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
COPY (
SELECT * FROM screen_definitions
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577)
) TO STDOUT WITH CSV HEADER" > /tmp/screen_def.csv
# 배포에 삽입
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
COPY screen_definitions FROM STDIN WITH CSV HEADER
ON CONFLICT DO NOTHING" < /tmp/screen_def.csv
# screen_layouts_pop 추출 (layout_id 제외)
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
COPY (
SELECT screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by
FROM screen_layouts_pop
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577)
) TO STDOUT WITH CSV HEADER" > /tmp/screen_layouts.csv
# 배포에 삽입
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
COPY screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
FROM STDIN WITH CSV HEADER
ON CONFLICT (screen_id, company_code) DO NOTHING" < /tmp/screen_layouts.csv
# screen_groups 추출 (그룹 4개: 3134, 3216, 3220, 3221)
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
COPY (
SELECT * FROM screen_groups
WHERE id IN (3134, 3216, 3220, 3221)
) TO STDOUT WITH CSV HEADER" > /tmp/screen_groups.csv
# 배포에 삽입
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
COPY screen_groups FROM STDIN WITH CSV HEADER
ON CONFLICT DO NOTHING" < /tmp/screen_groups.csv
# screen_group_screens 추출
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
COPY (
SELECT * FROM screen_group_screens
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577)
) TO STDOUT WITH CSV HEADER" > /tmp/screen_group_screens.csv
# 배포에 삽입
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
COPY screen_group_screens FROM STDIN WITH CSV HEADER
ON CONFLICT DO NOTHING" < /tmp/screen_group_screens.csv
# 시퀀스 동기화 (배포 DB에서 실행)
SELECT setval('screen_definitions_screen_id_seq',
GREATEST((SELECT MAX(screen_id) FROM screen_definitions),
(SELECT last_value FROM screen_definitions_screen_id_seq)));
SELECT setval('screen_layouts_pop_layout_id_seq',
GREATEST((SELECT MAX(layout_id) FROM screen_layouts_pop),
(SELECT last_value FROM screen_layouts_pop_layout_id_seq)));
SELECT setval('screen_groups_id_seq',
GREATEST((SELECT MAX(id) FROM screen_groups),
(SELECT last_value FROM screen_groups_id_seq)));
SELECT setval('screen_group_screens_id_seq',
GREATEST((SELECT MAX(id) FROM screen_group_screens),
(SELECT last_value FROM screen_group_screens_id_seq)));
```
---
### STEP 5: COMPANY_21 테스트 계정 사용자 생성
> COMPANY_21에 현재 등록된 사용자가 없습니다. 로그인하려면 계정이 필요합니다.
배포서버 관리자 화면(슈퍼 어드민)에서 COMPANY_21 소속 사용자를 추가합니다:
- 관리자 > 회사관리 > COMPANY_21 > 사용자 추가
- 또는 SQL:
```sql
-- user_info 테이블에 테스트 사용자 추가 (기존 패턴 참고)
-- 실제 password 해시는 기존 계정 방식과 동일하게 처리 필요
INSERT INTO user_info (user_id, user_name, company_code, user_type, status, password)
VALUES ('test21', '테스트계정', 'COMPANY_21', 'COMPANY_ADMIN', 'active', '-- 해시된 비밀번호 --');
```
---
### STEP 6: PopDeployModal로 COMPANY_7 → COMPANY_21 복사
1. 배포서버에서 `topseal_admin` 계정으로 로그인
2. POP 디자이너 > POP 관리 화면 > 카테고리 트리 접속
3. "탑씰" 그룹에서 배포 버튼 클릭
4. 대상 회사: **COMPANY_21 (테스트회사)** 선택
5. 5개 화면 포함 여부 확인 후 배포 실행
6. PopDeployModal이 COMPANY_21 전용 새 화면 ID 자동 부여 + 참조 자동 리매핑
---
### STEP 7: 검증
```sql
-- 배포 DB에서 실행 --
-- 7-1. 누락 테이블 생성 확인
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('work_order_process', 'cart_items', 'process_work_result', 'work_order_process_log');
-- 예상: 4건
-- 7-2. work_instruction 컬럼 확인
SELECT column_name FROM information_schema.columns
WHERE table_name = 'work_instruction' AND column_name IN ('reason', 'completed_qty');
-- 예상: 2건
-- 7-3. COMPANY_7 화면 삽입 확인
SELECT screen_id, screen_name, company_code FROM screen_definitions
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
-- 예상: 5건 (COMPANY_7)
-- 7-4. COMPANY_21 화면 복사 확인 (PopDeployModal 후)
SELECT sd.screen_id, sd.screen_name, sd.company_code,
CASE WHEN slp.layout_id IS NOT NULL THEN 'Y' ELSE 'N' END as has_layout
FROM screen_definitions sd
LEFT JOIN screen_layouts_pop slp ON sd.screen_id = slp.screen_id
WHERE sd.company_code = 'COMPANY_21'
AND sd.screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니');
-- 예상: 5건 + has_layout = Y
-- 7-5. 화면 간 참조 무결성 (COMPANY_21 기준 새 ID로 리매핑됐는지 확인)
SELECT slp.screen_id,
layout_data::text LIKE '%screenId%' as has_nav_ref,
layout_data::text LIKE '%cartScreenId%' as has_cart_ref
FROM screen_layouts_pop slp
JOIN screen_definitions sd ON slp.screen_id = sd.screen_id
WHERE sd.company_code = 'COMPANY_21'
AND sd.screen_name IN ('홈', '입고메뉴', '구매입고 담기', '구매입고 장바구니');
-- 7-6. 시퀀스 정합성
SELECT 'screen_definitions' as tbl,
(SELECT MAX(screen_id) FROM screen_definitions) as max_id,
(SELECT last_value FROM screen_definitions_screen_id_seq) as seq_val,
CASE WHEN (SELECT last_value FROM screen_definitions_screen_id_seq)
>= (SELECT MAX(screen_id) FROM screen_definitions)
THEN 'OK' ELSE 'MISMATCH' END as status;
```
---
## 5. COMPANY_21 테스트 환경 수정 가능 여부
| 항목 | 수정 가능? | 방법 |
|------|----------|------|
| 회사명/정보 | O | 관리자 > 회사관리 |
| 사용자 추가/수정 | O | 관리자 > 사용자관리 |
| POP 화면 수정 | O | POP 디자이너에서 직접 편집 |
| 화면 삭제 후 재배포 | O | PopDeployModal 재실행 |
| 기존 ERP 화면 영향 없음 | O | POP 레이아웃 별도 테이블 관리 |
> COMPANY_21은 테스트 전용 계정이므로 자유롭게 수정/삭제 가능합니다.
> 기존 23개 ERP 화면(구매관리, 영업관리 등)은 POP과 무관하므로 건드리지 않아도 됩니다.
---
## 6. 요약: 실행 순서
| 순서 | 작업 | 방법 | 담당 |
|------|------|------|------|
| STEP 0 | 배포 DB 백업 | psql | DB 담당자 |
| STEP 1 | 누락 테이블 4개 생성 | psql SQL | DB 담당자 |
| STEP 2 | work_instruction 컬럼 추가 | DDL UI 또는 psql | DB 담당자 |
| STEP 3 | table_type_columns 메타데이터 복사 | psql COPY | DB 담당자 |
| STEP 4 | 화면 5종 COMPANY_7에 삽입 | psql COPY | DB 담당자 |
| STEP 5 | COMPANY_21 테스트 사용자 생성 | 관리자 UI 또는 SQL | 어드민 계정 |
| STEP 6 | PopDeployModal로 COMPANY_21 복사 | 배포서버 UI | 어드민 계정 |
| STEP 7 | 검증 쿼리 실행 | psql | DB 담당자 |
| STEP 8 | 브라우저 POP 화면 테스트 | 브라우저 | 테스터 |
---
## 7. 롤백 방법
```sql
-- COMPANY_21 POP 화면 삭제 (PopDeployModal로 생성된 것)
DELETE FROM screen_group_screens
WHERE screen_id IN (
SELECT screen_id FROM screen_definitions WHERE company_code = 'COMPANY_21'
AND screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니')
);
DELETE FROM screen_layouts_pop
WHERE screen_id IN (
SELECT screen_id FROM screen_definitions WHERE company_code = 'COMPANY_21'
AND screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니')
);
DELETE FROM screen_definitions
WHERE company_code = 'COMPANY_21'
AND screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니');
-- COMPANY_7 화면 삭제 (SQL로 삽입한 것)
DELETE FROM screen_group_screens WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
DELETE FROM screen_layouts_pop WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
DELETE FROM screen_definitions WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
DELETE FROM screen_groups WHERE id IN (3134, 3216, 3220, 3221)
AND id NOT IN (SELECT id FROM backup_20260323_screen_groups);
-- 생성한 테이블 제거
DROP TABLE IF EXISTS work_order_process_log;
DROP TABLE IF EXISTS process_work_result;
DROP TABLE IF EXISTS work_order_process;
DROP TABLE IF EXISTS cart_items;
-- 추가 컬럼 제거
ALTER TABLE work_instruction DROP COLUMN IF EXISTS reason;
ALTER TABLE work_instruction DROP COLUMN IF EXISTS completed_qty;
```
---
*이 문서는 로컬 DB와 배포 DB를 읽기 전용으로 점검한 결과를 바탕으로 작성되었습니다.*
*실제 실행 전 반드시 배포 DB 백업을 완료하세요.*

View File

@ -0,0 +1,14 @@
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":110167}
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":196548}
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":253997}
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":339528}
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":380641}
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":413980}
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":401646}

View File

@ -0,0 +1,10 @@
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":59735}
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":93607}
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":136249}
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":261624}
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":139427}

View File

@ -0,0 +1,3 @@
{
"lastSentAt": "2026-03-25T05:06:13.529Z"
}

View File

@ -0,0 +1,7 @@
{
"tool_name": "Bash",
"tool_input_preview": "{\"command\":\"wc -l /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx\",\"description\":\"Get total line count of the file\"}",
"error": "Exit code 1\n(eval):1: no matches found: /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx",
"timestamp": "2026-03-25T05:00:38.410Z",
"retry_count": 1
}

View File

@ -0,0 +1,281 @@
{
"updatedAt": "2026-03-25T05:06:35.487Z",
"missions": [
{
"id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none",
"source": "session",
"name": "none",
"objective": "Session mission",
"createdAt": "2026-03-25T00:33:45.197Z",
"updatedAt": "2026-03-25T01:37:19.659Z",
"status": "done",
"workerCount": 5,
"taskCounts": {
"total": 5,
"pending": 0,
"blocked": 0,
"inProgress": 0,
"completed": 5,
"failed": 0
},
"agents": [
{
"name": "Explore:ad233db",
"role": "Explore",
"ownership": "ad233db7fa6f059dd",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:34:44.932Z"
},
{
"name": "Explore:a31a0f7",
"role": "Explore",
"ownership": "a31a0f729d328643f",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:35:24.588Z"
},
{
"name": "executor:a9510b7",
"role": "executor",
"ownership": "a9510b7d8ec5a1ce7",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:42:01.730Z"
},
{
"name": "executor:a1c1d18",
"role": "executor",
"ownership": "a1c1d186f0eb6dfc1",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:40:12.608Z"
},
{
"name": "executor:a9a231d",
"role": "executor",
"ownership": "a9a231d40fd5a150b",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T01:37:19.659Z"
}
],
"timeline": [
{
"id": "session-stop:a1c1d186f0eb6dfc1:2026-03-25T00:40:12.608Z",
"at": "2026-03-25T00:40:12.608Z",
"kind": "completion",
"agent": "executor:a1c1d18",
"detail": "completed",
"sourceKey": "session-stop:a1c1d186f0eb6dfc1"
},
{
"id": "session-stop:a9510b7d8ec5a1ce7:2026-03-25T00:42:01.730Z",
"at": "2026-03-25T00:42:01.730Z",
"kind": "completion",
"agent": "executor:a9510b7",
"detail": "completed",
"sourceKey": "session-stop:a9510b7d8ec5a1ce7"
},
{
"id": "session-start:a9a231d40fd5a150b:2026-03-25T01:35:00.232Z",
"at": "2026-03-25T01:35:00.232Z",
"kind": "update",
"agent": "executor:a9a231d",
"detail": "started executor:a9a231d",
"sourceKey": "session-start:a9a231d40fd5a150b"
},
{
"id": "session-stop:a9a231d40fd5a150b:2026-03-25T01:37:19.659Z",
"at": "2026-03-25T01:37:19.659Z",
"kind": "completion",
"agent": "executor:a9a231d",
"detail": "completed",
"sourceKey": "session-stop:a9a231d40fd5a150b"
}
]
},
{
"id": "session:037169c7-72ba-4843-8e9a-417ca1423715:none",
"source": "session",
"name": "none",
"objective": "Session mission",
"createdAt": "2026-03-25T04:59:24.101Z",
"updatedAt": "2026-03-25T05:06:35.487Z",
"status": "done",
"workerCount": 7,
"taskCounts": {
"total": 7,
"pending": 0,
"blocked": 0,
"inProgress": 0,
"completed": 7,
"failed": 0
},
"agents": [
{
"name": "executor:a32b34c",
"role": "executor",
"ownership": "a32b34c341b854da5",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:06:18.081Z"
},
{
"name": "executor:ad2c89c",
"role": "executor",
"ownership": "ad2c89cf14936ea42",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:02:45.524Z"
},
{
"name": "executor:a2c140c",
"role": "executor",
"ownership": "a2c140c5a5adb0719",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:05:13.388Z"
},
{
"name": "executor:a2e5213",
"role": "executor",
"ownership": "a2e52136ea8f04385",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:03:53.163Z"
},
{
"name": "executor:a3735bf",
"role": "executor",
"ownership": "a3735bf51a74d6fc8",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:01:33.817Z"
},
{
"name": "executor:a77742b",
"role": "executor",
"ownership": "a77742ba65fd2451c",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:06:09.324Z"
},
{
"name": "executor:a4eb932",
"role": "executor",
"ownership": "a4eb932c438b898c0",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:06:35.487Z"
}
],
"timeline": [
{
"id": "session-start:a3735bf51a74d6fc8:2026-03-25T04:59:43.650Z",
"at": "2026-03-25T04:59:43.650Z",
"kind": "update",
"agent": "executor:a3735bf",
"detail": "started executor:a3735bf",
"sourceKey": "session-start:a3735bf51a74d6fc8"
},
{
"id": "session-start:a77742ba65fd2451c:2026-03-25T04:59:48.683Z",
"at": "2026-03-25T04:59:48.683Z",
"kind": "update",
"agent": "executor:a77742b",
"detail": "started executor:a77742b",
"sourceKey": "session-start:a77742ba65fd2451c"
},
{
"id": "session-start:a4eb932c438b898c0:2026-03-25T04:59:53.841Z",
"at": "2026-03-25T04:59:53.841Z",
"kind": "update",
"agent": "executor:a4eb932",
"detail": "started executor:a4eb932",
"sourceKey": "session-start:a4eb932c438b898c0"
},
{
"id": "session-stop:a3735bf51a74d6fc8:2026-03-25T05:01:33.817Z",
"at": "2026-03-25T05:01:33.817Z",
"kind": "completion",
"agent": "executor:a3735bf",
"detail": "completed",
"sourceKey": "session-stop:a3735bf51a74d6fc8"
},
{
"id": "session-stop:ad2c89cf14936ea42:2026-03-25T05:02:45.524Z",
"at": "2026-03-25T05:02:45.524Z",
"kind": "completion",
"agent": "executor:ad2c89c",
"detail": "completed",
"sourceKey": "session-stop:ad2c89cf14936ea42"
},
{
"id": "session-stop:a2e52136ea8f04385:2026-03-25T05:03:53.163Z",
"at": "2026-03-25T05:03:53.163Z",
"kind": "completion",
"agent": "executor:a2e5213",
"detail": "completed",
"sourceKey": "session-stop:a2e52136ea8f04385"
},
{
"id": "session-stop:a2c140c5a5adb0719:2026-03-25T05:05:13.388Z",
"at": "2026-03-25T05:05:13.388Z",
"kind": "completion",
"agent": "executor:a2c140c",
"detail": "completed",
"sourceKey": "session-stop:a2c140c5a5adb0719"
},
{
"id": "session-stop:a77742ba65fd2451c:2026-03-25T05:06:09.324Z",
"at": "2026-03-25T05:06:09.324Z",
"kind": "completion",
"agent": "executor:a77742b",
"detail": "completed",
"sourceKey": "session-stop:a77742ba65fd2451c"
},
{
"id": "session-stop:a32b34c341b854da5:2026-03-25T05:06:18.081Z",
"at": "2026-03-25T05:06:18.081Z",
"kind": "completion",
"agent": "executor:a32b34c",
"detail": "completed",
"sourceKey": "session-stop:a32b34c341b854da5"
},
{
"id": "session-stop:a4eb932c438b898c0:2026-03-25T05:06:35.487Z",
"at": "2026-03-25T05:06:35.487Z",
"kind": "completion",
"agent": "executor:a4eb932",
"detail": "completed",
"sourceKey": "session-stop:a4eb932c438b898c0"
}
]
}
]
}

View File

@ -0,0 +1,116 @@
{
"agents": [
{
"agent_id": "ad233db7fa6f059dd",
"agent_type": "Explore",
"started_at": "2026-03-25T00:33:45.197Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:34:44.932Z",
"duration_ms": 59735
},
{
"agent_id": "a31a0f729d328643f",
"agent_type": "Explore",
"started_at": "2026-03-25T00:33:50.981Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:35:24.588Z",
"duration_ms": 93607
},
{
"agent_id": "a9510b7d8ec5a1ce7",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T00:37:40.106Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:42:01.730Z",
"duration_ms": 261624
},
{
"agent_id": "a1c1d186f0eb6dfc1",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T00:37:56.359Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:40:12.608Z",
"duration_ms": 136249
},
{
"agent_id": "a9a231d40fd5a150b",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T01:35:00.232Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T01:37:19.659Z",
"duration_ms": 139427
},
{
"agent_id": "a32b34c341b854da5",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:24.101Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:06:18.081Z",
"duration_ms": 413980
},
{
"agent_id": "ad2c89cf14936ea42",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:28.976Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:02:45.524Z",
"duration_ms": 196548
},
{
"agent_id": "a2c140c5a5adb0719",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:33.860Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:05:13.388Z",
"duration_ms": 339528
},
{
"agent_id": "a2e52136ea8f04385",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:39.166Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:03:53.163Z",
"duration_ms": 253997
},
{
"agent_id": "a3735bf51a74d6fc8",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:43.650Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:01:33.817Z",
"duration_ms": 110167
},
{
"agent_id": "a77742ba65fd2451c",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:48.683Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:06:09.324Z",
"duration_ms": 380641
},
{
"agent_id": "a4eb932c438b898c0",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:53.841Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:06:35.487Z",
"duration_ms": 401646
}
],
"total_spawned": 12,
"total_completed": 12,
"total_failed": 0,
"last_updated": "2026-03-25T05:06:35.589Z"
}

View File

@ -0,0 +1,752 @@
"use client";
/**
*
*
* 좌측: 설비 (equipment_mng)
* 우측: ( / / )
*
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Wrench, ClipboardCheck, Package, Copy, Info, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { ImageUpload } from "@/components/common/ImageUpload";
const EQUIP_TABLE = "equipment_mng";
const INSPECTION_TABLE = "equipment_inspection_item";
const CONSUMABLE_TABLE = "equipment_consumable";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "equipment_code", label: "설비코드", width: "w-[110px]" },
{ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" },
{ key: "equipment_type", label: "설비유형", width: "w-[90px]" },
{ key: "manufacturer", label: "제조사", width: "w-[100px]" },
{ key: "installation_location", label: "설치장소", width: "w-[100px]" },
{ key: "operation_status", label: "가동상태", width: "w-[80px]" },
];
const INSPECTION_COLUMNS: DataGridColumn[] = [
{ key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true },
{ key: "inspection_cycle", label: "점검주기", width: "w-[80px]" },
{ key: "inspection_method", label: "점검방법", width: "w-[80px]" },
{ key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true },
{ key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true },
];
const CONSUMABLE_COLUMNS: DataGridColumn[] = [
{ key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false },
{ key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true },
{ key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "specification", label: "규격", width: "w-[100px]", editable: true },
{ key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true },
];
export default function EquipmentInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측
const [equipments, setEquipments] = useState<any[]>([]);
const [equipLoading, setEquipLoading] = useState(false);
const [equipCount, setEquipCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
// 우측 탭
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
const [inspections, setInspections] = useState<any[]>([]);
const [inspectionLoading, setInspectionLoading] = useState(false);
const [consumables, setConsumables] = useState<any[]>([]);
const [consumableLoading, setConsumableLoading] = useState(false);
// 카테고리
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 모달
const [equipModalOpen, setEquipModalOpen] = useState(false);
const [equipEditMode, setEquipEditMode] = useState(false);
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 기본정보 탭 편집 폼
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
const [infoSaving, setInfoSaving] = useState(false);
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySourceEquip, setCopySourceEquip] = useState("");
const [copyItems, setCopyItems] = useState<any[]>([]);
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
const [copyLoading, setCopyLoading] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("equipment-info");
if (saved) applyTableSettings(saved);
}, []);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// equipment_mng 카테고리
for (const col of ["equipment_type", "operation_status"]) {
try {
const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
// inspection 카테고리
for (const col of ["inspection_cycle", "inspection_method"]) {
try {
const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCatOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return catOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 설비 조회
const fetchEquipments = useCallback(async () => {
setEquipLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
useEffect(() => { fetchEquipments(); }, [fetchEquipments]);
const selectedEquip = equipments.find((e) => e.id === selectedEquipId);
// 기본정보 탭 폼 초기화 (설비 선택 변경 시)
useEffect(() => {
if (selectedEquip) setInfoForm({ ...selectedEquip });
else setInfoForm({});
}, [selectedEquipId]); // eslint-disable-line react-hooks/exhaustive-deps
// 기본정보 저장
const handleInfoSave = async () => {
if (!infoForm.id) return;
setInfoSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = infoForm;
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("저장되었습니다.");
fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); }
finally { setInfoSaving(false); }
};
// 우측: 점검항목 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetch = async () => {
setInspectionLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setInspections(raw.map((r: any) => ({
...r,
inspection_cycle: resolve("inspection_cycle", r.inspection_cycle),
inspection_method: resolve("inspection_method", r.inspection_method),
})));
} catch { setInspections([]); } finally { setInspectionLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code, catOptions]);
// 우측: 소모품 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetch = async () => {
setConsumableLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
setConsumables(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setConsumables([]); } finally { setConsumableLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code]);
// 새로고침 헬퍼
const refreshRight = () => {
const eid = selectedEquipId;
setSelectedEquipId(null);
setTimeout(() => setSelectedEquipId(eid), 50);
};
// 설비 등록/수정
const openEquipRegister = () => { setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); };
const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); };
const handleEquipSave = async () => {
if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; }
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = equipForm;
if (equipEditMode && id) {
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, fields);
toast.success("등록되었습니다.");
}
setEquipModalOpen(false); fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
const handleEquipDelete = async () => {
if (!selectedEquipId) return;
const ok = await confirm("설비를 삭제하시겠습니까?", { 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();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 소모품 추가
// 소모품 품목 로드 (item_info에서 type 또는 division 라벨이 "소모품"인 것)
const loadConsumableItems = async () => {
try {
const flatten = (vals: any[]): any[] => {
const r: any[] = [];
for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); }
return r;
};
// type과 division 카테고리 모두에서 "소모품" 코드 찾기
const [typeRes, divRes] = await Promise.all([
apiClient.get(`/table-categories/item_info/type/values`),
apiClient.get(`/table-categories/item_info/division/values`),
]);
const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; }
// 두 필터 결과를 합산 (중복 제거)
const filters: any[] = [];
if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode });
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
const results = await Promise.all(filters.map((f) =>
apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [f] },
autoFilter: true,
})
));
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);
}
setConsumableItemOptions(Array.from(allItems.values()));
} catch { setConsumableItemOptions([]); }
};
const handleConsumableSave = async () => {
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
...consumableForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setConsumableModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 점검항목 복사: 소스 설비 선택 시 점검항목 로드
const loadCopyItems = async (equipCode: string) => {
setCopySourceEquip(equipCode);
setCopyChecked(new Set());
if (!equipCode) { setCopyItems([]); return; }
setCopyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
autoFilter: true,
});
setCopyItems(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setCopyItems([]); } finally { setCopyLoading(false); }
};
const handleCopyApply = async () => {
const selected = copyItems.filter((i) => copyChecked.has(i.id));
if (selected.length === 0) { toast.error("복사할 항목을 선택해주세요."); return; }
setSaving(true);
try {
for (const item of selected) {
const { id, created_date, updated_date, writer, company_code, equipment_code, ...fields } = item;
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...fields, equipment_code: selectedEquip?.equipment_code,
});
}
toast.success(`${selected.length}개 점검항목이 복사되었습니다.`);
setCopyModalOpen(false); refreshRight();
} catch { toast.error("복사 실패"); } finally { setSaving(false); }
};
// 엑셀
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
// 셀렉트 렌더링 헬퍼
const catSelect = (key: string, value: string, onChange: (v: string) => void, placeholder: string) => (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
return (
<div className="flex h-full flex-col gap-3 p-3">
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
try {
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
else toast.error("테이블 구조 분석 실패");
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
}}>
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />}
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 설비 목록 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Wrench className="w-4 h-4" /> <Badge variant="secondary" className="font-normal">{equipCount}</Badge>
</div>
<div className="flex 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="destructive" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid gridId="equip-left" columns={LEFT_COLUMNS} data={equipments} loading={equipLoading}
selectedId={selectedEquipId} onSelect={setSelectedEquipId} onRowDoubleClick={() => openEquipEdit()}
emptyMessage="등록된 설비가 없습니다" />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 탭 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-2 border-b bg-muted/10 shrink-0">
<div className="flex items-center gap-1">
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
<button key={tab} onClick={() => setRightTab(tab)}
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
<Icon className="w-3.5 h-3.5" />{label}
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
</button>
))}
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
</div>
<div className="flex gap-1.5">
{rightTab === "inspection" && (
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
{!selectedEquipId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm"> </div>
) : rightTab === "info" ? (
<div className="p-4 overflow-auto">
<div className="flex justify-end mb-3">
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={infoForm.introduction_date || ""} onChange={(v) => setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
</div>
</div>
</div>
) : rightTab === "inspection" ? (
<DataGrid gridId="equip-inspection" columns={INSPECTION_COLUMNS} data={inspections} loading={inspectionLoading}
showRowNumber={false} tableName={INSPECTION_TABLE} emptyMessage="점검항목이 없습니다"
onCellEdit={() => refreshRight()} />
) : (
<DataGrid gridId="equip-consumable" columns={CONSUMABLE_COLUMNS} data={consumables} loading={consumableLoading}
showRowNumber={false} tableName={CONSUMABLE_TABLE} emptyMessage="소모품이 없습니다"
onCellEdit={() => refreshRight()} />
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 설비 등록/수정 모달 */}
<FullscreenDialog open={equipModalOpen} onOpenChange={setEquipModalOpen}
title={equipEditMode ? "설비 수정" : "설비 등록"} description={equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}
defaultMaxWidth="max-w-2xl"
footer={<><Button variant="outline" onClick={() => setEquipModalOpen(false)}></Button>
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} </Button></>}>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<FormDatePicker value={equipForm.introduction_date || ""} onChange={(v) => setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
</div>
</FullscreenDialog>
{/* 점검항목 추가 모달 */}
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" /></div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.lower_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.upper_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}></Button>
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 소모품 추가 모달 */}
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5 col-span-2"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{consumableItemOptions.length > 0 ? (
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
setConsumableForm((p) => ({
...p,
consumable_name: v,
specification: item?.size || p.specification || "",
unit: item?.unit || p.unit || "",
manufacturer: item?.manufacturer || p.manufacturer || "",
}));
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
<SelectContent>
{consumableItemOptions.map((item) => (
<SelectItem key={item.id} value={item.item_name || item.item_number}>
{item.item_name}{item.size ? ` (${item.size})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div>
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
placeholder="소모품명 직접 입력" className="h-9" />
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
)}</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"></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>
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}></Button>
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 점검항목 복사 모달 */}
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader><DialogTitle> </DialogTitle>
<DialogDescription> {selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
<SelectContent>
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border rounded-lg overflow-auto max-h-[300px]">
{copyLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : copyItems.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
</TableHead>
<TableHead></TableHead><TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead><TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[70px]"></TableHead><TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyItems.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
<TableCell className="text-sm">{item.inspection_item}</TableCell>
<TableCell className="text-xs">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-xs">{resolve("inspection_method", item.inspection_method)}</TableCell>
<TableCell className="text-xs">{item.lower_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.upper_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{copyChecked.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCopyModalOpen(false)}></Button>
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 (멀티테이블) */}
{excelChainConfig && (
<MultiTableExcelUploadModal open={excelUploadOpen}
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
)}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={EQUIP_TABLE}
settingsId="equipment-info"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,926 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
ResizableHandle, ResizablePanel, ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandInput, CommandList, CommandEmpty, CommandItem } from "@/components/ui/command";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import {
Search, Plus, Trash2, RotateCcw, Loader2, Package, Box, X, Save, Edit2, Download, ChevronsUpDown, Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg,
} from "@/lib/api/packaging";
// --- 코드 → 라벨 매핑 ---
const PKG_TYPE_LABEL: Record<string, string> = {
BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡",
ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤",
};
const LOADING_TYPE_LABEL: Record<string, string> = {
PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트",
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-emerald-100 text-emerald-800" : "bg-gray-100 text-gray-600";
const fmtSize = (w: any, l: any, h: any) => {
const vals = [w, l, h].map(v => Number(v) || 0);
return vals.some(v => v > 0) ? vals.join("×") : "-";
};
// 규격 문자열에서 치수 파싱
function parseSpecDimensions(spec: string | null) {
if (!spec) return { w: 0, l: 0, h: 0 };
const m3 = spec.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/i);
if (m3) return { w: parseInt(m3[1]), l: parseInt(m3[2]), h: parseInt(m3[3]) };
const m2 = spec.match(/(\d+)\s*[x×]\s*(\d+)/i);
if (m2) return { w: parseInt(m2[1]), l: parseInt(m2[2]), h: 0 };
return { w: 0, l: 0, h: 0 };
}
export default function PackagingPage() {
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [activeTab, setActiveTab] = useState<"packing" | "loading">("packing");
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
// 포장재 데이터
const [pkgUnits, setPkgUnits] = useState<PkgUnit[]>([]);
const [pkgLoading, setPkgLoading] = useState(false);
const [selectedPkg, setSelectedPkg] = useState<PkgUnit | null>(null);
const [pkgItems, setPkgItems] = useState<PkgUnitItem[]>([]);
const [pkgItemsLoading, setPkgItemsLoading] = useState(false);
// 적재함 데이터
const [loadingUnits, setLoadingUnits] = useState<LoadingUnit[]>([]);
const [loadingLoading, setLoadingLoading] = useState(false);
const [selectedLoading, setSelectedLoading] = useState<LoadingUnit | null>(null);
const [loadingPkgs, setLoadingPkgs] = useState<LoadingUnitPkg[]>([]);
const [loadingPkgsLoading, setLoadingPkgsLoading] = useState(false);
// 모달
const [pkgModalOpen, setPkgModalOpen] = useState(false);
const [pkgModalMode, setPkgModalMode] = useState<"create" | "edit">("create");
const [pkgForm, setPkgForm] = useState<Record<string, any>>({});
const [pkgItemOptions, setPkgItemOptions] = useState<ItemInfoForPkg[]>([]);
const [pkgItemPopoverOpen, setPkgItemPopoverOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
const [loadModalMode, setLoadModalMode] = useState<"create" | "edit">("create");
const [loadForm, setLoadForm] = useState<Record<string, any>>({});
const [loadItemOptions, setLoadItemOptions] = useState<ItemInfoForPkg[]>([]);
const [loadItemPopoverOpen, setLoadItemPopoverOpen] = useState(false);
const [itemMatchModalOpen, setItemMatchModalOpen] = useState(false);
const [itemMatchKeyword, setItemMatchKeyword] = useState("");
const [itemMatchResults, setItemMatchResults] = useState<ItemInfoForPkg[]>([]);
const [itemMatchSelected, setItemMatchSelected] = useState<ItemInfoForPkg | null>(null);
const [itemMatchQty, setItemMatchQty] = useState(1);
const [pkgMatchModalOpen, setPkgMatchModalOpen] = useState(false);
const [pkgMatchQty, setPkgMatchQty] = useState(1);
const [pkgMatchMethod, setPkgMatchMethod] = useState("");
const [pkgMatchSelected, setPkgMatchSelected] = useState<PkgUnit | null>(null);
const [pkgMatchSearchKw, setPkgMatchSearchKw] = useState("");
const [saving, setSaving] = useState(false);
// --- 데이터 로드 ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
useEffect(() => { fetchPkgUnits(); fetchLoadingUnits(); }, [fetchPkgUnits, fetchLoadingUnits]);
// 포장재 선택 시 매칭 품목 로드
const selectPkg = useCallback(async (pkg: PkgUnit) => {
setSelectedPkg(pkg);
setPkgItemsLoading(true);
try {
const res = await getPkgUnitItems(pkg.pkg_code);
if (res.success) setPkgItems(res.data);
} catch { setPkgItems([]); } finally { setPkgItemsLoading(false); }
}, []);
// 적재함 선택 시 포장구성 로드
const selectLoading = useCallback(async (lu: LoadingUnit) => {
setSelectedLoading(lu);
setLoadingPkgsLoading(true);
try {
const res = await getLoadingUnitPkgs(lu.loading_code);
if (res.success) setLoadingPkgs(res.data);
} catch { setLoadingPkgs([]); } finally { setLoadingPkgsLoading(false); }
}, []);
// 검색 필터 적용
const filteredPkgUnits = pkgUnits.filter((p) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw));
});
const filteredLoadingUnits = loadingUnits.filter((l) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (l.loading_code?.toLowerCase().includes(kw) || l.loading_name?.toLowerCase().includes(kw));
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
}
setPkgItemPopoverOpen(false);
try {
const res = await getItemsByDivision("포장재");
if (res.success) setPkgItemOptions(res.data);
} catch { setPkgItemOptions([]); }
setPkgModalOpen(true);
};
const onPkgItemSelect = (item: ItemInfoForPkg) => {
setPkgItemPopoverOpen(false);
const dims = parseSpecDimensions(item.size);
setPkgForm((prev) => ({
...prev,
pkg_code: item.item_number,
pkg_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const savePkgUnit = async () => {
if (!pkgForm.pkg_code || !pkgForm.pkg_name) { toast.error("포장코드와 포장명은 필수입니다."); return; }
if (!pkgForm.pkg_type) { toast.error("포장유형을 선택해주세요."); return; }
setSaving(true);
try {
if (pkgModalMode === "create") {
const res = await createPkgUnit(pkgForm);
if (res.success) { toast.success("포장재 등록 완료"); setPkgModalOpen(false); fetchPkgUnits(); }
} else {
const res = await updatePkgUnit(pkgForm.id, pkgForm);
if (res.success) { toast.success("포장재 수정 완료"); setPkgModalOpen(false); fetchPkgUnits(); setSelectedPkg(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeletePkg = async (pkg: PkgUnit) => {
const ok = await confirm(`"${pkg.pkg_name}" 포장재를 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnit(pkg.id);
toast.success("삭제 완료");
setSelectedPkg(null); setPkgItems([]);
fetchPkgUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
}
setLoadItemPopoverOpen(false);
try {
const res = await getItemsByDivision("적재함");
if (res.success) setLoadItemOptions(res.data);
} catch { setLoadItemOptions([]); }
setLoadModalOpen(true);
};
const onLoadItemSelect = (item: ItemInfoForPkg) => {
setLoadItemPopoverOpen(false);
const dims = parseSpecDimensions(item.size);
setLoadForm((prev) => ({
...prev,
loading_code: item.item_number,
loading_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const saveLoadingUnit = async () => {
if (!loadForm.loading_code || !loadForm.loading_name) { toast.error("적재함코드와 적재함명은 필수입니다."); return; }
if (!loadForm.loading_type) { toast.error("적재유형을 선택해주세요."); return; }
setSaving(true);
try {
if (loadModalMode === "create") {
const res = await createLoadingUnit(loadForm);
if (res.success) { toast.success("적재함 등록 완료"); setLoadModalOpen(false); fetchLoadingUnits(); }
} else {
const res = await updateLoadingUnit(loadForm.id, loadForm);
if (res.success) { toast.success("적재함 수정 완료"); setLoadModalOpen(false); fetchLoadingUnits(); setSelectedLoading(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeleteLoading = async (lu: LoadingUnit) => {
const ok = await confirm(`"${lu.loading_name}" 적재함을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnit(lu.id);
toast.success("삭제 완료");
setSelectedLoading(null); setLoadingPkgs([]);
fetchLoadingUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 품목 추가 모달 (포장재 매칭) ---
const openItemMatchModal = async () => {
setItemMatchKeyword(""); setItemMatchSelected(null); setItemMatchQty(1);
setItemMatchModalOpen(true);
try {
const res = await getGeneralItems();
if (res.success) setItemMatchResults(res.data);
} catch { setItemMatchResults([]); }
};
const searchItemsForMatch = async () => {
try {
const res = await getGeneralItems(itemMatchKeyword || undefined);
if (res.success) setItemMatchResults(res.data);
} catch { setItemMatchResults([]); }
};
const saveItemMatch = async () => {
if (!selectedPkg || !itemMatchSelected) { toast.error("품목을 선택해주세요."); return; }
if (itemMatchQty <= 0) { toast.error("포장수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createPkgUnitItem({
pkg_code: selectedPkg.pkg_code,
item_number: itemMatchSelected.item_number,
pkg_qty: itemMatchQty,
});
if (res.success) { toast.success("품목 추가 완료"); setItemMatchModalOpen(false); selectPkg(selectedPkg); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeletePkgItem = async (item: PkgUnitItem) => {
const ok = await confirm("매칭 품목을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnitItem(item.id);
toast.success("삭제 완료");
if (selectedPkg) selectPkg(selectedPkg);
} catch { toast.error("삭제 실패"); }
};
// --- 포장단위 추가 모달 (적재함 구성) ---
const openPkgMatchModal = () => {
setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); setPkgMatchSearchKw("");
setPkgMatchModalOpen(true);
};
const savePkgMatch = async () => {
if (!selectedLoading || !pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; }
if (pkgMatchQty <= 0) { toast.error("최대적재수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createLoadingUnitPkg({
loading_code: selectedLoading.loading_code,
pkg_code: pkgMatchSelected.pkg_code,
max_load_qty: pkgMatchQty,
load_method: pkgMatchMethod || undefined,
});
if (res.success) { toast.success("포장단위 추가 완료"); setPkgMatchModalOpen(false); selectLoading(selectedLoading); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeleteLoadPkg = async (lp: LoadingUnitPkg) => {
const ok = await confirm("적재 구성을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnitPkg(lp.id);
toast.success("삭제 완료");
if (selectedLoading) selectLoading(selectedLoading);
} catch { toast.error("삭제 실패"); }
};
return (
<div className="flex h-full flex-col gap-3 p-4">
{/* 검색 바 */}
<div className="flex items-center gap-2 rounded-lg border bg-card p-3">
<Input
placeholder="포장코드 / 포장명 / 적재함명 검색"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="h-9 w-[280px] text-xs"
/>
<Button size="sm" variant="outline" onClick={() => setSearchKeyword("")} className="h-9">
<RotateCcw className="mr-1 h-4 w-4" />
</Button>
</div>
{/* 탭 */}
<div className="flex gap-1 border-b">
{([["packing", "포장재 관리", filteredPkgUnits.length] as const, ["loading", "적재함 관리", filteredLoadingUnits.length] as const]).map(([tab, label, count]) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
activeTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab === "packing" ? <Package className="h-4 w-4" /> : <Box className="h-4 w-4" />}
{label}
<Badge variant="secondary" className="text-[10px] px-1.5">{count}</Badge>
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<div className="flex-1 overflow-hidden">
{activeTab === "packing" ? (
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* 좌측: 포장재 목록 */}
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredPkgUnits.length})</span></span>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pkgLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredPkgUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredPkgUnits.map((p) => (
<TableRow
key={p.id}
className={cn("cursor-pointer text-xs", selectedPkg?.id === p.id && "bg-primary/5")}
onClick={() => selectPkg(p)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{p.pkg_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(p.status))}>{STATUS_LABEL[p.status] || p.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 상세 */}
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedPkg ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Package className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
{/* 요약 헤더 */}
<div className="flex items-center justify-between border-b bg-blue-50 dark:bg-blue-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-blue-600" />
<div>
<div className="font-bold text-sm">{selectedPkg.pkg_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedPkg.pkg_code} · {PKG_TYPE_LABEL[selectedPkg.pkg_type] || selectedPkg.pkg_type} · {fmtSize(selectedPkg.width_mm, selectedPkg.length_mm, selectedPkg.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("edit")}>
<Edit2 className="mr-1 h-3 w-3" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeletePkg(selectedPkg)}>
<Trash2 className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 매칭 품목 */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({pkgItems.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openItemMatchModal}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
{pkgItemsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : pkgItems.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{pkgItems.map((item) => (
<TableRow key={item.id} className="text-xs">
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
<X className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
) : (
/* 적재함 관리 탭 */
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredLoadingUnits.length})</span></span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[80px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredLoadingUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredLoadingUnits.map((l) => (
<TableRow
key={l.id}
className={cn("cursor-pointer text-xs", selectedLoading?.id === l.id && "bg-primary/5")}
onClick={() => selectLoading(l)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{l.loading_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{l.loading_name}</TableCell>
<TableCell className="p-2">{LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(l.status))}>{STATUS_LABEL[l.status] || l.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedLoading ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Box className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b bg-green-50 dark:bg-green-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Box className="h-5 w-5 text-green-600" />
<div>
<div className="font-bold text-sm">{selectedLoading.loading_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedLoading.loading_code} · {LOADING_TYPE_LABEL[selectedLoading.loading_type] || selectedLoading.loading_type} · {fmtSize(selectedLoading.width_mm, selectedLoading.length_mm, selectedLoading.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("edit")}><Edit2 className="mr-1 h-3 w-3" /> </Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeleteLoading(selectedLoading)}><Trash2 className="mr-1 h-3 w-3" /> </Button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({loadingPkgs.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openPkgMatchModal}><Plus className="mr-1 h-3 w-3" /> </Button>
</div>
<div className="flex-1 overflow-auto">
{loadingPkgsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : loadingPkgs.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{loadingPkgs.map((lp) => (
<TableRow key={lp.id} className="text-xs">
<TableCell className="p-2 font-medium">{lp.pkg_code}</TableCell>
<TableCell className="p-2">{lp.pkg_name || "-"}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(lp.max_load_qty).toLocaleString()}</TableCell>
<TableCell className="p-2">{lp.load_method || "-"}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteLoadPkg(lp)}><X className="h-3 w-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
{/* 포장재 등록/수정 모달 */}
<FullscreenDialog open={pkgModalOpen} onOpenChange={setPkgModalOpen}
title={pkgModalMode === "create" ? "포장재 등록" : "포장재 수정"}
description="품목정보에서 포장재를 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setPkgModalOpen(false)}></Button>
<Button onClick={savePkgUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{/* 품목정보 연결 */}
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-blue-50 dark:bg-blue-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 포장재)</Label>
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
{pkgForm.pkg_code
? `${pkgForm.pkg_name} (${pkgForm.pkg_code})`
: "품목정보에서 포장재를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = pkgItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{pkgItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={pkgForm.pkg_type || ""} onValueChange={(v) => setPkgForm((p) => ({ ...p, pkg_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(PKG_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={pkgForm.status || "ACTIVE"} onValueChange={(v) => setPkgForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.width_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.length_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.height_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.self_weight_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.max_load_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(L)</Label><Input type="number" value={pkgForm.volume_l || ""} onChange={(e) => setPkgForm((p) => ({ ...p, volume_l: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={pkgForm.remarks || ""} onChange={(e) => setPkgForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 적재함 등록/수정 모달 */}
<FullscreenDialog open={loadModalOpen} onOpenChange={setLoadModalOpen}
title={loadModalMode === "create" ? "적재함 등록" : "적재함 수정"}
description="품목정보에서 적재함을 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setLoadModalOpen(false)}></Button>
<Button onClick={saveLoadingUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{loadModalMode === "create" && (
<div className="rounded-lg border bg-green-50 dark:bg-green-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 적재함)</Label>
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
{loadForm.loading_code
? `${loadForm.loading_name} (${loadForm.loading_code})`
: "품목정보에서 적재함을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = loadItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{loadItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={loadForm.loading_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={loadForm.loading_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={loadForm.loading_type || ""} onValueChange={(v) => setLoadForm((p) => ({ ...p, loading_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(LOADING_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={loadForm.status || "ACTIVE"} onValueChange={(v) => setLoadForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.width_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.length_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.height_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.self_weight_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.max_load_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]"></Label><Input type="number" value={loadForm.max_stack || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_stack: e.target.value }))} className="h-8 text-xs" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={loadForm.remarks || ""} onChange={(e) => setLoadForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 품목 추가 모달 (포장재 매칭) */}
<Dialog open={itemMatchModalOpen} onOpenChange={setItemMatchModalOpen}>
<DialogContent className="max-w-[900px]">
<DialogHeader>
<DialogTitle> {selectedPkg?.pkg_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input placeholder="품목코드 / 품목명 검색 (입력 시 자동 검색)" value={itemMatchKeyword}
onChange={(e) => {
setItemMatchKeyword(e.target.value);
const kw = e.target.value;
clearTimeout((window as any).__itemMatchTimer);
(window as any).__itemMatchTimer = setTimeout(async () => {
try {
const res = await getGeneralItems(kw || undefined);
if (res.success) setItemMatchResults(res.data);
} catch { /* ignore */ }
}, 300);
}}
className="h-9 text-xs" />
<div className="max-h-[300px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2 w-[130px]"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[100px]"></TableHead>
<TableHead className="p-2 w-[80px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer text-xs", itemMatchSelected?.id === item.id && "bg-primary/10")}
onClick={() => setItemMatchSelected(item)}>
<TableCell className="p-2 text-center">{itemMatchSelected?.id === item.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium truncate max-w-[130px]">{item.item_number}</TableCell>
<TableCell className="p-2 truncate max-w-[200px]">{item.item_name}</TableCell>
<TableCell className="p-2 truncate">{item.spec || "-"}</TableCell>
<TableCell className="p-2 truncate">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="flex-1">
<Label className="text-xs"> </Label>
<Input value={itemMatchSelected ? `${itemMatchSelected.item_name} (${itemMatchSelected.item_number})` : ""} readOnly className="h-9 bg-muted text-xs" />
</div>
<div className="w-[120px]">
<Label htmlFor="pkg-item-match-qty" className="text-xs">(EA) <span className="text-destructive">*</span></Label>
<Input id="pkg-item-match-qty" type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setItemMatchModalOpen(false)}></Button>
<Button type="button" data-action-type="custom" onClick={saveItemMatch} disabled={saving || !itemMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 포장단위 추가 모달 (적재함 구성) */}
<Dialog open={pkgMatchModalOpen} onOpenChange={setPkgMatchModalOpen}>
<DialogContent className="max-w-[800px]">
<DialogHeader>
<DialogTitle> {selectedLoading?.loading_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="포장코드 / 포장명 검색"
value={pkgMatchSearchKw}
onChange={(e) => setPkgMatchSearchKw(e.target.value)}
className="h-9 text-xs"
/>
<div className="max-h-[300px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2 w-[120px]"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[100px]">(mm)</TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
const kw = pkgMatchSearchKw.toLowerCase();
const filtered = pkgUnits.filter(p =>
p.status === "ACTIVE"
&& !loadingPkgs.some(lp => lp.pkg_code === p.pkg_code)
&& (!kw || p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw))
);
return filtered.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : filtered.map((p) => (
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
onClick={() => setPkgMatchSelected(p)}>
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
<TableCell className="p-2">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
</TableRow>
));
})()}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="w-[150px]">
<Label htmlFor="loading-pkg-match-qty" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="loading-pkg-match-qty" type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
<div className="flex-1">
<Label className="text-xs"></Label>
<Input value={pkgMatchMethod} onChange={(e) => setPkgMatchMethod(e.target.value)} placeholder="수직/수평/혼합" className="h-9 text-xs" />
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPkgMatchModalOpen(false)}></Button>
<Button type="button" data-action-type="custom" onClick={savePkgMatch} disabled={saving || !pkgMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ConfirmDialogComponent}
</div>
);
}

View File

@ -18,14 +18,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import { FullscreenDialog } from "@/components/common/FullscreenDialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -693,14 +686,51 @@ export default function ReceivingPage() {
</div> </div>
{/* 입고 등록 모달 */} {/* 입고 등록 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}> <FullscreenDialog
<DialogContent className="flex h-[90vh] max-w-[95vw] flex-col p-0 sm:max-w-[1600px]"> open={isModalOpen}
<DialogHeader className="border-b px-6 py-4"> onOpenChange={setIsModalOpen}
<DialogTitle className="text-lg"> </DialogTitle> title="입고 등록"
<DialogDescription className="text-xs"> description="입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가하세요."
, . defaultMaxWidth="sm:max-w-[1600px]"
</DialogDescription> defaultWidth="w-[95vw]"
</DialogHeader> className="h-[90vh] p-0"
footer={
<div className="flex w-full items-center justify-between px-6 py-3">
<div className="text-muted-foreground text-xs">
{selectedItems.length > 0 ? (
<>
{totalSummary.count} | :{" "}
{totalSummary.qty.toLocaleString()} | :{" "}
{totalSummary.amount.toLocaleString()}
</>
) : (
"품목을 추가해주세요"
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-9 text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving || selectedItems.length === 0}
className="h-9 text-sm"
>
{saving ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Save className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</div>
}
>
{/* 입고유형 선택 */} {/* 입고유형 선택 */}
<div className="flex items-center gap-4 border-b px-6 py-3"> <div className="flex items-center gap-4 border-b px-6 py-3">
@ -974,43 +1004,7 @@ export default function ReceivingPage() {
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
{/* 푸터 */} </FullscreenDialog>
<DialogFooter className="flex items-center justify-between border-t px-6 py-3">
<div className="text-muted-foreground text-xs">
{selectedItems.length > 0 ? (
<>
{totalSummary.count} | :{" "}
{totalSummary.qty.toLocaleString()} | :{" "}
{totalSummary.amount.toLocaleString()}
</>
) : (
"품목을 추가해주세요"
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-9 text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving || selectedItems.length === 0}
className="h-9 text-sm"
>
{saving ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Save className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -0,0 +1,498 @@
"use client";
/**
*
*
* 좌측: 부서 (dept_info)
* 우측: 선택한 (user_info)
*
* 모달: 부서 (dept_info), (user_info)
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Building2, Users, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
{ key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" },
{ key: "parent_dept_code", label: "상위부서", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[70px]" },
];
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "sabun", label: "사번", width: "w-[80px]" },
{ key: "user_name", label: "이름", width: "w-[90px]" },
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
{ key: "position_name", label: "직급", width: "w-[80px]" },
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]" },
{ key: "email", label: "이메일", minWidth: "min-w-[150px]" },
];
export default function DepartmentPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 부서
const [depts, setDepts] = useState<any[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [deptCount, setDeptCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 우측: 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("department");
if (saved) applyTableSettings(saved);
}, []);
// 부서 조회
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
setDepts(data);
setDeptCount(res.data?.data?.total || data.length);
} catch (err) {
console.error("부서 조회 실패:", err);
toast.error("부서 목록을 불러오는데 실패했습니다.");
} finally {
setDeptLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchDepts(); }, [fetchDepts]);
// 선택된 부서
const selectedDept = depts.find((d) => d.id === selectedDeptId);
const selectedDeptCode = selectedDept?.dept_code || null;
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
const fetchMembers = useCallback(async () => {
setMemberLoading(true);
try {
const filters = selectedDeptCode
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
: [];
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
}, [selectedDeptCode]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 부서 등록
const openDeptRegister = () => {
setDeptForm({});
setDeptEditMode(false);
setDeptModalOpen(true);
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
setSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, {
originalData: { dept_code: deptForm.dept_code },
updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null },
});
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, {
dept_code: deptForm.dept_code || "",
dept_name: deptForm.dept_name,
parent_dept_code: deptForm.parent_dept_code || null,
});
toast.success("등록되었습니다.");
}
setDeptModalOpen(false);
fetchDepts();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 부서 삭제
const handleDeptDelete = async () => {
if (!selectedDeptCode) return;
const ok = await confirm("부서를 삭제하시겠습니까?", {
description: "해당 부서에 소속된 사원 정보는 유지됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, {
data: [{ dept_code: selectedDeptCode }],
});
toast.success("삭제되었습니다.");
setSelectedDeptId(null);
fetchDepts();
} catch { toast.error("삭제에 실패했습니다."); }
};
// 사원 추가
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
const handleUserFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setUserForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
};
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
setSaving(true);
try {
// 비밀번호 미입력 시 기본값 (신규만)
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
await apiClient.post("/admin/users/with-dept", {
userInfo: {
user_id: userForm.user_id,
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userForm.email || undefined,
tel: userForm.tel || undefined,
cell_phone: userForm.cell_phone || undefined,
sabun: userForm.sabun || undefined,
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
position_name: userForm.position_name || undefined,
} : undefined,
isUpdate: userEditMode,
});
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
setUserModalOpen(false);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (depts.length === 0) return;
const data = depts.map((d) => ({
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
}));
await exportToExcel(data, "부서관리.xlsx", "부서");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={DEPT_TABLE}
filterId="department"
onFilterChange={setSearchFilters}
dataCount={deptCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{deptCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={openDeptRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={handleDeptDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid
gridId="dept-left"
columns={LEFT_COLUMNS}
data={depts}
loading={deptLoading}
selectedId={selectedDeptId}
onSelect={(id) => {
setSelectedDeptId((prev) => (prev === id ? null : id));
}}
onRowDoubleClick={() => openDeptEdit()}
emptyMessage="등록된 부서가 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedDept ? "부서 인원" : "전체 사원"}
{selectedDept && <Badge variant="outline" className="font-normal">{selectedDept.dept_name}</Badge>}
{members.length > 0 && <Badge variant="secondary" className="font-normal">{members.length}</Badge>}
</div>
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
<DataGrid
gridId="dept-right"
columns={RIGHT_COLUMNS}
data={members}
loading={memberLoading}
showRowNumber={false}
tableName={USER_TABLE}
emptyMessage={selectedDeptCode ? "소속 사원이 없습니다" : "등록된 사원이 없습니다"}
onRowDoubleClick={(row) => openUserModal(row)}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 부서 등록/수정 모달 */}
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={deptForm.dept_code || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_code: e.target.value }))}
placeholder="부서코드" className="h-9" disabled={deptEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={deptForm.dept_name || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
placeholder="부서명" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeptModalOpen(false)}></Button>
<Button onClick={handleDeptSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 사원 추가 모달 */}
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
<DialogDescription>{userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"> ID <span className="text-destructive">*</span></Label>
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
placeholder="이름" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
<SelectContent>
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.regdate || ""} onChange={(v) => setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.end_date || ""} onChange={(v) => setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={handleUserSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DEPT_TABLE}
userId={user?.userId}
onSuccess={() => fetchDepts()}
/>
{ConfirmDialogComponent}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={DEPT_TABLE}
settingsId="department"
onSave={applyTableSettings}
/>
</div>
);
}

View File

@ -0,0 +1,517 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Plus, Trash2, RotateCcw, Save, Search, Loader2, FileSpreadsheet, Download,
Package, Pencil, Copy,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
// 테이블 컬럼 정의
const TABLE_COLUMNS = [
{ key: "item_number", label: "품목코드", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "division", label: "관리품목", width: "w-[100px]" },
{ key: "type", label: "품목구분", width: "w-[100px]" },
{ key: "size", label: "규격", width: "w-[100px]" },
{ key: "unit", label: "단위", width: "w-[80px]" },
{ key: "material", label: "재질", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[80px]" },
{ key: "selling_price", label: "판매가격", width: "w-[100px]" },
{ key: "standard_price", label: "기준단가", width: "w-[100px]" },
{ key: "weight", label: "중량", width: "w-[80px]" },
{ key: "inventory_unit", label: "재고단위", width: "w-[80px]" },
{ key: "user_type01", label: "대분류", width: "w-[100px]" },
{ key: "user_type02", label: "중분류", width: "w-[100px]" },
];
// 등록 모달 필드 정의
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
{ key: "weight", label: "중량", type: "text" },
{ key: "volum", label: "부피", type: "text" },
{ key: "specific_gravity", label: "비중", type: "text" },
{ key: "inventory_unit", label: "재고단위", type: "category" },
{ key: "selling_price", label: "판매가격", type: "text" },
{ key: "standard_price", label: "기준단가", type: "text" },
{ key: "currency_code", label: "통화", type: "category" },
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "meno", label: "메모", type: "textarea" },
];
const TABLE_NAME = "item_info";
export default function ItemInfoPage() {
const { user } = useAuth();
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
const [searchDivision, setSearchDivision] = useState("all");
const [searchType, setSearchType] = useState("all");
const [searchStatus, setSearchStatus] = useState("all");
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<Record<string, any>>({});
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 카테고리 옵션 (API에서 로드)
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 카테고리 컬럼 목록
const CATEGORY_COLUMNS = ["division", "type", "unit", "material", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
// 카테고리 옵션 로드 (table_name + column_name 기반)
useEffect(() => {
const loadCategories = async () => {
try {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
await Promise.all(
CATEGORY_COLUMNS.map(async (colName) => {
try {
const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[colName] = flatten(res.data.data);
}
} catch { /* skip */ }
})
);
setCategoryOptions(optMap);
} catch (err) {
console.error("카테고리 로드 실패:", err);
}
};
loadCategories();
}, []);
// 데이터 조회
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = [];
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
}
if (searchDivision !== "all") {
filters.push({ columnName: "division", operator: "equals", value: searchDivision });
}
if (searchType !== "all") {
filters.push({ columnName: "type", operator: "equals", value: searchType });
}
if (searchStatus !== "all") {
filters.push({ columnName: "status", operator: "equals", value: searchStatus });
}
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// 카테고리 코드→라벨 변환
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setTotalCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchKeyword, searchDivision, searchType, searchStatus, categoryOptions]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
// 카테고리 코드 → 라벨 변환
const getCategoryLabel = (columnName: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[columnName];
if (!opts) return code;
const found = opts.find((o) => o.code === code);
return found?.label || code;
};
// 등록 모달 열기
const openRegisterModal = () => {
setFormData({});
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 수정 모달 열기
const openEditModal = (item: any) => {
setFormData({ ...item });
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
};
// 복사 모달 열기
const openCopyModal = (item: any) => {
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.item_name) {
toast.error("품명은 필수 입력입니다.");
return;
}
setSaving(true);
try {
if (isEditMode && editId) {
// 수정
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
updatedData: updateFields,
});
toast.success("수정되었습니다.");
} else {
// 등록
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, insertFields);
toast.success("등록되었습니다.");
}
setIsModalOpen(false);
fetchItems();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 삭제
const handleDelete = async () => {
if (!selectedId) {
toast.error("삭제할 품목을 선택해주세요.");
return;
}
if (!confirm("선택한 품목을 삭제하시겠습니까?")) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: [{ id: selectedId }],
});
toast.success("삭제되었습니다.");
setSelectedId(null);
fetchItems();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return;
}
const exportData = items.map((item) => {
const row: Record<string, any> = {};
for (const col of TABLE_COLUMNS) {
row[col.label] = getCategoryLabel(col.key, item[col.key]) || item[col.key] || "";
}
return row;
});
await exportToExcel(exportData, "품목정보.xlsx", "품목정보");
toast.success("엑셀 다운로드 완료");
};
// 검색 초기화
const handleResetSearch = () => {
setSearchKeyword("");
setSearchDivision("all");
setSearchType("all");
setSearchStatus("all");
};
// 카테고리 셀렉트 렌더링
const renderCategorySelect = (field: typeof FORM_FIELDS[0]) => {
const options = categoryOptions[field.key] || [];
return (
<Select
value={formData[field.key] || ""}
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={`${field.label} 선택`} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.code} value={opt.code}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<Card className="shrink-0">
<CardContent className="p-4 flex flex-wrap items-end gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">/</Label>
<Input
placeholder="검색"
className="w-[180px] h-9"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchDivision} onValueChange={setSearchDivision}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchType} onValueChange={setSearchType}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["status"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{totalCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openCopyModal(item);
}}>
<Copy className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openEditModal(item);
}}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
<Package className="w-8 h-8 opacity-50" />
<span> </span>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[40px] text-center">No</TableHead>
{TABLE_COLUMNS.map((col) => (
<TableHead key={col.key} className={col.width}>{col.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow
key={item.id}
className={cn("cursor-pointer", selectedId === item.id && "bg-primary/5")}
onClick={() => setSelectedId(item.id)}
onDoubleClick={() => openEditModal(item)}
>
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
{TABLE_COLUMNS.map((col) => (
<TableCell key={col.key} className="text-sm">
{["division", "type", "unit", "material", "status", "inventory_unit", "user_type01", "user_type02", "currency_code"].includes(col.key)
? getCategoryLabel(col.key, item[col.key])
: item[col.key] || ""}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-auto">
<DialogHeader>
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
<DialogDescription>
{isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
{FORM_FIELDS.map((field) => (
<div key={field.key} className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}>
<Label className="text-sm">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "category" ? (
renderCategorySelect(field)
) : field.type === "textarea" ? (
<Textarea
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.label}
rows={3}
/>
) : (
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.disabled ? field.placeholder : field.label}
disabled={field.disabled && !isEditMode}
className="h-9"
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={TABLE_NAME}
userId={user?.userId}
onSuccess={() => {
fetchItems();
}}
/>
</div>
);
}

View File

@ -0,0 +1,534 @@
"use client";
/**
*
*
* 좌측: 품목 (subcontractor_item_mapping , item_info )
* 우측: 선택한 (subcontractor_item_mapping subcontractor_mng )
*
* ( subcontractor_item_mapping )
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, Settings2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "subcontractor_item_mapping";
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
// 좌측: 품목 컬럼
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[90px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// 우측: 외주업체 정보 컬럼
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" },
{ key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" },
{ key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" },
{ key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" },
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
];
export default function SubcontractorItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 우측: 외주업체
const [subcontractorItems, setSubcontractorItems] = useState<any[]>([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 외주업체 추가 모달
const [subSelectOpen, setSubSelectOpen] = useState(false);
const [subSearchKeyword, setSubSearchKeyword] = useState("");
const [subSearchResults, setSubSearchResults] = useState<any[]>([]);
const [subSearchLoading, setSubSearchLoading] = useState(false);
const [subCheckedIds, setSubCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("subcontractor-item");
if (saved) applyTableSettings(saved);
}, []);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
const outsourcingDivisionCode = categoryOptions["division"]?.find(
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
)?.code;
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
// division = 외주관리 필터 추가
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setItemLoading(false);
}
}, [searchFilters, categoryOptions, outsourcingDivisionCode]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 외주업체 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setSubcontractorItems([]); return; }
const itemKey = selectedItem.item_number;
const fetchSubcontractorItems = async () => {
setSubcontractorLoading(true);
try {
// subcontractor_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// subcontractor_id → subcontractor_mng 조인 (외주업체명)
const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))];
let subMap: Record<string, any> = {};
if (subIds.length > 0) {
try {
const subRes = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: subIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "subcontractor_code", operator: "in", value: subIds }] },
autoFilter: true,
});
for (const s of (subRes.data?.data?.data || subRes.data?.data?.rows || [])) {
subMap[s.subcontractor_code] = s;
}
} catch { /* skip */ }
}
setSubcontractorItems(mappings.map((m: any) => ({
...m,
subcontractor_code: m.subcontractor_id,
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
})));
} catch (err) {
console.error("외주업체 조회 실패:", err);
} finally {
setSubcontractorLoading(false);
}
};
fetchSubcontractorItems();
}, [selectedItem?.item_number]);
// 외주업체 검색
const searchSubcontractors = async () => {
setSubSearchLoading(true);
try {
const filters: any[] = [];
if (subSearchKeyword) filters.push({ columnName: "subcontractor_name", operator: "contains", value: subSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
// 이미 등록된 외주업체 제외
const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code));
setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code)));
} catch { /* skip */ } finally { setSubSearchLoading(false); }
};
// 외주업체 추가 저장
const addSelectedSubcontractors = async () => {
const selected = subSearchResults.filter((s) => subCheckedIds.has(s.id));
if (selected.length === 0 || !selectedItem) return;
try {
for (const sub of selected) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
subcontractor_id: sub.subcontractor_code,
item_id: selectedItem.item_number,
});
}
toast.success(`${selected.length}개 외주업체가 추가되었습니다.`);
setSubCheckedIds(new Set());
setSubSelectOpen(false);
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "외주업체 추가에 실패했습니다.");
}
};
// 품목 수정
const openEditItem = () => {
if (!selectedItem) return;
setEditItemForm({ ...selectedItem });
setEditItemOpen(true);
};
const handleEditSave = async () => {
if (!editItemForm.id) return;
setSaving(true);
try {
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editItemForm.id },
updatedData: {
selling_price: editItemForm.selling_price || null,
standard_price: editItemForm.standard_price || null,
currency_code: editItemForm.currency_code || null,
},
});
toast.success("수정되었습니다.");
setEditItemOpen(false);
fetchItems();
} catch (err: any) {
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "외주품목정보.xlsx", "외주품목");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={ITEM_TABLE}
filterId="subcontractor-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 외주품목 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{itemCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<DataGrid
gridId="subcontractor-item-left"
columns={LEFT_COLUMNS}
data={items}
loading={itemLoading}
selectedId={selectedItemId}
onSelect={setSelectedItemId}
onRowDoubleClick={() => openEditItem()}
emptyMessage="등록된 외주품목이 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 외주업체 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setSubCheckedIds(new Set()); setSubSelectOpen(true); searchSubcontractors(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="subcontractor-item-right"
columns={RIGHT_COLUMNS}
data={subcontractorItems}
loading={subcontractorLoading}
showRowNumber={false}
emptyMessage="등록된 외주업체가 없습니다"
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 품목 수정 모달 */}
<FullscreenDialog
open={editItemOpen}
onOpenChange={setEditItemOpen}
title="외주품목 수정"
description={`${editItemForm.item_number || ""}${editItemForm.item_name || ""}`}
defaultMaxWidth="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setEditItemOpen(false)}></Button>
<Button onClick={handleEditSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-4 py-4">
{[
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
].map((f) => (
<div key={f.key} className="space-y-1.5">
<Label className="text-sm text-muted-foreground">{f.label}</Label>
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
</div>
))}
<div className="col-span-2 border-t my-2" />
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
placeholder="판매가격" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
placeholder="기준단가" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</FullscreenDialog>
{/* 외주업체 추가 모달 */}
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="외주업체명 검색" value={subSearchKeyword}
onChange={(e) => setSubSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchSubcontractors} disabled={subSearchLoading} className="h-9">
{subSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={subSearchResults.length > 0 && subCheckedIds.size === subSearchResults.length}
onChange={(e) => {
if (e.target.checked) setSubCheckedIds(new Set(subSearchResults.map((s) => s.id)));
else setSubCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : subSearchResults.map((s) => (
<TableRow key={s.id} className={cn("cursor-pointer", subCheckedIds.has(s.id) && "bg-primary/5")}
onClick={() => setSubCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(s.id)) next.delete(s.id); else next.add(s.id);
return next;
})}>
<TableCell className="text-center"><input type="checkbox" checked={subCheckedIds.has(s.id)} readOnly /></TableCell>
<TableCell className="text-xs">{s.subcontractor_code}</TableCell>
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
<TableCell className="text-xs">{s.division}</TableCell>
<TableCell className="text-xs">{s.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{subCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setSubSelectOpen(false)}></Button>
<Button onClick={addSelectedSubcontractors} disabled={subCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {subCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={ITEM_TABLE}
settingsId="subcontractor-item"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -733,15 +733,17 @@ export default function WorkInstructionPage() {
<div className="max-h-[280px] overflow-auto"> <div className="max-h-[280px] overflow-auto">
<Table> <Table>
<TableHeader className="sticky top-0 bg-background z-10"> <TableHeader className="sticky top-0 bg-background z-10">
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead className="w-[100px] text-right"></TableHead><TableHead></TableHead><TableHead className="w-[60px]" /></TableRow> <TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[100px] text-right"></TableHead><TableHead></TableHead><TableHead className="w-[60px]" /></TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{editItems.length === 0 ? ( {editItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow> <TableRow><TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
) : editItems.map((item, idx) => ( ) : editItems.map((item, idx) => (
<TableRow key={idx}> <TableRow key={idx}>
<TableCell className="text-xs text-center">{idx + 1}</TableCell> <TableCell className="text-xs text-center">{idx + 1}</TableCell>
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell> <TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
<TableCell className="text-xs max-w-[180px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-xs max-w-[100px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell> <TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell> <TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell> <TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,947 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
// Card, CardContent 제거 — DynamicSearchFilter가 대체
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
const DETAIL_TABLE = "sales_order_detail";
// 천단위 구분자 표시용 (입력 중에는 콤마 포함 표시, 저장 시 숫자만)
const formatNumber = (val: string) => {
const num = val.replace(/[^\d.-]/g, "");
if (!num) return "";
const parts = num.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
};
const parseNumber = (val: string) => val.replace(/,/g, "");
const MASTER_TABLE = "sales_order_mng";
// 메인 목록 테이블 컬럼 (sales_order_detail 기준)
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "order_no", label: "수주번호", width: "w-[120px]" },
{ key: "part_code", label: "품번", width: "w-[120px]", editable: true },
{ key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true },
{ key: "spec", label: "규격", width: "w-[120px]", editable: true },
{ key: "unit", label: "단위", width: "w-[70px]", editable: true },
{ key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
{ key: "ship_qty", label: "출하수량", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
{ key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" },
{ key: "due_date", label: "납기일", width: "w-[110px]" },
{ key: "memo", label: "메모", width: "w-[100px]", editable: true },
];
// 조건부 레이어 설정 (input_mode, sell_mode에 따라 표시 필드가 달라짐)
// Zone 10: input_mode → 공급업체우선(CAT_MLZWPH5R_983R) / 품목우선(CAT_MLZWPUQC_PB8Z)
// Zone 17: sell_mode → 해외판매(CAT_MLZWFF2Z_BQCV)
export default function SalesOrderPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
// isModalFullscreen 제거됨 — FullscreenDialog 사용
const [isEditMode, setIsEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
const [detailRows, setDetailRows] = useState<any[]>([]);
// 품목 선택 모달 (리피터에서 품목 추가용)
const [itemSelectOpen, setItemSelectOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 출하계획 모달
const [shippingPlanOpen, setShippingPlanOpen] = useState(false);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 체크된 행 (다중선택)
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 테이블 설정
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(GRID_COLUMNS);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 테이블 설정 적용 (컬럼 + 필터)
const applyTableSettings = useCallback((settings: TableSettings) => {
// 컬럼 표시/숨김/순서/너비
const colMap = new Map(GRID_COLUMNS.map((c) => [c.key, c]));
const applied: DataGridColumn[] = [];
for (const cs of settings.columns) {
if (!cs.visible) continue;
const orig = colMap.get(cs.columnName);
if (orig) {
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
}
}
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
for (const col of GRID_COLUMNS) {
if (!settingKeys.has(col.key)) applied.push(col);
}
setGridColumns(applied.length > 0 ? applied : GRID_COLUMNS);
// 필터 설정 → DynamicSearchFilter에 전달
setFilterConfig(settings.filters);
}, []);
// 마운트 시 저장된 설정 복원
useEffect(() => {
const saved = loadTableSettings("sales-order");
if (saved) applyTableSettings(saved);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
const catColumns = ["sell_mode", "input_mode", "price_mode", "incoterms", "payment_term"];
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// 라벨 치환 + 중복 제거 (같은 label이면 첫 번째만 유지)
const LABEL_REPLACE: Record<string, string> = {
"공급업체 우선": "거래처 우선",
"공급업체우선": "거래처 우선",
};
const dedup = (items: { code: string; label: string }[]) => {
const seen = new Set<string>();
return items
.map((item) => ({ ...item, label: LABEL_REPLACE[item.label] || item.label }))
.filter((item) => {
const key = item.label.replace(/\s/g, "");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
await Promise.all(
catColumns.map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[col] = dedup(flatten(res.data.data));
}
} catch { /* skip */ }
})
);
// 거래처 목록도 로드
try {
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 500, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
} catch { /* skip */ }
// 사용자 목록 로드 (담당자 선택용)
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager_id"] = users.map((u: any) => ({
code: u.user_id || u.id,
label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`,
}));
} catch { /* skip */ }
// item_info 카테고리도 로드 (unit, material 등 코드→라벨 변환용)
for (const col of ["unit", "material", "division", "type"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[`item_${col}`] = flatten(res.data.data);
}
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
loadCategories();
}, []);
// 데이터 조회
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = searchFilters.map((f) => ({
columnName: f.columnName,
operator: f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "order_no", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// part_code → item_info 조인 (품명/규격이 비어있는 경우 보강)
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
if (partCodes.length > 0) {
try {
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: partCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: partCodes }] },
autoFilter: true,
});
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
for (const item of items) {
itemMap[item.item_number] = item;
}
} catch { /* skip */ }
}
// 조인 적용 + 카테고리 코드→라벨 변환
const resolveLabel = (key: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[key];
if (!opts) return code;
return opts.find((o) => o.code === code)?.label || code;
};
const data = rows.map((row: any) => {
const item = itemMap[row.part_code];
const rawUnit = row.unit || item?.unit || "";
return {
...row,
part_name: row.part_name || item?.item_name || "",
spec: row.spec || item?.size || "",
unit: resolveLabel("item_unit", rawUnit) || rawUnit,
};
});
setOrders(data);
setTotalCount(res.data?.data?.total || data.length);
} catch (err) {
console.error("수주 조회 실패:", err);
toast.error("수주 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
const found = categoryOptions[col]?.find((o) => o.code === code);
return found?.label || code;
};
// 등록 모달 열기
// 납품처 목록 (거래처 선택 시 조회)
const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]);
const loadDeliveryOptions = async (customerCode: string) => {
if (!customerCode) { setDeliveryOptions([]); return; }
try {
const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] },
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setDeliveryOptions(rows.map((r: any) => ({
code: r.destination_code || r.id,
label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`,
})));
} catch { setDeliveryOptions([]); }
};
const openRegisterModal = () => {
// 기본값: 각 카테고리의 첫 번째 옵션
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || "";
setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "" });
setDetailRows([]);
setDeliveryOptions([]);
setIsEditMode(false);
setIsModalOpen(true);
};
// 수정 모달 열기 (order_no로 마스터 + 디테일 조회)
const openEditModal = async (orderNo: string) => {
try {
// 마스터 조회
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0];
// 디테일 조회
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
setMasterForm(masterData || {});
setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` })));
setIsEditMode(true);
setIsModalOpen(true);
} catch (err) {
console.error("수주 상세 조회 실패:", err);
toast.error("수주 정보를 불러오는데 실패했습니다.");
}
};
// 삭제 (다중 선택)
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
const ok = await confirm(`${checkedIds.length}건의 수주 데이터를 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
// 선택된 디테일 행 삭제
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: checkedIds.map((id) => ({ id })),
});
// 해당 수주번호의 남은 디테일이 없으면 마스터도 삭제
for (const orderNo of orderNos) {
const remaining = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const rows = remaining.data?.data?.data || remaining.data?.data?.rows || [];
if (rows.length === 0) {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
});
}
}
}
toast.success("삭제되었습니다.");
setCheckedIds([]);
fetchOrders();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
// 저장 (마스터 + 디테일)
const handleSave = async () => {
if (!masterForm.order_no && !isEditMode) {
toast.error("수주번호는 필수입니다.");
return;
}
if (detailRows.length === 0) {
toast.error("품목을 1개 이상 추가해주세요.");
return;
}
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm;
if (isEditMode && id) {
// 마스터 수정
await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, {
originalData: { id },
updatedData: masterFields,
});
// 기존 디테일 삭제 후 재삽입
const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: masterForm.order_no }] },
autoFilter: true,
});
const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || [];
if (existings.length > 0) {
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: existings.map((d: any) => ({ id: d.id })),
});
}
} else {
// 마스터 등록
await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields);
}
// 디테일 등록
for (const row of detailRows) {
const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
...detailFields,
order_no: masterForm.order_no,
});
}
toast.success(isEditMode ? "수정되었습니다." : "등록되었습니다.");
setIsModalOpen(false);
fetchOrders();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 품목 검색 (리피터에서 추가)
const searchItems = async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [];
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
};
const addSelectedItemsToDetail = async () => {
const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id));
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
// 단가방식에 따라 단가 조회
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
const today = new Date().toISOString().split("T")[0];
// 거래처별 단가 조회 (선택된 품목들에 대해)
let customerPriceMap: Record<string, string> = {};
if (isCustomerPrice && partnerId) {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
dataFilter: {
enabled: true,
filters: [
{ columnName: "customer_id", operator: "equals", value: partnerId },
{ columnName: "item_id", operator: "in", value: itemIds },
],
},
autoFilter: true,
});
const mappings = res.data?.data?.data || res.data?.data?.rows || [];
for (const m of mappings) {
// calculated_price 우선, 없으면 current_unit_price
const price = m.calculated_price || m.current_unit_price || "";
if (price) customerPriceMap[m.item_id] = String(price);
}
} catch (err) {
console.error("거래처별 단가 조회 실패:", err);
}
}
const newRows = selected.map((item) => {
const itemCode = item.item_number || item.id;
let unitPrice = "";
if (isStandardPrice) {
// 기준단가: item_info의 standard_price 또는 selling_price
unitPrice = item.standard_price || item.selling_price || "";
} else if (isCustomerPrice && partnerId) {
// 거래처별 단가
unitPrice = customerPriceMap[itemCode] || "";
}
return {
_id: `new_${Date.now()}_${Math.random()}`,
part_code: itemCode,
part_name: item.item_name,
spec: item.size || "",
material: getCategoryLabel("item_material", item.material) || item.material || "",
unit: getCategoryLabel("item_unit", item.unit) || item.unit || "",
qty: "",
unit_price: unitPrice,
amount: "",
due_date: "",
};
});
setDetailRows((prev) => [...prev, ...newRows]);
toast.success(`${selected.length}개 품목이 추가되었습니다.`);
setItemCheckedIds(new Set());
setItemSelectOpen(false);
};
const updateDetailRow = (idx: number, field: string, value: string) => {
setDetailRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
// 수량 × 단가 = 금액 자동 계산
if (field === "qty" || field === "unit_price") {
const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0;
const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0;
next[idx].amount = (qty * price).toString();
}
return next;
});
};
const removeDetailRow = (idx: number) => {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
// input_mode 값으로 레이어 판단
// 거래처 우선 (구: 공급업체 우선) - 두 코드 모두 지원
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
const isItemFirst = masterForm.input_mode === "CAT_MLZWPUQC_PB8Z" || masterForm.input_mode === "CAT_MLKG5FZO_HS1B";
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
const data = orders.map((o) => {
const row: Record<string, any> = {};
for (const col of GRID_COLUMNS) row[col.label] = o[col.key] || "";
return row;
});
await exportToExcel(data, "수주관리.xlsx", "수주목록");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 필터 (사용자 설정 가능) */}
<DynamicSearchFilter
tableName={DETAIL_TABLE}
filterId="sales-order"
onFilterChange={setSearchFilters}
dataCount={totalCount}
externalFilterConfig={filterConfig}
/>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<ClipboardList className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{totalCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={checkedIds.length !== 1} onClick={() => {
const item = orders.find((o) => o.id === checkedIds[0]);
if (item) openEditModal(item.order_no);
}}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
<Button variant="outline" size="sm" disabled={checkedIds.length === 0} onClick={() => setShippingPlanOpen(true)}>
<Truck className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<DataGrid
gridId="sales-order"
columns={gridColumns}
data={orders}
loading={loading}
showCheckbox
showRowNumber={false}
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row.order_no)}
tableName={DETAIL_TABLE}
emptyMessage="등록된 수주가 없습니다"
onCellEdit={() => fetchOrders()}
/>
</div>
{/* 수주 등록/수정 모달 */}
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={isEditMode ? "수주 수정" : "수주 등록"}
description={isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."}
footer={
<>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="space-y-4 py-2">
{/* 기본 레이어 (항상 표시) */}
<div className="grid grid-cols-4 gap-4">
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={masterForm.order_no || ""} onChange={(e) => setMasterForm((p) => ({ ...p, order_no: e.target.value }))}
placeholder="수주번호" className="h-9" disabled={isEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={masterForm.order_date || ""} onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} placeholder="수주일" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["sell_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["price_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{/* 레이어 2: 거래처 우선 (거래처, 담당자, 납품처, 납품장소) */}
{isSupplierFirst && (
<div className="grid grid-cols-4 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["manager_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{deliveryOptions.length > 0 ? (
<Select value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
setMasterForm((p) => ({ ...p, delivery_partner_id: v }));
// 선택한 납품처의 주소를 자동 입력
const found = deliveryOptions.find((o) => o.code === v);
if (found) {
const addr = found.label.match(/\((.+)\)$/)?.[1] || "";
if (addr) setMasterForm((p) => ({ ...p, delivery_partner_id: v, delivery_address: addr }));
}
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="납품처 선택" /></SelectTrigger>
<SelectContent>
{deliveryOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input value={masterForm.delivery_partner_id || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} />
)}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.delivery_address || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))}
placeholder="납품장소" className="h-9" />
</div>
</div>
)}
{/* 레이어 4: 해외판매 (인코텀즈, 결제조건, 통화, 선적항, 도착항, HS코드) */}
{isOverseas && (
<div className="grid grid-cols-3 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["incoterms"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["payment_term"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.currency || ""} onChange={(e) => setMasterForm((p) => ({ ...p, currency: e.target.value }))}
placeholder="KRW" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.port_of_loading || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_loading: e.target.value }))}
className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.port_of_discharge || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_discharge: e.target.value }))}
className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm">HS Code</Label>
<Input value={masterForm.hs_code || ""} onChange={(e) => setMasterForm((p) => ({ ...p, hs_code: e.target.value }))}
className="h-9" />
</div>
</div>
)}
{/* 리피터 그리드 (품목 목록) — 레이어 2,3 공통 */}
<div className="border rounded-lg">
<div className="flex items-center justify-between p-3 border-b bg-muted/10">
<span className="text-sm font-semibold"> </span>
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="overflow-auto max-h-[300px]">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_code}>{row.part_code}</span></TableCell>
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-xs">{row.spec}</TableCell>
<TableCell className="text-xs">{row.unit}</TableCell>
<TableCell>
<Input value={formatNumber(row.qty || "")} onChange={(e) => updateDetailRow(idx, "qty", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell>
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell className="text-sm text-right font-medium">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell>
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* 메모 */}
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.memo || ""} onChange={(e) => setMasterForm((p) => ({ ...p, memo: e.target.value }))}
placeholder="메모" className="h-9" />
</div>
</div>
{/* 품목 선택 모달 (등록 모달 내부에 중첩) */}
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
<DialogContent className="max-w-3xl max-h-[70vh]" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="품명/품목코드 검색" value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
onChange={(e) => {
if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
else setItemCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : itemSearchResults.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
onClick={() => setItemCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
})}>
<TableCell className="text-center">
<input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly />
</TableCell>
<TableCell className="text-xs max-w-[130px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
<TableCell className="text-sm max-w-[150px]"><span className="block truncate" title={item.item_name}>{item.item_name}</span></TableCell>
<TableCell className="text-xs">{item.size}</TableCell>
<TableCell className="text-xs">{item.material}</TableCell>
<TableCell className="text-xs">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{itemCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(false); }}></Button>
<Button onClick={addSelectedItemsToDetail} disabled={itemCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {itemCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</FullscreenDialog>
{/* 출하계획 동시 등록 모달 */}
<ShippingPlanBatchModal
open={shippingPlanOpen}
onOpenChange={setShippingPlanOpen}
selectedDetailIds={checkedIds}
onSuccess={fetchOrders}
/>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DETAIL_TABLE}
userId={user?.userId}
onSuccess={() => fetchOrders()}
/>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={DETAIL_TABLE}
settingsId="sales-order"
onSave={applyTableSettings}
/>
{/* 공통 확인 다이얼로그 */}
{ConfirmDialogComponent}
</div>
);
}

View File

@ -0,0 +1,917 @@
"use client";
/**
*
*
* 좌측: 판매품목 (item_info, )
* 우측: 선택한 (customer_item_mapping customer_mng )
*
* ( customer_item_mapping )
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X, Settings2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "customer_item_mapping";
const CUSTOMER_TABLE = "customer_mng";
// 좌측: 판매품목 컬럼
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[90px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// 우측: 거래처 정보 컬럼
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
{ key: "customer_item_code", label: "거래처품번", width: "w-[100px]" },
{ key: "customer_item_name", label: "거래처품명", width: "w-[100px]" },
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
];
export default function SalesItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 테이블 설정
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 우측: 거래처
const [customerItems, setCustomerItems] = useState<any[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 거래처 추가 모달
const [custSelectOpen, setCustSelectOpen] = useState(false);
const [custSearchKeyword, setCustSearchKeyword] = useState("");
const [custSearchResults, setCustSearchResults] = useState<any[]>([]);
const [custSearchLoading, setCustSearchLoading] = useState(false);
const [custCheckedIds, setCustCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 거래처 상세 입력 모달 (거래처 품번/품명 + 단가)
const [custDetailOpen, setCustDetailOpen] = useState(false);
const [selectedCustsForDetail, setSelectedCustsForDetail] = useState<any[]>([]);
const [custMappings, setCustMappings] = useState<Record<string, Array<{ _id: string; customer_item_code: string; customer_item_name: string }>>>({});
const [custPrices, setCustPrices] = useState<Record<string, Array<{
_id: string; start_date: string; end_date: string; currency_code: string;
base_price_type: string; base_price: string; discount_type: string;
discount_value: string; rounding_type: string; rounding_unit_value: string;
calculated_price: string;
}>>>({});
const [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [editCustData, setEditCustData] = useState<any>(null);
// 테이블 설정 적용 (필터)
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
// 마운트 시 저장된 설정 복원
useEffect(() => {
const saved = loadTableSettings("sales-item");
if (saved) applyTableSettings(saved);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
// 단가 카테고리
const priceOpts: Record<string, { code: string; label: string }[]> = {};
for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) {
try {
const res = await apiClient.get(`/table-categories/customer_item_prices/${col}/values`);
if (res.data?.success) priceOpts[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setPriceCategoryOptions(priceOpts);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 좌측: 품목 조회
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setItemLoading(false);
}
}, [searchFilters, categoryOptions]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 거래처 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setCustomerItems([]); return; }
const itemKey = selectedItem.item_number;
const fetchCustomerItems = async () => {
setCustomerLoading(true);
try {
// customer_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// customer_id → customer_mng 조인 (거래처명)
const custIds = [...new Set(mappings.map((m: any) => m.customer_id).filter(Boolean))];
let custMap: Record<string, any> = {};
if (custIds.length > 0) {
try {
const custRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: custIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "in", value: custIds }] },
autoFilter: true,
});
for (const c of (custRes.data?.data?.data || custRes.data?.data?.rows || [])) {
custMap[c.customer_code] = c;
}
} catch { /* skip */ }
}
setCustomerItems(mappings.map((m: any) => ({
...m,
customer_code: m.customer_id,
customer_name: custMap[m.customer_id]?.customer_name || "",
})));
} catch (err) {
console.error("거래처 조회 실패:", err);
} finally {
setCustomerLoading(false);
}
};
fetchCustomerItems();
}, [selectedItem?.item_number]);
// 거래처 검색
const searchCustomers = async () => {
setCustSearchLoading(true);
try {
const filters: any[] = [];
if (custSearchKeyword) filters.push({ columnName: "customer_name", operator: "contains", value: custSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
// 이미 등록된 거래처 제외
const existing = new Set(customerItems.map((c: any) => c.customer_id || c.customer_code));
setCustSearchResults(all.filter((c: any) => !existing.has(c.customer_code)));
} catch { /* skip */ } finally { setCustSearchLoading(false); }
};
// 거래처 선택 → 상세 모달로 이동
const goToCustDetail = () => {
const selected = custSearchResults.filter((c) => custCheckedIds.has(c.id));
if (selected.length === 0) { toast.error("거래처를 선택해주세요."); return; }
setSelectedCustsForDetail(selected);
const mappings: typeof custMappings = {};
const prices: typeof custPrices = {};
for (const cust of selected) {
const key = cust.customer_code || cust.id;
mappings[key] = [];
prices[key] = [{
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
}];
}
setCustMappings(mappings);
setCustPrices(prices);
setCustSelectOpen(false);
setCustDetailOpen(true);
};
const addMappingRow = (custKey: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, customer_item_code: "", customer_item_name: "" }],
}));
};
const removeMappingRow = (custKey: string, rowId: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
}));
};
const updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
}));
};
const addPriceRow = (custKey: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: [...(prev[custKey] || []), {
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: "",
}],
}));
};
const removePriceRow = (custKey: string, rowId: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
}));
};
const updatePriceRow = (custKey: string, rowId: string, field: string, value: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).map((r) => {
if (r._id !== rowId) return r;
const updated = { ...r, [field]: value };
if (["base_price", "discount_type", "discount_value"].includes(field)) {
const bp = Number(updated.base_price) || 0;
const dv = Number(updated.discount_value) || 0;
const dt = updated.discount_type;
let calc = bp;
if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100);
else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv;
updated.calculated_price = String(Math.round(calc));
}
return updated;
}),
}));
};
const openEditCust = async (row: any) => {
const custKey = row.customer_code || row.customer_id;
// customer_mng에서 거래처 정보 조회
let custInfo: any = { customer_code: custKey, customer_name: row.customer_name || "" };
try {
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: custKey }] },
autoFilter: true,
});
const found = (res.data?.data?.data || res.data?.data?.rows || [])[0];
if (found) custInfo = found;
} catch { /* skip */ }
const mappingRows = [{
_id: `m_existing_${row.id}`,
customer_item_code: row.customer_item_code || "",
customer_item_name: row.customer_item_name || "",
}].filter((m) => m.customer_item_code || m.customer_item_name);
const priceRows = [{
_id: `p_existing_${row.id}`,
start_date: row.start_date || "",
end_date: row.end_date || "",
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: row.base_price ? String(row.base_price) : "",
discount_type: row.discount_type || "",
discount_value: row.discount_value ? String(row.discount_value) : "",
rounding_type: row.rounding_type || "",
rounding_unit_value: row.rounding_unit_value || "",
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
}].filter((p) => p.base_price || p.start_date);
if (priceRows.length === 0) {
priceRows.push({
_id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
rounding_type: "", rounding_unit_value: "", calculated_price: "",
});
}
setSelectedCustsForDetail([custInfo]);
setCustMappings({ [custKey]: mappingRows });
setCustPrices({ [custKey]: priceRows });
setEditCustData(row);
setCustDetailOpen(true);
};
const handleCustDetailSave = async () => {
if (!selectedItem) return;
const isEditingExisting = !!editCustData;
setSaving(true);
try {
for (const cust of selectedCustsForDetail) {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
if (isEditingExisting && editCustData?.id) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: editCustData.id },
updatedData: {
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
},
});
// 기존 prices 삭제 후 재등록
try {
const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "mapping_id", operator: "equals", value: editCustData.id },
]}, autoFilter: true,
});
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: existing.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
const priceRows = (custPrices[custKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
mapping_id: editCustData.id,
customer_id: custKey,
item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
} else {
// 신규 등록
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
});
const mappingId = mappingRes.data?.data?.id || null;
for (let mi = 1; mi < mappingRows.length; mi++) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_item_name || "",
});
}
const priceRows = (custPrices[custKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
mapping_id: mappingId || "", customer_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
}
}
toast.success(isEditingExisting ? "수정되었습니다." : `${selectedCustsForDetail.length}개 거래처가 추가되었습니다.`);
setCustDetailOpen(false);
setEditCustData(null);
setCustCheckedIds(new Set());
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 품목 수정
const openEditItem = () => {
if (!selectedItem) return;
setEditItemForm({ ...selectedItem });
setEditItemOpen(true);
};
const handleEditSave = async () => {
if (!editItemForm.id) return;
setSaving(true);
try {
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editItemForm.id },
updatedData: {
selling_price: editItemForm.selling_price || null,
standard_price: editItemForm.standard_price || null,
currency_code: editItemForm.currency_code || null,
},
});
toast.success("수정되었습니다.");
setEditItemOpen(false);
fetchItems();
} catch (err: any) {
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "판매품목정보.xlsx", "판매품목");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={ITEM_TABLE}
filterId="sales-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 판매품목 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{itemCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<DataGrid
gridId="sales-item-left"
columns={LEFT_COLUMNS}
data={items}
loading={itemLoading}
selectedId={selectedItemId}
onSelect={setSelectedItemId}
onRowDoubleClick={() => openEditItem()}
emptyMessage="등록된 판매품목이 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 거래처 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setCustCheckedIds(new Set()); setCustSelectOpen(true); searchCustomers(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="sales-item-right"
columns={RIGHT_COLUMNS}
data={customerItems}
loading={customerLoading}
showRowNumber={false}
emptyMessage="등록된 거래처가 없습니다"
onRowDoubleClick={(row) => openEditCust(row)}
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 품목 수정 모달 */}
<FullscreenDialog
open={editItemOpen}
onOpenChange={setEditItemOpen}
title="판매품목 수정"
description={`${editItemForm.item_number || ""}${editItemForm.item_name || ""}`}
defaultMaxWidth="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setEditItemOpen(false)}></Button>
<Button onClick={handleEditSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-4 py-4">
{/* 품목 기본정보 (읽기 전용) */}
{[
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
].map((f) => (
<div key={f.key} className="space-y-1.5">
<Label className="text-sm text-muted-foreground">{f.label}</Label>
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
</div>
))}
<div className="col-span-2 border-t my-2" />
{/* 판매 설정 (수정 가능) */}
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
placeholder="판매가격" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
placeholder="기준단가" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</FullscreenDialog>
{/* 거래처 추가 모달 */}
<Dialog open={custSelectOpen} onOpenChange={setCustSelectOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="거래처명 검색" value={custSearchKeyword}
onChange={(e) => setCustSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchCustomers()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchCustomers} disabled={custSearchLoading} className="h-9">
{custSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={custSearchResults.length > 0 && custCheckedIds.size === custSearchResults.length}
onChange={(e) => {
if (e.target.checked) setCustCheckedIds(new Set(custSearchResults.map((c) => c.id)));
else setCustCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{custSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : custSearchResults.map((c) => (
<TableRow key={c.id} className={cn("cursor-pointer", custCheckedIds.has(c.id) && "bg-primary/5")}
onClick={() => setCustCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(c.id)) next.delete(c.id); else next.add(c.id);
return next;
})}>
<TableCell className="text-center"><input type="checkbox" checked={custCheckedIds.has(c.id)} readOnly /></TableCell>
<TableCell className="text-xs">{c.customer_code}</TableCell>
<TableCell className="text-sm">{c.customer_name}</TableCell>
<TableCell className="text-xs">{c.division}</TableCell>
<TableCell className="text-xs">{c.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{custCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCustSelectOpen(false)}></Button>
<Button onClick={goToCustDetail} disabled={custCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {custCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 거래처 상세 입력/수정 모달 */}
<FullscreenDialog
open={custDetailOpen}
onOpenChange={setCustDetailOpen}
title={`📋 거래처 상세정보 ${editCustData ? "수정" : "입력"}${selectedItem?.item_name || ""}`}
description={editCustData ? "거래처 품번/품명과 기간별 단가를 수정합니다." : "선택한 거래처의 품번/품명과 기간별 단가를 설정합니다."}
defaultMaxWidth="max-w-[1100px]"
footer={
<>
<Button variant="outline" onClick={() => {
setCustDetailOpen(false);
if (!editCustData) setCustSelectOpen(true);
setEditCustData(null);
}}>{editCustData ? "취소" : "← 이전"}</Button>
<Button onClick={handleCustDetailSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="space-y-6 py-2">
{selectedCustsForDetail.map((cust, idx) => {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
const prices = custPrices[custKey] || [];
return (
<div key={custKey} className="border rounded-xl overflow-hidden bg-card">
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {cust.customer_name || custKey}</div>
<div className="text-xs text-muted-foreground">{custKey}</div>
</div>
<div className="flex gap-4 p-4">
{/* 좌: 거래처 품번/품명 */}
<div className="flex-1 border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> / </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : mappingRows.map((mRow, mIdx) => (
<div key={mRow._id} className="flex gap-2 items-center">
<span className="text-xs text-muted-foreground w-4 shrink-0">{mIdx + 1}</span>
<Input value={mRow.customer_item_code}
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_code", e.target.value)}
placeholder="거래처 품번" className="h-8 text-sm flex-1" />
<Input value={mRow.customer_item_name}
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_name", e.target.value)}
placeholder="거래처 품명" className="h-8 text-sm flex-1" />
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive shrink-0"
onClick={() => removeMappingRow(custKey, mRow._id)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
{/* 우: 기간별 단가 */}
<div className="flex-1 border rounded-lg p-4 bg-amber-50/30 dark:bg-amber-950/10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-3">
{prices.map((price, pIdx) => (
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span>
{prices.length > 1 && (
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive"
onClick={() => removePriceRow(custKey, price._id)}>
<X className="h-3 w-3" />
</Button>
)}
</div>
<div className="flex gap-2 items-center">
<div className="flex-1">
<FormDatePicker value={price.start_date}
onChange={(v) => updatePriceRow(custKey, price._id, "start_date", v)} placeholder="시작일" />
</div>
<span className="text-xs text-muted-foreground">~</span>
<div className="flex-1">
<FormDatePicker value={price.end_date}
onChange={(v) => updatePriceRow(custKey, price._id, "end_date", v)} placeholder="종료일" />
</div>
<div className="w-[80px]">
<Select value={price.currency_code} onValueChange={(v) => updatePriceRow(custKey, price._id, "currency_code", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-2 items-center">
<div className="w-[90px]">
<Select value={price.base_price_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "base_price_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input value={price.base_price}
onChange={(e) => updatePriceRow(custKey, price._id, "base_price", e.target.value)}
className="h-8 text-xs text-right flex-1" placeholder="기준가" />
<div className="w-[90px]">
<Select value={price.discount_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "discount_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{(priceCategoryOptions["discount_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input value={price.discount_value}
onChange={(e) => updatePriceRow(custKey, price._id, "discount_value", e.target.value)}
className="h-8 text-xs text-right w-[60px]" placeholder="0" />
<div className="w-[90px]">
<Select value={price.rounding_unit_value} onValueChange={(v) => updatePriceRow(custKey, price._id, "rounding_unit_value", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="반올림" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1 border-t">
<span className="text-xs text-muted-foreground"> :</span>
<span className="font-bold text-sm">{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
</FullscreenDialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
{ConfirmDialogComponent}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={ITEM_TABLE}
settingsId="sales-item"
onSave={applyTableSettings}
/>
</div>
);
}

View File

@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2 } from "lucide-react"; import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { import {
@ -24,6 +24,8 @@ import {
getSalesOrderSource, getSalesOrderSource,
getItemSource, getItemSource,
} from "@/lib/api/shipping"; } from "@/lib/api/shipping";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo"; type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
@ -84,6 +86,9 @@ export default function ShippingOrderPage() {
const [searchDateFrom, setSearchDateFrom] = useState(""); const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState(""); const [searchDateTo, setSearchDateTo] = useState("");
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 모달 // 모달
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
@ -467,6 +472,9 @@ export default function ShippingOrderPage() {
<Badge variant="secondary" className="font-normal">{orders.length}</Badge> <Badge variant="secondary" className="font-normal">{orders.length}</Badge>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={() => openModal()}> <Button size="sm" onClick={() => openModal()}>
<Plus className="w-4 h-4 mr-1.5" /> <Plus className="w-4 h-4 mr-1.5" />
</Button> </Button>
@ -577,14 +585,22 @@ export default function ShippingOrderPage() {
</div> </div>
{/* 등록/수정 모달 */} {/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}> <FullscreenDialog
<DialogContent className="max-w-[90vw] max-h-[90vh] w-[1400px] p-0 flex flex-col overflow-hidden"> open={isModalOpen}
<DialogHeader className="p-5 pb-4 border-b bg-primary text-primary-foreground shrink-0"> onOpenChange={setIsModalOpen}
<DialogTitle className="text-lg">{isEditMode ? "출하지시 수정" : "출하지시 등록"}</DialogTitle> title={isEditMode ? "출하지시 수정" : "출하지시 등록"}
<DialogDescription className="text-primary-foreground/70"> description={isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
{isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."} defaultMaxWidth="max-w-[90vw]"
</DialogDescription> defaultWidth="w-[1400px]"
</DialogHeader> footer={
<>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal"> <ResizablePanelGroup direction="horizontal">
@ -813,14 +829,17 @@ export default function ShippingOrderPage() {
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
<DialogFooter className="p-4 border-t bg-muted/30 shrink-0"> </FullscreenDialog>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}> {/* 엑셀 업로드 모달 */}
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} <ExcelUploadModal
</Button> open={excelUploadOpen}
</DialogFooter> onOpenChange={setExcelUploadOpen}
</DialogContent> tableName="shipment_instruction"
</Dialog> onSuccess={() => {
fetchOrders();
}}
/>
</div> </div>
); );
} }

View File

@ -315,15 +315,15 @@ export default function ShippingPlanPage() {
onCheckedChange={handleCheckAll} onCheckedChange={handleCheckAll}
/> />
</TableHead> </TableHead>
<TableHead className="w-[160px]"></TableHead> <TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead> <TableHead className="w-[8%] text-center"></TableHead>
<TableHead className="w-[120px]"></TableHead> <TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[100px]"></TableHead> <TableHead className="w-[20%]"></TableHead>
<TableHead></TableHead> <TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead> <TableHead className="w-[7%] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead> <TableHead className="w-[7%] text-right"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead> <TableHead className="w-[8%] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead> <TableHead className="w-[6%] text-center"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,839 @@
"use client";
import React, { useState, useMemo, useCallback, useEffect } from "react";
import {
Search,
RotateCcw,
Plus,
Pencil,
Trash2,
Calendar,
Upload,
PointerIcon,
Ruler,
ClipboardList,
FileText,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
getDesignRequestList,
createDesignRequest,
updateDesignRequest,
deleteDesignRequest,
} from "@/lib/api/design";
// ========== 타입 ==========
interface HistoryItem {
id?: string;
step: string;
history_date: string;
user_name: string;
description: string;
}
interface DesignRequest {
id: string;
request_no: string;
source_type: string;
request_date: string;
due_date: string;
design_type: string;
priority: string;
status: string;
approval_step: string;
target_name: string;
customer: string;
req_dept: string;
requester: string;
designer: string;
order_no: string;
spec: string;
change_type: string;
drawing_no: string;
urgency: string;
reason: string;
content: string;
apply_timing: string;
review_memo: string;
project_id: string;
ecn_no: string;
created_date: string;
updated_date: string;
writer: string;
company_code: string;
history: HistoryItem[];
impact: string[];
}
// ========== 스타일 맵 ==========
const STATUS_STYLES: Record<string, string> = {
: "bg-muted text-foreground",
: "bg-muted text-foreground",
: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
: "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300",
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
: "bg-muted text-muted-foreground",
};
const TYPE_STYLES: Record<string, string> = {
: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
};
const PRIORITY_STYLES: Record<string, string> = {
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
: "bg-muted text-foreground",
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
};
const STATUS_PROGRESS: Record<string, number> = {
신규접수: 0,
접수대기: 0,
검토중: 20,
설계진행: 50,
설계검토: 80,
출도완료: 100,
반려: 0,
종료: 100,
};
function getProgressColor(p: number) {
if (p >= 100) return "bg-emerald-500";
if (p >= 60) return "bg-amber-500";
if (p >= 20) return "bg-blue-500";
return "bg-muted";
}
function getProgressTextColor(p: number) {
if (p >= 100) return "text-emerald-500";
if (p >= 60) return "text-amber-500";
if (p >= 20) return "text-blue-500";
return "text-muted-foreground";
}
const INITIAL_FORM = {
request_no: "",
request_date: "",
due_date: "",
design_type: "",
priority: "보통",
target_name: "",
customer: "",
req_dept: "",
requester: "",
designer: "",
order_no: "",
spec: "",
drawing_no: "",
content: "",
};
// ========== 메인 컴포넌트 ==========
export default function DesignRequestPage() {
const [requests, setRequests] = useState<DesignRequest[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [filterStatus, setFilterStatus] = useState("");
const [filterType, setFilterType] = useState("");
const [filterPriority, setFilterPriority] = useState("");
const [filterKeyword, setFilterKeyword] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(INITIAL_FORM);
const today = useMemo(() => new Date(), []);
// 데이터 조회
const fetchRequests = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { source_type: "dr" };
if (filterStatus && filterStatus !== "__all__") params.status = filterStatus;
if (filterType && filterType !== "__all__") {
// design_type은 서버에서 직접 필터링하지 않으므로 클라이언트에서 처리
}
if (filterPriority && filterPriority !== "__all__") params.priority = filterPriority;
if (filterKeyword) params.search = filterKeyword;
const res = await getDesignRequestList(params);
if (res.success && res.data) {
setRequests(res.data);
} else {
setRequests([]);
}
} catch {
setRequests([]);
} finally {
setLoading(false);
}
}, [filterStatus, filterPriority, filterKeyword]);
useEffect(() => {
fetchRequests();
}, [fetchRequests]);
// 클라이언트 사이드 필터링 (design_type은 서버에서 지원하지 않으므로)
const filteredRequests = useMemo(() => {
let list = requests;
if (filterType && filterType !== "__all__") {
list = list.filter((item) => item.design_type === filterType);
}
return list;
}, [requests, filterType]);
const selectedItem = useMemo(() => {
if (!selectedId) return null;
return requests.find((r) => r.id === selectedId) || null;
}, [selectedId, requests]);
const statusCounts = useMemo(() => {
return {
접수대기: requests.filter((r) => r.status === "접수대기" || r.status === "신규접수").length,
설계진행: requests.filter((r) => r.status === "설계진행").length,
출도완료: requests.filter((r) => r.status === "출도완료").length,
};
}, [requests]);
const handleResetFilter = useCallback(() => {
setFilterStatus("");
setFilterType("");
setFilterPriority("");
setFilterKeyword("");
}, []);
// 채번: 기존 데이터 기반으로 다음 번호 생성
const generateNextNo = useCallback(() => {
const year = new Date().getFullYear();
const existing = requests.filter((r) => r.request_no?.startsWith(`DR-${year}-`));
const maxNum = existing.reduce((max, r) => {
const parts = r.request_no?.split("-");
const num = parts?.length >= 3 ? parseInt(parts[2]) : 0;
return num > max ? num : max;
}, 0);
return `DR-${year}-${String(maxNum + 1).padStart(4, "0")}`;
}, [requests]);
const handleOpenRegister = useCallback(() => {
setIsEditMode(false);
setEditingId(null);
setForm({
...INITIAL_FORM,
request_no: generateNextNo(),
request_date: new Date().toISOString().split("T")[0],
});
setModalOpen(true);
}, [generateNextNo]);
const handleOpenEdit = useCallback(() => {
if (!selectedItem) return;
setIsEditMode(true);
setEditingId(selectedItem.id);
setForm({
request_no: selectedItem.request_no || "",
request_date: selectedItem.request_date || "",
due_date: selectedItem.due_date || "",
design_type: selectedItem.design_type || "",
priority: selectedItem.priority || "보통",
target_name: selectedItem.target_name || "",
customer: selectedItem.customer || "",
req_dept: selectedItem.req_dept || "",
requester: selectedItem.requester || "",
designer: selectedItem.designer || "",
order_no: selectedItem.order_no || "",
spec: selectedItem.spec || "",
drawing_no: selectedItem.drawing_no || "",
content: selectedItem.content || "",
});
setModalOpen(true);
}, [selectedItem]);
const handleSave = useCallback(async () => {
if (!form.target_name.trim()) { alert("설비/제품명을 입력하세요."); return; }
if (!form.design_type) { alert("의뢰 유형을 선택하세요."); return; }
if (!form.due_date) { alert("납기를 입력하세요."); return; }
if (!form.spec.trim()) { alert("요구사양을 입력하세요."); return; }
setSaving(true);
try {
const payload = {
request_no: form.request_no,
source_type: "dr",
request_date: form.request_date,
due_date: form.due_date,
design_type: form.design_type,
priority: form.priority,
target_name: form.target_name,
customer: form.customer,
req_dept: form.req_dept,
requester: form.requester,
designer: form.designer,
order_no: form.order_no,
spec: form.spec,
drawing_no: form.drawing_no,
content: form.content,
};
let res;
if (isEditMode && editingId) {
res = await updateDesignRequest(editingId, payload);
} else {
res = await createDesignRequest({
...payload,
status: "신규접수",
history: [{
step: "신규접수",
history_date: form.request_date || new Date().toISOString().split("T")[0],
user_name: form.requester || "시스템",
description: `${form.req_dept || ""}에서 설계의뢰 등록`,
}],
});
}
if (res.success) {
setModalOpen(false);
await fetchRequests();
if (isEditMode && editingId) {
setSelectedId(editingId);
} else if (res.data?.id) {
setSelectedId(res.data.id);
}
} else {
alert(`저장 실패: ${res.message || "알 수 없는 오류"}`);
}
} catch (err: any) {
alert(`저장 중 오류가 발생했습니다: ${err.message}`);
} finally {
setSaving(false);
}
}, [form, isEditMode, editingId, fetchRequests]);
const handleDelete = useCallback(async () => {
if (!selectedId || !selectedItem) return;
const displayNo = selectedItem.request_no || selectedId;
if (!confirm(`${displayNo} 설계의뢰를 삭제하시겠습니까?`)) return;
try {
const res = await deleteDesignRequest(selectedId);
if (res.success) {
setSelectedId(null);
await fetchRequests();
} else {
alert(`삭제 실패: ${res.message || "알 수 없는 오류"}`);
}
} catch (err: any) {
alert(`삭제 중 오류가 발생했습니다: ${err.message}`);
}
}, [selectedId, selectedItem, fetchRequests]);
const getDueDateInfo = useCallback(
(dueDate: string) => {
if (!dueDate) return { text: "-", color: "text-muted-foreground" };
const due = new Date(dueDate);
const diff = Math.ceil((due.getTime() - today.getTime()) / 86400000);
if (diff < 0) return { text: `${Math.abs(diff)}일 초과`, color: "text-destructive" };
if (diff === 0) return { text: "오늘", color: "text-amber-500" };
if (diff <= 7) return { text: `${diff}일 남음`, color: "text-amber-500" };
return { text: `${diff}일 남음`, color: "text-emerald-500" };
},
[today]
);
const getProgress = useCallback((status: string) => {
return STATUS_PROGRESS[status] ?? 0;
}, []);
return (
<div className="flex h-full flex-col gap-2 p-3">
{/* 검색 섹션 */}
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-lg border bg-card px-3 py-2">
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="상태 전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["신규접수", "접수대기", "검토중", "설계진행", "설계검토", "출도완료", "반려", "종료"].map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="유형 전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["신규설계", "유사설계", "개조설계"].map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterPriority} onValueChange={setFilterPriority}>
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="우선순위 전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["긴급", "높음", "보통", "낮음"].map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={filterKeyword}
onChange={(e) => setFilterKeyword(e.target.value)}
placeholder="의뢰번호 / 설비명 / 고객명 검색"
className="h-7 w-[240px] pl-7 text-xs"
/>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleResetFilter}>
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => fetchRequests()}>
<Search className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 메인 영역 */}
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
{/* 왼쪽: 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
<span className="text-sm font-bold">
<Ruler className="mr-1 inline h-4 w-4" />
(<span className="text-primary">{filteredRequests.length}</span>)
</span>
<Button size="sm" className="h-7 text-xs" onClick={handleOpenRegister}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<ScrollArea className="flex-1">
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px] text-[11px]"></TableHead>
<TableHead className="w-[70px] text-center text-[11px]"></TableHead>
<TableHead className="w-[70px] text-center text-[11px]"></TableHead>
<TableHead className="w-[60px] text-center text-[11px]"></TableHead>
<TableHead className="text-[11px]">/</TableHead>
<TableHead className="w-[90px] text-[11px]"></TableHead>
<TableHead className="w-[70px] text-[11px]"></TableHead>
<TableHead className="w-[85px] text-[11px]"></TableHead>
<TableHead className="w-[65px] text-center text-[11px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRequests.length === 0 && (
<TableRow>
<TableCell colSpan={9} className="py-12 text-center">
<div className="flex flex-col items-center gap-1 text-muted-foreground">
<Ruler className="h-8 w-8" />
<span className="text-sm"> </span>
</div>
</TableCell>
</TableRow>
)}
{filteredRequests.map((item) => {
const progress = getProgress(item.status);
return (
<TableRow
key={item.id}
className={cn("cursor-pointer", selectedId === item.id && "bg-accent")}
onClick={() => setSelectedId(item.id)}
>
<TableCell className="text-[11px] font-semibold text-primary">{item.request_no || "-"}</TableCell>
<TableCell className="text-center">
{item.design_type ? (
<Badge className={cn("text-[9px]", TYPE_STYLES[item.design_type])}>{item.design_type}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-center">
<Badge className={cn("text-[9px]", STATUS_STYLES[item.status])}>{item.status}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge className={cn("text-[9px]", PRIORITY_STYLES[item.priority])}>{item.priority}</Badge>
</TableCell>
<TableCell className="text-xs font-medium">{item.target_name || "-"}</TableCell>
<TableCell className="text-[11px]">{item.customer || "-"}</TableCell>
<TableCell className="text-[11px]">{item.designer || "-"}</TableCell>
<TableCell className="text-[11px]">{item.due_date || "-"}</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
</div>
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 상세 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
<span className="text-sm font-bold">
<ClipboardList className="mr-1 inline h-4 w-4" />
</span>
{selectedItem && (
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-6 text-[10px]" onClick={handleOpenEdit}>
<Pencil className="mr-0.5 h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-6 text-[10px] text-destructive hover:text-destructive" onClick={handleDelete}>
<Trash2 className="mr-0.5 h-3 w-3" />
</Button>
</div>
)}
</div>
<ScrollArea className="flex-1">
<div className="p-3">
{/* 상태 카드 */}
<div className="mb-3 grid grid-cols-3 gap-2">
<Card
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
onClick={() => setFilterStatus("접수대기")}
>
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-blue-500">{statusCounts.}</div>
</Card>
<Card
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
onClick={() => setFilterStatus("설계진행")}
>
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-amber-500">{statusCounts.}</div>
</Card>
<Card
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
onClick={() => setFilterStatus("출도완료")}
>
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-emerald-500">{statusCounts.}</div>
</Card>
</div>
{/* 상세 내용 */}
{!selectedItem ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<PointerIcon className="h-8 w-8" />
<span className="text-sm"> </span>
</div>
) : (
<div className="space-y-4">
{/* 기본 정보 */}
<div>
<div className="mb-2 text-xs font-bold">
<FileText className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-lg border bg-muted/10 p-3">
<InfoRow label="의뢰번호" value={<span className="font-semibold text-primary">{selectedItem.request_no || "-"}</span>} />
<InfoRow label="상태" value={<Badge className={cn("text-[10px]", STATUS_STYLES[selectedItem.status])}>{selectedItem.status}</Badge>} />
<InfoRow label="유형" value={selectedItem.design_type ? <Badge className={cn("text-[10px]", TYPE_STYLES[selectedItem.design_type])}>{selectedItem.design_type}</Badge> : "-"} />
<InfoRow label="우선순위" value={<Badge className={cn("text-[10px]", PRIORITY_STYLES[selectedItem.priority])}>{selectedItem.priority}</Badge>} />
<InfoRow label="설비/제품명" value={selectedItem.target_name || "-"} />
<InfoRow label="고객명" value={selectedItem.customer || "-"} />
<InfoRow label="의뢰부서 / 의뢰자" value={`${selectedItem.req_dept || "-"} / ${selectedItem.requester || "-"}`} />
<InfoRow label="설계담당" value={selectedItem.designer || "미배정"} />
<InfoRow label="의뢰일자" value={selectedItem.request_date || "-"} />
<InfoRow
label="납기"
value={
selectedItem.due_date ? (
<span>
{selectedItem.due_date}{" "}
<span className={cn("text-[11px]", getDueDateInfo(selectedItem.due_date).color)}>
({getDueDateInfo(selectedItem.due_date).text})
</span>
</span>
) : "-"
}
/>
<InfoRow label="수주번호" value={selectedItem.order_no || "-"} />
<InfoRow
label="진행률"
value={
(() => {
const progress = getProgress(selectedItem.status);
return (
<div className="flex items-center gap-2">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full", getProgressColor(progress))} style={{ width: `${progress}%` }} />
</div>
<span className={cn("text-xs font-bold", getProgressTextColor(progress))}>{progress}%</span>
</div>
);
})()
}
/>
</div>
</div>
{/* 요구사양 */}
<div>
<div className="mb-2 text-xs font-bold">
<FileText className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="rounded-lg border bg-muted/10 p-3">
<pre className="whitespace-pre-wrap font-sans text-xs leading-relaxed">{selectedItem.spec || "-"}</pre>
{selectedItem.drawing_no && (
<div className="mt-2 text-xs">
<span className="text-muted-foreground"> : </span>
<span className="text-primary">{selectedItem.drawing_no}</span>
</div>
)}
{selectedItem.content && (
<div className="mt-1 text-xs">
<span className="text-muted-foreground">: </span>{selectedItem.content}
</div>
)}
</div>
</div>
{/* 진행 이력 */}
{selectedItem.history && selectedItem.history.length > 0 && (
<div>
<div className="mb-2 text-xs font-bold">
<Calendar className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="space-y-0">
{selectedItem.history.map((h, idx) => {
const isLast = idx === selectedItem.history.length - 1;
const isDone = h.step === "출도완료" || h.step === "종료";
return (
<div key={h.id || idx} className="flex gap-3">
<div className="flex flex-col items-center">
<div
className={cn(
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full border-2",
isLast && !isDone
? "border-blue-500 bg-blue-500"
: isDone || !isLast
? "border-emerald-500 bg-emerald-500"
: "border-muted-foreground bg-muted-foreground"
)}
/>
{!isLast && <div className="w-px flex-1 bg-border" />}
</div>
<div className="pb-3">
<Badge className={cn("text-[9px]", STATUS_STYLES[h.step])}>{h.step}</Badge>
<div className="mt-0.5 text-xs">{h.description}</div>
<div className="text-[10px] text-muted-foreground">{h.history_date} · {h.user_name}</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
</ScrollArea>
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* 등록/수정 모달 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px]">
<DialogHeader>
<DialogTitle className="text-lg">
{isEditMode ? <><Pencil className="mr-1.5 inline h-5 w-5" /> </> : <><Plus className="mr-1.5 inline h-5 w-5" /> </>}
</DialogTitle>
<DialogDescription className="text-sm">
{isEditMode ? "설계의뢰 정보를 수정합니다." : "새 설계의뢰를 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="flex gap-6">
{/* 좌측: 기본 정보 */}
<div className="w-[420px] shrink-0 space-y-4">
<div className="text-sm font-bold">
<FileText className="mr-1 inline h-4 w-4" />
</div>
<div>
<Label className="text-sm"></Label>
<Input value={form.request_no} readOnly className="h-9 text-sm" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"></Label>
<Input type="date" value={form.request_date} onChange={(e) => setForm((p) => ({ ...p, request_date: e.target.value }))} className="h-9 text-sm" />
</div>
<div>
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input type="date" value={form.due_date} onChange={(e) => setForm((p) => ({ ...p, due_date: e.target.value }))} className="h-9 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Select value={form.design_type} onValueChange={(v) => setForm((p) => ({ ...p, design_type: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["신규설계", "유사설계", "개조설계"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Select value={form.priority} onValueChange={(v) => setForm((p) => ({ ...p, priority: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-sm">/ <span className="text-destructive">*</span></Label>
<Input value={form.target_name} onChange={(e) => setForm((p) => ({ ...p, target_name: e.target.value }))} placeholder="설비 또는 제품명 입력" className="h-9 text-sm" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"></Label>
<Select value={form.req_dept} onValueChange={(v) => setForm((p) => ({ ...p, req_dept: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["영업팀", "기획팀", "생산팀", "품질팀"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm"></Label>
<Input value={form.requester} onChange={(e) => setForm((p) => ({ ...p, requester: e.target.value }))} placeholder="의뢰자명" className="h-9 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"></Label>
<Input value={form.customer} onChange={(e) => setForm((p) => ({ ...p, customer: e.target.value }))} placeholder="고객/거래처명" className="h-9 text-sm" />
</div>
<div>
<Label className="text-sm"></Label>
<Input value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} placeholder="관련 수주번호" className="h-9 text-sm" />
</div>
</div>
<div>
<Label className="text-sm"></Label>
<Select value={form.designer} onValueChange={(v) => setForm((p) => ({ ...p, designer: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["이설계", "박도면", "최기구", "김전장"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
{/* 우측: 상세 내용 */}
<div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="text-sm font-bold">
<FileText className="mr-1 inline h-4 w-4" />
</div>
<div className="flex-1">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Textarea
value={form.spec}
onChange={(e) => setForm((p) => ({ ...p, spec: e.target.value }))}
placeholder={"고객 요구사양 또는 설비 사양을 상세히 기술하세요\n\n예시:\n- 작업 대상: SUS304 Φ20 파이프\n- 가공 방식: 자동 절단 + 면취\n- 생산 속도: 60EA/분\n- 치수 공차: ±0.1mm"}
className="min-h-[180px] text-sm"
/>
</div>
<div>
<Label className="text-sm"> </Label>
<Input value={form.drawing_no} onChange={(e) => setForm((p) => ({ ...p, drawing_no: e.target.value }))} placeholder="유사 설비명 또는 참조 도면번호" className="h-9 text-sm" />
</div>
<div>
<Label className="text-sm"></Label>
<Textarea value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} placeholder="기타 참고 사항" className="min-h-[70px] text-sm" rows={3} />
</div>
<div>
<div className="text-sm font-bold">
<Upload className="mr-1 inline h-4 w-4" />
</div>
<div className="mt-1.5 cursor-pointer rounded-lg border-2 border-dashed p-5 text-center transition-colors hover:border-primary hover:bg-accent/50">
<Upload className="mx-auto h-6 w-6 text-muted-foreground" />
<div className="mt-1.5 text-sm text-muted-foreground"> (, , )</div>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setModalOpen(false)} className="h-10 px-6 text-sm" disabled={saving}></Button>
<Button onClick={handleSave} className="h-10 px-6 text-sm" disabled={saving}>
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
{saving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ========== 정보 행 서브컴포넌트 ==========
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start gap-1">
<span className="min-w-[80px] shrink-0 text-[11px] text-muted-foreground">{label}</span>
<span className="text-xs font-medium">{value}</span>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,752 @@
"use client";
/**
*
*
* 좌측: 설비 (equipment_mng)
* 우측: ( / / )
*
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Wrench, ClipboardCheck, Package, Copy, Info, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { ImageUpload } from "@/components/common/ImageUpload";
const EQUIP_TABLE = "equipment_mng";
const INSPECTION_TABLE = "equipment_inspection_item";
const CONSUMABLE_TABLE = "equipment_consumable";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "equipment_code", label: "설비코드", width: "w-[110px]" },
{ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" },
{ key: "equipment_type", label: "설비유형", width: "w-[90px]" },
{ key: "manufacturer", label: "제조사", width: "w-[100px]" },
{ key: "installation_location", label: "설치장소", width: "w-[100px]" },
{ key: "operation_status", label: "가동상태", width: "w-[80px]" },
];
const INSPECTION_COLUMNS: DataGridColumn[] = [
{ key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true },
{ key: "inspection_cycle", label: "점검주기", width: "w-[80px]" },
{ key: "inspection_method", label: "점검방법", width: "w-[80px]" },
{ key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true },
{ key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true },
];
const CONSUMABLE_COLUMNS: DataGridColumn[] = [
{ key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false },
{ key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true },
{ key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "specification", label: "규격", width: "w-[100px]", editable: true },
{ key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true },
];
export default function EquipmentInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측
const [equipments, setEquipments] = useState<any[]>([]);
const [equipLoading, setEquipLoading] = useState(false);
const [equipCount, setEquipCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
// 우측 탭
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
const [inspections, setInspections] = useState<any[]>([]);
const [inspectionLoading, setInspectionLoading] = useState(false);
const [consumables, setConsumables] = useState<any[]>([]);
const [consumableLoading, setConsumableLoading] = useState(false);
// 카테고리
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 모달
const [equipModalOpen, setEquipModalOpen] = useState(false);
const [equipEditMode, setEquipEditMode] = useState(false);
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 기본정보 탭 편집 폼
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
const [infoSaving, setInfoSaving] = useState(false);
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySourceEquip, setCopySourceEquip] = useState("");
const [copyItems, setCopyItems] = useState<any[]>([]);
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
const [copyLoading, setCopyLoading] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("equipment-info");
if (saved) applyTableSettings(saved);
}, []);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// equipment_mng 카테고리
for (const col of ["equipment_type", "operation_status"]) {
try {
const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
// inspection 카테고리
for (const col of ["inspection_cycle", "inspection_method"]) {
try {
const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCatOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return catOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 설비 조회
const fetchEquipments = useCallback(async () => {
setEquipLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
useEffect(() => { fetchEquipments(); }, [fetchEquipments]);
const selectedEquip = equipments.find((e) => e.id === selectedEquipId);
// 기본정보 탭 폼 초기화 (설비 선택 변경 시)
useEffect(() => {
if (selectedEquip) setInfoForm({ ...selectedEquip });
else setInfoForm({});
}, [selectedEquipId]); // eslint-disable-line react-hooks/exhaustive-deps
// 기본정보 저장
const handleInfoSave = async () => {
if (!infoForm.id) return;
setInfoSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = infoForm;
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("저장되었습니다.");
fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); }
finally { setInfoSaving(false); }
};
// 우측: 점검항목 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetch = async () => {
setInspectionLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setInspections(raw.map((r: any) => ({
...r,
inspection_cycle: resolve("inspection_cycle", r.inspection_cycle),
inspection_method: resolve("inspection_method", r.inspection_method),
})));
} catch { setInspections([]); } finally { setInspectionLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code, catOptions]);
// 우측: 소모품 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetch = async () => {
setConsumableLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
setConsumables(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setConsumables([]); } finally { setConsumableLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code]);
// 새로고침 헬퍼
const refreshRight = () => {
const eid = selectedEquipId;
setSelectedEquipId(null);
setTimeout(() => setSelectedEquipId(eid), 50);
};
// 설비 등록/수정
const openEquipRegister = () => { setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); };
const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); };
const handleEquipSave = async () => {
if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; }
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = equipForm;
if (equipEditMode && id) {
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, fields);
toast.success("등록되었습니다.");
}
setEquipModalOpen(false); fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
const handleEquipDelete = async () => {
if (!selectedEquipId) return;
const ok = await confirm("설비를 삭제하시겠습니까?", { 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();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 소모품 추가
// 소모품 품목 로드 (item_info에서 type 또는 division 라벨이 "소모품"인 것)
const loadConsumableItems = async () => {
try {
const flatten = (vals: any[]): any[] => {
const r: any[] = [];
for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); }
return r;
};
// type과 division 카테고리 모두에서 "소모품" 코드 찾기
const [typeRes, divRes] = await Promise.all([
apiClient.get(`/table-categories/item_info/type/values`),
apiClient.get(`/table-categories/item_info/division/values`),
]);
const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; }
// 두 필터 결과를 합산 (중복 제거)
const filters: any[] = [];
if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode });
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
const results = await Promise.all(filters.map((f) =>
apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [f] },
autoFilter: true,
})
));
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);
}
setConsumableItemOptions(Array.from(allItems.values()));
} catch { setConsumableItemOptions([]); }
};
const handleConsumableSave = async () => {
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
...consumableForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setConsumableModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 점검항목 복사: 소스 설비 선택 시 점검항목 로드
const loadCopyItems = async (equipCode: string) => {
setCopySourceEquip(equipCode);
setCopyChecked(new Set());
if (!equipCode) { setCopyItems([]); return; }
setCopyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
autoFilter: true,
});
setCopyItems(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setCopyItems([]); } finally { setCopyLoading(false); }
};
const handleCopyApply = async () => {
const selected = copyItems.filter((i) => copyChecked.has(i.id));
if (selected.length === 0) { toast.error("복사할 항목을 선택해주세요."); return; }
setSaving(true);
try {
for (const item of selected) {
const { id, created_date, updated_date, writer, company_code, equipment_code, ...fields } = item;
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...fields, equipment_code: selectedEquip?.equipment_code,
});
}
toast.success(`${selected.length}개 점검항목이 복사되었습니다.`);
setCopyModalOpen(false); refreshRight();
} catch { toast.error("복사 실패"); } finally { setSaving(false); }
};
// 엑셀
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
// 셀렉트 렌더링 헬퍼
const catSelect = (key: string, value: string, onChange: (v: string) => void, placeholder: string) => (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
return (
<div className="flex h-full flex-col gap-3 p-3">
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
try {
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
else toast.error("테이블 구조 분석 실패");
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
}}>
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />}
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 설비 목록 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Wrench className="w-4 h-4" /> <Badge variant="secondary" className="font-normal">{equipCount}</Badge>
</div>
<div className="flex 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="destructive" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid gridId="equip-left" columns={LEFT_COLUMNS} data={equipments} loading={equipLoading}
selectedId={selectedEquipId} onSelect={setSelectedEquipId} onRowDoubleClick={() => openEquipEdit()}
emptyMessage="등록된 설비가 없습니다" />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 탭 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-2 border-b bg-muted/10 shrink-0">
<div className="flex items-center gap-1">
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
<button key={tab} onClick={() => setRightTab(tab)}
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
<Icon className="w-3.5 h-3.5" />{label}
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
</button>
))}
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
</div>
<div className="flex gap-1.5">
{rightTab === "inspection" && (
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
{!selectedEquipId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm"> </div>
) : rightTab === "info" ? (
<div className="p-4 overflow-auto">
<div className="flex justify-end mb-3">
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={infoForm.introduction_date || ""} onChange={(v) => setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
</div>
</div>
</div>
) : rightTab === "inspection" ? (
<DataGrid gridId="equip-inspection" columns={INSPECTION_COLUMNS} data={inspections} loading={inspectionLoading}
showRowNumber={false} tableName={INSPECTION_TABLE} emptyMessage="점검항목이 없습니다"
onCellEdit={() => refreshRight()} />
) : (
<DataGrid gridId="equip-consumable" columns={CONSUMABLE_COLUMNS} data={consumables} loading={consumableLoading}
showRowNumber={false} tableName={CONSUMABLE_TABLE} emptyMessage="소모품이 없습니다"
onCellEdit={() => refreshRight()} />
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 설비 등록/수정 모달 */}
<FullscreenDialog open={equipModalOpen} onOpenChange={setEquipModalOpen}
title={equipEditMode ? "설비 수정" : "설비 등록"} description={equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}
defaultMaxWidth="max-w-2xl"
footer={<><Button variant="outline" onClick={() => setEquipModalOpen(false)}></Button>
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} </Button></>}>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<FormDatePicker value={equipForm.introduction_date || ""} onChange={(v) => setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
</div>
</FullscreenDialog>
{/* 점검항목 추가 모달 */}
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" /></div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.lower_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.upper_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}></Button>
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 소모품 추가 모달 */}
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5 col-span-2"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{consumableItemOptions.length > 0 ? (
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
setConsumableForm((p) => ({
...p,
consumable_name: v,
specification: item?.size || p.specification || "",
unit: item?.unit || p.unit || "",
manufacturer: item?.manufacturer || p.manufacturer || "",
}));
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
<SelectContent>
{consumableItemOptions.map((item) => (
<SelectItem key={item.id} value={item.item_name || item.item_number}>
{item.item_name}{item.size ? ` (${item.size})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div>
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
placeholder="소모품명 직접 입력" className="h-9" />
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
)}</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"></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>
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}></Button>
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 점검항목 복사 모달 */}
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader><DialogTitle> </DialogTitle>
<DialogDescription> {selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
<SelectContent>
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border rounded-lg overflow-auto max-h-[300px]">
{copyLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : copyItems.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
</TableHead>
<TableHead></TableHead><TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead><TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[70px]"></TableHead><TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyItems.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
<TableCell className="text-sm">{item.inspection_item}</TableCell>
<TableCell className="text-xs">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-xs">{resolve("inspection_method", item.inspection_method)}</TableCell>
<TableCell className="text-xs">{item.lower_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.upper_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{copyChecked.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCopyModalOpen(false)}></Button>
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 (멀티테이블) */}
{excelChainConfig && (
<MultiTableExcelUploadModal open={excelUploadOpen}
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
)}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={EQUIP_TABLE}
settingsId="equipment-info"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);
}

View File

@ -0,0 +1,597 @@
"use client";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Search,
RotateCcw,
Package,
ClipboardList,
Factory,
MapPin,
AlertTriangle,
CheckCircle2,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
getWorkOrders,
getMaterialStatus,
getWarehouses,
type WorkOrder,
type MaterialData,
type WarehouseData,
} from "@/lib/api/materialStatus";
const formatDate = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
return map[status] || status;
};
const getStatusStyle = (status: string) => {
const map: Record<string, string> = {
planned: "bg-amber-100 text-amber-700 border-amber-200",
pending: "bg-amber-100 text-amber-700 border-amber-200",
in_progress: "bg-blue-100 text-blue-700 border-blue-200",
completed: "bg-emerald-100 text-emerald-700 border-emerald-200",
cancelled: "bg-gray-100 text-gray-500 border-gray-200",
};
return map[status] || "bg-gray-100 text-gray-500 border-gray-200";
};
export default function MaterialStatusPage() {
const today = new Date();
const monthAgo = new Date(today);
monthAgo.setMonth(today.getMonth() - 1);
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<number[]>([]);
const [selectedWoId, setSelectedWoId] = useState<number | null>(null);
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
const [warehouse, setWarehouse] = useState("");
const [materialSearch, setMaterialSearch] = useState("");
const [showShortageOnly, setShowShortageOnly] = useState(false);
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [materialsLoading, setMaterialsLoading] = useState(false);
// 창고 목록 초기 로드
useEffect(() => {
(async () => {
const res = await getWarehouses();
if (res.success && res.data) {
setWarehouses(res.data);
}
})();
}, []);
// 작업지시 검색
const handleSearch = useCallback(async () => {
setWorkOrdersLoading(true);
try {
const res = await getWorkOrders({
dateFrom: searchDateFrom,
dateTo: searchDateTo,
itemCode: searchItemCode || undefined,
itemName: searchItemName || undefined,
});
if (res.success && res.data) {
setWorkOrders(res.data);
setCheckedWoIds([]);
setSelectedWoId(null);
setMaterials([]);
}
} finally {
setWorkOrdersLoading(false);
}
}, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]);
// 초기 로드
useEffect(() => {
handleSearch();
}, []);
const isAllChecked =
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
const handleCheckAll = useCallback(
(checked: boolean) => {
setCheckedWoIds(checked ? workOrders.map((wo) => wo.id) : []);
},
[workOrders]
);
const handleCheckWo = useCallback((id: number, checked: boolean) => {
setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
}, []);
const handleSelectWo = useCallback((id: number) => {
setSelectedWoId((prev) => (prev === id ? null : id));
}, []);
// 선택된 작업지시의 자재 조회
const handleLoadSelectedMaterials = useCallback(async () => {
if (checkedWoIds.length === 0) {
alert("자재를 조회할 작업지시를 선택해주세요.");
return;
}
setMaterialsLoading(true);
try {
const res = await getMaterialStatus({
planIds: checkedWoIds,
warehouseCode: warehouse || undefined,
});
if (res.success && res.data) {
setMaterials(res.data);
}
} finally {
setMaterialsLoading(false);
}
}, [checkedWoIds, warehouse]);
const handleResetSearch = useCallback(() => {
const t = new Date();
const m = new Date(t);
m.setMonth(t.getMonth() - 1);
setSearchDateFrom(formatDate(m));
setSearchDateTo(formatDate(t));
setSearchItemCode("");
setSearchItemName("");
setMaterialSearch("");
setShowShortageOnly(false);
}, []);
const filteredMaterials = useMemo(() => {
return materials.filter((m) => {
const searchLower = materialSearch.toLowerCase();
const matchesSearch =
!materialSearch ||
m.code.toLowerCase().includes(searchLower) ||
m.name.toLowerCase().includes(searchLower);
const matchesShortage = !showShortageOnly || m.current < m.required;
return matchesSearch && matchesShortage;
});
}, [materials, materialSearch, showShortageOnly]);
return (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-4 bg-muted/30 p-4">
{/* 헤더 */}
<div className="shrink-0">
<div className="flex items-center gap-3">
<Package className="h-7 w-7 text-primary" />
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
</div>
{/* 검색 영역 */}
<Card className="shrink-0">
<CardContent className="flex flex-wrap items-end gap-3 p-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<div className="flex items-center gap-2">
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="품목코드"
className="h-9 w-[140px]"
value={searchItemCode}
onChange={(e) => setSearchItemCode(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="품목명"
className="h-9 w-[140px]"
value={searchItemName}
onChange={(e) => setSearchItemName(e.target.value)}
/>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleResetSearch}
>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button
size="sm"
className="h-9"
onClick={handleSearch}
disabled={workOrdersLoading}
>
{workOrdersLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
</Button>
</div>
</CardContent>
</Card>
{/* 메인 콘텐츠 (좌우 분할) */}
<div className="flex-1 overflow-hidden rounded-lg border bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 왼쪽: 작업지시 리스트 */}
<ResizablePanel defaultSize={35} minSize={25}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/10 p-3 shrink-0">
<div className="flex items-center gap-2">
<Checkbox
checked={isAllChecked}
onCheckedChange={handleCheckAll}
/>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-normal">
{workOrders.length}
</Badge>
<Button
size="sm"
className="h-8"
onClick={handleLoadSelectedMaterials}
disabled={materialsLoading}
>
{materialsLoading ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Search className="mr-1.5 h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
{/* 작업지시 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{workOrdersLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
...
</p>
</div>
) : workOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
workOrders.map((wo) => (
<div
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border-2 p-3 transition-all cursor-pointer",
"hover:border-primary hover:shadow-md hover:-translate-y-0.5",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-md"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
)}
>
{getStatusLabel(wo.status)}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">
{wo.item_name}
</span>
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
<span className="mx-1">|</span>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span>
</div>
</div>
</div>
))
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 원자재 현황 */}
<ResizablePanel defaultSize={65} minSize={35}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center gap-2 border-b bg-muted/10 p-3 shrink-0">
<Factory className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</div>
{/* 필터 */}
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/5 px-4 py-3 shrink-0">
<Input
placeholder="원자재 검색"
className="h-9 min-w-[150px] flex-1"
value={materialSearch}
onChange={(e) => setMaterialSearch(e.target.value)}
/>
<Select value={warehouse} onValueChange={setWarehouse}>
<SelectTrigger className="h-9 w-[200px]">
<SelectValue placeholder="전체 창고" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{warehouses.map((wh) => (
<SelectItem
key={wh.warehouse_code}
value={wh.warehouse_code}
>
{wh.warehouse_name}
{wh.warehouse_type
? ` (${wh.warehouse_type})`
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium">
<Checkbox
checked={showShortageOnly}
onCheckedChange={(c) => setShowShortageOnly(c as boolean)}
/>
<span> </span>
</label>
<span className="ml-auto text-sm font-semibold text-muted-foreground">
{filteredMaterials.length}
</span>
</div>
{/* 원자재 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{materialsLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
...
</p>
</div>
) : materials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : filteredMaterials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
filteredMaterials.map((material) => {
const shortage = material.required - material.current;
const isShortage = shortage > 0;
const percentage =
material.required > 0
? Math.min(
(material.current / material.required) * 100,
100
)
: 100;
return (
<div
key={material.code}
className={cn(
"rounded-lg border-2 p-3 transition-all hover:shadow-md hover:-translate-y-0.5",
isShortage
? "border-destructive/40 bg-destructive/2"
: "border-emerald-300/50 bg-emerald-50/20"
)}
>
{/* 메인 정보 라인 */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold">
{material.name}
</span>
<span className="text-xs text-muted-foreground">
({material.code})
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span className="text-xs font-semibold text-blue-600">
{material.required.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-foreground"
)}
>
{material.current.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
{isShortage ? "부족:" : "여유:"}
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-emerald-600"
)}
>
{Math.abs(shortage).toLocaleString()}
{material.unit}
</span>
<span className="text-xs font-semibold text-muted-foreground">
({percentage.toFixed(0)}%)
</span>
{isShortage ? (
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-destructive bg-destructive/10 px-2 py-0.5 text-[11px] font-semibold text-destructive">
<AlertTriangle className="h-3 w-3" />
</span>
) : (
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-emerald-500 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
<CheckCircle2 className="h-3 w-3" />
</span>
)}
</div>
{/* 위치별 재고 */}
{material.locations.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
{material.locations.map((loc, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
{material.unit}
</span>
</span>
))}
</div>
)}
</div>
);
})
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,926 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
ResizableHandle, ResizablePanel, ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandInput, CommandList, CommandEmpty, CommandItem } from "@/components/ui/command";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import {
Search, Plus, Trash2, RotateCcw, Loader2, Package, Box, X, Save, Edit2, Download, ChevronsUpDown, Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg,
} from "@/lib/api/packaging";
// --- 코드 → 라벨 매핑 ---
const PKG_TYPE_LABEL: Record<string, string> = {
BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡",
ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤",
};
const LOADING_TYPE_LABEL: Record<string, string> = {
PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트",
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-emerald-100 text-emerald-800" : "bg-gray-100 text-gray-600";
const fmtSize = (w: any, l: any, h: any) => {
const vals = [w, l, h].map(v => Number(v) || 0);
return vals.some(v => v > 0) ? vals.join("×") : "-";
};
// 규격 문자열에서 치수 파싱
function parseSpecDimensions(spec: string | null) {
if (!spec) return { w: 0, l: 0, h: 0 };
const m3 = spec.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/i);
if (m3) return { w: parseInt(m3[1]), l: parseInt(m3[2]), h: parseInt(m3[3]) };
const m2 = spec.match(/(\d+)\s*[x×]\s*(\d+)/i);
if (m2) return { w: parseInt(m2[1]), l: parseInt(m2[2]), h: 0 };
return { w: 0, l: 0, h: 0 };
}
export default function PackagingPage() {
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [activeTab, setActiveTab] = useState<"packing" | "loading">("packing");
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
// 포장재 데이터
const [pkgUnits, setPkgUnits] = useState<PkgUnit[]>([]);
const [pkgLoading, setPkgLoading] = useState(false);
const [selectedPkg, setSelectedPkg] = useState<PkgUnit | null>(null);
const [pkgItems, setPkgItems] = useState<PkgUnitItem[]>([]);
const [pkgItemsLoading, setPkgItemsLoading] = useState(false);
// 적재함 데이터
const [loadingUnits, setLoadingUnits] = useState<LoadingUnit[]>([]);
const [loadingLoading, setLoadingLoading] = useState(false);
const [selectedLoading, setSelectedLoading] = useState<LoadingUnit | null>(null);
const [loadingPkgs, setLoadingPkgs] = useState<LoadingUnitPkg[]>([]);
const [loadingPkgsLoading, setLoadingPkgsLoading] = useState(false);
// 모달
const [pkgModalOpen, setPkgModalOpen] = useState(false);
const [pkgModalMode, setPkgModalMode] = useState<"create" | "edit">("create");
const [pkgForm, setPkgForm] = useState<Record<string, any>>({});
const [pkgItemOptions, setPkgItemOptions] = useState<ItemInfoForPkg[]>([]);
const [pkgItemPopoverOpen, setPkgItemPopoverOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
const [loadModalMode, setLoadModalMode] = useState<"create" | "edit">("create");
const [loadForm, setLoadForm] = useState<Record<string, any>>({});
const [loadItemOptions, setLoadItemOptions] = useState<ItemInfoForPkg[]>([]);
const [loadItemPopoverOpen, setLoadItemPopoverOpen] = useState(false);
const [itemMatchModalOpen, setItemMatchModalOpen] = useState(false);
const [itemMatchKeyword, setItemMatchKeyword] = useState("");
const [itemMatchResults, setItemMatchResults] = useState<ItemInfoForPkg[]>([]);
const [itemMatchSelected, setItemMatchSelected] = useState<ItemInfoForPkg | null>(null);
const [itemMatchQty, setItemMatchQty] = useState(1);
const [pkgMatchModalOpen, setPkgMatchModalOpen] = useState(false);
const [pkgMatchQty, setPkgMatchQty] = useState(1);
const [pkgMatchMethod, setPkgMatchMethod] = useState("");
const [pkgMatchSelected, setPkgMatchSelected] = useState<PkgUnit | null>(null);
const [pkgMatchSearchKw, setPkgMatchSearchKw] = useState("");
const [saving, setSaving] = useState(false);
// --- 데이터 로드 ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
useEffect(() => { fetchPkgUnits(); fetchLoadingUnits(); }, [fetchPkgUnits, fetchLoadingUnits]);
// 포장재 선택 시 매칭 품목 로드
const selectPkg = useCallback(async (pkg: PkgUnit) => {
setSelectedPkg(pkg);
setPkgItemsLoading(true);
try {
const res = await getPkgUnitItems(pkg.pkg_code);
if (res.success) setPkgItems(res.data);
} catch { setPkgItems([]); } finally { setPkgItemsLoading(false); }
}, []);
// 적재함 선택 시 포장구성 로드
const selectLoading = useCallback(async (lu: LoadingUnit) => {
setSelectedLoading(lu);
setLoadingPkgsLoading(true);
try {
const res = await getLoadingUnitPkgs(lu.loading_code);
if (res.success) setLoadingPkgs(res.data);
} catch { setLoadingPkgs([]); } finally { setLoadingPkgsLoading(false); }
}, []);
// 검색 필터 적용
const filteredPkgUnits = pkgUnits.filter((p) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw));
});
const filteredLoadingUnits = loadingUnits.filter((l) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (l.loading_code?.toLowerCase().includes(kw) || l.loading_name?.toLowerCase().includes(kw));
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
}
setPkgItemPopoverOpen(false);
try {
const res = await getItemsByDivision("포장재");
if (res.success) setPkgItemOptions(res.data);
} catch { setPkgItemOptions([]); }
setPkgModalOpen(true);
};
const onPkgItemSelect = (item: ItemInfoForPkg) => {
setPkgItemPopoverOpen(false);
const dims = parseSpecDimensions(item.size);
setPkgForm((prev) => ({
...prev,
pkg_code: item.item_number,
pkg_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const savePkgUnit = async () => {
if (!pkgForm.pkg_code || !pkgForm.pkg_name) { toast.error("포장코드와 포장명은 필수입니다."); return; }
if (!pkgForm.pkg_type) { toast.error("포장유형을 선택해주세요."); return; }
setSaving(true);
try {
if (pkgModalMode === "create") {
const res = await createPkgUnit(pkgForm);
if (res.success) { toast.success("포장재 등록 완료"); setPkgModalOpen(false); fetchPkgUnits(); }
} else {
const res = await updatePkgUnit(pkgForm.id, pkgForm);
if (res.success) { toast.success("포장재 수정 완료"); setPkgModalOpen(false); fetchPkgUnits(); setSelectedPkg(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeletePkg = async (pkg: PkgUnit) => {
const ok = await confirm(`"${pkg.pkg_name}" 포장재를 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnit(pkg.id);
toast.success("삭제 완료");
setSelectedPkg(null); setPkgItems([]);
fetchPkgUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
}
setLoadItemPopoverOpen(false);
try {
const res = await getItemsByDivision("적재함");
if (res.success) setLoadItemOptions(res.data);
} catch { setLoadItemOptions([]); }
setLoadModalOpen(true);
};
const onLoadItemSelect = (item: ItemInfoForPkg) => {
setLoadItemPopoverOpen(false);
const dims = parseSpecDimensions(item.size);
setLoadForm((prev) => ({
...prev,
loading_code: item.item_number,
loading_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const saveLoadingUnit = async () => {
if (!loadForm.loading_code || !loadForm.loading_name) { toast.error("적재함코드와 적재함명은 필수입니다."); return; }
if (!loadForm.loading_type) { toast.error("적재유형을 선택해주세요."); return; }
setSaving(true);
try {
if (loadModalMode === "create") {
const res = await createLoadingUnit(loadForm);
if (res.success) { toast.success("적재함 등록 완료"); setLoadModalOpen(false); fetchLoadingUnits(); }
} else {
const res = await updateLoadingUnit(loadForm.id, loadForm);
if (res.success) { toast.success("적재함 수정 완료"); setLoadModalOpen(false); fetchLoadingUnits(); setSelectedLoading(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeleteLoading = async (lu: LoadingUnit) => {
const ok = await confirm(`"${lu.loading_name}" 적재함을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnit(lu.id);
toast.success("삭제 완료");
setSelectedLoading(null); setLoadingPkgs([]);
fetchLoadingUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 품목 추가 모달 (포장재 매칭) ---
const openItemMatchModal = async () => {
setItemMatchKeyword(""); setItemMatchSelected(null); setItemMatchQty(1);
setItemMatchModalOpen(true);
try {
const res = await getGeneralItems();
if (res.success) setItemMatchResults(res.data);
} catch { setItemMatchResults([]); }
};
const searchItemsForMatch = async () => {
try {
const res = await getGeneralItems(itemMatchKeyword || undefined);
if (res.success) setItemMatchResults(res.data);
} catch { setItemMatchResults([]); }
};
const saveItemMatch = async () => {
if (!selectedPkg || !itemMatchSelected) { toast.error("품목을 선택해주세요."); return; }
if (itemMatchQty <= 0) { toast.error("포장수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createPkgUnitItem({
pkg_code: selectedPkg.pkg_code,
item_number: itemMatchSelected.item_number,
pkg_qty: itemMatchQty,
});
if (res.success) { toast.success("품목 추가 완료"); setItemMatchModalOpen(false); selectPkg(selectedPkg); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeletePkgItem = async (item: PkgUnitItem) => {
const ok = await confirm("매칭 품목을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnitItem(item.id);
toast.success("삭제 완료");
if (selectedPkg) selectPkg(selectedPkg);
} catch { toast.error("삭제 실패"); }
};
// --- 포장단위 추가 모달 (적재함 구성) ---
const openPkgMatchModal = () => {
setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); setPkgMatchSearchKw("");
setPkgMatchModalOpen(true);
};
const savePkgMatch = async () => {
if (!selectedLoading || !pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; }
if (pkgMatchQty <= 0) { toast.error("최대적재수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createLoadingUnitPkg({
loading_code: selectedLoading.loading_code,
pkg_code: pkgMatchSelected.pkg_code,
max_load_qty: pkgMatchQty,
load_method: pkgMatchMethod || undefined,
});
if (res.success) { toast.success("포장단위 추가 완료"); setPkgMatchModalOpen(false); selectLoading(selectedLoading); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeleteLoadPkg = async (lp: LoadingUnitPkg) => {
const ok = await confirm("적재 구성을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnitPkg(lp.id);
toast.success("삭제 완료");
if (selectedLoading) selectLoading(selectedLoading);
} catch { toast.error("삭제 실패"); }
};
return (
<div className="flex h-full flex-col gap-3 p-4">
{/* 검색 바 */}
<div className="flex items-center gap-2 rounded-lg border bg-card p-3">
<Input
placeholder="포장코드 / 포장명 / 적재함명 검색"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="h-9 w-[280px] text-xs"
/>
<Button size="sm" variant="outline" onClick={() => setSearchKeyword("")} className="h-9">
<RotateCcw className="mr-1 h-4 w-4" />
</Button>
</div>
{/* 탭 */}
<div className="flex gap-1 border-b">
{([["packing", "포장재 관리", filteredPkgUnits.length] as const, ["loading", "적재함 관리", filteredLoadingUnits.length] as const]).map(([tab, label, count]) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
activeTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab === "packing" ? <Package className="h-4 w-4" /> : <Box className="h-4 w-4" />}
{label}
<Badge variant="secondary" className="text-[10px] px-1.5">{count}</Badge>
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<div className="flex-1 overflow-hidden">
{activeTab === "packing" ? (
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* 좌측: 포장재 목록 */}
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredPkgUnits.length})</span></span>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pkgLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredPkgUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredPkgUnits.map((p) => (
<TableRow
key={p.id}
className={cn("cursor-pointer text-xs", selectedPkg?.id === p.id && "bg-primary/5")}
onClick={() => selectPkg(p)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{p.pkg_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(p.status))}>{STATUS_LABEL[p.status] || p.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 상세 */}
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedPkg ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Package className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
{/* 요약 헤더 */}
<div className="flex items-center justify-between border-b bg-blue-50 dark:bg-blue-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-blue-600" />
<div>
<div className="font-bold text-sm">{selectedPkg.pkg_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedPkg.pkg_code} · {PKG_TYPE_LABEL[selectedPkg.pkg_type] || selectedPkg.pkg_type} · {fmtSize(selectedPkg.width_mm, selectedPkg.length_mm, selectedPkg.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("edit")}>
<Edit2 className="mr-1 h-3 w-3" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeletePkg(selectedPkg)}>
<Trash2 className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 매칭 품목 */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({pkgItems.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openItemMatchModal}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
{pkgItemsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : pkgItems.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{pkgItems.map((item) => (
<TableRow key={item.id} className="text-xs">
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
<X className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
) : (
/* 적재함 관리 탭 */
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredLoadingUnits.length})</span></span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[80px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredLoadingUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredLoadingUnits.map((l) => (
<TableRow
key={l.id}
className={cn("cursor-pointer text-xs", selectedLoading?.id === l.id && "bg-primary/5")}
onClick={() => selectLoading(l)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{l.loading_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{l.loading_name}</TableCell>
<TableCell className="p-2">{LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(l.status))}>{STATUS_LABEL[l.status] || l.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedLoading ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Box className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b bg-green-50 dark:bg-green-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Box className="h-5 w-5 text-green-600" />
<div>
<div className="font-bold text-sm">{selectedLoading.loading_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedLoading.loading_code} · {LOADING_TYPE_LABEL[selectedLoading.loading_type] || selectedLoading.loading_type} · {fmtSize(selectedLoading.width_mm, selectedLoading.length_mm, selectedLoading.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("edit")}><Edit2 className="mr-1 h-3 w-3" /> </Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeleteLoading(selectedLoading)}><Trash2 className="mr-1 h-3 w-3" /> </Button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({loadingPkgs.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openPkgMatchModal}><Plus className="mr-1 h-3 w-3" /> </Button>
</div>
<div className="flex-1 overflow-auto">
{loadingPkgsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : loadingPkgs.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{loadingPkgs.map((lp) => (
<TableRow key={lp.id} className="text-xs">
<TableCell className="p-2 font-medium">{lp.pkg_code}</TableCell>
<TableCell className="p-2">{lp.pkg_name || "-"}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(lp.max_load_qty).toLocaleString()}</TableCell>
<TableCell className="p-2">{lp.load_method || "-"}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteLoadPkg(lp)}><X className="h-3 w-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
{/* 포장재 등록/수정 모달 */}
<FullscreenDialog open={pkgModalOpen} onOpenChange={setPkgModalOpen}
title={pkgModalMode === "create" ? "포장재 등록" : "포장재 수정"}
description="품목정보에서 포장재를 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setPkgModalOpen(false)}></Button>
<Button onClick={savePkgUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{/* 품목정보 연결 */}
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-blue-50 dark:bg-blue-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 포장재)</Label>
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
{pkgForm.pkg_code
? `${pkgForm.pkg_name} (${pkgForm.pkg_code})`
: "품목정보에서 포장재를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = pkgItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{pkgItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={pkgForm.pkg_type || ""} onValueChange={(v) => setPkgForm((p) => ({ ...p, pkg_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(PKG_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={pkgForm.status || "ACTIVE"} onValueChange={(v) => setPkgForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.width_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.length_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.height_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.self_weight_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.max_load_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(L)</Label><Input type="number" value={pkgForm.volume_l || ""} onChange={(e) => setPkgForm((p) => ({ ...p, volume_l: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={pkgForm.remarks || ""} onChange={(e) => setPkgForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 적재함 등록/수정 모달 */}
<FullscreenDialog open={loadModalOpen} onOpenChange={setLoadModalOpen}
title={loadModalMode === "create" ? "적재함 등록" : "적재함 수정"}
description="품목정보에서 적재함을 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setLoadModalOpen(false)}></Button>
<Button onClick={saveLoadingUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{loadModalMode === "create" && (
<div className="rounded-lg border bg-green-50 dark:bg-green-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 적재함)</Label>
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
{loadForm.loading_code
? `${loadForm.loading_name} (${loadForm.loading_code})`
: "품목정보에서 적재함을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = loadItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{loadItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={loadForm.loading_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={loadForm.loading_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={loadForm.loading_type || ""} onValueChange={(v) => setLoadForm((p) => ({ ...p, loading_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(LOADING_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={loadForm.status || "ACTIVE"} onValueChange={(v) => setLoadForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.width_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.length_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.height_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.self_weight_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.max_load_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]"></Label><Input type="number" value={loadForm.max_stack || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_stack: e.target.value }))} className="h-8 text-xs" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={loadForm.remarks || ""} onChange={(e) => setLoadForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 품목 추가 모달 (포장재 매칭) */}
<Dialog open={itemMatchModalOpen} onOpenChange={setItemMatchModalOpen}>
<DialogContent className="max-w-[900px]">
<DialogHeader>
<DialogTitle> {selectedPkg?.pkg_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input placeholder="품목코드 / 품목명 검색 (입력 시 자동 검색)" value={itemMatchKeyword}
onChange={(e) => {
setItemMatchKeyword(e.target.value);
const kw = e.target.value;
clearTimeout((window as any).__itemMatchTimer);
(window as any).__itemMatchTimer = setTimeout(async () => {
try {
const res = await getGeneralItems(kw || undefined);
if (res.success) setItemMatchResults(res.data);
} catch { /* ignore */ }
}, 300);
}}
className="h-9 text-xs" />
<div className="max-h-[300px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2 w-[130px]"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[100px]"></TableHead>
<TableHead className="p-2 w-[80px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer text-xs", itemMatchSelected?.id === item.id && "bg-primary/10")}
onClick={() => setItemMatchSelected(item)}>
<TableCell className="p-2 text-center">{itemMatchSelected?.id === item.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium truncate max-w-[130px]">{item.item_number}</TableCell>
<TableCell className="p-2 truncate max-w-[200px]">{item.item_name}</TableCell>
<TableCell className="p-2 truncate">{item.spec || "-"}</TableCell>
<TableCell className="p-2 truncate">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="flex-1">
<Label className="text-xs"> </Label>
<Input value={itemMatchSelected ? `${itemMatchSelected.item_name} (${itemMatchSelected.item_number})` : ""} readOnly className="h-9 bg-muted text-xs" />
</div>
<div className="w-[120px]">
<Label htmlFor="pkg-item-match-qty" className="text-xs">(EA) <span className="text-destructive">*</span></Label>
<Input id="pkg-item-match-qty" type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setItemMatchModalOpen(false)}></Button>
<Button type="button" data-action-type="custom" onClick={saveItemMatch} disabled={saving || !itemMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 포장단위 추가 모달 (적재함 구성) */}
<Dialog open={pkgMatchModalOpen} onOpenChange={setPkgMatchModalOpen}>
<DialogContent className="max-w-[800px]">
<DialogHeader>
<DialogTitle> {selectedLoading?.loading_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="포장코드 / 포장명 검색"
value={pkgMatchSearchKw}
onChange={(e) => setPkgMatchSearchKw(e.target.value)}
className="h-9 text-xs"
/>
<div className="max-h-[300px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2 w-[120px]"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[100px]">(mm)</TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
const kw = pkgMatchSearchKw.toLowerCase();
const filtered = pkgUnits.filter(p =>
p.status === "ACTIVE"
&& !loadingPkgs.some(lp => lp.pkg_code === p.pkg_code)
&& (!kw || p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw))
);
return filtered.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : filtered.map((p) => (
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
onClick={() => setPkgMatchSelected(p)}>
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
<TableCell className="p-2">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
</TableRow>
));
})()}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="w-[150px]">
<Label htmlFor="loading-pkg-match-qty" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="loading-pkg-match-qty" type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
<div className="flex-1">
<Label className="text-xs"></Label>
<Input value={pkgMatchMethod} onChange={(e) => setPkgMatchMethod(e.target.value)} placeholder="수직/수평/혼합" className="h-9 text-xs" />
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPkgMatchModalOpen(false)}></Button>
<Button type="button" data-action-type="custom" onClick={savePkgMatch} disabled={saving || !pkgMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ConfirmDialogComponent}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,498 @@
"use client";
/**
*
*
* 좌측: 부서 (dept_info)
* 우측: 선택한 (user_info)
*
* 모달: 부서 (dept_info), (user_info)
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Building2, Users, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
{ key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" },
{ key: "parent_dept_code", label: "상위부서", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[70px]" },
];
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "sabun", label: "사번", width: "w-[80px]" },
{ key: "user_name", label: "이름", width: "w-[90px]" },
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
{ key: "position_name", label: "직급", width: "w-[80px]" },
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]" },
{ key: "email", label: "이메일", minWidth: "min-w-[150px]" },
];
export default function DepartmentPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 부서
const [depts, setDepts] = useState<any[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [deptCount, setDeptCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 우측: 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("department");
if (saved) applyTableSettings(saved);
}, []);
// 부서 조회
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
setDepts(data);
setDeptCount(res.data?.data?.total || data.length);
} catch (err) {
console.error("부서 조회 실패:", err);
toast.error("부서 목록을 불러오는데 실패했습니다.");
} finally {
setDeptLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchDepts(); }, [fetchDepts]);
// 선택된 부서
const selectedDept = depts.find((d) => d.id === selectedDeptId);
const selectedDeptCode = selectedDept?.dept_code || null;
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
const fetchMembers = useCallback(async () => {
setMemberLoading(true);
try {
const filters = selectedDeptCode
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
: [];
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
}, [selectedDeptCode]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 부서 등록
const openDeptRegister = () => {
setDeptForm({});
setDeptEditMode(false);
setDeptModalOpen(true);
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
setSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, {
originalData: { dept_code: deptForm.dept_code },
updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null },
});
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, {
dept_code: deptForm.dept_code || "",
dept_name: deptForm.dept_name,
parent_dept_code: deptForm.parent_dept_code || null,
});
toast.success("등록되었습니다.");
}
setDeptModalOpen(false);
fetchDepts();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 부서 삭제
const handleDeptDelete = async () => {
if (!selectedDeptCode) return;
const ok = await confirm("부서를 삭제하시겠습니까?", {
description: "해당 부서에 소속된 사원 정보는 유지됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, {
data: [{ dept_code: selectedDeptCode }],
});
toast.success("삭제되었습니다.");
setSelectedDeptId(null);
fetchDepts();
} catch { toast.error("삭제에 실패했습니다."); }
};
// 사원 추가
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
const handleUserFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setUserForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
};
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
setSaving(true);
try {
// 비밀번호 미입력 시 기본값 (신규만)
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
await apiClient.post("/admin/users/with-dept", {
userInfo: {
user_id: userForm.user_id,
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userForm.email || undefined,
tel: userForm.tel || undefined,
cell_phone: userForm.cell_phone || undefined,
sabun: userForm.sabun || undefined,
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
position_name: userForm.position_name || undefined,
} : undefined,
isUpdate: userEditMode,
});
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
setUserModalOpen(false);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (depts.length === 0) return;
const data = depts.map((d) => ({
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
}));
await exportToExcel(data, "부서관리.xlsx", "부서");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={DEPT_TABLE}
filterId="department"
onFilterChange={setSearchFilters}
dataCount={deptCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{deptCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={openDeptRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={handleDeptDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid
gridId="dept-left"
columns={LEFT_COLUMNS}
data={depts}
loading={deptLoading}
selectedId={selectedDeptId}
onSelect={(id) => {
setSelectedDeptId((prev) => (prev === id ? null : id));
}}
onRowDoubleClick={() => openDeptEdit()}
emptyMessage="등록된 부서가 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedDept ? "부서 인원" : "전체 사원"}
{selectedDept && <Badge variant="outline" className="font-normal">{selectedDept.dept_name}</Badge>}
{members.length > 0 && <Badge variant="secondary" className="font-normal">{members.length}</Badge>}
</div>
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
<DataGrid
gridId="dept-right"
columns={RIGHT_COLUMNS}
data={members}
loading={memberLoading}
showRowNumber={false}
tableName={USER_TABLE}
emptyMessage={selectedDeptCode ? "소속 사원이 없습니다" : "등록된 사원이 없습니다"}
onRowDoubleClick={(row) => openUserModal(row)}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 부서 등록/수정 모달 */}
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={deptForm.dept_code || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_code: e.target.value }))}
placeholder="부서코드" className="h-9" disabled={deptEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={deptForm.dept_name || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
placeholder="부서명" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeptModalOpen(false)}></Button>
<Button onClick={handleDeptSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 사원 추가 모달 */}
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
<DialogDescription>{userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"> ID <span className="text-destructive">*</span></Label>
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
placeholder="이름" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
<SelectContent>
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.regdate || ""} onChange={(v) => setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.end_date || ""} onChange={(v) => setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={handleUserSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DEPT_TABLE}
userId={user?.userId}
onSuccess={() => fetchDepts()}
/>
{ConfirmDialogComponent}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={DEPT_TABLE}
settingsId="department"
onSave={applyTableSettings}
/>
</div>
);
}

View File

@ -0,0 +1,517 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Plus, Trash2, RotateCcw, Save, Search, Loader2, FileSpreadsheet, Download,
Package, Pencil, Copy,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
// 테이블 컬럼 정의
const TABLE_COLUMNS = [
{ key: "item_number", label: "품목코드", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "division", label: "관리품목", width: "w-[100px]" },
{ key: "type", label: "품목구분", width: "w-[100px]" },
{ key: "size", label: "규격", width: "w-[100px]" },
{ key: "unit", label: "단위", width: "w-[80px]" },
{ key: "material", label: "재질", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[80px]" },
{ key: "selling_price", label: "판매가격", width: "w-[100px]" },
{ key: "standard_price", label: "기준단가", width: "w-[100px]" },
{ key: "weight", label: "중량", width: "w-[80px]" },
{ key: "inventory_unit", label: "재고단위", width: "w-[80px]" },
{ key: "user_type01", label: "대분류", width: "w-[100px]" },
{ key: "user_type02", label: "중분류", width: "w-[100px]" },
];
// 등록 모달 필드 정의
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
{ key: "weight", label: "중량", type: "text" },
{ key: "volum", label: "부피", type: "text" },
{ key: "specific_gravity", label: "비중", type: "text" },
{ key: "inventory_unit", label: "재고단위", type: "category" },
{ key: "selling_price", label: "판매가격", type: "text" },
{ key: "standard_price", label: "기준단가", type: "text" },
{ key: "currency_code", label: "통화", type: "category" },
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "meno", label: "메모", type: "textarea" },
];
const TABLE_NAME = "item_info";
export default function ItemInfoPage() {
const { user } = useAuth();
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
const [searchDivision, setSearchDivision] = useState("all");
const [searchType, setSearchType] = useState("all");
const [searchStatus, setSearchStatus] = useState("all");
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<Record<string, any>>({});
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 카테고리 옵션 (API에서 로드)
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 카테고리 컬럼 목록
const CATEGORY_COLUMNS = ["division", "type", "unit", "material", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
// 카테고리 옵션 로드 (table_name + column_name 기반)
useEffect(() => {
const loadCategories = async () => {
try {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
await Promise.all(
CATEGORY_COLUMNS.map(async (colName) => {
try {
const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[colName] = flatten(res.data.data);
}
} catch { /* skip */ }
})
);
setCategoryOptions(optMap);
} catch (err) {
console.error("카테고리 로드 실패:", err);
}
};
loadCategories();
}, []);
// 데이터 조회
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = [];
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
}
if (searchDivision !== "all") {
filters.push({ columnName: "division", operator: "equals", value: searchDivision });
}
if (searchType !== "all") {
filters.push({ columnName: "type", operator: "equals", value: searchType });
}
if (searchStatus !== "all") {
filters.push({ columnName: "status", operator: "equals", value: searchStatus });
}
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// 카테고리 코드→라벨 변환
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setTotalCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchKeyword, searchDivision, searchType, searchStatus, categoryOptions]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
// 카테고리 코드 → 라벨 변환
const getCategoryLabel = (columnName: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[columnName];
if (!opts) return code;
const found = opts.find((o) => o.code === code);
return found?.label || code;
};
// 등록 모달 열기
const openRegisterModal = () => {
setFormData({});
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 수정 모달 열기
const openEditModal = (item: any) => {
setFormData({ ...item });
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
};
// 복사 모달 열기
const openCopyModal = (item: any) => {
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.item_name) {
toast.error("품명은 필수 입력입니다.");
return;
}
setSaving(true);
try {
if (isEditMode && editId) {
// 수정
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
updatedData: updateFields,
});
toast.success("수정되었습니다.");
} else {
// 등록
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, insertFields);
toast.success("등록되었습니다.");
}
setIsModalOpen(false);
fetchItems();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 삭제
const handleDelete = async () => {
if (!selectedId) {
toast.error("삭제할 품목을 선택해주세요.");
return;
}
if (!confirm("선택한 품목을 삭제하시겠습니까?")) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: [{ id: selectedId }],
});
toast.success("삭제되었습니다.");
setSelectedId(null);
fetchItems();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return;
}
const exportData = items.map((item) => {
const row: Record<string, any> = {};
for (const col of TABLE_COLUMNS) {
row[col.label] = getCategoryLabel(col.key, item[col.key]) || item[col.key] || "";
}
return row;
});
await exportToExcel(exportData, "품목정보.xlsx", "품목정보");
toast.success("엑셀 다운로드 완료");
};
// 검색 초기화
const handleResetSearch = () => {
setSearchKeyword("");
setSearchDivision("all");
setSearchType("all");
setSearchStatus("all");
};
// 카테고리 셀렉트 렌더링
const renderCategorySelect = (field: typeof FORM_FIELDS[0]) => {
const options = categoryOptions[field.key] || [];
return (
<Select
value={formData[field.key] || ""}
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={`${field.label} 선택`} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.code} value={opt.code}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<Card className="shrink-0">
<CardContent className="p-4 flex flex-wrap items-end gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">/</Label>
<Input
placeholder="검색"
className="w-[180px] h-9"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchDivision} onValueChange={setSearchDivision}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchType} onValueChange={setSearchType}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["status"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{totalCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openCopyModal(item);
}}>
<Copy className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openEditModal(item);
}}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
<Package className="w-8 h-8 opacity-50" />
<span> </span>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[40px] text-center">No</TableHead>
{TABLE_COLUMNS.map((col) => (
<TableHead key={col.key} className={col.width}>{col.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow
key={item.id}
className={cn("cursor-pointer", selectedId === item.id && "bg-primary/5")}
onClick={() => setSelectedId(item.id)}
onDoubleClick={() => openEditModal(item)}
>
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
{TABLE_COLUMNS.map((col) => (
<TableCell key={col.key} className="text-sm">
{["division", "type", "unit", "material", "status", "inventory_unit", "user_type01", "user_type02", "currency_code"].includes(col.key)
? getCategoryLabel(col.key, item[col.key])
: item[col.key] || ""}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-auto">
<DialogHeader>
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
<DialogDescription>
{isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
{FORM_FIELDS.map((field) => (
<div key={field.key} className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}>
<Label className="text-sm">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "category" ? (
renderCategorySelect(field)
) : field.type === "textarea" ? (
<Textarea
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.label}
rows={3}
/>
) : (
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.disabled ? field.placeholder : field.label}
disabled={field.disabled && !isEditMode}
className="h-9"
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={TABLE_NAME}
userId={user?.userId}
onSuccess={() => {
fetchItems();
}}
/>
</div>
);
}

View File

@ -0,0 +1,534 @@
"use client";
/**
*
*
* 좌측: 품목 (subcontractor_item_mapping , item_info )
* 우측: 선택한 (subcontractor_item_mapping subcontractor_mng )
*
* ( subcontractor_item_mapping )
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, Settings2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "subcontractor_item_mapping";
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
// 좌측: 품목 컬럼
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[90px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// 우측: 외주업체 정보 컬럼
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" },
{ key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" },
{ key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" },
{ key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" },
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
];
export default function SubcontractorItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 우측: 외주업체
const [subcontractorItems, setSubcontractorItems] = useState<any[]>([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 외주업체 추가 모달
const [subSelectOpen, setSubSelectOpen] = useState(false);
const [subSearchKeyword, setSubSearchKeyword] = useState("");
const [subSearchResults, setSubSearchResults] = useState<any[]>([]);
const [subSearchLoading, setSubSearchLoading] = useState(false);
const [subCheckedIds, setSubCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("subcontractor-item");
if (saved) applyTableSettings(saved);
}, []);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
const outsourcingDivisionCode = categoryOptions["division"]?.find(
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
)?.code;
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
// division = 외주관리 필터 추가
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setItemLoading(false);
}
}, [searchFilters, categoryOptions, outsourcingDivisionCode]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 외주업체 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setSubcontractorItems([]); return; }
const itemKey = selectedItem.item_number;
const fetchSubcontractorItems = async () => {
setSubcontractorLoading(true);
try {
// subcontractor_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// subcontractor_id → subcontractor_mng 조인 (외주업체명)
const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))];
let subMap: Record<string, any> = {};
if (subIds.length > 0) {
try {
const subRes = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: subIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "subcontractor_code", operator: "in", value: subIds }] },
autoFilter: true,
});
for (const s of (subRes.data?.data?.data || subRes.data?.data?.rows || [])) {
subMap[s.subcontractor_code] = s;
}
} catch { /* skip */ }
}
setSubcontractorItems(mappings.map((m: any) => ({
...m,
subcontractor_code: m.subcontractor_id,
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
})));
} catch (err) {
console.error("외주업체 조회 실패:", err);
} finally {
setSubcontractorLoading(false);
}
};
fetchSubcontractorItems();
}, [selectedItem?.item_number]);
// 외주업체 검색
const searchSubcontractors = async () => {
setSubSearchLoading(true);
try {
const filters: any[] = [];
if (subSearchKeyword) filters.push({ columnName: "subcontractor_name", operator: "contains", value: subSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
// 이미 등록된 외주업체 제외
const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code));
setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code)));
} catch { /* skip */ } finally { setSubSearchLoading(false); }
};
// 외주업체 추가 저장
const addSelectedSubcontractors = async () => {
const selected = subSearchResults.filter((s) => subCheckedIds.has(s.id));
if (selected.length === 0 || !selectedItem) return;
try {
for (const sub of selected) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
subcontractor_id: sub.subcontractor_code,
item_id: selectedItem.item_number,
});
}
toast.success(`${selected.length}개 외주업체가 추가되었습니다.`);
setSubCheckedIds(new Set());
setSubSelectOpen(false);
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "외주업체 추가에 실패했습니다.");
}
};
// 품목 수정
const openEditItem = () => {
if (!selectedItem) return;
setEditItemForm({ ...selectedItem });
setEditItemOpen(true);
};
const handleEditSave = async () => {
if (!editItemForm.id) return;
setSaving(true);
try {
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editItemForm.id },
updatedData: {
selling_price: editItemForm.selling_price || null,
standard_price: editItemForm.standard_price || null,
currency_code: editItemForm.currency_code || null,
},
});
toast.success("수정되었습니다.");
setEditItemOpen(false);
fetchItems();
} catch (err: any) {
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "외주품목정보.xlsx", "외주품목");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={ITEM_TABLE}
filterId="subcontractor-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 외주품목 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{itemCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<DataGrid
gridId="subcontractor-item-left"
columns={LEFT_COLUMNS}
data={items}
loading={itemLoading}
selectedId={selectedItemId}
onSelect={setSelectedItemId}
onRowDoubleClick={() => openEditItem()}
emptyMessage="등록된 외주품목이 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 외주업체 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setSubCheckedIds(new Set()); setSubSelectOpen(true); searchSubcontractors(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="subcontractor-item-right"
columns={RIGHT_COLUMNS}
data={subcontractorItems}
loading={subcontractorLoading}
showRowNumber={false}
emptyMessage="등록된 외주업체가 없습니다"
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 품목 수정 모달 */}
<FullscreenDialog
open={editItemOpen}
onOpenChange={setEditItemOpen}
title="외주품목 수정"
description={`${editItemForm.item_number || ""}${editItemForm.item_name || ""}`}
defaultMaxWidth="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setEditItemOpen(false)}></Button>
<Button onClick={handleEditSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-4 py-4">
{[
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
].map((f) => (
<div key={f.key} className="space-y-1.5">
<Label className="text-sm text-muted-foreground">{f.label}</Label>
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
</div>
))}
<div className="col-span-2 border-t my-2" />
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
placeholder="판매가격" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
placeholder="기준단가" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</FullscreenDialog>
{/* 외주업체 추가 모달 */}
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="외주업체명 검색" value={subSearchKeyword}
onChange={(e) => setSubSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchSubcontractors} disabled={subSearchLoading} className="h-9">
{subSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={subSearchResults.length > 0 && subCheckedIds.size === subSearchResults.length}
onChange={(e) => {
if (e.target.checked) setSubCheckedIds(new Set(subSearchResults.map((s) => s.id)));
else setSubCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : subSearchResults.map((s) => (
<TableRow key={s.id} className={cn("cursor-pointer", subCheckedIds.has(s.id) && "bg-primary/5")}
onClick={() => setSubCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(s.id)) next.delete(s.id); else next.add(s.id);
return next;
})}>
<TableCell className="text-center"><input type="checkbox" checked={subCheckedIds.has(s.id)} readOnly /></TableCell>
<TableCell className="text-xs">{s.subcontractor_code}</TableCell>
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
<TableCell className="text-xs">{s.division}</TableCell>
<TableCell className="text-xs">{s.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{subCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setSubSelectOpen(false)}></Button>
<Button onClick={addSelectedSubcontractors} disabled={subCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {subCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={ITEM_TABLE}
settingsId="subcontractor-item"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,845 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Loader2,
Settings,
Plus,
Pencil,
Trash2,
Search,
RotateCcw,
Wrench,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import {
getProcessList,
createProcess,
updateProcess,
deleteProcesses,
getProcessEquipments,
addProcessEquipment,
removeProcessEquipment,
getEquipmentList,
type ProcessMaster,
type ProcessEquipment,
type Equipment,
} from "@/lib/api/processInfo";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
const ALL_VALUE = "__all__";
export function ProcessMasterTab() {
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
const [loadingInitial, setLoadingInitial] = useState(true);
const [loadingList, setLoadingList] = useState(false);
const [loadingEquipments, setLoadingEquipments] = useState(false);
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
const [filterType, setFilterType] = useState<string>(ALL_VALUE);
const [filterUseYn, setFilterUseYn] = useState<string>(ALL_VALUE);
const [selectedProcess, setSelectedProcess] = useState<ProcessMaster | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
const [equipmentPick, setEquipmentPick] = useState<string>("");
const [addingEquipment, setAddingEquipment] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<"add" | "edit">("add");
const [savingForm, setSavingForm] = useState(false);
const [formProcessCode, setFormProcessCode] = useState("");
const [formProcessName, setFormProcessName] = useState("");
const [formProcessType, setFormProcessType] = useState<string>("");
const [formStandardTime, setFormStandardTime] = useState("");
const [formWorkerCount, setFormWorkerCount] = useState("");
const [formUseYn, setFormUseYn] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const processTypeMap = useMemo(() => {
const m = new Map<string, string>();
processTypeOptions.forEach((o) => m.set(o.valueCode, o.valueLabel));
return m;
}, [processTypeOptions]);
const getProcessTypeLabel = useCallback(
(code: string) => processTypeMap.get(code) ?? code,
[processTypeMap]
);
const loadProcesses = useCallback(async () => {
setLoadingList(true);
try {
const res = await getProcessList({
processCode: filterCode.trim() || undefined,
processName: filterName.trim() || undefined,
processType: filterType === ALL_VALUE ? undefined : filterType,
useYn: filterUseYn === ALL_VALUE ? undefined : filterUseYn,
});
if (!res.success) {
toast.error(res.message || "공정 목록을 불러오지 못했습니다.");
return;
}
setProcesses(res.data ?? []);
} finally {
setLoadingList(false);
}
}, [filterCode, filterName, filterType, filterUseYn]);
const loadInitial = useCallback(async () => {
setLoadingInitial(true);
try {
const [procRes, eqRes] = await Promise.all([getProcessList(), getEquipmentList()]);
if (!procRes.success) {
toast.error(procRes.message || "공정 목록을 불러오지 못했습니다.");
} else {
setProcesses(procRes.data ?? []);
}
if (!eqRes.success) {
toast.error(eqRes.message || "설비 목록을 불러오지 못했습니다.");
} else {
setEquipmentMaster(eqRes.data ?? []);
}
const ptRes = await getCategoryValues("process_mng", "process_type");
if (ptRes.success && "data" in ptRes && Array.isArray(ptRes.data)) {
const activeValues = ptRes.data.filter((v: any) => v.isActive !== false);
const seen = new Set<string>();
const unique = activeValues.filter((v: any) => {
if (seen.has(v.valueCode)) return false;
seen.add(v.valueCode);
return true;
});
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
}
} finally {
setLoadingInitial(false);
}
}, []);
useEffect(() => {
void loadInitial();
}, [loadInitial]);
useEffect(() => {
setSelectedProcess((prev) => {
if (!prev) return prev;
if (!processes.some((p) => p.id === prev.id)) return null;
return prev;
});
}, [processes]);
useEffect(() => {
setEquipmentPick("");
}, [selectedProcess?.id]);
useEffect(() => {
if (!selectedProcess) {
setProcessEquipments([]);
return;
}
let cancelled = false;
setLoadingEquipments(true);
void (async () => {
const res = await getProcessEquipments(selectedProcess.process_code);
if (cancelled) return;
if (!res.success) {
toast.error(res.message || "공정 설비를 불러오지 못했습니다.");
setProcessEquipments([]);
} else {
setProcessEquipments(res.data ?? []);
}
setLoadingEquipments(false);
})();
return () => {
cancelled = true;
};
}, [selectedProcess?.process_code]);
const allSelected = useMemo(() => {
if (processes.length === 0) return false;
return processes.every((p) => selectedIds.has(p.id));
}, [processes, selectedIds]);
const toggleAll = (checked: boolean) => {
if (checked) {
setSelectedIds(new Set(processes.map((p) => p.id)));
} else {
setSelectedIds(new Set());
}
};
const toggleOne = (id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) next.add(id);
else next.delete(id);
return next;
});
};
const handleResetFilters = () => {
setFilterCode("");
setFilterName("");
setFilterType(ALL_VALUE);
setFilterUseYn(ALL_VALUE);
};
const handleSearch = () => {
void loadProcesses();
};
const openAdd = () => {
setFormMode("add");
setEditingId(null);
setFormProcessCode("");
setFormProcessName("");
setFormProcessType(processTypeOptions[0]?.valueCode ?? "");
setFormStandardTime("");
setFormWorkerCount("");
setFormUseYn("Y");
setFormOpen(true);
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택하세요.");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setFormOpen(true);
};
const submitForm = async () => {
if (!formProcessName.trim()) {
toast.error("공정명을 입력하세요.");
return;
}
setSavingForm(true);
try {
if (formMode === "add") {
const res = await createProcess({
process_name: formProcessName.trim(),
process_type: formProcessType,
standard_time: formStandardTime.trim() || "0",
worker_count: formWorkerCount.trim() || "0",
use_yn: formUseYn,
});
if (!res.success || !res.data) {
toast.error(res.message || "등록에 실패했습니다.");
return;
}
toast.success("공정이 등록되었습니다.");
setFormOpen(false);
await loadProcesses();
setSelectedProcess(res.data);
setSelectedIds(new Set());
} else if (editingId) {
const res = await updateProcess(editingId, {
process_name: formProcessName.trim(),
process_type: formProcessType,
standard_time: formStandardTime.trim() || "0",
worker_count: formWorkerCount.trim() || "0",
use_yn: formUseYn,
});
if (!res.success || !res.data) {
toast.error(res.message || "수정에 실패했습니다.");
return;
}
toast.success("공정이 수정되었습니다.");
setFormOpen(false);
await loadProcesses();
setSelectedProcess(res.data);
}
} finally {
setSavingForm(false);
}
};
const openDelete = () => {
if (selectedIds.size === 0) {
toast.message("삭제할 공정을 체크박스로 선택하세요.");
return;
}
setDeleteOpen(true);
};
const confirmDelete = async () => {
const ids = Array.from(selectedIds);
setDeleting(true);
try {
const res = await deleteProcesses(ids);
if (!res.success) {
toast.error(res.message || "삭제에 실패했습니다.");
return;
}
toast.success(`${ids.length}건 삭제되었습니다.`);
setDeleteOpen(false);
setSelectedIds(new Set());
if (selectedProcess && ids.includes(selectedProcess.id)) {
setSelectedProcess(null);
}
await loadProcesses();
} finally {
setDeleting(false);
}
};
const availableEquipments = useMemo(() => {
const used = new Set(processEquipments.map((e) => e.equipment_code));
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
}, [equipmentMaster, processEquipments]);
const handleAddEquipment = async () => {
if (!selectedProcess) return;
if (!equipmentPick) {
toast.message("추가할 설비를 선택하세요.");
return;
}
setAddingEquipment(true);
try {
const res = await addProcessEquipment({
process_code: selectedProcess.process_code,
equipment_code: equipmentPick,
});
if (!res.success) {
toast.error(res.message || "설비 추가에 실패했습니다.");
return;
}
toast.success("설비가 등록되었습니다.");
setEquipmentPick("");
const listRes = await getProcessEquipments(selectedProcess.process_code);
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
} finally {
setAddingEquipment(false);
}
};
const handleRemoveEquipment = async (row: ProcessEquipment) => {
const res = await removeProcessEquipment(row.id);
if (!res.success) {
toast.error(res.message || "설비 제거에 실패했습니다.");
return;
}
toast.success("설비가 제거되었습니다.");
if (selectedProcess) {
const listRes = await getProcessEquipments(selectedProcess.process_code);
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
}
};
const listBusy = loadingInitial || loadingList;
return (
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/30 p-3 sm:p-4">
<div className="flex flex-wrap items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden />
<span className="text-sm font-semibold sm:text-base"> </span>
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={filterCode}
onChange={(e) => setFilterCode(e.target.value)}
placeholder="코드"
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[140px] sm:text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
placeholder="이름"
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[160px] sm:text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-8 w-[120px] text-xs sm:h-10 sm:w-[130px] sm:text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
</SelectItem>
{processTypeOptions.map((o, idx) => (
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
{o.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
<SelectTrigger className="h-8 w-[100px] text-xs sm:h-10 sm:w-[110px] sm:text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
</SelectItem>
<SelectItem value="Y" className="text-xs sm:text-sm"></SelectItem>
<SelectItem value="N" className="text-xs sm:text-sm"></SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={handleResetFilters}
>
<RotateCcw className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={handleSearch}
disabled={listBusy}
>
<Search className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openAdd}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openEdit}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openDelete}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-2 sm:p-3">
{listBusy ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="mt-2 text-xs sm:text-sm"> ...</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 text-center">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="전체 선택"
className="mx-auto"
/>
</TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-right text-xs sm:text-sm">()</TableHead>
<TableHead className="text-right text-xs sm:text-sm"></TableHead>
<TableHead className="text-center text-xs sm:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{processes.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
<p className="text-xs sm:text-sm"> .</p>
</TableCell>
</TableRow>
) : (
processes.map((row) => (
<TableRow
key={row.id}
className={cn(
"cursor-pointer transition-colors",
selectedProcess?.id === row.id && "bg-accent"
)}
onClick={() => setSelectedProcess(row)}
>
<TableCell
className="text-center"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={selectedIds.has(row.id)}
onCheckedChange={(v) => toggleOne(row.id, v === true)}
aria-label={`${row.process_code} 선택`}
className="mx-auto"
/>
</TableCell>
<TableCell className="text-xs font-medium sm:text-sm">
{row.process_code}
</TableCell>
<TableCell className="text-xs sm:text-sm">{row.process_name}</TableCell>
<TableCell className="text-xs sm:text-sm">
<Badge variant="secondary" className="text-[10px] sm:text-xs">
{getProcessTypeLabel(row.process_type)}
</Badge>
</TableCell>
<TableCell className="text-right text-xs sm:text-sm">
{row.standard_time ?? "-"}
</TableCell>
<TableCell className="text-right text-xs sm:text-sm">
{row.worker_count ?? "-"}
</TableCell>
<TableCell className="text-center text-xs sm:text-sm">
<Badge
variant={row.use_yn === "N" ? "outline" : "default"}
className="text-[10px] sm:text-xs"
>
{row.use_yn === "Y" ? "사용" : "미사용"}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-2 sm:px-4 sm:py-3">
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold sm:text-base"> </p>
{selectedProcess ? (
<p className="truncate text-xs text-muted-foreground sm:text-sm">
{selectedProcess.process_name}{" "}
<span className="text-muted-foreground/80">({selectedProcess.process_code})</span>
</p>
) : (
<p className="text-xs text-muted-foreground sm:text-sm"> </p>
)}
</div>
</div>
{!selectedProcess ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-center text-muted-foreground">
<Settings className="h-10 w-10 opacity-40" />
<p className="text-sm font-medium text-foreground"> </p>
<p className="max-w-xs text-xs sm:text-sm">
.
</p>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4">
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-0 flex-1 space-y-1 sm:max-w-xs">
<Label className="text-xs sm:text-sm"> </Label>
<Select
key={selectedProcess.id}
value={equipmentPick || undefined}
onValueChange={setEquipmentPick}
disabled={addingEquipment || availableEquipments.length === 0}
>
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="설비를 선택하세요" />
</SelectTrigger>
<SelectContent>
{availableEquipments.map((eq) => (
<SelectItem
key={eq.id}
value={eq.equipment_code}
className="text-xs sm:text-sm"
>
{eq.equipment_code} · {eq.equipment_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={() => void handleAddEquipment()}
disabled={addingEquipment || !equipmentPick}
>
{addingEquipment ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="mr-1 h-3.5 w-3.5" />
)}
</Button>
</div>
<div className="min-h-0 flex-1">
{loadingEquipments ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-7 w-7 animate-spin" />
<p className="mt-2 text-xs sm:text-sm"> ...</p>
</div>
) : processEquipments.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground sm:text-sm">
. .
</p>
) : (
<ScrollArea className="h-[min(420px,calc(100vh-20rem))] pr-3">
<ul className="space-y-2">
{processEquipments.map((pe) => (
<li key={pe.id}>
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
<CardContent className="flex items-center gap-3 p-3 sm:p-4">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium sm:text-sm">
{pe.equipment_code}
</p>
<p className="truncate text-xs text-muted-foreground sm:text-sm">
{pe.equipment_name || "설비명 없음"}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 text-xs sm:h-9 sm:text-sm"
onClick={() => void handleRemoveEquipment(pe)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
</ScrollArea>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{formMode === "add" ? "공정 추가" : "공정 수정"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="pm-process-name" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="pm-process-name"
value={formProcessName}
onChange={(e) => setFormProcessName(e.target.value)}
placeholder="공정명"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Select value={formProcessType} onValueChange={setFormProcessType}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{processTypeOptions.map((o, idx) => (
<SelectItem key={`pt-form-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
{o.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="pm-standard-time" className="text-xs sm:text-sm">
()
</Label>
<Input
id="pm-standard-time"
value={formStandardTime}
onChange={(e) => setFormStandardTime(e.target.value)}
placeholder="0"
inputMode="numeric"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="pm-worker-count" className="text-xs sm:text-sm">
</Label>
<Input
id="pm-worker-count"
value={formWorkerCount}
onChange={(e) => setFormWorkerCount(e.target.value)}
placeholder="0"
inputMode="numeric"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Select value={formUseYn} onValueChange={setFormUseYn}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs sm:text-sm"></SelectItem>
<SelectItem value="N" className="text-xs sm:text-sm"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => setFormOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={savingForm}
>
</Button>
<Button
type="button"
onClick={() => void submitForm()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={savingForm}
>
{savingForm ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{selectedIds.size} . - .
.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => setDeleteOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={deleting}
>
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void confirmDelete()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={deleting}
>
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,17 @@
"use client";
import { ProcessWorkStandardComponent } from "@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent";
export function ProcessWorkStandardTab() {
return (
<div className="h-[calc(100vh-12rem)]">
<ProcessWorkStandardComponent
config={{
itemListMode: "registered",
screenCode: "screen_1599",
leftPanelTitle: "등록 품목 및 공정",
}}
/>
</div>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import React, { useState } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Settings, GitBranch, ClipboardList } from "lucide-react";
import { ProcessMasterTab } from "./ProcessMasterTab";
import { ItemRoutingTab } from "./ItemRoutingTab";
import { ProcessWorkStandardTab } from "./ProcessWorkStandardTab";
export default function ProcessInfoPage() {
const [activeTab, setActiveTab] = useState("process");
return (
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
<div className="shrink-0 border-b bg-background px-4">
<TabsList className="h-12 bg-transparent gap-1">
<TabsTrigger
value="process"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<Settings className="mr-2 h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="routing"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<GitBranch className="mr-2 h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="workstandard"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<ClipboardList className="mr-2 h-4 w-4" />
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="process" className="flex-1 overflow-hidden mt-0">
<ProcessMasterTab />
</TabsContent>
<TabsContent value="routing" className="flex-1 overflow-hidden mt-0">
<ItemRoutingTab />
</TabsContent>
<TabsContent value="workstandard" className="flex-1 overflow-hidden mt-0">
<ProcessWorkStandardTab />
</TabsContent>
</Tabs>
</div>
);
}

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