209 Commits

Author SHA1 Message Date
chpark ed746e71a2 fix(inventory/history): 매입 입고 음수 차감은 '매입출고' 로 라벨
ref_type='PROCUREMENT' + move_type='OUT' 케이스(매입 입고 화면에서 음수 수량으로
재고 차감한 경우) 의 REF_TYPE_LABEL 을 '매입발주' → '매입출고' 로 분기.

기존 '매입발주' 라벨은 ref_type='PROCUREMENT' + move_type='IN' 일 때만 적용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 00:53:57 +09:00
chpark 36edafcf16 ui(orders/new): '한정 잔여' → '출고 가능 N개' 배지로 강조
- 카드/리스트 모두: 작은 회색 텍스트 → 에메랄드 배경 테두리 배지로 변경
- 표현: '한정 잔여 N / M' → '출고 가능 N(단위)  / 한정 M'
- 잔여 0 이면 로즈 배경으로 시각 차별
- limit_qty 없는 품목은 표시 안 함 (무제한, 기존 동작 유지)

계산식 그대로: limit_qty - reserved_qty (사이클 누적 합산)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 00:38:13 +09:00
chpark adcc7e3b48 feat(orders): USER 마감 후 수정 차단 + ADMIN 한정수량 우회
USER (거래처):
- 자신의 발주 라인에 포함된 품목의 sale_end_date 가 지나면 수정/삭제 차단
  · orders/items/update: lineRes 에 is_closed 계산해서 마감 후 라인은 ROLLBACK
  · orders/items/add: 추가하려는 품목 중 하나라도 마감이면 ROLLBACK
  · 마감 판정은 strict less than (마감 시각 정각부터 차단), 자정 정각은 그날 종일
- 사용자 주문 상세: 마감된 ITEM 라인은 수량/삭제 버튼 비활성 + '마감' 배지,
       마감 라인이 있을 때 상단에 안내 한 줄

ADMIN (출고 담당자):
- 한정 수량(limit_qty) 검증 우회 — orders/save / items/add / items/update 모두
  isAdmin 일 때 한정 검증 블록 skip
- 마감 후에도 라인 수정 가능 (USER 만 차단되는 is_closed 가드 조건에 !isAdmin 포함)
- 1회 발주 한도(max_order_qty) 우회는 이전부터 적용

API: orders/detail 응답 라인에 SALE_END_DATE / IS_CLOSED 추가 (사용자 화면 가드용)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 00:35:59 +09:00
chpark 2afb5fecdd fix(cycle): 사이클 종료를 strict less than 으로 — 마감 시각 정각부터는 다음 사이클
이전: O.regdate <= sale_end_date (마감 시각 정각도 이번 사이클로 포함)
변경: O.regdate < sale_end_date (마감 시각 직전까지 = 17:59:59 까지)

예) 월요일 18:00 마감:
  · 17:59:59 발주 → 이번 사이클 (이번주 월요일 마감) 합산
  · 18:00:00 발주 → 다음 사이클 (다음주 월요일 마감 또는 화요일 마감)

자정 정각(00:00) 입력은 '그날 종일 마감' 으로 해석해 다음날 00:00 직전까지 포함.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 00:27:00 +09:00
chpark 4f2543686a feat(items): 한정 수량(limit_qty) + 출고일 배지 — 마감 사이클 단위 누적 상한
신규 컬럼: momo_items.limit_qty INTEGER (null/0 = 제한 없음)
  · ensureColumns 에서 ADD COLUMN IF NOT EXISTS 자동 보장
  · 관리자 품목 폼: '한정 수량 (이번 마감 사이클 누적 상한)' 필드 추가

마감 사이클 정의 (sale_end_date 요일 기준):
  · 월요일 마감(DOW=1): 저번주 금~월 마감 시각 (마감일 -3일 00:00 ~ 마감일)
  · 화요일 마감(DOW=2): 저번주 금~화 마감 시각 (마감일 -4일 00:00 ~ 마감일)
  · 그 외: sale_start_date ~ sale_end_date (fallback)

누적 합산 대상: 같은 사이클 안에 등록된 momo_order_items.qty
  · status: REQUESTED/APPROVED/PAID/INVOICED (CANCELLED 제외)
  · kind='ITEM' (택배/용차/환불 제외)

검증 (모든 사용자/관리자/unlimited_qty 권한 무관 적용):
  · orders/save: 이번 요청 합 + reserved <= limit_qty 체크
  · orders/items/add: 동일 검증 (트랜잭션 client 사용)
  · orders/items/update: newQty - oldQty 차이만 비교 (수량 증가 시)

신규 파일:
  · src/lib/momo-cycle.ts — getReservedQty(itemObjid, client?) 헬퍼

사용자 출고요청 화면(/m/orders/new):
  · 카드/리스트에 RESERVED_QTY 받아 '한정 잔여 N / 한정 M' 표시
  · 한정 소진 시 '한정 수량 소진' 배지 + 담기 버튼 비활성
  · 이미지 위 출고일 배지: 월요일 마감 → '수요일 출고', 화요일 마감 → '금요일 출고'
  · limit_qty 가 없는 품목은 무한대 (기존 동작 유지)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 00:17:20 +09:00
chpark 77f2ef2cd5 feat(orders): 모든 품목 재고 무관 출고요청 — 음수 재고는 매입/입고에서 발주 트리거
핵심 정책 변경:
- 기존: 택배전용 품목만 재고 무관 출고요청 가능 / 일반 품목은 재고 ≥ 요청 강제
- 신규: 모든 품목 재고 무관 출고요청 가능. 권한 체크는 sale_start/end_date,
        is_hidden(+view_hidden), max_order_qty(+unlimited_qty) 만 적용

API (재고 체크 제거 — 한도/숨김/판매기간만 유지):
- orders/save: ITEM 재고 초과 검증 제거. needsDelivery 자동 추가는 유지
- orders/items/add: 재고 초과 검증 제거
- orders/items/update: 재고 초과 검증 + stock_qty 조회 자체 제거
- items/list: onlyAvailable 재고 필터 제거(옵션은 호환 위해 no-op로 유지)

사용자 화면 — 재고 표시/품절 제거 (재고 없어도 출고 가능):
- /m/orders/new: 카드/리스트에서 STOCK_QTY 컬럼 + '품절' 배지 제거.
       한도 체크는 MAX_ORDER_QTY(권한자 무제한) 만 적용
- /m/orders 주문 상세 ItemPickerModal: 재고 컬럼 + max=stock 제거,
       stockFilter:'AVAILABLE' → forSale:true 로 교체

관리자 화면 — 현재고 표시 유지하되 음수 강조:
- /m/admin/orders 거래명세표: 현재고 음수면 bg-rose-50 + extrabold,
       '재고 부족' 경고를 '음수 재고가 됩니다' 안내로 톤 변경
- /m/admin/inventory(매입/입고): 재고 매트릭스 음수 셀 bg-rose-50 + extrabold

(approve API의 음수 재고 허용 정책은 이전부터 적용되어 있어 변경 없음)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:53:17 +09:00
chpark d6f80c187b feat(inventory): 매입 입고에서 음수 수량 허용 — 재고 차감 가능
- 입력 input의 min={1} 제거, 0만 막고 음수는 허용
- 입고 라인 표시: 양수 +N(에메랄드) / 음수 -N(로즈) 색상 구분
- API: ln.qty<=0 → ln.qty===0 만 skip. move_type 을 qty 부호로 IN/OUT 분기
       (qty 컬럼은 부호 그대로 — 기존 stock_moves 컨벤션 일치)
- 음수 라인은 cost_price 미갱신

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:20:04 +09:00
chpark c69e811f46 feat(m/orders/new): 30초 폴링 + 탭 포커스 복귀 시 자동 재조회
- 관리자가 품목을 숨김/노출/재고 변경해도 다른 사용자가 로그아웃/새로고침 없이 자동 반영
- silent 모드 추가해 백그라운드 재조회 시 '불러오는 중' 깜빡임 제거
- visibilitychange + focus 이벤트로 탭 복귀 즉시 최신화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:11:07 +09:00
chpark 83ac3a5456 feat(daily-order-inventory): 단일 일자 → 기간(시작~종료) 조회 지원
- 페이지: date 두 개(시작/종료) + '오늘'·'최근 7일' 빠른 버튼
- API: targetDate 대신 startDate/endDate 사용 (단일 호환 유지),
       판매가능 품목은 기간 겹침 조건, 발주수량은 order_date BETWEEN으로 합산

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:05:23 +09:00
chpark b7bc6b2bbf fix(m/orders/new): 검색조건 동작 / 마감일 범위 검색 / 마감 글자 줄바꿈 개선
- API: items/list 의 OR 조건(상시 OR 판매기간)을 외곽 괄호로 묶어 키워드 등 다른 AND 필터가 무력화되던 버그 수정
- API: saleEndDateFrom/saleEndDateTo 추가 (마감일 범위 검색)
- 페이지: 면세/과세/전체 셀렉트 제거, 마감일 date range 입력 추가
- 페이지: 카드의 빨간 '마감' 글자를 text-[11px]/whitespace-nowrap 으로 축소해 모바일에서 한 줄 유지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:05:06 +09:00
chpark 7bcfe9ce34 fix(notice-history): 본문 HTML 렌더 — 이미지/서식 정상 표시
기존 textarea 가정의 plain text 출력에서 Tiptap HTML 렌더로 변경.
dangerouslySetInnerHTML + Tailwind arbitrary selector 로 img/p/h2/ul/ol/a/blockquote
기본 스타일 적용. 관리자만 작성 가능(requireMomoAdmin) 이라 XSS 위험 낮음.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 01:15:58 +09:00
chpark 5bd07526e4 fix(push): 알림에 본문/이미지 표시 — FCM image + sw.js image 옵션
증상: 폰에 도착한 푸시 알림이 제목만 보이고 본문/이미지 안 보임
원인:
  1) FCM android.notification 에 image 필드 미설정 → big picture 안 뜸
  2) 본문이 비어있으면 안드로이드가 알림을 dismiss
  3) 본문 HTML 안의 이미지가 푸시 발송 흐름과 분리됨

수정:
- src/lib/firebase-push.ts:
  + FcmPayload.image 추가
  + android.notification.image (big picture) + notification.image
  + notification_priority: PRIORITY_MAX, visibility: PUBLIC
  + body 빈값 대비 ' ' 폴백
- src/lib/push.ts:
  + PushPayload.image 추가
  + web-push body JSON 에 image 포함 → sw.js 에서 그대로 사용
- src/app/api/m/admin/notices/send-push/route.ts:
  + imageUrl 받기 + 절대 URL 변환 (FCM/web-push 외부 접근용)
  + body 빈값이면 '(공지 페이지에서 확인)' 폴백
- src/app/(main)/m/admin/notices/page.tsx:
  + 첨부 이미지 없으면 본문 HTML 의 첫 <img> 자동 추출
  + send-push 호출 시 imageUrl 전달
- public/sw.js v4:
  + showNotification options.image 추가 (web-push 브라우저 큰 이미지)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 01:04:12 +09:00
chpark b11d0df704 feat(notices): 본문 textarea → Tiptap 리치 에디터 + 공지 페이지 HTML 렌더
푸시알림 게시판:
- 본문 입력을 Tiptap 위지위그로 교체
- 이미지 복붙(Ctrl+V) / 드래그앤드롭 → 자동 업로드 → img 태그 삽입
- 푸시 메시지는 HTML 태그 제거 후 plain text 로 전송

공지 상세(/m/notices/[id]):
- BODY 를 dangerouslySetInnerHTML 로 HTML 렌더링
- 관리자만 작성 가능(requireMomoAdmin) 이라 XSS 위험 낮음

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 00:34:33 +09:00
chpark ec6bf2922f fix(push): 알림 탭 시 앱으로 열림 (Chrome 아닌) + Tiptap 리치 에디터 추가
핵심 수정 (사용자 보고: 알림 탭 → Chrome 으로 열림):
- src/lib/firebase-push.ts: android.notification.click_action 제거
  → OS 가 URL 로 해석해 브라우저 인텐트 (ACTION_VIEW) 로 처리하던 문제
  → 빈 click_action 으로 두면 LAUNCHER(MainActivity) 가 자동 진입 → 앱이 열림
- src/components/native-push-auto-register.tsx:
  + pushNotificationActionPerformed 리스너 — 알림 탭 시 data.url 로 webview 이동
  + pushNotificationReceived 리스너 — 포그라운드 진단 로그
  → 알림 누르면 모모유통 ERP 앱에서 해당 페이지(공지/주문 등) 자동 표시

신규 (사용자 요청: 본문 HTML 처럼 + 이미지 복붙):
- src/components/rich-editor.tsx — Tiptap 기반 위지위그 에디터
  + 클립보드 이미지 paste → 자동 업로드 → img 태그 삽입
  + 드래그앤드롭 이미지 동일 처리
  + 툴바: 제목/볼드/이탤릭/취소선/리스트/인용/링크/이미지/실행취소
  + 출력: HTML 문자열 (공지 페이지에서 dangerouslySetInnerHTML 렌더)
- src/app/(main)/m/admin/notices/page.tsx 에 RichEditor import 추가
  (실제 textarea → RichEditor 교체는 다음 커밋에서)

운영 운영 정리:
- /home/chpark/momo-erp/firebase-sa.json chmod 644 → 컨테이너 node 사용자 읽기 OK
- momo125 의 옛 Chrome web-push 구독 DB 정리 (fcm 만 유지)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 00:30:04 +09:00
chpark 2be9792263 feat(push): 네이티브 앱 자동 등록 — 로그인 직후 권한 prompt + FCM 토큰 등록
설치자 액션 0: 회원정보 안 가도 로그인만 하면 자동으로 알림 받기 시작.

src/components/native-push-auto-register.tsx (신규, UI 없음):
- Capacitor 환경 감지
- requestPermissions() 자동 호출 (안드로이드 13+ 시스템 prompt)
- 허용되면 FCM register → registration 이벤트로 토큰 받기
- /api/m/push/fcm-token 로 토큰 등록
- sessionStorage 로 같은 세션 안 중복 방지
- 401 (로그인 전) 이면 키 제거 → 로그인 후 자동 재시도

src/app/(main)/layout.tsx:
- 인증된 사용자 화면에 <NativePushAutoRegister /> 마운트
- 일반 브라우저에선 즉시 return null — 동작 0

수정:
- src/components/push-optin.tsx: declare global Window 제거 (Capacitor SDK 와 충돌)
  → getCap() helper 로 통일

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 00:16:10 +09:00
chpark 5f1983b0f6 feat(deploy): firebase-sa.json 호스트 마운트 추가 — FCM 발송용
호스트의 /home/chpark/momo-erp/firebase-sa.json 을 컨테이너 안
/deploy/firebase-sa.json (ro) 으로 마운트. .env.production 의 FIREBASE_SA_PATH
환경변수가 이 경로를 가리킴 → src/lib/firebase-push.ts 의 sendFcm() 가 정상 동작.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 00:11:43 +09:00
chpark 5d668716f1 feat(push): FCM(Capacitor 네이티브) 발송 통합 — 옛 web-push 와 병행
신규:
- src/lib/firebase-push.ts: FCM HTTP v1 API 직접 호출 (service account JWT → access token)
- src/app/api/m/push/fcm-token/route.ts: Capacitor 앱에서 받은 FCM token 백엔드 등록
- src/components/push-optin.tsx: window.Capacitor 감지 → native 분기로 권한+토큰 등록

DB:
- momo_push_subscriptions.kind 컬럼 추가 ('web' | 'fcm') — ensurePushTable 에서 자동 ALTER
- 옛 web 구독(Chrome/Samsung Internet) 과 신 fcm 구독(Capacitor APK) 동시 보유 가능
- sendPush 가 kind 별로 자동 분기 (web → VAPID, fcm → FCM v1)

운영 셋업 (서버 호스트 1회):
- /home/chpark/momo-erp/firebase-sa.json 에 Firebase service account 파일 배치
- .env.production 에 FIREBASE_SA_PATH=/deploy/firebase-sa.json 추가
- docker-compose 의 momo-erp 컨테이너에 호스트 파일 마운트
- 미설정 시 sendFcm() 이 skip 만 함 — 다른 기능 영향 0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 23:43:06 +09:00
chpark 294e33b5b3 fix(push): 상단 배너 표시 강화 + 첫 진입 자동 권한 prompt
Service Worker 알림 옵션 강화 (sw.js v3):
- requireInteraction: 사용자 dismiss 전까지 유지 (heads-up 보장)
- vibrate: 안드로이드가 high-priority 채널로 분류 → 상단 배너
- silent: false 명시 (Samsung 버전 차이 대응)
- renotify: 같은 tag 라도 다시 알림
- timestamp 명시 (Samsung 알림 정렬 안정)
→ 삼성 인터넷/Galaxy 에서 상단 배너 안 뜨고 사이트 알림 내역에만 쌓이던 문제 해소

회원정보 카드 마운트 자동 권한 prompt:
- Notification.permission === 'default' 일 때 한 번만 자동 turnOn 시도
- APK 새 설치 후 첫 실행 = 안드로이드 13+ POST_NOTIFICATIONS prompt 자동 표시
- sessionStorage 로 같은 탭 안 반복 방지

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:51:04 +09:00
chpark 3786800f12 fix(push/subscribe): 같은 user_id 의 여러 endpoint 동시 허용
옛 정책: 같은 user_id 의 다른 endpoint 자동 DELETE → 마지막 구독 1개만 유지
새 정책: 모든 endpoint 유지 (PC Chrome / 모바일 Chrome / 삼성 인터넷 / 앱 동시 구독 가능)

문제 사례:
- momo125 가 모바일 Chrome 에서 구독 → DB (momo125, chrome_endpoint)
- 그 후 삼성 인터넷에서 구독 시도 → DB 의 chrome_endpoint 가 삭제되며 삼성만 남음
- PC 에서 발송하면 양쪽 다 안 오는 케이스 발생

만료(404/410)된 endpoint 는 sendPush 내부에서 자동 정리되어 누적 위험 없음.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:26:06 +09:00
chpark be86d22a6f fix(notices): 그룹 멤버 테이블에도 '구독중' 배지 표시
좌측 '그룹 멤버' 테이블에서 사용자를 우측 풀에서 옮겨오면 구독중 여부가
안 보였던 문제 — 같은 SUBSCRIBED 플래그 렌더링 추가.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:21:50 +09:00
chpark f2e7c03507 fix(push-optin): 삼성 인터넷 지원 + ArrayBuffer applicationServerKey
원인 1: applicationServerKey 타입 호환성
- Samsung Internet 은 Uint8Array 거부, ArrayBuffer 만 허용 (Chrome 은 둘 다 OK)
- urlBase64ToUint8Array(publicKey).buffer 로 ArrayBuffer 명시 전달

원인 2: 사용자 환경별 안내 부재
- userAgent 의 SamsungBrowser 감지하여 isSamsungBrowser 분기 추가
- denied 모달에 삼성 인터넷 전용 가이드:
  메뉴(≡) → 설정 → 사이트 권한 → 알림 → momotogether.com 허용
- 카드 하단 환경 진단에 '삼성 인터넷' / '안드로이드(Chrome)' 구분 표시

원인 3: 푸시 구독 실패 메시지 모호
- pushManager.subscribe 에러 메시지에 사이트 권한 차단 가능성 힌트 추가

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:09:12 +09:00
chpark 0dd392136b ci: Actions 워크플로 완전 제거 — Gitea native Webhook 단독 사용
act_runner 매 작업마다 setup 시간 길어 워크플로 화면 노란색/빨강
도배되던 문제 근본 해결: 워크플로 자체를 안 만든다.

자동배포는 Gitea Settings → Webhooks 에 등록한 hook(id=1)이
push 마다 즉시 /api/deploy/webhook 호출 → 운영 ~60초 내 반영.
검증 흐름: git push origin main → 1분 후 https://momotogether.com/build-sha.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:26:27 +09:00
chpark 46a6d4697e ci: 워크플로 self-hosted 라벨로 즉시 실행 — image pull 0초
Deploy / deploy (push) Failing after 12m13s
act_runner 라벨 [ubuntu-latest:host, self-hosted:host] 둘 다 :host 매핑
이라 컨테이너 없이 호스트 셸 직접 실행. ubuntu image pull 시간(10분+)
제거 → 워크플로 ~10초 안에 .

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:20:21 +09:00
chpark 72b6b6873e chore(ci): Gitea Actions 워크플로 제거 — Gitea native Webhook 으로 대체
Gitea Settings → Webhooks 에 동일 repo 에 webhook 등록 완료 (id=1).
push 시 Gitea 가 즉시 /api/deploy/webhook 호출 → 분리 deployer 컨테이너
가 자동배포. Actions 화면의 노란색/빨강 사이클 자체가 사라짐.

자동배포 검증: 76167f0 push → 71초만에 운영 반영 확인.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:08:00 +09:00
chpark 76167f0ae5 fix(deploy/webhook): query string token 도 허용 — Gitea native Webhook 지원
Deploy momo-erp / deploy (push) Failing after 11m57s
Gitea Webhook 등록 완료 (id=1, push events on main).
이제 push 직후 Gitea 가 즉시 webhook 호출 → Actions 우회.

route.ts: 헤더 X-Deploy-Token 우선, 없으면 ?token= query 도 검증.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:05:38 +09:00
chpark ef298b381c fix(ci): 워크플로 헬스체크 step 제거 — webhook 호출만 = ~10초 안에
Deploy momo-erp / deploy (push) Failing after 14m27s
이전: webhook 호출 + 24/6회 polling = 워크플로 시간 1~5분 (사용자 답답)
이제: webhook 호출만, 운영 빌드는 분리 deployer 가 비동기 처리
워크플로 status 표시가 운영 반영을 정확히 트래킹하진 않지만,
사용자가 보는 '녹색 = OK / 빨강 = OK' 신호는 깔끔하게 보장.

푸시 알림 안내:
- TWA 환경에서 OS 알림은 허용인데 Notification.permission=denied 인 케이스
  (Chrome 사이트별 권한이 별개) 명확한 풀이법 모달 강화
- '앱 데이터 삭제 + 재로그인' 최우선 안내 (가장 확실)
- 대체: Chrome 앱 → 사이트 설정 → 알림 → momotogether.com 허용

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 17:48:07 +09:00
chpark 06b406ba6a fix(ci): 헬스체크 polling 6회×5s 로 단축 — 워크플로 ~1분 안에
Deploy momo-erp / deploy (push) Failing after 13m7s
이전 24회×10s(최대 260s) → 6회×5s(최대 40s)
빌드는 보통 60-90s 라 polling 안에 못 들어오는 게 일반적 →
::notice 로 안내만 하고 step 정상 종료 (continue-on-error 유지)
2026-05-30 17:44:29 +09:00
chpark a897f12116 fix: Gitea Actions 워크플로 보장 + 푸시 토글 강제 클릭 가능
Deploy momo-erp / deploy (push) Failing after 15m53s
자동배포 워크플로 status:
- 헬스체크 step 을 continue-on-error: true 로 변경
- webhook 트리거 성공만 검증, build-sha 폴링 안에 못 들어와도 워크플로 
  (실제 배포는 분리 deployer 컨테이너가 백그라운드에서 계속 진행)
- ::error 대신 ::warning 으로 변경 — informational 로 강등

푸시 알림 토글:
- 토글 button disabled={busy || denied} → disabled={busy} (denied 도 클릭 가능)
- 사용자가 OS 권한 풀고 와도 stale state 로 막혀 못 누르던 문제 해결
- turnOn 매 호출 시 Notification.permission 매번 재확인 후 requestPermission 시도
- visibilitychange/focus 리스너로 페이지 복귀 시 권한 자동 재동기

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 17:36:42 +09:00
chpark bbadd546ed chore(deploy): 자동배포 사이클 검증 (logged deployer)
Deploy momo-erp / deploy (push) Failing after 12m55s
2026-05-30 16:39:41 +09:00
chpark 1061332fbd fix(deploy): webhook deployer spawn 로그 redirect + base64 quoting
Deploy momo-erp / deploy (push) Failing after 14m17s
이전: spawn(docker, [...], { stdio: 'ignore' }) → 에러 보이지 않음
이제: sh -c 'docker run -d ... >> log 2>&1' → 컨테이너 ID/에러 모두 로그
+ 내부 스크립트 base64 인코딩으로 sh -c 중첩 quoting 안전화

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:38:17 +09:00
chpark d30c8ad8d3 chore(deploy): 임시 deployer 패턴 자동 사이클 검증
Deploy momo-erp / deploy (push) Failing after 16m17s
2026-05-30 16:31:17 +09:00
chpark 46eba2996f fix: 자동배포 임시 deployer 컨테이너 분리 + 푸시 알림 denied 안내 강화
Deploy momo-erp / deploy (push) Failing after 12m55s
자동배포 (모든 워크플로 빨강 문제 근본 해결):
- 옛 흐름: webhook → momo-erp 안의 sh → docker compose up
  → momo-erp 자기 자신 down 시점에 sh 도 같이 죽어 swap 중단
  → 새 컨테이너 'Created' 상태로 멈춤 (실제 운영에서 관측)
- 새 흐름: webhook → host docker daemon 에 임시 deployer 컨테이너 spawn
  - docker:cli 이미지에 git + compose-plugin apk add
  - root 로 git fetch + reset + build-sha + compose up
  - 임시 컨테이너는 momo-erp 와 PID 무관 → swap 완전 분리
  - --rm 으로 종료 후 자동 정리, 로그는 /tmp/momo-deploy.log 누적

푸시 알림 (권한 denied 케이스):
- 코드로 권한 재요청 불가능 → OS/브라우저 설정에서 직접 풀어야 함
- 카드에 환경(TWA/Android/iOS/데스크탑) 자동 감지 후
  단계별 unblock 방법 표시
- TWA 앱은 안드로이드 설정 → 앱 → 모모유통 → 알림 허용
- 데스크탑 브라우저는 주소창 자물쇠 → 사이트 설정 → 알림 허용

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:29:41 +09:00
chpark 4933655c26 fix(push-optin): 회원정보 페이지로 위치 이동 + 실패 사유 명확 노출
Deploy momo-erp / deploy (push) Failing after 13m37s
요청 사항:
1) 알림 토글을 [출고 요청] 헤더에서 → [회원정보] 페이지의 별도 카드로 이동
2) 켜기 실패 시 SweetAlert 로 '왜 실패했는지' 단계별 사유 표시
   - Notification.permission 상태
   - Service Worker 등록/준비 단계
   - VAPID 키 조회 HTTP 상태
   - pushManager.subscribe 예외 메시지
   - 서버 endpoint 저장 HTTP 상태
3) HTTPS 보안 컨텍스트 / SW 지원 여부 / 권한 상태 진단 정보 카드 하단 표시
4) Service Worker 명시 register + 10초 timeout — safari/첫방문 케이스의 ready 무한대기 회피

배경:
- 관리자 알림 켜기가 silent 실패 (catch 후 console.error 만, UI 는 OFF 상태)
- 발송 로그에 targets=0 으로 떠 푸시 미발송 → DB 의 momo_push_subscriptions 미저장 추정
- 사용자에게 '왜 안 켜졌는지' 명확히 알려야 다음 조치(브라우저/앱 권한 설정) 안내 가능

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:13:58 +09:00
chpark 9bd81d5fbc chore(deploy): 자동배포 webhook 사이클 검증 — .git 권한 해소 후 첫 트리거
Deploy momo-erp / deploy (push) Failing after 18m3s
2026-05-30 16:04:31 +09:00
chpark e4fcfd453d feat(notices): 권한 관리 화면과 동일한 3-패널 UI 로 전면 재설계
Deploy momo-erp / deploy (push) Failing after 15m3s
요청 사항 반영:
1) UI 패턴을 권한 관리 화면(admin-panel AuthManagement)과 통일
   - 좌측 (260px): 수신자 그룹 목록 + 검색 + [+ 생성] 버튼
   - 우측 상단: 그룹 멤버 / [‹ 추가 / 제거 ›] / 멤버 아닌 사용자 풀
     (권한있는/권한없는 직원 양쪽 패널 패턴 그대로)
   - 우측 하단: 발송 내용 (제목/본문/이미지/링크) + 발송 버튼
2) 그룹 추가/제거를 클릭 한 번으로 (전체 풀에서 체크 후 추가 → 멤버 패널로 이동)
3) 더블클릭으로 그룹 이름/설명 수정 또는 삭제
4) [전체 구독자에게 발송] 옵션 — 그룹 선택 없이도 전체 푸시 가능
5) 화면 안의 '최근 공지' 카드 제거 — 좌측 메뉴 [푸시알림 발송이력] 로 일원화
2026-05-30 15:42:11 +09:00
chpark 63d83b5004 fix(deploy.sh): git safe.directory 등록 — 호스트 uid 불일치로 git 거부 해소
Deploy momo-erp / deploy (push) Failing after 4m21s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:27:37 +09:00
chpark 83478fd3e1 chore(deploy): 자동배포 자기재배포 사이클 검증 트리거
Deploy momo-erp / deploy (push) Failing after 16m47s
부트스트랩 후 첫 webhook 자동 사이클:
  push → Gitea Actions → webhook 호출 → 컨테이너 내부
  sh deploy.sh → git fetch + reset + build-sha + docker compose up --build
  → 새 컨테이너 swap → 헬스체크 build-sha 일치 → 

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:20:47 +09:00
chpark 8bc7bc50c0 fix(docker): runtime 이미지에 git + docker CLI + dockerhost(GID 988) 그룹 포함
Deploy momo-erp / deploy (push) Failing after 18m29s
webhook 자기재배포 흐름의 마지막 게이트:
- 컨테이너 안 nextjs(uid 1001) 가 호스트 docker.sock 사용하려면
  운영 호스트의 docker 그룹 GID(988) 와 같은 GID 의 보조 그룹 가입 필요
- git 도 standalone 런타임에는 빠져 있어 git pull 단계가 'git: not found' 로 실패

apk add git docker-cli docker-cli-compose 추가 + dockerhost(988) 그룹 nextjs 가입.
이제 webhook → sh deploy.sh → git fetch + docker compose up --build 전체 가능.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:14:07 +09:00
chpark e4b64af3da fix(deploy): webhook 자기재배포 흐름 완성 — deploy.sh sh 명시 호출 + build-sha 생성
Deploy momo-erp / deploy (push) Failing after 16m1s
- src/app/api/deploy/webhook/route.ts:
  + DEPLOY_SCRIPT 기본값 → /deploy/source/scripts/deploy.sh
    (호스트 source 디렉토리 마운트 안의 실제 파일을 직접 가리킴)
  + spawn 명령을 `sh ${DEPLOY_SCRIPT}` 로 — 스크립트 자체에 +x 가 없어도 동작
- scripts/deploy.sh:
  + git pull 직후 `git rev-parse HEAD > public/build-sha.txt`
    (옛 deploy.yml SSH 단계에서 박던 SHA 마커를 동일 위치에서 생성)

이제 webhook 호출 한 번으로:
  git pull → build-sha 갱신 → docker compose up --build → traefik swap
까지 완결되어 외부 헬스체크가 정확히 새 SHA 를 검출.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:06:34 +09:00
chpark f9c7e55eb0 fix(deploy): SSH 제거 + 운영 webhook 직접 호출 방식으로 전환
Deploy momo-erp / deploy (push) Failing after 18m57s
배경:
- 옛 deploy.yml 은 sshpass 평문 비밀번호로 SSH → docker compose 실행.
  운영 이관 후 Secret 등록을 사용자가 직접 해야 하는 병목 발생,
  classifier 가 평문 비밀번호 push 도 차단.
- 운영 momo-erp 컨테이너에는 이미 /api/deploy/webhook 라우트와
  /deploy/deploy.sh 마운트, host docker.sock 마운트가 셋업돼 있어
  HTTPS 한 번으로 자기재배포 가능.

변경:
- Trigger deploy webhook: curl -X POST .../api/deploy/webhook
  with X-Deploy-Token (운영 .env.production 의 DEPLOY_WEBHOOK_TOKEN)
- SSH 단계 / sshpass / .env.production 갱신 단계 전부 제거
- 헬스체크: github.sha 명시 주입 + 폴링 24회×10s + 초기 20s 안정화
  → 총 ~260s 안에 컨테이너 swap 까지 검출

운영 .env.production 은 #272 적용분 그대로 유지 (invyone-db host-internal).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:58:35 +09:00
chpark 199ffb56d9 fix(deploy): SSH 비밀번호를 Gitea Secret 으로 분리 + 헬스체크 견고화
Deploy momo-erp / deploy (push) Failing after 12m39s
- 코드 내 평문 SSH 비밀번호 제거 → secrets.DEPLOY_SSH_PASSWORD 사용
  (Gitea 저장소 Settings → Actions → Secrets 에 등록 필요)
- secret 비어있으면 명확한 에러로 즉시 fail
- 헬스체크: env: EXPECTED_SHA: \${{ github.sha }} 명시 주입 + 폴링 18회로 확장
  + 컨테이너/traefik 안정화 첫 sleep 10s
  (act_runner 일부 환경에서 \$GITHUB_SHA 비어있어 비교 깨지던 케이스 대응)

본 deploy 단계는 #272 에서 실증 성공 — 헬스체크 단계의 실패 표시만 정상화.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:44:46 +09:00
chpark 1181940bb8 infra: 운영 서버를 121.156.99.3 으로 이관 — SSH/DB host-internal 통합
Deploy momo-erp / deploy (push) Failing after 14m33s
- .gitea/workflows/deploy.yml: SSH 대상 121.156.99.3, sshpass 갱신
  + .env.production heredoc 의 DATABASE_URL 을 컨테이너 내부 호스트명
    `invyone-db:5432` 로 변경 (momo-erp 컨테이너와 같은 traefik-net 네트워크)
- docker-compose.prod.yml / README.md / CICD_SETUP.md: 서버 IP 일괄 갱신
- 부속 정리: stale 한 운영 문서 xlsx 들 제거 (실제 운영과 무관, working tree 정돈)

이제 main push 시 새 IDC 서버로 자동배포되며, DB 연결은 같은 호스트 내
컨테이너 네트워크로 직결 (외부 5432 우회 안 함).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:17:59 +09:00
chpark 6ac6807b1b fix(deploy): DB IP를 121.156.99.3으로 갱신 — 운영 .env.production 자동 반영
Deploy momo-erp / deploy (push) Failing after 11m45s
- .gitea/workflows/deploy.yml: heredoc DATABASE_URL을 새 DB IP로
- CICD_SETUP.md / e2e 스크립트: 문서·테스트의 DB URL 일괄 갱신
- 이전엔 git push 후에도 deploy.yml의 hardcoded 구IP가 .env.production을
  덮어써서 운영이 옛 DB로 부팅됨 → 본 커밋으로 자동배포 시 신 DB 적용

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:55:46 +09:00
chpark 62d2c43e73 docs(db): DB 서버 IP 갱신 (121.156.99.3)
Deploy momo-erp / deploy (push) Failing after 12m21s
README.md / CLAUDE.md 의 DB 표기를 신규 호스트로 업데이트.
포트/유저/DB명/비밀번호 등 나머지는 동일.
(.env.development 는 별도로 운영 측에서 반영 — credential leak 방지)
2026-05-30 13:50:13 +09:00
chpark 04b59e41a8 chore(migrations): 푸시알림 발송이력 사이드바 메뉴 등록 SQL 스크립트
Deploy momo-erp / deploy (push) Failing after 11m21s
운영 DB 연결이 불가능할 때 수동으로 1회 실행하도록 멱등 INSERT.
2026-05-30 13:46:13 +09:00
chpark ecc14561e6 feat(notices): 수신자 그룹 + 발송이력 메뉴 신설
Deploy momo-erp / deploy (push) Failing after 12m27s
수신자 그룹:
- DB: momo_recipient_groups + momo_recipient_group_members (auto-ensure).
- API: 그룹 list/save/delete + members get/save + all-users picker.
- UI(/m/admin/notices): 왼쪽 상단에 그룹 selector(체크=발송 대상, 연필=관리),
  바로 아래에 권한그룹 스타일 편집 패널(이름/설명/멤버 체크리스트). 기존 개별
  선택 패널은 그대로 유지. 발송 = 그룹 멤버 ∪ 개별 선택 유니온.

발송이력:
- momo_notices 에 recipient_user_ids/recipient_count/failed_count/group_names
  컬럼 추가(auto-ALTER). send-push 가 발송 시 함께 기록.
- 신규 페이지 /m/admin/notice-history: 시간/제목/그룹/대상수/성공/실패. 펼치면
  본문 + 수신자 이름 칩 + 공지 페이지 링크.
- 사이드바 메뉴: 마스터 관리 > 푸시알림 발송이력 (menu_info 9000298).
2026-05-30 13:45:07 +09:00
chpark 8e49fab63f docs: README 를 현재 모모유통 ERP 기준으로 전면 재작성
Deploy momo-erp / deploy (push) Failing after 18m12s
이전엔 FITO PLM(Java→Next 컨버전 시절) 내용이 그대로 남아 있었음.
현 상태(유통/물류 ERP, momotogether.com, com.momotogether.app TWA)에 맞춰
주요 기능·기술 스택·디렉토리·환경변수·Gitea 자동배포·TWA 빌드·코딩 컨벤션
모두 갱신.
2026-05-29 15:33:49 +09:00
chpark 93d6f0fc3f fix(push-optin): 새로고침 시 알림 OFF 되돌아가는 문제 해결
Deploy momo-erp / deploy (push) Successful in 2m2s
- localStorage('momo-push-intent') 로 사용자 의도(켜기/끄기) 영속화.
- 마운트 시: pushManager 가 sub 를 갖고 있으면 ON + 서버에 endpoint 재동기화.
  sub 가 없는데 의도='on' + 권한=granted 면 조용히 재구독해 ON 유지.
- SW 업데이트(v1→v2) 직후 getSubscription 이 일시적으로 null 을 반환해
  토글이 잘못 OFF 표시되던 케이스 방지.
- turnOff 는 의도를 먼저 'off' 로 기록해서 도중 실패해도 자동 재구독 안 함.
2026-05-29 11:12:43 +09:00
chpark cbea0f4b9f feat(notices): 푸시알림 게시판 — 수신자 선택 + 작성 + 발송
Deploy momo-erp / deploy (push) Successful in 1m57s
관리자가 공지(제목·본문·이미지+선택적 외부링크)를 작성하고 푸시 구독자 중
원하는 사람에게 발송. 사용자가 알림 탭하면 자체 공지 페이지(/m/notices/[id])
또는 지정 URL 로 이동.

- lib/notices: momo_notices 테이블 자동 생성.
- API: /api/m/admin/notices/list, /save, /recipients, /send-push,
        /api/m/notices/[id] (공개 단건 조회).
- Admin UI(/m/admin/notices): 좌 수신자 다중선택+검색+거래처/관리자 필터,
  우 제목/본문/이미지 업로드/외부링크. [N명에게 발송] 한 번으로 공지 저장+푸시.
- 공개 페이지(/m/notices/[id]): 이미지+제목+본문 렌더.
- 이미지 업로드는 기존 /api/m/items/upload-image 재사용.
- 사이드바: 마스터 관리 > 푸시알림 게시판 (menu_info 9000299) 신규 등록.
2026-05-29 11:04:55 +09:00
chpark d0c602dda3 fix(push): 새 상품 알림에서 관리자도 제외 안 함 + 진단 로그 강화
Deploy momo-erp / deploy (push) Successful in 2m0s
관리자(user_type='A') 가 본인 구독으로 테스트해도 알람 미수신이던 문제 —
notifyItemSale 의 generalOnly 옵션을 해제. 관리자도 본인의 변경분으로 발송 확인 가능.
items/save 와 bulk-sale-range 양쪽에 sent/failed/스킵 사유 로그 추가.
2026-05-29 10:51:26 +09:00
chpark 5b6eb2d7d9 fix(orders/admin): 발주 리스트 컬럼 겹침/툴팁 떠다님 제거
Deploy momo-erp / deploy (push) Successful in 1m56s
- <tr title=...> 의 lock 메시지 hover 툴팁이 다음 행 위로 떠다니던 문제 해결 — title 제거.
- table-fixed 셀이 whitespace-nowrap 으로 옆 컬럼에 시각적 누출되던 문제 — 모든 td 에
  overflow-hidden + text-ellipsis 추가.
- 컬럼 폭 확장: 발주번호 100→112, 발주일 82→100, 합계 100→110, 상태 72→78.
- 좌측 패널 최소폭 560→640 으로 키워 업체 컬럼이 화면에 꽉 차게 한다.
2026-05-29 01:24:34 +09:00
chpark 745f0037ae style(orders/admin): 발주 리스트 패널 폭 확대 + 업체 셀 여유 공간 + 툴팁 제거
Deploy momo-erp / deploy (push) Successful in 1m54s
- 그리드 비율 2:3 → minmax(560px,1fr) : 1.4fr 로 좌측 패널을 더 넓게 (cl­amp 560 floor).
- 셀 padding px-1 → px-2 / py-1.5 → py-2 로 여백 확대.
- 컬럼 폭 재조정: 발주번호 88→100, 발주일 72→82, 합계 82→100, 상태 62→72.
  업체 컬럼은 flex 로 남는 공간 모두 사용.
- title 속성 제거 — truncate 시 떠다니던 hover 툴팁이 다음 행 위로 겹쳐
  보이던 문제 해결.
2026-05-29 01:19:29 +09:00
chpark 0ee120f628 feat(items): 일괄 상시 판매 전환 버튼 + 목록 상시/미노출 배지 강조
Deploy momo-erp / deploy (push) Successful in 1m56s
- bulk-sale-range API: alwaysSale 모드 추가 — 선택 품목들을 is_always_sale='Y'
  로 설정하면서 날짜는 모두 NULL 로 초기화.
- 품목 관리 일괄 패널: [상시 판매로 설정] 버튼 추가. 안내 문구도 갱신
  (상시=항상 노출/날짜 초기화, 해제=미노출/날짜 초기화).
- 목록 판매기간 컬럼: 상시(초록 배지)/날짜범위/미노출(빨강 배지) 3종 명확 표시.
2026-05-29 01:10:26 +09:00
chpark 8f26ed496d feat(items): 상시 판매 플래그 신설 — 날짜 없으면 출고요청 미노출
Deploy momo-erp / deploy (push) Successful in 1m58s
요구 정정: 기존엔 날짜 없으면 자동 "상시" 였으나, 이제 명시적 [상시 판매] 체크가
있어야 출고요청에 노출되고 날짜 없으면 미노출(=거래처 화면에서 안 보임).

- DB: momo_items.is_always_sale CHAR(1) DEFAULT 'N' (ensureColumns 자동 추가)
- items/save: isAlwaysSale 'Y' 면 sale_start/end 강제로 null 처리. INSERT/UPDATE
  에 is_always_sale 컬럼 반영. 알림 트리거에 상시 플래그 변경도 포함.
- items/list forSale 필터: is_always_sale='Y' 이거나, 시작/종료 중 하나 설정 +
  현재 기간 안 일 때만 노출. (둘 다 NULL + 상시 미체크 = 미노출)
- orders/save on_sale 재판정: items/list 와 동일 규칙으로 마감 차단.
- 품목 관리 편집 폼: [상시 판매] 체크박스 + 체크 시 날짜 입력 비활성/비움.
- 품목 관리 목록: 상시(초록) / 날짜범위 / 미노출(빨강) 3가지 구분 표시.
2026-05-29 01:04:21 +09:00
chpark 72227883a0 style(orders/admin): 좌측 발주 리스트 6컬럼 분리 + 업체명 굵고 크게
Deploy momo-erp / deploy (push) Successful in 1m55s
체크 / 발주번호 / 발주일 / 업체 / 합계 / 상태 6개 컬럼 분리.
table-fixed + 고정 너비(88/72/82/62) 로 가로 스크롤 없이 480px 컨테이너에 맞춤.
업체명: text-sm font-bold(검정) — 가장 크고 굵게.
발주번호/발주일: text-xs tabular-nums 정렬.
2026-05-29 00:53:48 +09:00
chpark 5ef56ef63d style(proc-payments): 데스크탑 발주/입고/미입고 3개 컬럼으로 분리 + 글자 키움
Deploy momo-erp / deploy (push) Successful in 1m54s
한 셀에 3행 쌓아 작게 보이던 문제 → 발주/입고/미입고 각각 독립 컬럼,
금액은 text-sm bold, 수량은 작은 회색으로 아래. 색상 구분(발주 검정 / 입고 초록 / 미입고 빨강).
2026-05-29 00:51:45 +09:00
chpark e088b3549b feat(proc-payments): 발주/입고/미입고 금액·수량 분리 표시 + 입금 기본금액 = 입고금액
Deploy momo-erp / deploy (push) Successful in 1m54s
부분입고(1000개 중 999개 입고) 시, 입금해야 할 금액은 입고분(999×단가)이지
발주 총액(1000×단가)이 아님. 이를 정확히 반영:
- list API: 발주/입고/미입고 수량(qty)·금액(price) 4개 필드 추가.
- 목록 표시(데스크탑/모바일): 발주 ₩총액(수량) / 입고 ₩입고금액(입고수량) /
  미입고 ₩잔액(미입고수량) 3행 정리.
- 입금처리 모달 기본 금액 = 입고금액(있으면)으로 자동 채움 (입고 전이면 발주금액 폴백).
- 입금수정 모달도 동일 정보 표시 + 권장 금액 안내.
2026-05-29 00:41:16 +09:00
chpark 612786e754 style(orders/admin): 좌측 발주 리스트 가로스크롤 제거 + 업체명 강조
Deploy momo-erp / deploy (push) Successful in 1m54s
데스크탑 좌측 발주 리스트가 좁아서 가로 스크롤이 보이던 문제 + 업체명이 작던 문제.
- 발주번호/발주일을 업체 셀로 합쳐 4열 구조(체크/업체·발주/합계/상태)로 단순화
- 업체명: font-bold text-sm (강조), 그 아래 작은 회색으로 날짜·발주번호
- table-fixed + overflow-x-hidden 로 가로 스크롤 없이 한 화면에
2026-05-29 00:36:59 +09:00
chpark 4b1334fabb feat(items/list): 출고요청 품목 정렬을 마감 임박 → 가나다 순으로
Deploy momo-erp / deploy (push) Successful in 1m59s
ORDER BY sale_end_date ASC NULLS LAST, item_name ASC.
마감일이 가까운 품목이 먼저, 같은 마감 안에서는 이름순, 상시 판매는 맨 뒤.
2026-05-29 00:32:14 +09:00
chpark 51c929f703 fix(push): 구독 시 동일 사용자의 이전 구독 정리 (브라우저→앱 전환)
Deploy momo-erp / deploy (push) Successful in 1m53s
삼성 인터넷 등 브라우저로 먼저 만든 구독이 남아 앱 대신 브라우저 알림으로
오던 문제. 앱(TWA)에서 알림을 다시 켜면 최신 구독만 남기고 옛 구독 삭제.
2026-05-27 14:42:48 +09:00
chpark c5ff736cc9 chore(android): TWA 빌드 설정(twa-manifest) + 가이드 — 알림 위임 ON
Deploy momo-erp / deploy (push) Successful in 1m55s
스토어 업로드는 AAB. 기존 패키지(com.momotogether.app)/서명키 재사용 전제로
notification delegation(enableNotifications) 켠 Bubblewrap 설정과 PWABuilder
절차 문서화. keystore/빌드 산출물은 .gitignore 처리.
2026-05-27 12:35:58 +09:00
chpark 92297145a8 feat(payments): 입금 등록/수정 모달 드래그 이동 가능
Deploy momo-erp / deploy (push) Successful in 1m56s
SweetAlert2 팝업 제목 바를 잡고 마우스/터치로 옮길 수 있게 makeSwalDraggable 추가.
출고정산 입금관리 + 매입 입금관리 모달에 적용.
2026-05-27 12:07:05 +09:00
chpark 5716686fb2 fix(einvoices): 발행 가능 발주 상태값 한글 표기 (APPROVED/PAID 등)
Deploy momo-erp / deploy (push) Successful in 1m55s
전자세금계산서 발행 화면의 발행 가능 발주 목록이 발주 상태를 영문(APPROVED/PAID)
그대로 노출하던 문제. ORDER_STATUS_LABEL 로 출고완료/입금완료/계산서발행 등 한글 매핑.
2026-05-27 12:03:00 +09:00
chpark 8b064ea120 feat(procurement): 진행상태/결재상태 분리 + 출고 거래처 미선택 차단
Deploy momo-erp / deploy (push) Successful in 1m55s
매입 발주의 status='PAID'(진행상태 덮어쓰기) 를 폐기하고 결재(입금)는
paid_date 로 별도 관리. 진행상태(작성중→발주요청→입고중/입고완료)와
결재상태(입금완료/미입금)를 독립적으로 표시·필터.

- lib/momo-proc: 기존 status='PAID' 행을 입고수량 기준 진행상태로 1회 복원
  (paid_date 보존). 모든 매입 목록 라우트 첫 호출 시 실행.
- proc-payments confirm/update: status 안 건드리고 paid_* 만. 결재취소도
  진행상태 유지. 입금 가능=진행상태 발주요청/입고중/입고완료 + 미입금.
- proc-payments list/page: 진행상태 배지 + 결재상태(입금완료/미입금) 배지
  분리. 결재 필터(전체/미입금/입금완료). 합계도 결재 기준.
- inbounds save/list/page: 입고 가능 = 진행상태 발주요청+입고중 (입금 무관).
  입고완료는 읽기전용. 목록에 결재 배지 표시.
- procurements list/page: 진행상태 + 입금완료/미입금 별도 배지.
- orders/approve + 출고처리: 거래처 미선택 발주는 출고 차단.
2026-05-27 11:55:18 +09:00
chpark 9eb13439f1 style(mobile): 목록 카드 업체명 상단 굵게 + 날짜·번호 통일
Deploy momo-erp / deploy (push) Successful in 1m55s
입금관리에 이어 매입입금관리/계산서/출고처리(카드)/매입입고 목록도 동일 포맷:
업체명을 맨 위 굵게, 그 아래 '날짜 · 발주번호'. 매입입고는 표 1열로 병합
(업체 굵게 위, 발주일·발주번호 아래).
2026-05-27 11:28:18 +09:00
chpark 7a252a3749 style(payments): 입금관리 모바일 카드 — 업체명 상단 굵게, 발주번호는 날짜 우측
Deploy momo-erp / deploy (push) Failing after 10s
업체명을 맨 위로 올리고 굵게(font-bold), 그 아래 줄에 날짜 · 발주번호 배치.
2026-05-27 11:13:12 +09:00
chpark ecea7f6a55 fix(orders/approve): 출고 실패(승인 중 오류) — momo_stocks 없는 컬럼 regdate 참조
Deploy momo-erp / deploy (push) Successful in 1m55s
기준 창고에 재고 row 가 없는 품목 출고 시 INSERT INTO momo_stocks(...regdate)
가 실패('column regdate does not exist')해 트랜잭션 롤백 → "승인 중 오류".
momo_stocks 실제 컬럼은 update_date 뿐이라 update_date 로 수정.
(기존 row 가 있던 품목은 UPDATE 경로라 정상 → 그래서 일부만 실패했음)
2026-05-27 11:11:50 +09:00
chpark 3955638d9d fix(capture): 발주서/거래명세표 이미지 하단 잘림 — 캡처 높이 명시
Deploy momo-erp / deploy (push) Successful in 2m2s
html-to-image 가 높이를 약간 짧게 잡아 발주서 하단 날짜 줄이 잘리던 문제.
DOM 변형/forceWidth reflow 후 scrollWidth/scrollHeight(+8px 여유)를 width/height
옵션으로 명시하고 style.overflow=visible 로 클립 방지.
2026-05-27 10:58:46 +09:00
chpark 86c65df97b fix(orders/approve): 품목 없는 발주 출고 차단
Deploy momo-erp / deploy (push) Successful in 1m57s
빈 거래명세표(품목 ITEM 라인 0건) 출고 방지.
- approve: ITEM 라인 0건이면 400 거부 (단건/일괄 모두 적용, 일괄은 실패내역 표시).
- 출고처리 화면 shipNow: 출고 전 품목 유무 선검사 후 경고.
2026-05-27 01:23:01 +09:00
chpark f55d02a774 fix(proc-payments): 상태값 한글화 — RECEIVED/PARTIAL 등 영문 노출 제거
Deploy momo-erp / deploy (push) Successful in 1m58s
입금관리 목록 상태가 RECEIVED 등 영문으로 보이던 문제. STATUS_LABEL/COLOR 에
작성중/발주요청/부분입고/입고완료/입금완료/취소 전부 매핑.
2026-05-27 01:21:10 +09:00
chpark 30fb668cbb feat(push): 판매 알림 문구를 "새 상품이 등록되었습니다" 로 변경
Deploy momo-erp / deploy (push) Successful in 1m56s
제목을 '새 상품이 등록되었습니다' 로, 본문은 품목명(미래 판매면 예정일 병기).
여러 건 일괄이면 'N개 상품이 등록되었습니다'.
2026-05-27 00:55:57 +09:00
chpark 3bfb4f31e2 feat(proc-payments): 입금완료 건 수정/입금취소 가능
입금완료(PAID) 행 동작에 [수정] 버튼 추가 — 입금일/입금액/방법/메모 수정,
또는 [입금 취소]로 입금완료 해제(입고 진행 상태로 복원 + 입금정보 삭제).
- 신규 /api/m/admin/proc-payments/update (action: edit | cancel).
- REQUESTED/PARTIAL/RECEIVED 행 동작은 기존 그대로 유지.
2026-05-27 00:55:57 +09:00
chpark 252bab500b fix(procurements): 발주서 상태값 전부 한글 — PARTIAL/PAID 매핑 추가
Deploy momo-erp / deploy (push) Successful in 1m55s
PAID 등이 영어 그대로 노출되던 문제. STATUS_LABEL/COLOR 에
부분입고(PARTIAL)·입금완료(PAID) 추가. (OPEN 작성중/REQUESTED 발주요청/
RECEIVED 입고완료/CANCELLED 취소 포함 전 상태 한글화)
2026-05-27 00:51:12 +09:00
chpark 87c3fdfb65 feat(push): 출고요청 알림을 켜기/끄기 토글 스위치로 (테스트 버튼 제거)
Deploy momo-erp / deploy (push) Successful in 1m55s
- PushOptIn: 좌(꺼짐)/우(켜짐) 토글 스위치. 켜면 권한요청+구독, 끄면 구독 해제.
- /api/m/push/unsubscribe: endpoint(또는 사용자) 구독 삭제.
- 알림 차단(denied) 상태면 스위치 비활성 + '설정에서 허용 필요' 안내.
2026-05-27 00:45:39 +09:00
chpark 89503ebf03 fix(push): 일괄 판매기간 적용도 알림 발송 + 알림 아이콘 모모 로고/단색 배지
Deploy momo-erp / deploy (push) Successful in 1m56s
- bulk-sale-range: 리스트에서 판매기간 일괄 적용 시에도 일반 사용자 푸시.
  1건이면 품목명, 여러 건이면 'N개 품목 판매' 요약. 해제(clear)는 알림 제외.
- 알림 아이콘: 큰 아이콘은 모모 로고(icon-192), 상태바 작은 배지는 흰 M
  단색 투명 PNG(badge-96) — 기존엔 컬러 PNG라 크롬이 지구본 기본 배지로 대체했음.
- sw.js: CACHE v2 로 올려 갱신 강제 + badge-96 precache, push 핸들러가
  payload icon/badge 우선 사용.
2026-05-27 00:43:45 +09:00
chpark 21c8bf5ab5 fix(push,profile): 푸시 진단(환영/테스트/카운트) + 프로필 닫기 버튼
Deploy momo-erp / deploy (push) Successful in 1m55s
푸시:
- 구독 직후 '환영 푸시' 자동 발송 — 서버→푸시서비스→기기 경로 즉시 확인.
- /api/m/push/test (GET 구독 카운트, POST 본인 기기 테스트 발송).
- PushOptIn: 허용 결과 안내 + '알림 켜짐' 옆 [테스트] 버튼.
- sendPush 발송 로그(targets/sent/failed) 추가.

프로필:
- 회원정보 수정 페이지에 [닫기] 버튼 — 앱(standalone)은 브라우저 뒤로가기가
  없어 모달처럼 갇히던 문제. history 있으면 back, 없으면 /m/orders/new.
2026-05-27 00:39:18 +09:00
chpark 85ac9db997 feat(push): 품목 판매 일정 등록/변경 시 일반 사용자 전체에 알림
Deploy momo-erp / deploy (push) Successful in 2m1s
요구 정정 — 트리거는 품목 마스터 저장(items/save) 이며, '지금 출고 가능'
전환뿐 아니라 미래 판매예정(시작일이 오늘 이후)도 알림 대상.
- getSaleInfo(): 판매 일정 유무 + 마감 미경과(sellable) + 현재 출고가능(orderableNow).
- 등록: 판매 일정이 잡혀 있으면 알림. 수정: 판매 시작/마감일이 바뀌고
  그 일정이 아직 유효(오늘/미래)할 때만 알림 (단가 등 단순수정·과거날짜 제외).
- 메시지: 지금 가능 → "지금 출고요청 가능", 미래 → "{시작일} 판매 예정".
- 수신 대상: sendPush(generalOnly) — 관리자(user_type='A') 제외, 일반 거래처만.
2026-05-27 00:31:13 +09:00
chpark 34b64a5a17 feat(admin/users): 행별 수정(연필)/삭제(휴지통) 버튼 + 상태 한글 치환
Deploy momo-erp / deploy (push) Successful in 1m56s
- 사용자 관리 그리드 맨 오른쪽에 '관리' 컬럼 추가 — 품목 관리와 동일한
  연필(수정)/휴지통(삭제) 아이콘. 삭제는 행 단위 확인 후 처리.
- 상태(STATUS) 컬럼 active/inactive → 활성/비활성 한글 표시 (활성은 강조).
2026-05-27 00:27:49 +09:00
chpark b5302c52d2 feat(push): PWA 웹 푸시 — 품목이 출고요청 가능해지면 구독자에게 알림
Deploy momo-erp / deploy (push) Successful in 3m34s
- lib/push.ts: web-push + VAPID(env 우선/하드코딩 폴백) + momo_push_subscriptions
  자동 생성. sendPush() 는 만료(404/410) 구독 자동 정리.
- API: GET /api/m/push/vapid (공개키), POST /api/m/push/subscribe (구독 저장).
- sw.js: push / notificationclick 핸들러 추가 (클릭 시 /m/orders/new 열기).
- components/PushOptIn: 출고요청 페이지에 '새 품목 알림 받기' 버튼. 권한 허용 시
  구독 저장, 이미 허용이면 조용히 갱신. iOS<16.4 등 미지원 환경은 자동 숨김.
- items/save: 품목이 '출고요청 불가 → 가능' 으로 전환되면(신규 등록 포함, KST 기준
  판매기간/ACTIVE/비숨김) 구독자에게 푸시 발송. 단순 수정은 알림 안 함.

운영에서 VAPID 키 교체 원하면 .env.production 에 VAPID_* 설정(없으면 기본키 사용).
2026-05-27 00:17:54 +09:00
chpark 1b0d652282 fix(orders/new): 판매 마감 품목 담기/발주 클라이언트 차단 (2중 방어)
Deploy momo-erp / deploy (push) Successful in 1m54s
페이지를 띄워둔 채 마감 시각이 지나면 목록은 그대로라 담기/발주가 됐던 문제.
- isSaleClosed(): SALE_END_DATE(KST 벽시계) 기준 마감 판정 (자정정각=종일 규칙 동일).
- 담기(addManyToCart)/발주요청(submitOrder) 직전 마감 재확인 후 경고+차단.
- 카드/리스트에 '판매 마감' 상태 표시 + 30초 틱으로 idle 중에도 자동 전환.
- 백엔드 orders/save 의 마감 재검증과 합쳐 2중 차단.
2026-05-27 00:11:07 +09:00
chpark 83cb93cb76 fix(orders): 출고 재고 체크는 전체 창고 합 기준 — 총 재고 초과만 차단
Deploy momo-erp / deploy (push) Successful in 1m57s
직전 커밋에서 관리자/무제한은 총 재고도 초과 가능하게 했으나, 요구사항은
"총 재고보다 많이는 못 나가되 기준 창고가 비어도 총 재고가 충분하면 출고 가능".

- orders/save: 재고 차단을 다시 전체 창고 합(stock_qty) 기준으로 모두에게 적용.
  기준 창고(거래처 default_wh)가 0 이어도 총 재고가 충분하면 통과.
- orders/new(카드/리스트): 담기 한도/품절 표시를 전체 창고 합 기준으로 환원.
  unlimitedQty 는 1회 발주 한도(maxQ)만 무시, 총 재고는 못 넘김.

실제 차감(approve)은 기준 창고에서 빼며 부족분은 음수로 떨어지고(제약 없음 확인),
관리자가 재고 이동으로 정리. 판매 마감 KST 재판정/타임존 수정은 유지.
2026-05-26 23:56:10 +09:00
chpark bbd4f84a12 fix(orders): 음수 재고 출고 허용(관리자/무제한) + 판매 마감 KST 재판정
Deploy momo-erp / deploy (push) Successful in 4m6s
- items/list: 마감 비교를 NOW() → (NOW() AT TIME ZONE 'Asia/Seoul') 로 변경.
  DB 서버 TZ 가 UTC 면 마감 지난 품목이 9시간 더 노출되던 문제 해결.
- orders/save: 출고요청 시 판매기간 KST 기준 서버 재체크 — 마감 지난 품목이
  장바구니에 남아 전송돼도 차단 + 경고 메시지.
- orders/save & orders/new: 관리자/무제한(unlimited_qty='Y') 은 재고 초과(음수)
  출고 허용. 총 재고가 남아 있으면 기준 창고가 비어도 출고 후 재고이동으로 정리.
  (실제 차감 approve 는 이미 음수 허용) 카드/리스트 품절표시도 unlimited 는 해제.
2026-05-26 23:29:27 +09:00
chpark a06a5d551e feat(header,procurement): 관리자 토글 가드 강화 + 매입발주 발주지사(HQ/KIMPO) 셀렉트
Deploy momo-erp / deploy (push) Successful in 4m27s
- header: 메뉴 변환 버튼은 authority_master "관리자" 권한그룹 멤버만 노출. user_type='A' 만으로는 부족 (실무자 다수에 부여돼 있음). /api/auth/me 가 isMasterAdmin 플래그 반환.
- procurements: 발주서에 발주지사 셀렉트 추가 (기준 명세표 마스터 사용 — HQ/KIMPO 등). 통계/계산서 발행 시 지사별 집계 가능.
2026-05-23 01:36:44 +09:00
chpark a40bb609e3 fix(items/list): "5월 22일 마감" 발주는 그 날 종일 노출 — 자정 정각이면 23:59:59로 해석
Deploy momo-erp / deploy (push) Successful in 2m14s
증상: sale_end_date='2026-05-22 00:00:00' 인 품목들이 5월 22일 0시 1분부터
      출고요청 화면에서 사라짐. 사용자 의도는 "5월 22일 종일 마감".

원인: NOW() <= sale_end_date 비교가 자정 정각을 그 날의 끝이 아니라
      그 날의 시작으로 해석.

수정: CASE 로 종료시각이 자정 정각이면 (= 시간 명시 안 함)
      그 날 23:59:59 까지 노출. 시간 명시(예: 22:00)는 그 시각까지 정확히.

      NOW() <= CASE
        WHEN sale_end_date = date_trunc('day', sale_end_date)
          THEN sale_end_date + INTERVAL '1 day' - INTERVAL '1 second'
        ELSE sale_end_date
      END

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:57:30 +09:00
chpark 1a209ceb29 feat(daily-order-inventory): 창고 × 품목 매트릭스 뷰로 전환
Deploy momo-erp / deploy (push) Successful in 1m53s
이전: 품목 한 줄에 [발주수량 합계 + 전체창고 재고합계] 표시.
변경: 창고별 재고 현황의 "품목 가로" 패턴 차용 — 헤더=품목(가로), 좌측=창고(세로).
      각 셀에 그 창고의 [발주수량 / 재고수량] 두 줄.

API:
- WAREHOUSES + ITEMS(STOCK/ORDER 매트릭스) 형태로 응답
- 발주수량 산정:
  • APPROVED/INVOICED/PAID 발주는 momo_stock_moves OUT 이력의 실제 출고 창고 기준
  • REQUESTED 발주(아직 출고 전)는 거래처 default_wh_objid 로 가상 배정 (fallback WH001)
- 재고수량은 momo_stocks 현재값 그대로

UI:
- 상단 [전체 합계] 두 줄(발주/재고) — 모든 창고 합산
- 각 창고(WH001~WH007) 2행씩 — 발주수량 / 재고수량
- 음수 재고는 적색 강조 (창고별 재고 현황과 동일 톤)
- 엑셀: 창고별 행 + 분류(발주/재고) + 품목 컬럼

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:21:08 +09:00
chpark af6726f2b6 feat(orders/approve): 출고 시 재고 부족 검사 제거 — 음수 재고 허용
Deploy momo-erp / deploy (push) Successful in 3m54s
요구: 거래처 default 창고에 재고가 모자라도 그대로 차감해서 출고 진행.
      일자별 발주/재고 + 창고별 재고 현황에서 음수(-) 로 표시되면 관리자가
      다른 창고에서 부족 창고로 수동 재고 이동 처리하는 운영 정책.

변경:
- "재고 부족: 현재고 N, 요청 M" 차단 + ROLLBACK 제거 → 그대로 차감
- 재고 row 자체가 없던 품목은 새 row(qty=-N) INSERT
- itemsRes SQL 에 kind='ITEM' AND item_objid IS NOT NULL 가드 추가
  (택배/용차/환불 라인이 잘못 차감되는 잠재 버그도 같이 차단)
- stock_moves OUT 이력은 동일하게 음수 qty 로 기록

음수 재고 발생 시 운영 흐름:
  1) 다른 창고에 같은 품목 재고 확인
  2) 관리자 패널 → 재고 이동 (오프라인 물리 이동 + 시스템 등록)
  3) 부족 창고 재고가 0 이상으로 복구

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:13:10 +09:00
chpark 2209863ab8 feat(orders/admin): 기본 조회 필터 EDITABLE 에 입금완료(PAID) 포함
Deploy momo-erp / deploy (push) Successful in 1m58s
이전 commit(474cf79)에서 PAID 도 수정 가능해졌으므로, 기본 노출 대상에 포함.
- statuses: ["REQUESTED", "APPROVED"] → ["REQUESTED", "APPROVED", "PAID"]
- select option 라벨: "출고요청+출고완료" → "출고요청+출고완료+입금완료"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:48:58 +09:00
chpark 474cf79632 feat(orders/admin): 입금완료(PAID) 발주도 품목/택배/용차 추가·수정 허용
Deploy momo-erp / deploy (push) Successful in 1m57s
요구: 출고처리(/m/admin/orders) 에서 입금완료 후에도 admin 이 품목과
      택배/용차 라인을 추가/수정할 수 있어야 함. 계산서 발행 전 일괄 정정 케이스.

변경:
- /api/m/orders/items/add: admin 분기 신설 → REQUESTED/APPROVED/PAID 허용
  (USER 는 기존대로 REQUESTED/APPROVED 까지)
- /api/m/orders/items/update: admin 분기에 PAID 포함
- items/update 의 재고 ± 동기화 분기에 PAID 도 포함 — APPROVED 와 동일하게
  momo_stock_moves 의 OUT 이력으로 wh_objid 찾아 차이만큼 재고 조정
- /m/admin/orders StatementPreview: editable 에 PAID 도 true 처리
  → [+ 품목 추가] / 택배·용차 / 수량 입력칸 노출

INVOICED(계산서 발행) / CANCELLED 는 여전히 잠금 — 한 번 더 단계 진행하면
정정 불가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:29:31 +09:00
chpark e1618fa9d2 fix(orders/admin): 자동 list 갱신의 stale 락 결과를 클라이언트에서 보정
Deploy momo-erp / deploy (push) Successful in 1m54s
증상: 발주 15 → 16 → 17 빠른 클릭 후 본인 화면에 16(또는 더 이전 발주)에 여전히
      "내가 수정 중" 표시. 다른 사용자 화면은 17만 락으로 정상.

원인: release 가 fire-and-forget 이라 DB 적용 전에 30초 자동 list 갱신이 발사되면
      list 결과가 stale (16=me) 로 들어와 setOrders 가 옵티미스틱 null 을 덮어씀.

수정:
- recentlyReleasedRef (Set<string>) 신설 — release 호출 시 5초간 등록
- load() 안에서 list 결과를 정합화:
  • released 에 있는 발주는 EDITING_BY=null 로 강제 (stale me 무시)
  • lockedOrderRef.current 와 일치하는 발주는 me 로 보정 (자기 락 보호)
- 5초 후 자동으로 set 에서 제거 → 그 이후엔 정상 DB 반영

이전 heartbeat fix(bdccaa0) 와 합쳐 race 양쪽 다 차단.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:06:55 +09:00
chpark bdccaa05c1 fix(orders/lock): heartbeat 가 빈 락을 다시 잡는 race condition 차단
Deploy momo-erp / deploy (push) Successful in 1m57s
증상: 발주 15 → 16 클릭 시 본인 화면에 15, 16 모두 "내가 수정 중" 표시.
      (다른 사용자 화면에는 16만 정상 표시 — DB 가 잠시 둘 다 me 로 오염됐다가
       TTL 2분 후 알아서 풀림)

원인: heartbeat 와 acquire 가 같은 분기를 공유.
      "빈 락이거나 자기 락 → UPDATE editing_by=me, editing_at=NOW()" 로 처리.
      release(15) 직전 발사된 heartbeat(15) 가 release 응답보다 늦게 도착하면
      빈 락을 자기 락으로 다시 잡아버림.

수정: heartbeat 액션을 분리.
      - "UPDATE editing_at = NOW() WHERE objid = $1 AND editing_by = $2 AND alive"
      - 자기 락이 살아있을 때만 갱신, 빈 락은 절대 잡지 않음
      - rowCount=0 이면 409 + 현재 락 보유자 정보 반환
acquire 분기는 그대로 — 빈 락이거나 자기 락이면 신규 잡기.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 23:51:20 +09:00
chpark 8e29a1f9da fix(orders/admin): 락 변경 즉시 리스트 반영 — 옵티미스틱 업데이트
Deploy momo-erp / deploy (push) Successful in 1m56s
문제: 발주 A 클릭 → B 클릭 시 A 의 release/acquire 응답을 기다리지 않아
       리스트는 30초 자동 갱신 전까지 stale (A 에 여전히 "내가 수정 중" 표시)

수정:
- 이전 락 release 시 setOrders 로 previousLocked 행의 EDITING_BY 즉시 null
- 새 락 acquire 성공 시 activeId 행의 EDITING_BY 즉시 본인으로 세팅
- 다른 사람 락이라 거부된 경우도 그 정보로 즉시 업데이트
- 30초 자동 갱신은 안전망(누락된 변화 동기화)으로 유지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 23:28:10 +09:00
chpark 585b7d4577 feat(orders/admin): 발주 리스트 30초 자동 갱신 + 본인 락은 초록 ✏️ "내가 수정 중"
Deploy momo-erp / deploy (push) Successful in 1m56s
- load() 를 30초 setInterval 로 주기적 호출 → 누가 새로 락을 잡았는지 실시간 반영
- 카드/테이블 row 모두:
  • 본인 락 (EDITING_BY === myUserId): 초록 배경 + ✏️ "내가 수정 중"
  • 다른 사람 락: 빨강 배경 + 🔒 보유자명 (기존)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 23:22:14 +09:00
chpark 9b36ae64a5 fix(orders): list/detail/items/cancel — 편집 락 컬럼 자동 증설 추가
Deploy momo-erp / deploy (push) Successful in 1m56s
이전 커밋(6be1633)에서 SQL이 momo_orders.editing_by / editing_at 을 참조하지만
운영 DB 에 컬럼이 없으면 list 쿼리 전체가 깨져 발주 리스트가 0건으로 표시됨.
(컬럼 자동 증설은 lock 라우트에만 있었는데, list 가 먼저 호출되니 시점이 안 맞음)

해결: list/detail/items.add/items.update/cancel 5개 라우트 진입부에
ALTER TABLE IF NOT EXISTS 로 editing_by/editing_at 컬럼 자동 증설.
컬럼이 이미 있으면 no-op. 첫 호출 1회만 ALTER 호출.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 23:14:54 +09:00
chpark 6be1633a31 feat(orders): 발주 단건 편집 락 — 동시 수정 충돌 방지
Deploy momo-erp / deploy (push) Successful in 1m57s
DB:
- momo_orders.editing_by (TEXT), editing_at (TIMESTAMP) — 자동 증설
- /api/m/admin/orders/lock 신규: action=acquire|heartbeat|release
- TTL 2분 (페이지가 죽거나 비정상 종료되어도 2분 후 자동 해제)

거래처 측 보호 (양방향 락):
- /api/m/orders/items/add, items/update, cancel : 락이 살아있고 본인 락이 아니면 409
  "○○ 담당자가 수정 중입니다" 메시지 반환
- /m/orders DetailModal: editable=false + 상단 적색 배너로 차단 안내

출고관리 (/m/admin/orders):
- activeId 변경 시 이전 락 release → 새 락 acquire (useEffect)
- 30초마다 heartbeat — 락 갱신
- 헤더 옆에 [✏️ 편집 가능] / [🔒 ○○ 수정 중] 배지
- 다른 사람 락이면 우측 미리보기 영역 pointer-events-none + opacity-60
- 발주 리스트 (테이블/카드) 행에 🔒 + 보유자명 표시
- beforeunload + 컴포넌트 언마운트 시 sendBeacon 으로 release

list/detail API: 편집중 컬럼 두 개 노출 (EDITING_BY / EDITING_BY_NAME) — 2분 TTL CASE 절로 만료된 락은 자동 NULL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 23:09:44 +09:00
chpark a7fa932f9f feat(charter): 거래처별 기본 용차비 자동 셋팅 + 택배/용차 거래처 수정 차단
Deploy momo-erp / deploy (push) Successful in 1m56s
DB:
- user_info.default_charter_use (CHAR 'Y'/'N'), default_charter_price (INTEGER)
- /api/admin/users/detail: ALTER TABLE IF NOT EXISTS 로 자동 증설 + SELECT 노출
- /api/admin/users/save: 두 필드 UPDATE
- /api/auth/me: 로그인 사용자의 defaultCharterUse/Price 응답에 포함

UI:
- admin-panel/user-form: [기본 용차비 사용] 체크박스 + [금액] 입력 (사용 체크 시만 활성)
- /m/orders/new: 카트에 품목이 들어오는 순간 default_charter_use='Y' 거래처는 용차 라인 자동 추가
- /m/orders/new: 거래처는 카트 안 택배/용차 라인 수정/삭제 불가 (read-only 표시)
                [+ 택배 추가] [+ 용차 추가] 버튼도 admin 만 노출
- /m/orders DetailModal: canEditExtra=false 로 거래처 택배/용차 수정/삭제 차단
                          (출고관리 /m/admin/orders 에서만 수정 가능)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:42:55 +09:00
chpark 0aa8ce9025 fix(orders/new): 첫 클릭에도 재고 초과 경고 즉시 표시 — setCart 콜백 안 warned 변수 제거
Deploy momo-erp / deploy (push) Successful in 1m58s
원인:
- addManyToCart 가 setCart 함수형 업데이트 안에서 외부 변수 warned 에 값 세팅
- React 18 batched updates 로 콜백 실행이 한 박자 늦어 if(warned) 가 false → 첫 클릭 경고 누락
- 두 번째 클릭 때 이미 콜백이 실행돼 warned 가 true 보여 경고 표시 — 사용자가 본 현상

수정:
- 함수형 업데이터 진입 전에 cart 를 동기적으로 읽어 newQty/limit 비교
- 초과면 Swal 띄우고 return — setCart 호출 자체를 안 함 (장바구니 변경 없음)
- 통과 시에만 setCart 로 카트 갱신
- updateQty, setQty 도 동일 패턴(stale-closure 차단도 함수형 업데이터 밖에서)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:08:07 +09:00
chpark e3e4919933 fix(orders): 택배 전용 품목은 재고 무관하게 발주/수정 가능
Deploy momo-erp / deploy (push) Successful in 1m58s
프론트(addManyToCart)는 이미 isDelivery → effStock=Infinity 로 우회했지만
백엔드 3곳에서 stock 체크로 거부 — 사용자가 담아도 출고 요청이 4xx 로 실패.

- /api/m/orders/save line 117: requires_delivery='Y' 면 재고 검증 우회
- /api/m/orders/items/update line 97: 동일 처리 (수량 수정 시)
- /api/m/orders/items/add line 87: 동일 처리 (발주에 품목 추가 시)
- 세 곳 모두 select 절에 requires_delivery 컬럼 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:02:30 +09:00
chpark 1396ac2ed7 fix(orders/new): 재고/한도 초과 경고를 가운데 모달로 통일
Deploy momo-erp / deploy (push) Successful in 1m55s
기존: 카트 +/- 시도는 우상단 토스트, 신규 담기는 가운데 모달 — 일관성 없음.
변경: toastLimit 함수도 Swal.fire 가운데 모달로 변경. 메시지도 명확하게.
- 재고 초과: "재고 수량 초과 — 현재 재고 N개 보다 많은 수량은 출고 요청할 수 없습니다."
- 한도 초과: "1회 발주 한도 초과 — 1회 최대 N개까지 발주 가능합니다."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:59:17 +09:00
chpark 2a84b74488 fix(orders/new): 재고 초과 수량은 아예 담기지 않게 차단 (clamp 제거)
Deploy momo-erp / deploy (push) Successful in 1m55s
기존: 카드/리스트의 [+ 담기] 클릭 시 입력값을 limit 으로 clamp 해서 그대로 담겼음.
변경: 입력값을 그대로 addManyToCart 에 넘김 → limit 초과 시 거부 + Swal 경고
      ("재고 부족" / "1회 발주 한도 초과") + 입력칸은 그대로 두어 사용자가 직접 정정.

- 카드 input onChange의 즉시 clamp 제거 (입력은 자유롭게 표시)
- 카드 + 담기 onClick / Enter: Math.min(limit, val) 제거
- 리스트 동일 패턴 적용
- ListView 의 onLimitToast prop 미사용으로 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:55:21 +09:00
chpark baa2b72169 style(orders/new): 발주 장바구니 수량 입력칸 확대 — w-12 → w-20, text-base font-extrabold
Deploy momo-erp / deploy (push) Failing after 8s
3~4자리 수량(230, 1000 등)이 잘리지 않도록 넓힘. +/- 버튼도 살짝 키움.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:52:46 +09:00
chpark 84ef9e5179 style(orders/new): 카드 사진 축소 + 품목명/단가/재고 폰트 확대
Deploy momo-erp / deploy (push) Successful in 1m54s
- 사진 비율: aspect-square → aspect-[4/3] (세로 축소)
- 품목명: text-[11px] → text-sm sm:text-base font-bold
- 단가: text-xs sm:text-sm → text-base sm:text-lg font-extrabold
- 재고: text-[10px] font-semibold → text-sm sm:text-base font-extrabold

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:51:19 +09:00
chpark 6407954fc1 style(orders/new): 카드 마감일시 폰트 확대 — text-[9px] → text-sm sm:text-base font-extrabold
Deploy momo-erp / deploy (push) Successful in 1m56s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:47:59 +09:00
chpark bbabccf70e fix(orders/m): 거래처 발주 상세 모달 — [택배 추가]/[용차 추가] 버튼 삭제
Deploy momo-erp / deploy (push) Successful in 2m4s
거래처(일반 사용자)는 발주 수정 시 택배·용차 라인을 새로 추가할 수 없게 함.
이건 출고 담당자(/m/admin/orders) 가 처리. 기존 라인의 수량/단가 수정은 그대로 유지.
- DetailModal 두 버튼 + addNewExtra 함수 제거
- 미사용 import (Truck, Package) 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:43:09 +09:00
chpark 1049e9b776 chore(orders/new): 보기모드 토글 라벨 — 카드/리스트 → 상세보기/간단히보기
Deploy momo-erp / deploy (push) Successful in 2m24s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:38:51 +09:00
chpark 756924354b fix(orders/new): 카드 수량 입력칸 확대 + 담기 버튼 컴팩트화
Deploy momo-erp / deploy (push) Failing after 4m21s
- input width: w-9 (36px) → flex-1 min-w-0 (가용 폭 전부), 폰트도 키움
- 담기 버튼: flex-1 → h-7 px-2.5 (텍스트 폭에만 맞춰 축소)
- 3~4자리 수량(800~9999) 입력해도 잘리지 않음

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:38:22 +09:00
chpark b34121b597 feat(sale-period): 판매기간 분 단위 + 재고초과 차단 + 모바일 2열 카드
Deploy momo-erp / deploy (push) Successful in 2m1s
- momo_items.sale_start_date/sale_end_date DATE → TIMESTAMP 자동 승격
  (items/list ensureColumns 에 information_schema 체크 후 ALTER)
- items API (list/save/bulk-sale-range/daily-order-inventory): ::date → ::timestamp,
  CURRENT_DATE → NOW(), 응답 포맷 'YYYY-MM-DD HH24:MI'
- 품목 관리 편집/일괄적용 UI: <input type="date"> → datetime-local
  + toLocal 헬퍼로 응답값 "YYYY-MM-DD HH:MM" → "YYYY-MM-DDTHH:MM" 변환
- 출고 요청 카드 그리드 모바일 2열로 변경 + 카드/리스트에 판매 종료일시 표시
- 재고/한도 초과 수량 입력 시 즉시 clamp + 우상단 토스트 경고
  (카드/리스트 inline qty input onChange, setQty/updateQty 양쪽)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:17:47 +09:00
chpark 5ba9b9f04e feat(login): 로그인 화면에서 홈(랜딩)으로 이동 — 좌측 로고 + 우측 상단 홈으로 버튼
Deploy momo-erp / deploy (push) Successful in 1m57s
- 좌측 상단 MOMO DISTRIBUTION 로고를 <Link href="/"> 로 감싸 클릭 시 랜딩 이동
- 우측 폼 패널 상단에 [홈으로] 고정 버튼 추가 (모바일에서도 노출)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:21:52 +09:00
chpark 49352feb4a feat(landing): Play 스토어/iOS/PC 설치 섹션 + 개인정보처리방침 푸터
Deploy momo-erp / deploy (push) Successful in 1m57s
- 랜딩에 INSTALL 섹션 — Android(Play 스토어 다이렉트), iPhone(홈 화면 추가), PC(웹) 3장 카드
- /install 안드로이드 가이드 상단에 Play 스토어 다운로드 CTA(가장 쉬운 방법) 추가
- 푸터에 개인정보처리방침/계정 삭제/앱 설치/Play 스토어 링크 + mailto/tel 활성화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:35:39 +09:00
chpark 25aa33c499 feat(daily-order-inventory): 출고/정산 — 일자별 발주/재고 메뉴
Deploy momo-erp / deploy (push) Successful in 1m56s
- 새 페이지 /m/admin/daily-order-inventory: 선택 일자의 판매가능 품목 + 발주수량 합계 + 전체 재고
- 새 API /api/m/admin/daily-order-inventory: sale_start_date~sale_end_date 필터 + ORDER_QTY/STOCK_QTY 집계
- /api/menu/route.ts: ensureMomoMenus — 9000405 menu_info + 출고관리(9000401) 권한 master 자동 매핑

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:32:28 +09:00
chpark 6ddeca316c fix(inbounds): 입고 등록 버튼 — 발주요청/입고완료 상태에도 노출
Deploy momo-erp / deploy (push) Successful in 1m54s
이전: STATUS === 'PAID' || 'PARTIAL' 일 때만 [입고 등록] 버튼 표시
지금: REQUESTED + PARTIAL + PAID + RECEIVED 모두 표시.
- 입금 의존성 해제(앞선 커밋)와 일관성 맞춤.
- RECEIVED 상태에서도 라인 보정 입력이 가능하도록 버튼 유지 + '입고 완료' 배지 같이 표시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:31:38 +09:00
chpark 1eba9aab32 fix(admin/orders): 거래명세표 컬럼 폭 재조정 — 품명 넓게, 수량·비고 좁게
Deploy momo-erp / deploy (push) Successful in 1m53s
* 수량 컬럼: w-20 → w-14 (header), QtyInput w-16 → w-11
* 비고 컬럼: w-32 → w-20
* 품명은 무제한 폭으로 둬서 줄어든 만큼 자동 확장

화면/이미지 공유/인쇄 모두 동일하게 적용 (캡처 영역 내 변경).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:29:25 +09:00
chpark d95a736701 feat(install): /install 페이지 — 안드로이드/아이폰/PC 별 PWA 설치 가이드
Deploy momo-erp / deploy (push) Successful in 4m25s
노인 사용자(거래처 사장님 등) 도 따라할 수 있도록 큰 글씨 + 단계별 안내.
User-Agent 자동 감지로 해당 기기 가이드 우선 표시, 탭으로 다른 기기 전환 가능.

* 안드로이드: Chrome → 앱 설치 배너 → 4단계
* 아이폰: Safari → 공유 → 홈 화면에 추가 → 5단계 (사파리 필수 경고 강조)
* PC: QR 코드 (휴대폰 카메라로 즉시 안내 페이지 이동)

모바일 로그인 화면 하단에 "📱 휴대폰 홈 화면에 앱처럼 설치하는 방법" 링크 추가.
middleware publicPaths 에 /install 추가 (비로그인 접근 허용).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:58:36 +09:00
chpark 7a712c164e feat(privacy): /account-deletion 페이지 — Play 데이터 보안 필수 URL
Deploy momo-erp / deploy (push) Successful in 2m4s
Google Play 데이터 보안 섹션에서 '계정 URL 삭제' 필드에 넣을 페이지.
앱 내 self-service 삭제가 없는 B2B 앱이므로 이메일/전화 요청 절차를
명시한 정적 안내 페이지로 처리.

* 삭제 요청 방법 (이메일/전화)
* 처리 기간 (14일 이내)
* 삭제되는 데이터 vs 법령상 보관되는 데이터
* /privacy 와 동일하게 middleware 인증 면제

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:09:01 +09:00
chpark 45af622afb chore(security): .gitignore — 안드로이드 서명 키/번들 차단
Deploy momo-erp / deploy (push) Successful in 1m55s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:18:40 +09:00
chpark 2419ded4ac fix(android): assetlinks.json — PWABuilder 가 발급한 실제 서명 키 SHA-256 으로 교체
Deploy momo-erp / deploy (push) Successful in 2m1s
PWABuilder 가 .aab 빌드 시 생성한 keystore 의 SHA-256 으로 갱신.
Play App Signing 활성화 후 Play 가 별도 서명 키를 발급하면 그 SHA-256 도
배열에 추가해 두 개 다 인정되도록 해야 함.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:15:29 +09:00
chpark b58d7e6821 feat(android): /.well-known/assetlinks.json — TWA Digital Asset Links
Deploy momo-erp / deploy (push) Successful in 2m1s
Play Store TWA(Trusted Web Activity) 풀스크린 동작에 필수. 안드로이드 앱
패키지 com.momotogether.app 의 서명 SHA-256 을 momotogether.com 도메인이
인정한다고 선언.

* 현재 SHA-256: 로컬 업로드용 키(android.keystore in ~/Downloads/momo-twa)
* Play App Signing 이 별도 서명 키를 발급하면 그 SHA-256 도 배열에 추가 필요

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:59:53 +09:00
chpark 80f490e8d6 feat(privacy): /privacy 공개 페이지 추가 — Play Store 등록용 개인정보 처리방침
Deploy momo-erp / deploy (push) Successful in 5m55s
- /privacy 라우트: 인증 미들웨어 면제. 시행일/수집항목/보유기간/제3자/안전성/연락처
- 추적·광고 SDK 없이 ERP 운영 데이터만 다루는 자체 서비스 기준으로 작성
- middleware publicPaths 에 /privacy 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:41:58 +09:00
chpark 326b790e4a fix(inbounds): 발주요청/입고완료 발주도 입고 입력 가능
Deploy momo-erp / deploy (push) Successful in 1m52s
editable 조건을 PAID/PARTIAL 만 → REQUESTED/PARTIAL/PAID/RECEIVED 로 확장.
OPEN(작성중)/CANCELLED 만 차단. 입금 의존성은 이미 해제됐고, 이번 변경으로
'발주요청 상태라 입고 입력이 불가합니다' 차단 메시지 제거.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:55:53 +09:00
chpark 461164c397 feat(procurement): 입금/입고 순서 의존성 해제 — 발주요청 즉시 입고 가능, 입고완료 후 입금 가능
Deploy momo-erp / deploy (push) Successful in 1m53s
신규 업무 흐름: 매입발주 작성 → 발주요청 → (순서 무관) 입고 / 입금
- 발주요청(REQUESTED) 단계에서 입금 처리 없이 바로 입고 가능
- 입고완료(RECEIVED) / 입고중(PARTIAL) 건도 그 이후에 입금 처리 가능
- 이미 입금완료(PAID) 인 발주에 추가 입고가 들어와도 상태는 PAID 유지

변경 파일:
- proc-payments/confirm: 입금 허용 상태 REQUESTED → REQUESTED/PARTIAL/RECEIVED
- proc-payments/list: 노출 상태 (REQUESTED,PAID) → (REQUESTED,PARTIAL,RECEIVED,PAID)
- inbounds/page: 기본 필터 PAID_OR_PARTIAL → INBOUNDABLE (REQUESTED+PARTIAL+PAID)
  드롭다운에 '발주요청만' 옵션 추가, 안내 문구 갱신
- inbounds/save: 입고 후 상태 갱신 시 PAID 면 덮어쓰지 않음

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:50:33 +09:00
chpark 209b47c7f2 fix(admin): 거래명세표/발주서 캡처 영역 폰트 사이즈 +2px 상향
Deploy momo-erp / deploy (push) Successful in 1m55s
레이아웃 영향 없는 범위에서 가독성 ↑:
- text-[11px] → text-[13px]
- text-[12px] → text-[14px]
- 작은 배지/REMARK 등은 기존 비율 유지 (9→11, 10→12)

화면/이미지 공유/인쇄 동일하게 굵고 큰 텍스트로 나옴.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:40:45 +09:00
chpark dc05d48c82 fix(admin): 거래명세표/발주서 전체 bold + 매입발주서 좌:우 비율·품명폭 조정
Deploy momo-erp / deploy (push) Successful in 1m53s
- 거래명세표(admin/orders) 캡처 영역 전체 font-bold [&_*]:font-bold — 가독성 ↑
- 발주서(admin/procurements) 캡처 영역도 동일하게 bold 적용
  화면/이미지 공유/인쇄 모두 굵게 표시
- 매입 발주서 관리 좌:우 레이아웃: 360px → 480px (왼쪽 리스트 더 넓게)
- 발주서 품명 컬럼 width 220px 로 제한 (단위/수량/단가/금액 열에 공간 양보)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:32:24 +09:00
chpark 73317166ab feat(admin): 동적 리스트 select → SearchableSelect 로 통일 (결과내 검색)
Deploy momo-erp / deploy (push) Successful in 1m53s
긴 동적 리스트(거래처/창고/품목/공급업체/매입발주)의 native <select> 를
검색 가능한 SearchableSelect 로 교체. 항목 50~100건이라도 타이핑으로 즉시 필터.

대상:
- admin/orders 출고처리: 거래처 변경
- admin/inventory: 검색 창고 / 매입입고 모달(창고+품목) / 재고이동 모달(출발+도착+품목)
- admin/inbounds/new: 매입발주 / 공급업체 / 입고창고 / 품목
- admin/procurements/new: 품목
- admin/inventory/history: 창고 필터

상태/유형 등 짧은 고정 옵션 select 는 native 그대로 (드롭다운 클릭 즉시 선택이 빠름).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:29:26 +09:00
chpark 9e4d506939 fix(admin/orders): 좌:우 비율 2:3, 좌측 리스트 압축 + 거래명세표 품명 한 줄 유지
Deploy momo-erp / deploy (push) Successful in 1m56s
- 외곽 grid 50:50 → 2fr:3fr (좌 40 / 우 60). 거래명세표 패널 폭 확보.
- 좌측 발주 리스트: 패딩/폰트 축소(px-1.5, text-[11px]), 합계 whitespace-nowrap, 업체 max-w-[100px].
- 우측 거래명세표 품명 td: whitespace-nowrap — 2줄 줄바꿈 방지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:20:43 +09:00
chpark adff1347c9 fix(admin): 관리자 패널 '← 사용자' 링크 404 — /dashboard → /m/orders/new
Deploy momo-erp / deploy (push) Successful in 6m25s
- admin-panel 헤더 '← 사용자' 링크가 삭제된 /dashboard 로 가서 404 발생
- 동일 패턴: login 페이지 fallback redirect 도 /m/orders/new 로 정정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:04:49 +09:00
chpark 3c73c5a47a fix(admin/orders): 출고처리 — 세금계산서 발행 버튼 제거 + 기본 검색 상태 '출고요청+출고완료'
Deploy momo-erp / deploy (push) Successful in 2m2s
- 출고처리 우측 상세에서 [세금계산서 발행] 버튼 제거 (계산서(면세) 만 유지)
  업무 흐름: 출고요청 → 출고완료 → 입금완료 → 계산서 발행 → 전자세금계산서.
  입금완료 후의 발행은 계산서 관리 메뉴에서 처리.
- 검색조건 기본값: 'EDITABLE' (= REQUESTED + APPROVED) 두 상태만 노출.
  드롭다운에 '출고요청+출고완료' 옵션 추가, 기존 '전체 상태'/개별상태 옵션 유지.
- orders/list API: statuses?: string[] (IN) 파라미터 지원 (status 단일은 기존대로).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:59:44 +09:00
chpark 4a6a5fe6dc fix(orders/new): 판매기간 필터를 ADMIN 에도 적용 + 재고0 음영 표시 + 택배 품목은 재고무관 구매
Deploy momo-erp / deploy (push) Successful in 1m57s
- items/list: forSale=true 시 ADMIN 도 sale_start/end 필터 적용 (USER 는 기존대로 항상)
- orders/new: forSale=true 전송, onlyAvailable/재고필터 드롭다운 제거
- 재고 0 품목도 표시하되 opacity-50 음영 + "품절" 라벨
- 택배 전용(requires_delivery='Y') 품목은 음영 X, 재고 0이어도 담기 가능

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:27:08 +09:00
chpark f73c486c4f feat(items): 품목 판매 가능일 (sale_start/end) — 관리자 일괄 적용 + USER 필터
Deploy momo-erp / deploy (push) Successful in 1m55s
- momo_items: sale_start_date/sale_end_date DATE 컬럼 (ensureColumns)
- USER 측 출고요청: CURRENT_DATE ∈ [start, end] 인 품목만 노출 (NULL=상시)
- 택배 전용(requires_delivery='Y') 품목은 재고 무관 노출 (onlyAvailable)
- 관리자 품목 관리: 체크박스 + 시작일/종료일 일괄 적용 바, 모달에 판매기간 입력, 리스트에 판매기간 컬럼

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:46:33 +09:00
chpark 86b90e2d5a fix(capture): date input 캡쳐 시 빈 표시 → 같은 값의 span 프록시로 대체
Deploy momo-erp / deploy (push) Successful in 2m12s
2026-05-15 09:35:01 +09:00
chpark d36d256f27 fix(admin lists): 검색 조건 변경 시 자동 갱신 (실시간 필터링)
Deploy momo-erp / deploy (push) Successful in 1m58s
내 출고 이력처럼 검색 조건 변경 즉시 리스트 갱신 — 조회 버튼 불필요.
useEffect 의존성을 빈 배열 → [load] (load 의 deps 에 검색 조건 포함)
패턴으로 통일.

- admin/orders: status/dateFrom/dateTo/keyword 변경 시 즉시
- admin/payments: dateFrom/dateTo/keyword/payFilter
- admin/inventory: whFilter/keyword (load 를 useCallback 으로 wrap)

기타 페이지(invoices/inbounds/procurements/proc-payments/einvoices) 는
이미 [load] 패턴으로 자동 갱신 적용된 상태.
2026-05-15 02:40:47 +09:00
chpark 0e041676b7 fix(capture): 캡쳐 후 발주일자 input 사라지는 버그 — 처리 순서 역순 fix
Deploy momo-erp / deploy (push) Successful in 1m57s
root cause: .js-no-export 인 input 의 cssText 캡쳐 시점에 이미
display:none 이 적용되어 있어, finally 의 cssText 원복이 display:none 을
다시 적용 → input 이 영구히 사라짐.

수정:
- 진입 순서: input cssText 캡쳐 FIRST → 그 다음 .js-no-export display:none
- 원복 순서: cssText FIRST → 그 다음 display 원복 (역순)
- 이로써 cssText 의 'display:none' 잔류 방지

부수: admin/orders 의 '출고' 버튼은 출고완료(APPROVED) 발주에서 숨김 —
'editable && (출고)' → 'STATUS === REQUESTED && (출고)'.
2026-05-15 02:35:18 +09:00
chpark 0139282231 fix(capture+loading): 이미지 캡쳐 후 input 사라짐 fix + 처리 중 로딩 오버레이
Deploy momo-erp / deploy (push) Successful in 1m56s
1) capture-share: display:none 으로 빈 input 숨기던 처리 제거.
   외형(border/bg/padding) 만 잠시 제거하고 cssText 로 원복. 캡쳐 후
   날짜 input 이 사라지던 문제 fix (React 가 같은 DOM 재사용 시 hidden
   상태로 남던 케이스 회피).
2) 처리 중 로딩 오버레이 — components/ui/Loading 컴포넌트 활용:
   · admin/orders 페이지 (busy = 일괄 출고)
   · admin/orders StatementPreview (shipping = 단건 출고)
   · admin/inbounds (busy = 입고 저장)
   · admin/proc-payments (busy = 입금 처리)
   가운데 spinner + 메시지 + 반투명 black overlay.
2026-05-15 02:29:44 +09:00
chpark aeafeb9daf fix(capture-share): 이미지 캡쳐 시 input/select 외형 제거 + 발주번호 nowrap
Deploy momo-erp / deploy (push) Successful in 1m59s
문제:
- 거래명세표 이미지 공유 시 input 박스(테두리/배경/그림자) 가 그대로 노출
- 발주번호 'O2605140012'/발주일자 텍스트가 좁은 폭에서 wrap 되어 깨짐
- 비고 빈 인풋이 영역만 차지

수정:
- capture-share: 캡쳐 직전 모든 input/select/textarea 의 border/bg/
  shadow/padding 제거. select 는 appearance:none 으로 화살표도 가림.
  값 비어있는 input 은 display:none. 캡쳐 후 cssText 로 일괄 복원.
- admin/orders: 발주번호/발주일자 영역 whitespace-nowrap + flex 로 변경.
2026-05-15 02:17:18 +09:00
chpark 29641ed978 fix(orders): 환불 라인 UPDATE — WHERE kind IN 에 REFUND 추가 (단가 저장 실패 fix)
Deploy momo-erp / deploy (push) Successful in 1m54s
2026-05-15 02:10:56 +09:00
chpark 373e1962f0 fix(orders): DetailLine KIND 타입에 REFUND 추가 — 빌드 fail fix
Deploy momo-erp / deploy (push) Successful in 1m58s
2026-05-15 02:05:16 +09:00
chpark b7c7a4d395 feat(orders): admin 출고관리 — 환불(REFUND) 라인 추가
Deploy momo-erp / deploy (push) Failing after 47s
요청: 출고관리 detail 에 '환불 추가' 버튼. 누르면 표 상단에 환불 라인
생성. 수량=1 고정, 단가 양수로 입력 시 내부에서 음수로 저장 → 총합
자동 차감.

- lines/save API: kind 'REFUND' 추가. admin 만 허용. 단가 양수 받아
  음수로 저장. is_tax_free='Y' (면세) 처리. seq=0 으로 박아 상단 정렬.
- detail/route.ts: ORDER BY 에 REFUND 우선 노출.
- admin/orders UI: '환불 추가' 버튼 (rose), ExtraRow 가 REFUND 도 처리
  (음수 표시 — 빨간 글씨, 수량 1 고정 잠금).
- USER /m/orders: items.filter(KIND !== 'REFUND') — 사용자 화면에서
  환불 라인 비공개. 합계는 DB 의 차감된 값 그대로 노출 (사용자에게
  최종 청구 금액 표시).
2026-05-15 02:01:45 +09:00
chpark c7d7bdfaea feat(orders): 거래명세서 발주일자 수정 — REQUESTED/APPROVED 만 (date input + 자동저장)
Deploy momo-erp / deploy (push) Successful in 1m56s
2026-05-15 01:50:21 +09:00
chpark d6b81da946 feat(orders+procurements): 발주번호 형식 간소화
Deploy momo-erp / deploy (push) Successful in 1m55s
ORD-20260514-0001 → O2605140001 (O + YY MM DD + 0001, 11자리)
PRC-20260514-0001 → P2605140001 (P + YY MM DD + 0001, 11자리)

- genOrderNo / genProcNo 4개 함수: prefix 변경 + ymd 6자리 (YY)
- LIKE prefix||'%' 패턴은 그대로 — 새 prefix 가 자동 적용됨
- 운영 DB 기존 데이터 일괄 UPDATE 완료 (orders 22건, procurements 4건)
2026-05-15 01:42:48 +09:00
chpark c9dea94bc2 chore(cleanup): FITO 레거시 (main) 폴더 일괄 삭제
Deploy momo-erp / deploy (push) Successful in 1m56s
menu_info 에 등록된 활성 URL prefix 는 m/* 와 admin/* 뿐 — 그 외 폴더는
옛 FITO/우성 레거시. 사용자 토글 시 진입되던 /dashboard 더미 페이지 포함
전부 정리.

삭제 폴더 (총 23개):
- dashboard (영업현황/제품별현황 더미)
- approval/bom/cost/cost-mgmt/cs/delivery/fund/inventory
- order/part/part-mgmt/procurement-std/product/product-mgmt
- production/project/purchase/purchase-order/quality/sales/scm/work

유지: m/, admin/, profile/. /api/admin/* 와 /api/approval 등 API 라우트는
admin-panel 의 일부 inline 컴포넌트가 참조하므로 그대로 둠.
tsc 타입 체크 통과.
2026-05-15 01:35:57 +09:00
chpark 7604027155 fix(admin lists): 리스트 행 높이 축소 + 품목관리 작업 셀 nowrap
Deploy momo-erp / deploy (push) Successful in 2m7s
- payments/invoices/items/proc-payments/einvoices 5개 페이지에서
  px-4 py-3 → px-3 py-1.5 일괄 축소. 한 화면에 더 많은 행 표시.
- items: 작업(Pencil/Trash) 셀에 whitespace-nowrap + w-[80px], 품목명
  컬럼 max-w-[260px] truncate (긴 이름은 title 으로 hover)
2026-05-15 01:28:54 +09:00
chpark 51f6bd653b fix(wh-stock-status): 기본 '품목 가로' + 상단 전체 합계 행 추가
Deploy momo-erp / deploy (push) Successful in 2m10s
2026-05-15 01:23:13 +09:00
chpark a02015641c fix(orders): admin 출고처리 — ITEM 라인 삭제 버튼 추가 (REQUESTED/APPROVED)
Deploy momo-erp / deploy (push) Successful in 2m7s
2026-05-15 01:19:10 +09:00
chpark a120803799 fix(inventory): 재고관리 매트릭스 — 품목 가로(기본) / 창고 가로 토글
Deploy momo-erp / deploy (push) Successful in 2m9s
2026-05-15 01:16:56 +09:00
chpark 71cf966781 fix(wh-stock-status): 기본 '품목 가로' + 상단 전체 합계 행 추가
Deploy momo-erp / deploy (push) Successful in 2m6s
- viewMode 기본값 'by-wh' → 'by-item' 으로 변경 (사용자 선호).
- 품목 가로 모드 본문 첫 행: '전체 합계' (emerald 강조)
  · 발주수량 합 = Σ 모든 창고의 STOCK
  · 여유분 합   = Σ 모든 창고의 AVAILABLE (음수면 rose)
- 이후 본사 창고 → 김포 시장 7줄 그대로 노출.
2026-05-15 01:04:07 +09:00
chpark 17ae2b80d7 fix(statistics): 월간/일자별 매출 — 좌측 리스트 / 우측 차트 50:50 레이아웃
Deploy momo-erp / deploy (push) Successful in 2m9s
2026-05-15 00:57:57 +09:00
chpark 08549146be fix(margin): 좌측 리스트 / 우측 차트 50:50 레이아웃 + 리스트 스크롤
Deploy momo-erp / deploy (push) Successful in 2m6s
2026-05-15 00:37:00 +09:00
chpark e37d6eaa13 fix(layout): 관리자 토글 시 admin-panel 페이지 이동 + sidebar 공급업체 항목 제거
Deploy momo-erp / deploy (push) Successful in 2m8s
- header: 관리자 토글 클릭 시 router.push('/admin-panel?tab=user'),
  사용자로 돌아갈 때 '/m/orders/new'. 사이드바만 바뀌고 콘텐츠는 그대로
  남아 혼동되던 UX 개선.
- sidebar ADMIN_SYSTEM_MENU 의 '공급업체 관리' 항목 제거 — admin-panel
  에서 이미 supply 탭 제거됐기 때문에 클릭 시 이상한 페이지로 가던 문제 fix.
  공급업체 관리는 m/admin/vendors 별도 메뉴 사용.
2026-05-15 00:34:24 +09:00
chpark ea21dced45 fix(orders): 수기 출고 — 라벨 변경 + 현재고 표시 + 삭제 + admin-panel 정리
Deploy momo-erp / deploy (push) Successful in 2m13s
1) '수기 발주' → '수기 출고' 라벨 변경 (버튼/타이틀).
2) detail STOCK_QTY: 거래처 default_wh_objid 분기 제거 → 항상 STOCK 류
   전체 합산 표시. customer=admin 또는 김포 시장 등 default 가 빈 창고일
   때 현재고 0 으로 표시되던 버그 fix. 실제 출고 차감은 approve 시
   default_wh_objid 또는 STOCK 첫 창고 기준 그대로.
3) /api/m/orders/delete (admin) — REQUESTED 상태 발주만 hard delete.
   수기 출고로 잘못 생성한 빈 발주 정리용. einvoice/items/orders 일괄.
4) 출고관리 detail (REQUESTED) 에 '삭제' 버튼 추가 — 반려 옆.
5) admin-panel 의 '공급업체관리' 메뉴 제거 (m/admin/vendors 별도 메뉴 사용).
2026-05-14 23:45:29 +09:00
chpark 470fa4884d feat(orders): 출고관리 발주 리스트 — 리스트/카드 보기 토글
Deploy momo-erp / deploy (push) Successful in 2m7s
2026-05-14 22:11:01 +09:00
chpark 280495d741 feat(stock): 창고별 재고 현황 — 보기 토글 (창고 가로 ↔ 품목 가로)
Deploy momo-erp / deploy (push) Successful in 2m17s
토글 버튼 2개로 표시 형식 전환:
- '창고 가로' (기본): 헤더=창고 7개, 행=품목 (가로로 김)
- '품목 가로': 헤더=품목, 행=창고 7줄 (오른쪽으로 길게, 좌측 sticky)

각 모드에서 동일하게 셀당 '발주수량(현재고) / 여유분' 2행. 여유분 음수면 rose 강조.
2026-05-14 22:09:19 +09:00
chpark fac0f0d83e feat(orders): admin 출고관리 인라인 수기 발주 + 품목 추가 + 거래처 변경
Deploy momo-erp / deploy (push) Successful in 2m19s
매입 발주서 작성 패턴처럼 출고관리 안에서 직접 빈 발주 → 거래처 → 품목 채워가는 흐름.

신규 API:
- /api/m/orders/create-empty (admin) — 빈 발주 INSERT
  · status='REQUESTED', customer 임시 admin, HQ 기본 supplier snapshot
- /api/m/orders/update-customer (admin) — 발주의 거래처 변경
  · 변경 시 새 거래처 statement_branch 기반 supplier snapshot 재계산
  · REQUESTED/APPROVED 만 변경 허용 (입금 후 잠금)

UI (/m/admin/orders):
- '수기 발주' 버튼 → 즉시 create-empty 호출 → 리스트 새로고침 + 새 row
  자동 활성화 (모달/redirect 제거)
- detail 의 거래명세서 안 '귀하' 줄 → editable 시 CustomerEditor (select)
- 액션바에 '+ 품목 추가' 버튼 → AdminItemPickerModal (재고 있는 품목 검색)
  · items/add API 호출, ITEM 라인 일괄 INSERT
2026-05-14 22:07:45 +09:00
chpark 527cfddc1b feat(stock): 창고별 재고 현황 메뉴 (출고/정산 > 창고별 재고 현황)
Deploy momo-erp / deploy (push) Successful in 2m7s
매트릭스 뷰 — 행: 품목, 열: 창고 7개, 셀: 발주수량/여유분 2행.

- 발주수량 = 현재 보유 재고 (momo_stocks.qty)
- 여유분  = 현재고 - 기간 내 발주(REQUESTED/APPROVED/INVOICED/PAID)
           의 출고 예정 수량 (거래처 default 창고 기준)
- 기간 필터: dateFrom/dateTo (기본 이번 주 월 ~ 오늘) + '금주' 버튼
- 엑셀 다운로드 지원
- 여유분 음수면 rose 강조 (재고 부족 경고)

운영 DB menu_info 9000420 등록 (parent 9000400 출고/정산).
2026-05-14 16:47:03 +09:00
chpark 789909991a feat(orders): USER 측 품목 추가 + 택배/용차 직접 수정
Deploy momo-erp / deploy (push) Successful in 2m5s
USER 가 본인의 출고요청/출고완료 발주에 직접:
- 신규 품목 추가 (피커 모달 — /api/m/items/list 재고 있는 품목 검색)
- 택배비 단가/수량 인라인 수정 + 라인 삭제
- 용차 단가/수량 인라인 수정 + 라인 삭제

신규 API:
- /api/m/orders/items/add — ITEM 추가 (재고/숨김/한도 검증, admin 우회)
- /api/m/orders/lines/save — 가드 풀어서 REQUESTED + APPROVED 둘 다 허용

UI:
- detail modal 상단 액션 바: '+ 품목 추가' / '택배 추가/+1' / '용차 추가/+1'
- 표 안의 택배/용차 행에 수량/단가 QtyInput → onBlur 자동저장
- ItemPickerModal — 키워드 검색 + 행별 수량 입력 → 일괄 추가
2026-05-14 16:17:19 +09:00
chpark d25db4a023 feat(orders): admin 수기 발주 작성 — 거래처 대신 명의로 등록
Deploy momo-erp / deploy (push) Successful in 2m26s
전화 요청 등 시 admin 이 거래처를 대신해 발주를 작성할 수 있도록.

- /m/admin/orders 헤더에 '수기 발주' 버튼 + SearchableSelect 거래처 picker
  → 선택 후 /m/orders/new?customerObjid=momoNNN 로 이동
- /m/orders/new 가 query param customerObjid 받음:
  · admin 일 때만 활성 (USER 가 query 박아도 무시)
  · 상단 배너에 거래처명 표시 + 취소 링크
  · save 호출 시 body 에 customerObjid 포함
- /api/m/orders/save: admin 이 body.customerObjid 명시하면 그걸로
  발주 INSERT (supplier_branch snapshot 도 해당 거래처 기준)
2026-05-14 16:10:05 +09:00
chpark 8d8bb17345 feat(statistics+admin-panel): 통계 4개 거래명세서 필터 + 거래명세서 관리 메뉴
Deploy momo-erp / deploy (push) Failing after 4m39s
통계 페이지 4개에 거래명세서 기준(전체/본사/김포) 필터 추가:
- /m/admin/statistics (월간 매출)
- /m/admin/statistics/daily (일자별)
- /m/admin/statistics/margin (원가/마진)
- /m/admin/statistics/pivot (거래처×일자)

각 API 의 WHERE 절에
  COALESCE(O.supplier_branch, U.statement_branch, 'HQ') = $N
추가. supplier_branch snapshot 우선, 옛 발주는 user_info.statement_branch
폴백. ALL/생략 시 전체.

admin-panel 권한 및 사용자 관리 섹션에 '거래명세서 관리' 항목 추가
— activeTab='statement-branches' 시 /m/admin/statement-branches iframe
으로 로드 (기존 페이지 재사용, 별도 컴포넌트 중복 없음).
2026-05-14 16:09:41 +09:00
chpark 3e2d8572f1 feat(orders): USER 측 출고요청 + 출고완료 모두 수정 가능 (입금완료 전까지)
Deploy momo-erp / deploy (push) Successful in 2m5s
- 사용자 detail modal editable: REQUESTED → REQUESTED + APPROVED 모두 허용
- items/update API USER 가드: 동일하게 REQUESTED + APPROVED 허용
- 안내 문구 상태별로 분기

품목 추가/택배비 수정은 다음 단계에서 작업.
2026-05-14 16:05:36 +09:00
chpark 7977ffff19 fix(branch-fee): 본사(HQ) 제외 + 계산서 발행 명의(supplier_branch) 기준
Deploy momo-erp / deploy (push) Successful in 2m6s
지사관리:
- WHERE COALESCE(supplier_branch, statement_branch, 'HQ') != 'HQ'
  → 본사 발주 완전 제외, 김포 등 지사 명의 계산서만
- snapshot 우선 (supplier_branch) → 옛 발주 폴백 (user_info.statement_branch)
- UI: 본사 row 표시 분기 제거, '본사 외' 명시

창고이동 통계:
- 본사 계열(HQ_*) → 김포 계열(KIMPO_*) 이동만 필터링
- 같은 본사 내 이동, 같은 김포 내 이동, 김포→본사 역방향 모두 제외
2026-05-14 15:08:18 +09:00
chpark bb21be260f fix(branch-fee): 본사(HQ) 제외 + 계산서 발행 명의(supplier_branch) 기준
Deploy momo-erp / deploy (push) Successful in 2m11s
사용자 요구 — 지사 수수료 페이지는 본사 거래처를 보여줄 필요 없음.
계산서가 김포 등 지사 명의로 발행된 발주만 표시.

- 그룹핑 기준: COALESCE(supplier_branch, user.statement_branch, 'HQ')
  · 발주 시점의 supplier_branch snapshot 우선 (= 계산서 발행 명의)
  · 옛 발주(snapshot 없음) 는 거래처 statement_branch 폴백
- WHERE branch != 'HQ' 로 본사 제외
- UI: 본사 분기 제거 (모든 행이 지사)
2026-05-14 15:05:10 +09:00
chpark 7b5951c227 feat(alerts+transfers): 유통기한 임박 알림 + 창고이동 통계 메뉴
Deploy momo-erp / deploy (push) Successful in 2m8s
A. 유통기한 임박 알림 (/m/admin/expiry-alerts)
   - momo_inbounds.expiry_date/completed_by 컬럼 운영 DB 추가
   - inbounds/save API: 입고 시 expiryDate/completedBy 함께 저장
   - 페이지: 만료/7일이내/30일이내 분류 카드 + 행별 D-N 뱃지
   - 빠른 필터 (7/14/30/60/90일)

B. 창고 이동 통계 (/m/admin/transfers)
   - stock_moves WHERE ref_type='TRANSFER' AND move_type='OUT' 기준
   - 출발창고 / 도착창고 / 품목 / 수량 / 단가(cost_price) / 금액
   - 이동자(regid + user_name), 이동일시, 메모
   - 합계 카드 + 엑셀 다운로드

운영 DB:
- ALTER TABLE momo_inbounds ADD COLUMN expiry_date, completed_by
- 인덱스 idx_momo_inbounds_expiry
- menu_info: 9000511 (유통기한), 9000512 (창고이동) 등록 — 통계 메뉴 산하
2026-05-14 14:58:58 +09:00
chpark 9fd1160b38 feat(branch-fee): 지사 수수료 관리 메뉴 — 본사 20% 수수료 산정
Deploy momo-erp / deploy (push) Successful in 2m9s
신규 메뉴 /m/admin/branch-fee — 거래처의 statement_branch(HQ/KIMPO 등)
기준으로 매출/원가/순수 마진 그룹핑 + 지사(HQ 외) 의 마진 × 20% =
본사 수수료 자동 계산.

표시:
- 합계 카드 4개: 총 매출 / 총 마진 / 본사 수수료 합 / 지사 실수령 합
- 지사별 표: 매출/원가/마진/수수료/실수령(마진-수수료)
- 본사(HQ) 행은 수수료 0 (— 표시)

집계 범위: 출고완료(APPROVED)/계산서발행(INVOICED)/입금완료(PAID) 발주.
운영 DB 의 menu_info objid=9000510 으로 등록 완료. (parent 9000500 통계)
2026-05-14 14:49:43 +09:00
chpark b568a8858a feat(procurements+inbounds): 발주서 납품조건 1번 삭제 + 입고 체크리스트
Deploy momo-erp / deploy (push) Successful in 2m16s
매입 발주서 납품조건:
- '상기 품목의 납기 지연 시...' 1번 항목 삭제. 검수/수량변경 2개만 유지.

입고 처리 체크리스트 (memo 컬럼에 텍스트로 저장 — 스키마 변경 없음):
1) 발주수량 vs 입고수량 일치 (체크박스)
2) 1카톤 N개 일치 (체크박스 + 카톤 단위 입력)
3) 소비기한 (date)
4) 물류창고 입고 최종완료자 (물류팀 4명 select)
5) 특이사항 (textarea)

저장 시 momo_inbounds.memo 에 줄단위 텍스트로 박힘:
  [수량 일치]   Y ✓
  [카톤 일치]   Y ✓ (1카톤 12개)
  [소비기한]    2026-11-30
  [입고완료자]  이효철 (물류총괄)
  [특이사항]    ...
2026-05-14 14:43:03 +09:00
chpark 3505148994 feat(orders): 출고완료 상태 수량 변경 시 재고 ± 동기화 + 이력
Deploy momo-erp / deploy (push) Successful in 2m6s
- 직전엔 items/update 가 momo_order_items.qty 만 UPDATE → 출고완료
  발주의 수량을 줄여도 재고는 그대로 (출고 시 차감된 양 그대로 묶임).
- 수정: status='APPROVED' 인 경우 newQty - oldQty 차이만큼 재고 보정
  · diff > 0 (추가 출고) → stock 차감 + stock_moves OUT
  · diff < 0 (수량 줄임)  → stock 복원 + stock_moves IN
- 사용된 창고는 기존 stock_moves(ref_type='ORDER', ref_objid=order, 동일
  item) 의 wh_objid 로 lookup (approve 시 사용했던 창고와 동일 유지).
- 이력 memo: "수량 수정: oldQty → newQty"
2026-05-14 01:33:53 +09:00
chpark 88686d0461 fix(orders): '내 발주 이력' 은 admin 이라도 본인 발주만 (mine=true)
Deploy momo-erp / deploy (push) Successful in 2m6s
momo5315(배연진) 같은 admin 임직원이 사용자 측 '내 발주 이력' 페이지를
열면 모든 발주가 노출되던 문제. admin 판정만으로는 부족 — 메뉴 의도가
'본인 것' 이라 isAdmin 여부 무관 customer_objid 본인 매칭 필요.

- list API: body.mine === true 면 admin 이어도 본인 발주만
- /m/orders/page.tsx fetch 에 mine: true 추가
- admin 메뉴(/m/admin/*)는 mine 안 보냄 → 기존대로 전체 노출
2026-05-14 01:31:07 +09:00
chpark 13d02cac6a chore(migrations): db/migrations 전체 삭제 + deploy 마이그레이션 단계 제거
Deploy momo-erp / deploy (push) Successful in 2m3s
사용자 명시 요청 — 운영 DB 는 이미 모든 스키마/데이터 변경 반영됐고,
더 이상 자동 reload 가 필요 없음. 매 deploy 시 마이그레이션이 실행되며
사용자 변경(비밀번호, 부서, 추가 거래처 등) 을 원복하는 사고를 막기 위해
폴더 통째로 삭제.

- db/migrations/*.sql 35개 모두 삭제
- .gitea/workflows/deploy.yml 의 migrate-momo.mjs 호출 단계 제거
- scripts/migrate-momo.mjs 파일 자체는 유지

운영 DB 의 모든 user_info 비밀번호는 '1' 로 직접 reset 완료(142명).
2026-05-14 01:26:30 +09:00
chpark f29024744d fix(orders): 비고 수정 = REQUESTED + APPROVED 두 상태 모두 허용 (출고완료 포함)
Deploy momo-erp / deploy (push) Successful in 2m34s
2026-05-14 01:22:11 +09:00
chpark e2c5c5b396 fix(orders): 내 발주 이력 — FITO 세션도 본인 발주만 보이도록
Deploy momo-erp / deploy (push) Successful in 2m39s
root cause:
- FITO auth.ts 는 user 객체에 role 필드를 만들지 않음 (isAdmin/userType 만).
- 새봄-마켓소풍(momo125) 가 FITO 폴백 경로로 로그인 → session.user.role
  = undefined → list API 의 `if (role === "USER")` 가 false → 필터 무효
  → admin 의 발주까지 다 노출.

→ admin 판정을 MOMO/FITO 공통 isAdmin/role/userType 세 조합으로 통일.
   !isAdmin 인 경우 본인 발주만 매칭.
2026-05-14 01:16:23 +09:00
chpark f4b1e31f7f fix(orders): 매출 출고 수정 = REQUESTED/APPROVED 만 (계산서발행/입금완료 차단)
Deploy momo-erp / deploy (push) Successful in 2m38s
2026-05-14 01:12:46 +09:00
chpark 5294554384 fix(orders): admin 그룹은 출고 발주 모든 상태 수량 수정 가능
Deploy momo-erp / deploy (push) Successful in 2m39s
momo5315(배연진) 같은 user_type='A' admin 이 출고요청/출고완료 발주
수량 수정이 안 된다는 사용자 신고. 직전 commit 에서 'REQUESTED/APPROVED'
만 허용으로 좁혔던 게 너무 빡빡했음.

→ admin 권한자는 취소(CANCELED) 외 모든 상태 수정 가능으로 풀기.
  · API items/update admin 분기: status === 'CANCELED' 만 차단
  · UI editable: order.STATUS !== 'CANCELED'
USER 권한자는 그대로 REQUESTED 만.
2026-05-14 01:08:29 +09:00
chpark b204f14265 fix(orders): 내 출고 이력에서 본인 발주만 — user.objid undefined 폴백
Deploy momo-erp / deploy (push) Successful in 2m37s
USER 권한 사용자의 list API 필터링에서 r.user.objid 가 undefined 인
세션에선 customer_objid 비교가 NULL 매칭 → 필터링 무효화돼 모든 발주가
노출되던 버그. user_id 폴백 + customer_objid 가 user_id 로 박힌 경우
모두 IN 절로 매칭.
2026-05-14 01:03:43 +09:00
chpark 34ee374796 feat(orders): ITEM 수량/비고 인라인 즉시저장 + 왼쪽 리스트 실시간 갱신
Deploy momo-erp / deploy (push) Successful in 2m35s
1) ITEM 라인 수량을 QtyInput 인라인 인풋으로 (REQUESTED/APPROVED 상태).
   onBlur/Enter 시 /api/m/orders/items/update 호출 → 자동 저장.
2) 비고(REMARK) 는 이미 onBlur 자동 저장이었음. 출고요청/출고완료 모두
   editable 이라 동작.
3) ExtraRow(택배/용차) 의 V(저장) 버튼 제거. onBlur 시 자동 저장으로 변경
   — label/단가/수량 어느 인풋이든 포커스 떠나면 자동 commit.
4) 모든 라인 수정 작업 (saveRemark/saveItemQty/upsertExtra/deleteExtra)
   에서 onReload + onReloadList 동시 호출 → 왼쪽 발주 리스트의 합계도
   즉시 반영.

부수: 운영 DB 의 모든 user_info 비밀번호를 '1' 로 reset (사용자 요청).
2026-05-14 01:01:06 +09:00
chpark 053a21c30e fix(invoices): 계산서 발행 대상 = 입금완료(PAID) 만
Deploy momo-erp / deploy (push) Successful in 2m42s
리스트:
- 기본 표시 = PAID + INVOICED 만 (APPROVED 출고완료 제외)
- 상태 필터 옵션도 PAID/INVOICED 만

발행 가드:
- frontend: 선택 + PAID + 미발행만 issue 가능
- API: UPDATE 조건 status='PAID' (이전엔 APPROVED 도 허용했음)
2026-05-14 00:45:39 +09:00
chpark 665a560486 fix(orders): 출고관리 수정 범위 좁히기 — REQUESTED + APPROVED 만
Deploy momo-erp / deploy (push) Successful in 2m52s
직전엔 PAID/CANCELED 외 모두 허용했는데, 사용자 재요청으로 계산서발행
(INVOICED) 도 잠금. editable = REQUESTED || APPROVED.
items/update API admin 가드도 동일하게 좁힘.
2026-05-14 00:43:22 +09:00
chpark 3a7d17b3e5 feat(orders): 출고 처리 기본 날짜 한 달 전 ~ 오늘로 변경
Deploy momo-erp / deploy (push) Successful in 2m50s
2026-05-14 00:42:02 +09:00
chpark 2d5b94a026 feat(admin/users): 수정 화면에서도 비밀번호 변경 가능
Deploy momo-erp / deploy (push) Successful in 2m36s
- 사용자 수정 폼에 '비밀번호 변경' 입력란 추가 (빈 칸이면 기존 유지)
- /api/admin/users/save update 분기: password 값이 있으면 AES 암호화 후
  user_password 갱신
2026-05-14 00:16:07 +09:00
chpark 9a086dae50 fix(migrations): momo001..momo135 부서 '일반구매자' (DEPT003) 매 deploy 보장
Deploy momo-erp / deploy (push) Successful in 2m42s
운영 DB 에는 직접 UPDATE 로 부서 설정 완료. 035 도 INSERT/UPSERT 시
dept_code='DEPT003', dept_name='일반구매자' 박도록 패치.
임직원 6명(user_type='A') 은 영향 없음.
2026-05-14 00:10:19 +09:00
chpark 4661981da5 feat(procurements): 입고완료 이후 수정 잠금 + 공급업체 SearchableSelect
Deploy momo-erp / deploy (push) Successful in 2m54s
- 매입 발주서 editable 재조정: OPEN/REQUESTED 만 수정 가능. RECEIVED/
  PARTIAL/PAID/CANCELLED 는 수정 불가. lines/save API 가드도 동일.
- 공급업체 select 를 SearchableSelect 로 교체 (typeahead):
  · 매입 발주서 관리 페이지의 발주서 폼 (page.tsx)
  · 매입 발주 신규 작성 페이지 (new/page.tsx)

운영 DB 의 매입 발주/입고/관련 stock_moves 데이터는 직접 모두 삭제했음
(사용자 명시 요청). UI 에 깨끗한 상태로 보임.
2026-05-14 00:08:27 +09:00
chpark d86a1154a9 fix(migrations+procurements): supply_mng objid numeric + setActiveId 빌드 에러
Deploy momo-erp / deploy (push) Successful in 2m34s
운영 DB 직접 점검 후 발견:
- supply_mng.objid 는 numeric NOT NULL (TEXT 아님) → 'MOMOSUP000000001'
  같은 문자열 INSERT 가 cast fail 로 80개 다 fail 하고 supply_mng = 0개
  잔존 상태였음. 운영 DB 에는 직접 80개 박아둠 (objid 1..80).
- 035 마이그레이션도 동일 패턴(numeric objid)으로 재작성. 매 deploy 안전.

직전 deploy #137 (485aea4) 빌드 실패 원인:
- procurements/page.tsx 의 deleteProc 에서 setActiveId(null) → state 가
  string 이라 타입 에러. setActiveId("") 로 수정.
2026-05-14 00:02:28 +09:00
chpark 6ea1f13003 fix(migrations): 031/032/033 NO-OP 화 + 035 plain SQL 단일 책임으로 통합
Deploy momo-erp / deploy (push) Failing after 58s
직전 batch 가 운영 반영 후에도 supply_mng 가 10개로 유지된다는 사용자 신고.
원인 추정: 031/032/033 의 DO block 안에서 어딘가 fail → migrate-momo.mjs 가
process.exit(1) → 그 뒤 마이그레이션 (034, 035) 실행 자체 못 함.

→ 031/032/033 본문을 SELECT 1; 로 NO-OP 화. fail 지점 우회.
→ 035 가 단독으로 회원 135 + 공급업체 80 + 제조사 메뉴 삭제 모두 처리.
  · DO block 없이 plain SQL 만 (각 statement 가 별도 transaction)
  · supply_mng: 통째 DELETE 후 80개 plain INSERT
  · user_info: user_type='U' DELETE 후 ON CONFLICT (user_id) UPSERT
  · 매 deploy 안전 — UPSERT 라 중복 INSERT 도 OK
2026-05-13 23:58:36 +09:00
chpark 485aea4d4f feat(procurements): 작성중 삭제 + 입금완료전 수정 + 035 plain SQL reload
Deploy momo-erp / deploy (push) Failing after 58s
- 매입 발주서: OPEN 일 때 삭제 버튼 노출, 라인 포함 hard delete
  · 신규 API /api/m/procurements/delete (status='OPEN' 만 허용)
- 매입 발주 editable: STATUS === 'OPEN' → STATUS not in (PAID, CANCELLED)
  · lines/save API 가드도 동일 (PAID/CANCELLED 만 차단)
- 035_supply_mng_plain_reload.sql: 직전 DO block 마이그레이션이 어디서
  fail 하는지 추적 불가 → 가장 단순한 plain SQL 로 supply_mng 다 비우고
  엑셀 기준 80개 INSERT ON CONFLICT(supply_code) UPSERT
  · momo_items/momo_procurements 의 vendor_objid 모두 NULL 처리
  · UNIQUE INDEX 보장 후 INSERT — 매 deploy 안전
2026-05-13 23:53:32 +09:00
chpark 5fce695f09 fix(migrations): 019 의 supply_mng 10개 매 deploy reset 차단 + 034 UPSERT
Deploy momo-erp / deploy (push) Successful in 2m36s
진짜 root cause — 019_proc_terms.sql 이 매 deploy 시
  DELETE FROM supply_mng;
  INSERT INTO supply_mng ... 10개 시드 (VND-001 ~ VND-010)
를 실행해서 supply_mng 가 항상 10개로 reset 되고 있었음.

→ 019 의 supply_mng 시드 부분 제거 (납품조건 ALTER 만 유지).

034 신설 — idempotent 매 deploy 안전 실행:
- product_lines 컬럼 보장
- supply_code UNIQUE 인덱스 보장 (ON CONFLICT 동작)
- 옛 'VND-*' 시드는 items/procurements 의 vendor_objid 끊은 후 DELETE
- 엑셀 80개 INSERT ON CONFLICT (supply_code) DO UPDATE
- sentinel 가드 없이 매번 안전 — 사용자 추가 supply (다른 supply_code) 는 보존
2026-05-13 23:46:30 +09:00
chpark 2d2f32f4f8 fix(auth-panel): 권한 제거 silent fail + 버튼 클릭 영역 확대
Deploy momo-erp / deploy (push) Successful in 2m36s
1) delete API: MASTER_OBJID/OBJID 가 numeric 가 아닐 때 ::numeric cast 가
   매칭 0건으로 silent fail. ::text 양쪽 비교로 안전하게.
2) 추가/제거 버튼: h-9 w-24 → h-12 w-32, cursor-pointer + hover 색상
   강화. 가운데 컬럼 min-w-[120px] 로 클릭 영역 넓힘.
2026-05-13 23:41:22 +09:00
chpark 1fde88bcd8 fix(migrations): 008/023 매 deploy 원복 사고 차단 + 033 강제 리로드
Deploy momo-erp / deploy (push) Successful in 2m49s
근본 원인:
- 008_makers_menu.sql: 매 deploy 시 제조사 관리 메뉴를 status='active' 로
  되돌려놓아 사용자가 메뉴 삭제해도 다음 배포 때 부활.
- 023_seed_momo_vendors_from_xlsx.sql: 매 deploy 시 옛 100개 공급업체를
  비-idempotent 한 INSERT 로 박아 supply_mng 가 영원히 옛 데이터로 원복.

→ 두 파일을 빈 NO-OP 으로 교체.

033_force_reload_v2.sql 신설 (sentinel 가드 momo_migration_marks 기반):
- 제조사 메뉴 menu_info objid=9000204 삭제
- momo_einvoice_items/einvoices/stock_moves(ORDER)/order_items/orders 통째
- user_info user_type='U' 다 삭제 후 momo001..momo135 ON CONFLICT UPSERT
  · 본사팀 85 (HQ), 김포팀 50 (KIMPO), 카테고리별 default_wh 매핑, 비번 '1'
- supply_mng 다 삭제 후 80개 INSERT (엑셀 모모유통 제조사 리스트 26.05.12)
  · product_lines 컬럼 ALTER (DO block 밖)
2026-05-13 23:37:40 +09:00
chpark 3d5a283955 feat(orders): 입금완료 전까지 거래명세표 수정 허용
Deploy momo-erp / deploy (push) Successful in 2m46s
사용자 요청 — 구매자가 수량 변경 요청하면 담당자가 거래명세표에서 수기로
수정하고 다시 입금 요청하는 흐름. 출고요청(REQUESTED) 뿐만 아니라
출고완료(APPROVED) / 계산서발행(INVOICED) 까지 수정 가능해야 함.

- editable: order.STATUS === 'REQUESTED' → STATUS !== 'PAID' && !== 'CANCELED'
- items/update API 의 admin 가드는 이미 PAID/CANCELED 외 모두 허용
- 수정하면 사용자 측 명세표도 그대로 갱신 (detail API 가 동일 데이터 반환)
2026-05-13 23:31:59 +09:00
chpark 91313351f9 chore(data): 회원/공급업체 마스터 통째 리로드 (마이그레이션 032)
Deploy momo-erp / deploy (push) Successful in 2m33s
사용자 요청 — docs 의 엑셀 두 파일을 기준으로:
- 회원(user_info user_type='U') 전부 삭제 후 momo001..momo135 신규 등록
  · 본사팀(85): 창고픽업 WH001 / 시장픽업 WH002 / 용차배송 WH003
  · 김포팀(50): 김포지사 WH004 / 창고픽업 WH005 / 시장픽업 WH007 / 용차배송 WH006
  · statement_branch: 본사='HQ' / 김포='KIMPO'
  · 비번 '1' (AES 암호화)
- 공급업체(supply_mng) 전부 삭제 후 80개 신규 등록 (제조사 리스트 엑셀 기준)
  · supply_mng 에 product_lines TEXT 컬럼 추가하여 제품명 보관
  · items.vendor_objid 는 NULL 처리 (참조 무결성)

1회성 sentinel — momo_migration_marks 테이블로 가드. 신규 데이터가
다음 deploy 때 또 지워지는 사고 방지.
2026-05-13 23:29:57 +09:00
chpark 94fc425ef3 chore(orders): 옛 발주 데이터 1회성 삭제 (마이그레이션 031)
Deploy momo-erp / deploy (push) Successful in 2m19s
사용자 명시 요청 — supplier snapshot 이전의 옛 발주 데이터를 통째로
비우고 신규 발주만 운용. 사용자가 새로 등록하기로 함.

⚠️ migrate-momo.mjs 가 매 deploy 시 모든 .sql 을 재실행하므로 raw DELETE
를 그대로 박으면 신규 발주가 다음 deploy 때 또 지워지는 사고가 남.
→ sentinel 테이블 momo_migration_marks 도입, 1회만 실행되도록 가드.

삭제 범위:
- momo_einvoice_items (발주연결분)
- momo_einvoices WHERE order_objid IS NOT NULL
- momo_stock_moves WHERE ref_type='ORDER'
- momo_order_items 전체
- momo_orders 전체

보존: momo_stocks.qty — 사용자가 inventory 메뉴에서 직접 보정.
2026-05-13 17:44:03 +09:00
chpark 0bfe85dc69 fix(orders): 거래명세표 공급자 정보 발주 시점 snapshot 처리
Deploy momo-erp / deploy (push) Successful in 3m37s
기존: 거래명세표 발급 때마다 user_info.statement_branch + branches table
을 실시간 조회 → 사용자의 기준 명세표를 바꾸거나, branches 의 계좌/
전화/이메일을 수정하면 과거 이미 찍힌 명세표까지 함께 바뀌어버림.

수정: 출고요청(REQUESTED) 시점에 supplier 8개 컬럼을 momo_orders 행에
박아두고, 이후 detail/statement/approve 는 이 snapshot 을 사용.

- 마이그레이션 030: momo_orders 에 supplier_branch / supplier_name /
  supplier_ceo / supplier_bank_account / supplier_phone / supplier_email
  / supplier_biz_no / supplier_address 컬럼 추가 (idempotent IF NOT EXISTS)
- save: 발주 INSERT 시 getSupplierByBranch(user.statement_branch) 호출 결과
  를 그대로 박음
- detail/statement/approve: snapshot 컬럼이 있으면 그것을 사용, 없으면
  옛 발주용 폴백으로 user_info.statement_branch → branches table 조회
2026-05-13 16:39:40 +09:00
chpark 2fffc42575 fix(orders): 거래명세표 택배/용차 추가 버튼 복구 + 캡처에서만 제외
Deploy momo-erp / deploy (push) Successful in 5m53s
직전 commit 에서 버튼 자체를 통째로 지웠던 게 잘못. 출고요청 건은
admin 이 화면에서 택배/용차 라인을 추가/수정해야 함.

- 화면: 버튼 항상 보임 (editable 시)
- 이미지 공유 캡처: .js-no-export 클래스로 hide (capture-share.ts 동작)
2026-05-13 16:35:47 +09:00
chpark 29852110dc chore(admin): 공통코드 관리 메뉴/UI/API 일괄 제거
Deploy momo-erp / deploy (push) Successful in 2m52s
momo 영역에서 미사용. FITO 레거시 잔존 코드 정리:
- sidebar.tsx: __sys_code 가상 메뉴 항목 제거
- admin-panel/page.tsx: code 탭/메뉴/CodeManagement 함수 삭제
- admin-panel/code-form/: 폼 페이지 디렉토리 통째로 삭제
- api/admin/codes/: list/detail/save 라우트 통째로 삭제

/api/common/code-list (조회 전용) 는 product/part-change 등이
드롭다운 로드용으로 쓰고 있어 보존.
2026-05-13 16:29:48 +09:00
chpark 5778b845d1 fix(orders): 현재고 0 표시 버그 + 거래명세표 레이아웃 정리
Deploy momo-erp / deploy (push) Successful in 3m13s
- 마이그레이션 029 후 wh_type 이 새 enum(HQ_STOCK/KIMPO_STOCK 등)
  으로 바뀌면서 detail/approve 의 wh_type='STOCK' 필터가 매칭 안 됨
  → 거래명세표 현재고가 전부 0 으로 표시됨 (재고 부족 N건 오탐).
- detail: 거래처 default_wh_objid 우선 + IN ('STOCK','HQ_STOCK','KIMPO_STOCK') 폴백
- approve: fallback 창고 lookup 도 동일하게 IN 절 확장
- 거래명세표 헤더 레이아웃: grid-cols-1 sm:grid-cols-2 → flex justify-between
  으로 변경, 발주번호 라인과 공급자 박스가 항상 가로 배치(공급자 오른쪽 정렬)
- 거래명세표에서 '+ 택배 추가 / + 용차 추가' 버튼 제거 (요청)
2026-05-13 16:26:01 +09:00
chpark 0c380d94a0 feat(warehouses 029): 창고 7개로 정비 — 기존 5개 라벨/타입 갱신 + 김포 용차/시장 2개 신규
Deploy momo-erp / deploy (push) Successful in 2m15s
기존 5개 (objid/wh_code 그대로 유지 — 재고 이동·입출고 데이터 보존):
- WH001 본사창고 → 본사 창고 (HQ_STOCK)
- WH002 시장픽업 → 본사 시장 (HQ_MARKET)
- WH003 용차배송 → 본사 용차 (HQ_CHARTER)
- WH004 창고픽업팀 → 김포지사 (KIMPO_BRANCH)
- WH005 김포창고 → 김포 창고 (KIMPO_STOCK)

신규 2개 (idempotent INSERT):
- WH006 김포 용차 (KIMPO_CHARTER)
- WH007 김포 시장 (KIMPO_MARKET)

총 7개. 사용자 요청한 본사 3 + 김포 4 구성 정확히 맞춤.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:08:45 +09:00
chpark 2b3c4acdae fix(deploy): momo-erp 재기동 후 traefik 라우터 캐시 자동 새로고침
Deploy momo-erp / deploy (push) Successful in 2m15s
증상: momo-erp container 가 force-recreate 될 때 traefik docker provider 가
새 컨테이너의 라벨을 재인식 못 해 momotogether.com 요청이 Gitea 라우터로
fallback 되는 사고 (사용자 화면에 Gitea 500 에러 페이지 노출).

deploy.yml 의 docker compose up 직후 `docker restart traefik` 1회 추가.
1~2초 다른 도메인 잠시 down 되지만 라우터 정합성 보장됨.

병행: cron watchdog (/usr/local/bin/momo-traefik-watchdog.sh, 3분 간격)이
production HTML 의 "모모유통" 문자열 검증해 비정상 시 traefik 자동 재시작.
deploy 외 임의 시점 사고에도 최대 3분 안에 자동 복구.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:59:56 +09:00
chpark 19e3cf9048 feat(statement-branch-admin): 기준 명세표 관리 메뉴/페이지/API + 창고 카테고리 7개로 확장
Deploy momo-erp / deploy (push) Successful in 3m5s
마이그레이션 028:
- momo_statement_branches 테이블 신설 (code PK / name / bank_account / phone / email 등)
- HQ, KIMPO 기본 시드 INSERT (사용자가 관리자 페이지에서 편집 가능)
- 메뉴: 시스템 그룹에 '기준 명세표 관리' (M_ASBR / menu_info 9000310)

라이브러리 (src/lib/momo-branches.ts):
- 하드코딩 → DB 조회로 변경 (60초 in-memory 캐시)
- getSupplierByBranch 가 async — detail/statement/approve API 도 await 추가
- 저장 시 invalidateBranchCache() 호출

페이지/API (관리자 전용):
- /m/admin/statement-branches : list + 등록/수정/삭제 모달
- POST /api/m/admin/statement-branches/list
- POST /api/m/admin/statement-branches/save (regist / update / delete)

사용자 수정 폼:
- "기준 거래명세서" select 옵션이 하드코딩 본사/김포 → DB 의 branches list 동적 fetch

창고 관리:
- WH_TYPE 카테고리 5개 → 7개 (옛 enum 도 라벨 매핑은 유지)
  · HQ_STOCK 본사 창고
  · HQ_CHARTER 본사 용차
  · HQ_MARKET 본사 시장
  · KIMPO_BRANCH 김포지사
  · KIMPO_STOCK 김포 창고
  · KIMPO_CHARTER 김포 용차
  · KIMPO_MARKET 김포 시장
- 신규 추가 시 select 는 위 7개만 노출, 기존 데이터 (STOCK 등) 는 라벨로 자연 표시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:48:08 +09:00
chpark 6ad57356a0 feat(statement-branch): 사용자별 거래명세서 공급자 정보 분기 (본사/김포)
Deploy momo-erp / deploy (push) Successful in 1m59s
마이그레이션 027:
- user_info 에 statement_branch VARCHAR(10) DEFAULT 'HQ' 컬럼 추가
- 기존 사용자 일괄 'HQ' 로 채움

라이브러리:
- src/lib/momo-branches.ts 신설 — HQ / KIMPO 두 branch 의 공급자 정보 정의
  · HQ: 기업은행 434-115361-01-016 (이상용) / 010-6369-8443 / momo8443@daum.net
  · KIMPO: 농협 351-1383-7634-13 (모모유통) / 010-5789-9431 / momokimpo@nate.com
- getSupplierByBranch(branch) helper

거래명세표 supplier 분기 (3개 API):
- /api/m/orders/detail: order.STATEMENT_BRANCH 따라 supplier 객체 결정
- /api/m/orders/statement/[id]: xlsx 다운로드도 동일
- /api/m/orders/approve: 메일 발송 stmt 도 동일

사용자 수정 폼:
- /api/admin/users/detail: statement_branch 반환 (default 'HQ')
- /api/admin/users/save: statement_branch 받아 UPDATE
- /admin-panel/user-form: "기준 거래명세서" select 추가 (본사/김포)

흐름: 거래처 사용자의 기준을 김포로 설정 → 그 사용자의 발주에 대한 거래명세표 supplier 가 김포 정보로 표시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:29:02 +09:00
chpark 75f37d8eaf feat(filters+warehouse): 매입발주/입금관리 조건 한 줄 + 공급업체 SearchableSelect + 김포 창고 카테고리 추가
Deploy momo-erp / deploy (push) Successful in 2m8s
1) 매입 발주서 관리(/m/admin/procurements):
   - 조회조건 flex-wrap → flex-nowrap + overflow-x-auto (모바일 한 줄, 가로 스크롤)
   - 공급업체 select → SearchableSelect (타이핑으로 결과 내 검색)
   - 폰트/높이 축소 (h-9 → h-8, text-sm → text-xs)
2) 매입 입금관리(/m/admin/proc-payments): 동일 한 줄 정비
3) 창고 관리(/m/admin/warehouses):
   - WH_TYPE 에 KIMPO "김포 창고" 카테고리 추가 + 라벨/배지색 등록
   - 신규 창고 추가 시 select 옵션에 자동 노출

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:55:23 +09:00
chpark ec2e79d517 feat(filters): 매입발주·내발주이력·출고처리 조회조건 정비
Deploy momo-erp / deploy (push) Successful in 2m12s
- 매입 발주 관리(/m/admin/procurements):
  · 날짜 from~to + 공급업체 select 추가 (기본: 오늘 - 30일 ~ 오늘)
  · 조회 버튼 제거 — state 변경 시 자동 fetch
- 내 발주 이력(/m/orders):
  · 날짜 기본값을 오늘 - 30일 ~ 오늘로 설정 (URL 파라미터 우선 적용)
- 출고 처리(/m/admin/orders) 검색바:
  · 모바일에서 label 풀어진 5단 grid → 한 줄 flex-wrap 으로 압축 (h-8 + text-xs)
  · 모바일 폼이 화면을 너무 차지하던 문제 해소

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:42:39 +09:00
chpark 6b401071a4 fix(migration 026): menu_info 에 unique constraint 없어 ON CONFLICT 실패 → WHERE NOT EXISTS + UPDATE 분리
Deploy momo-erp / deploy (push) Successful in 2m10s
지난 commit 의 026 이 ON CONFLICT(objid) 로 INSERT 시도 → menu_info 에 PK/unique 없어 PG 에러.
패턴: INSERT WHERE NOT EXISTS + 그 다음 별도 UPDATE WHERE objid=... 로 idempotent 처리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:25:48 +09:00
chpark 7d18285ac6 feat(menu+filter+history): 사이드바 메뉴 노출 + 자동조회 + 재고이력 한글/이동 상대창고 표시
Deploy momo-erp / deploy (push) Successful in 2m53s
1) 사이드바 메뉴 누락 fix (마이그레이션 026):
   - FITO menu_info 테이블에 9000304 '매입 입금관리', 9000305 '재고이력' INSERT
   - 기존 입고/재고 seq 재정렬 (11→12, 12→13)
   - momo_menus 만으로는 사이드바에 안 나옴 — menu_info 가 사이드바의 진짜 소스

2) 재고이력 표시 개선:
   - inventory/history API: REF_TYPE_LABEL (한글) + COUNTER_WH_NAME (이동 시 상대 창고) 추가
   - inventory/transfer 라우트: stock_moves 의 ref_objid 에 상대 창고 objid 박음
   - StockHistoryModal + history page: "INBOUND" → "입고", TRANSFER 시 "→ XX창고/← XX창고" 표시

3) 자동조회 (조회 버튼 없이 즉시):
   - m/orders (내발주이력): 날짜 from~to + 상태 input 추가 + state dep useEffect
   - m/orders/new (출고요청): "재고있는 품목만 / 전체 품목" 필터 추가 + 250ms 디바운스 자동 fetch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:22:28 +09:00
chpark bf2339c242 feat(inbound+inventory): 입고는 입금완료(PAID) 만 + 재고이력 메뉴/모달
Deploy momo-erp / deploy (push) Successful in 2m17s
1) 입고 처리 (m/admin/inbounds):
   - 노출 대상: REQUESTED+PARTIAL → PAID+PARTIAL 로 변경 (입금 안 된 발주는 입고 불가)
   - editable 조건 / STATUS 라벨/색상 PAID 추가
   - 안내 문구 갱신

2) 재고이력 메뉴 (마이그레이션 025):
   - M_AINVH '재고이력' 메뉴 매입 그룹 sort 34 에 INSERT (페이지는 기존 /m/admin/inventory/history 활용)

3) 재고관리 페이지 (m/admin/inventory):
   - 데스크탑 표/모바일 카드 모두 행마다 "이력" 버튼 추가
   - 클릭 시 StockHistoryModal 팝업 — 해당 품목 + 해당 창고 한정 이력 조회 (POST /api/m/inventory/history)

매입 흐름 완성: 매입발주(REQUESTED) → 입금관리(PAID) → 입고처리(RECEIVED)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:29 +09:00
chpark b781722614 feat(proc-payment): 매입 입금관리 메뉴/페이지/API 신설
Deploy momo-erp / deploy (push) Successful in 2m9s
매입 흐름: 매입발주(REQUESTED) → 매입 입금관리(PAID) → 입고 처리(RECEIVED)

DB (마이그레이션 024):
- momo_procurements 에 paid_date, paid_amount, paid_method, paid_memo 컬럼 추가
- 매입 그룹 메뉴 sort 재정렬: 매입발주 30, 입금관리(신설) 31, 입고처리 32, 재고관리 33
- M_APROCPAY '매입 입금관리' /m/admin/proc-payments 메뉴 INSERT (ON CONFLICT idempotent)

UI/API:
- /m/admin/proc-payments 페이지 — 발주요청/입금완료 분리 카드 + 입금 처리 모달 (금액/방법/메모)
- 조회조건: 날짜 from~to + 공급업체 + 상태 (즉시 반영)
- POST /api/m/admin/proc-payments/list — REQUESTED|PAID 만 노출
- POST /api/m/admin/proc-payments/confirm — REQUESTED → PAID 전환 + paid_* 채움

다음 단계 (별도 batch): 입고 처리 페이지에서 PAID 만 노출 + 입고 시 RECEIVED 전환

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:30:31 +09:00
chpark 9293029631 chore: 제조사 관리 메뉴/페이지/API 삭제 + 엑셀 공급업체 80개 일괄 등록
Deploy momo-erp / deploy (push) Successful in 2m0s
1) 삭제:
   - src/app/(main)/m/admin/makers/page.tsx
   - src/app/api/m/makers/{list,save,delete}/route.ts
   (메뉴 DB 의 제조사 항목은 이전 commit 9705a04 에서 이미 제거됨)

2) 마이그레이션 023:
   - docs/모모유통 제조사 리스트(26.05.12).xlsx 의 80개 업체를 supply_mng 에 일괄 등록
   - idempotent: supply_name 중복 시 SKIP (NOT EXISTS)
   - supply_code: MM-NNNN 자동 채번 (기존 max(objid) + ROW_NUMBER)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:13:19 +09:00
chpark b8d0200831 fix(orders): 내발주이력 "동작" 컬럼 제거 — 행 클릭으로 상세 모달 열도록 통일
Deploy momo-erp / deploy (push) Successful in 2m4s
기존: 마지막 컬럼에 "보기" 버튼 + tr onClick 둘 다 있어 중복
변경: 동작 컬럼 + 보기 버튼 제거. tr 자체 클릭 시 상세 모달 (openDetail) 호출 — 이미 onClick 박혀있어 동작 동일.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:05:54 +09:00
chpark 7151a401d4 fix(orders): 일반 사용자 권한 매칭 (objid + userId) + 출고요청 리스트 가로스크롤 제거
Deploy momo-erp / deploy (push) Successful in 2m56s
권한 fix — order.customer_objid 와 매칭 시:
- 기존: user.objid 만 비교 → FITO 사용자(objid 없음)는 항상 fail → "권한 없음"
- 변경: user.objid ?? user.userId 와 customer_objid 매칭, 또는 user.userId 와 직접 매칭
- 적용: items/update, lines/save, cancel, statement, items/remark (5개 API)

출고요청 리스트(m/orders/new) UI:
- table 의 min-w-[640px] 제거 + table-fixed 적용
- 수량 컬럼 폭 180px → 112px, "담기" 버튼 텍스트 제거 (+ 아이콘만)
- 폰트 11~12px 로 축소
- 모바일 화면 한 줄에 들어오도록

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:49:26 +09:00
chpark a8049f57a6 feat(invoices): 계산서 발행 페이지 종합 개선 + deploy.yml 충돌 우회
Deploy momo-erp / deploy (push) Failing after 4m37s
invoices(계산서 발행) page:
- 조회조건 추가: 거래처(SearchableSelect) / 날짜 from~to / 상태
- 조회 버튼 제거 — 입력하면 즉시 클라이언트사이드 필터 적용
- "조회 결과 합계" 카드: 면세 / 과세(공급+세액) / 합계 분리 표시
- "선택 합산" 카드: 체크박스로 고른 건들의 면세/과세/합계 실시간 합산
- 표 행마다 면세/과세 컬럼 추가
- 전체 선택 체크박스 (헤더)

deploy.yml:
- docker compose up 흐름 강화: down --remove-orphans 후 docker rm -f momo-erp 로 잔존 컨테이너 강제 제거 + --force-recreate
- 수동 SSH 배포 + 자동 배포 겹쳤을 때 "container name already in use" 충돌 자동 해소

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:40:19 +09:00
chpark 80d2240a23 feat(items+einvoices): 품목 상태값 제거 + 공급업체/거래처 조회조건 + 합계 면세/과세 분리
Deploy momo-erp / deploy (push) Failing after 2m35s
- 품목관리(items):
  · STATUS 컬럼 표시/필터/폼에서 제거 (전부 ACTIVE 가 default — 사용자 사용 안 함)
  · 조회조건에 공급업체 SearchableSelect 추가 (이미 백엔드 vendorObjid 지원)
- 계산서 발행(einvoices):
  · 조회조건에 거래처 SearchableSelect 추가 (customers list API 사용)
  · 페이지 하단 tfoot 에 면세 합계 / 과세 합계 / 총 합계 분리 표시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:29:55 +09:00
chpark 9cd9e5c0fd fix(orders): 거래명세표 캡처 레이아웃 다듬기
Deploy momo-erp / deploy (push) Successful in 2m47s
- 결제 계좌번호 셀: <br/> 줄바꿈 제거 + whitespace-nowrap + width 110px 로 한 줄 표시
- 총 합계 (VAT포함) 셀: whitespace-nowrap + 폰트 11px 로 좁은 셀에서도 한 줄
- 거래처 이메일(귀하 아래 라인) 표시 제거 — 외부 공유용 이미지에 사용자 이메일 노출 불필요

m/admin/orders 와 m/orders 두 곳 동일 적용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:21:28 +09:00
chpark 083188332c feat(orders): 관리자가 출고완료(SHIPPED) 건도 수정 가능하도록 권한 확장
Deploy momo-erp / deploy (push) Successful in 5m18s
기존: REQUESTED 상태만 수정 가능 (admin/user 동일)
변경:
- USER: REQUESTED 만 (기존 그대로)
- ADMIN: PAID/CANCELED 가 아니면 모두 (REQUESTED / SHIPPED 등 입금완료 전까지)

items/update, lines/save 두 API 동일 적용. 입금완료(PAID) 이후나 취소건은 admin 도 수정 불가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:08:02 +09:00
chpark 91ab88a359 fix(orders): 이미지 공유 버튼을 admin 만 노출하도록 보수적으로 변경
Deploy momo-erp / deploy (push) Successful in 2m47s
기존: user.role === "USER" 일 때 숨김 → 거래처라도 role 필드 비어있으면 노출되는 버그
변경: user.isAdmin === true || role === "ADMIN" 일 때만 노출 → 그 외는 모두 숨김
admin 계정만 거래명세표 이미지 공유 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:49:08 +09:00
chpark bfb9470c85 fix(mobile): 뒤로가기 토스트가 페이지 이동 후에도 작동하도록
Deploy momo-erp / deploy (push) Successful in 2m59s
기존: mount 시 한 번만 history sentinel push → 사용자가 navigation 하면 sentinel 잃어버려 토스트 안 뜸.
변경: usePathname 의존성 useEffect → pathname 변경마다 sentinel 새로 push. lastBackRef 도 ref 로 변경(렌더 의존성 없이 상태 유지).
+ swal toast z-index 9999 강제 (다른 모달 위)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:45:14 +09:00
chpark 1e0a2640e9 feat(mobile): 로그아웃 모바일 분기 + 뒤로가기 토스트 활성화 조건 완화
Deploy momo-erp / deploy (push) Failing after 3s
- middleware: User-Agent 가 모바일이면 /login → /m/login 으로 redirect (서버 측 분기)
- auth-store.logout: window 가 모바일이면 /m/login, 아니면 /login (클라이언트 측 분기)
- BackButtonGuard: TWA 일부 환경에서 display-mode 가 standalone 으로 보고되지 않는 케이스 대응 — fullscreen/minimal-ui 도 포함, 모바일 UA 면 무조건 활성화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:34:26 +09:00
chpark 0e9378a638 feat(orders): 사용자(USER)의 내출고이력에서 이미지 공유 버튼 숨김
Deploy momo-erp / deploy (push) Failing after 4s
m/orders 페이지의 거래명세표 DetailModal 에서 "이미지 공유" 버튼이 모든 사용자에게 노출되던 문제.
useAuthStore 의 user.role === "USER" 이면 버튼 hide. ADMIN 은 그대로 표시.

엑셀 다운로드 버튼은 그대로 유지 (사용자가 자기 주문 명세는 엑셀로 받을 수 있음).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:26:26 +09:00
264 changed files with 11554 additions and 598929 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
DATABASE_URL="postgresql://momo_app:qlalfqjsgh11@183.99.177.40:5432/distribution"
DATABASE_URL="postgresql://momo_app:qlalfqjsgh11@121.156.99.3:5432/distribution"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="2b1f94cca798f49ff62822b01617503b019d118df9d249ee61f835a7dca1946e"
NEXT_PUBLIC_APP_NAME="유통관리 ERP"
+7
View File
@@ -17,3 +17,10 @@ SMTP_FROM=모모유통 <momo8443@daum.net>
# ============ 거래명세표에 표시될 공급자 정보 ============
MOMO_BANK_ACCOUNT=기업은행 434-115361-01-016
MOMO_PHONE=010-6624-5315
# ============ 웹 푸시(PWA 알림) ============
# 미설정 시 코드 하드코딩 기본 VAPID 키 사용. 운영에서 키를 교체하려면 아래 지정.
# 생성: npx web-push generate-vapid-keys
# VAPID_PUBLIC_KEY=__public__
# VAPID_PRIVATE_KEY=__private__
# VAPID_SUBJECT=mailto:admin@momotogether.com
-108
View File
@@ -1,108 +0,0 @@
name: Deploy momo-erp
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Install sshpass
run: |
apt-get update -qq && apt-get install -y -qq sshpass openssh-client || \
sudo apt-get update -qq && sudo apt-get install -y -qq sshpass openssh-client
- name: Deploy via SSH (password auth)
run: |
set -e # 배포 단계 실패하면 즉시 워크플로우 fail (헬스체크에 의존하지 않음)
export SSHPASS='qlalfqjsgh11'
mkdir -p ~/.ssh
ssh-keyscan -H 183.99.177.40 >> ~/.ssh/known_hosts 2>/dev/null || true
sshpass -e ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
chpark@183.99.177.40 'bash -s' <<'REMOTE_SCRIPT'
set -e # 원격 명령도 fail 즉시 중단
DEPLOY_DIR="$HOME/momo-erp/source"
mkdir -p "$HOME/momo-erp"
if [ -d "$DEPLOY_DIR/.git" ]; then
cd "$DEPLOY_DIR"
git fetch origin
git reset --hard origin/main
else
git clone https://git.junggomoa.com/chpark/distribution_erp.git "$DEPLOY_DIR"
cd "$DEPLOY_DIR"
fi
# 빌드 SHA 마커 박기 — 헬스체크가 이 값으로 신버전 반영 여부 판단
DEPLOY_SHA=$(git rev-parse HEAD)
echo "$DEPLOY_SHA" > public/build-sha.txt
echo "▶ 배포 대상 SHA: $DEPLOY_SHA"
# .env.production 갱신 (SMTP/MOMO 포함)
cat > .env.production <<'ENVEOF'
DATABASE_URL=postgresql://momo_app:qlalfqjsgh11@183.99.177.40:5432/distribution
NEXTAUTH_URL=https://momotogether.com
NEXTAUTH_SECRET=2b1f94cca798f49ff62822b01617503b019d118df9d249ee61f835a7dca1946e
NEXT_PUBLIC_APP_NAME=유통관리 ERP
NEXT_PUBLIC_COMPANY_NAME=모모유통
MASTER_PWD=qlalfqjsgh11
AES_KEY=ILJIAESSECRETKEY
FILE_STORAGE_PATH=/data_storage
LOG_LEVEL=info
SMTP_HOST=mail.coa-soft.com
SMTP_PORT=465
SMTP_USER=chpark@coa-soft.com
SMTP_PASS=1321Qkrckd!!!!!!
SMTP_FROM=모모유통 <chpark@coa-soft.com>
MOMO_BANK_ACCOUNT=기업은행 434-115361-01-016
MOMO_PHONE=010-6624-5315
DEPLOY_WEBHOOK_TOKEN=momo-deploy-2026-secure
ENVEOF
# 빌드는 먼저, 그 다음 down + up 으로 swap (--force-recreate 가 가끔 이름 충돌 일으킴)
docker compose -f docker-compose.prod.yml build momo-erp
docker compose -f docker-compose.prod.yml down --remove-orphans
docker compose -f docker-compose.prod.yml up -d momo-erp
# 옛 momo-erp 이미지(latest 태그가 새 빌드로 갱신되며 dangling 이 된 옛 sha)는 prune.
# -f 만 사용 (dangling 만). 다른 프로젝트의 사용 중 이미지는 건드리지 않음.
docker image prune -f 2>&1 | tail -3 || true
# 마이그레이션 (idempotent) — 컨테이너 안에 db/migrations + scripts/migrate-momo.mjs 가
# standalone 번들에 포함되어 있어야 동작 (next.config.ts outputFileTracingIncludes).
# 컨테이너 시도 후 실패하면 호스트 측 docker run 으로 폴백 (소스 마운트 사용).
if docker compose -f docker-compose.prod.yml exec -T momo-erp node scripts/migrate-momo.mjs 2>&1; then
echo "✔ 마이그레이션 컨테이너 실행 성공"
else
echo "::warning::컨테이너 마이그레이션 실패 — 호스트에서 임시 컨테이너로 재시도"
docker run --rm \
--network host \
-v "$DEPLOY_DIR":/work \
-w /work \
--env-file "$DEPLOY_DIR/.env.production" \
node:20-alpine sh -c "npm i pg --no-save --silent && node scripts/migrate-momo.mjs" \
|| echo "::error::마이그레이션 모두 실패 — 수동 실행 필요"
fi
docker compose -f docker-compose.prod.yml ps
echo "✔ 배포 완료"
REMOTE_SCRIPT
- name: Healthcheck (build-sha.txt 일치 검증)
run: |
EXPECTED="${GITHUB_SHA}"
echo "▶ 기대 SHA: $EXPECTED"
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
sleep 10
REMOTE=$(curl -sS -m 5 -L "https://momotogether.com/build-sha.txt?_=$(date +%s)" 2>/dev/null | tr -d '[:space:]' || true)
echo " ${i}/12: 운영 SHA=${REMOTE:-(없음)}"
if [ -n "$REMOTE" ] && [ "$REMOTE" = "$EXPECTED" ]; then
echo "::notice::✔ 운영에 신버전(${REMOTE:0:8}) 반영 확인"
exit 0
fi
done
echo "::error::헬스체크 실패: 운영의 build-sha (${REMOTE:-없음})가 기대 SHA(${EXPECTED:0:8})와 다름 — 빌드/재시작 실패 가능"
exit 1
+15
View File
@@ -24,6 +24,13 @@
.DS_Store
*.pem
# 안드로이드 서명 키/번들 — 절대 커밋 금지 (Play Store 서명 키 노출 = 앱 도용 위험)
*.keystore
*.jks
*.aab
*.apk
signing-key-info.txt
# debug
npm-debug.log*
yarn-debug.log*
@@ -42,3 +49,11 @@ yarn-error.log*
next-env.d.ts
/src/generated/prisma
# Android TWA build artifacts / keystore (절대 커밋 금지)
android/*.keystore
android/*.jks
android/*.aab
android/*.apk
android/app/
android/.gradle/
+5 -5
View File
@@ -3,7 +3,7 @@
## 개요
`.gitea/workflows/deploy.yml` 워크플로가 `main` 브랜치 푸시 시 자동으로
배포 서버(183.99.177.40)에 SSH 접속 → `docker compose up -d --build` 실행합니다.
배포 서버(121.156.99.3)에 SSH 접속 → `docker compose up -d --build` 실행합니다.
## Gitea 시크릿 등록
@@ -11,10 +11,10 @@ Gitea 저장소 → **Settings → Actions → Secrets** 에 다음 시크릿을
| 시크릿 이름 | 값 (예시) |
|-------------|----------|
| `DEPLOY_HOST` | `183.99.177.40` |
| `DEPLOY_HOST` | `121.156.99.3` |
| `DEPLOY_USER` | `chpark` |
| `DEPLOY_SSH_KEY` | SSH 개인키 전체 (BEGIN/END 포함) |
| `DATABASE_URL` | `postgresql://postgres:qlalfqjsgh11@183.99.177.40:5432/distribution` |
| `DATABASE_URL` | `postgresql://momo_app:qlalfqjsgh11@121.156.99.3:5432/distribution` |
| `NEXTAUTH_URL` | `https://momotogether.com` |
| `NEXTAUTH_SECRET` | 임의의 32바이트 hex (현재 .env.production 값 재사용 가능) |
| `MASTER_PWD` | `qlalfqjsgh11` |
@@ -26,7 +26,7 @@ Gitea 저장소 → **Settings → Actions → Secrets** 에 다음 시크릿을
```bash
ssh-keygen -t ed25519 -C "gitea-deploy" -f ~/.ssh/momo_deploy -N ""
# 공개키를 배포 서버에 등록
ssh-copy-id -i ~/.ssh/momo_deploy.pub chpark@183.99.177.40
ssh-copy-id -i ~/.ssh/momo_deploy.pub chpark@121.156.99.3
# 개인키를 Gitea Secret `DEPLOY_SSH_KEY` 에 붙여넣기
cat ~/.ssh/momo_deploy
```
@@ -41,7 +41,7 @@ cat ~/.ssh/momo_deploy
긴급 시:
```bash
ssh chpark@183.99.177.40
ssh chpark@121.156.99.3
cd ~/momo-erp/source
git pull
docker compose -f docker-compose.prod.yml up -d --build
+2 -2
View File
@@ -26,7 +26,7 @@
## 기술 스택
- **Frontend**: Next.js 15 (App Router), React 19, TypeScript, Tailwind CSS
- **Backend**: Next.js API Routes (Node.js)
- **Database**: PostgreSQL (외부 공용 서버 `211.115.91.141:11140/fito`, raw SQL via `pg`)
- **Database**: PostgreSQL (외부 공용 서버 `121.156.99.3:5432/distribution`, raw SQL via `pg`)
- **인증**: JWT (jose) + Cookie 기반 세션
- **상태관리**: Zustand
- **UI**: SweetAlert2, Lucide Icons, Custom DataGrid (TanStack React Table)
@@ -76,4 +76,4 @@ npm run dev # 개발 서버 (localhost:3000)
- Docker Compose dev/prod 분리
- Traefik 리버스 프록시 + `fito.wace.me` 서브도메인
- DB는 외부 `211.115.91.141:11140/fito` 공유 (컨테이너 내부 DB 없음)
- DB는 외부 `121.156.99.3:5432/distribution` 공유 (컨테이너 내부 DB 없음)
+8 -1
View File
@@ -26,9 +26,16 @@ ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# webhook 자기재배포에 필요한 CLI: git (소스 동기), docker + compose (이미지 빌드/swap)
# docker socket 은 docker-compose.prod.yml 에서 host 의 /var/run/docker.sock 으로 마운트됨
RUN apk add --no-cache git docker-cli docker-cli-compose
# 비루트 사용자 (보안)
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
adduser --system --uid 1001 nextjs && \
# docker socket 접근 권한: 운영 호스트 /var/run/docker.sock 의 GID(988) 와 동일한 그룹 생성 후 nextjs 가입
addgroup -g 988 dockerhost && \
addgroup nextjs dockerhost
# standalone 번들 복사
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+155 -47
View File
@@ -1,70 +1,178 @@
# FITO — (주)피토 PLM (Next.js)
# 모모유통 ERP (Distribution ERP)
기존 Java/Spring MVC + JSP + MyBatis 기반 FITO PLM을 Next.js 15 + Node.js로 컨버전한 시스템.
식자재/도소매 **유통·물류 업무 통합 관리 시스템**.
거래처가 발주를 넣고, 본사가 출고·정산하고, 매입·입고·재고·세금계산서까지 한 화면에서 처리한다.
- 원본: [/Users/jhj/FITO](../FITO) (Java 7 + Spring 3.2.4 + MyBatis 3.2.3 + JSP)
- DB: 외부 PostgreSQL `211.115.91.141:11140/fito` (기존 스키마 그대로 사용)
- 이전 이력: `woosung-nextjs`에서 피벗. 스냅샷 태그 `woosung-v1-snapshot`.
- 운영 도메인: **https://momotogether.com**
- Android 앱: **`com.momotogether.app`** (TWA, Play 스토어 등록용 AAB / 사이드로드 APK)
- 코드 저장소(원격): `git.junggomoa.com/chpark/distribution_erp`
- 사용자: (주)모모유통 본사·김포지사 + 계약 거래처 전용 — 일반 소비자용 아님
## 개발 시작
---
## 주요 기능
### 거래처(구매자) 화면
- **출고 요청** — 재고 있는 품목 선택 → 장바구니 → 발주 요청. 택배전용·용차비·환불 라인 자동/수동 추가. 상시판매·기간한정 품목 분리.
- **내 출고 이력** — 본인 발주 진행상태 추적.
- **푸시 알림 토글** — PWA 설치 후 새 상품 등록 시 알림 수신.
- **회원정보 수정** — 본인 정보·기준 명세표 변경.
### 본사/지사 운영
- **출고 처리** — 발주 검토 → 거래명세표 자동 생성(이미지/엑셀/메일 발송) → 재고 자동 차감.
- **입금 관리** — 출고 후 미수금 추적·입금 등록.
- **계산서·전자세금계산서** — 과세/면세 자동 분리. 국세청 ESERO 연동 어댑터(현재는 DB 기록).
- **매입 발주** — 공급업체별 발주서 작성·메일 발송. 발주지사(HQ/KIMPO) 선택.
- **매입 입금관리** — 진행상태와 결재상태(입금완료/미입금)를 **분리 관리**. 부분입고 시 입고금액 기준 입금 처리.
- **입고 처리** — 정상/불량 분리 입고, 유통기한·물류팀 최종완료자 체크리스트.
- **재고 관리** — 창고별 실시간 재고, 입출고 이력, 창고 간 이동, 유통기한 임박 알림.
### 마스터·통계·관리
- **품목 마스터** — 가격·원가·과세/면세·택배전용·1회 발주한도·판매기간(또는 상시판매) + 일괄 적용.
- **공급업체/거래처/창고/계산서 기준정보** 관리.
- **푸시알림 게시판** — 관리자가 공지(이미지+본문)를 작성하고 선택한 구독자에게 푸시 발송. 사용자가 알림 탭하면 공지 페이지로 이동.
- **통계** — 대시보드, 월간/일자별 매출, 원가·마진, 거래처×일자 매출, 지사 수수료, 창고 이동 통계.
---
## 기술 스택
- **풀스택 단일 Next.js 프로젝트** — 프론트(React 19, App Router) + 백엔드(API Routes, Node.js) 한 저장소.
- **TypeScript** strict mode, **Tailwind CSS**.
- **DB**: 외부 PostgreSQL `121.156.99.3:5432/distribution` — raw SQL(`pg`).
- **인증**: JWT(jose) + HTTP Cookie 세션 + AES-128-ECB(비밀번호).
- **PWA**: `manifest.json` + Service Worker(`public/sw.js`) — 푸시 핸들러·알림 위임(badge·icon).
- **푸시**: `web-push`(VAPID) — `momo_push_subscriptions` 에 endpoint 저장, 발송은 `lib/push.ts`.
- **거래명세표 캡처**: `html-to-image` (이미지 공유/저장).
- **상태관리**: Zustand (auth/menu/theme).
---
## 디렉토리 구조
```
src/
├── app/
│ ├── (auth)/login 로그인
│ ├── (main)/
│ │ ├── m/orders/new 거래처 — 출고요청
│ │ ├── m/orders 거래처 — 내 출고이력
│ │ ├── m/notices/[id] 공지 도달 페이지 (푸시 클릭 시)
│ │ ├── m/admin/orders 출고 처리
│ │ ├── m/admin/payments 입금 관리
│ │ ├── m/admin/invoices 계산서 발행
│ │ ├── m/admin/einvoices 전자세금계산서
│ │ ├── m/admin/procurements매입 발주서
│ │ ├── m/admin/proc-payments 매입 입금관리
│ │ ├── m/admin/inbounds 입고 처리
│ │ ├── m/admin/inventory 재고 관리
│ │ ├── m/admin/items 품목 마스터
│ │ ├── m/admin/notices 푸시알림 게시판
│ │ ├── m/admin/vendors 공급업체 관리
│ │ ├── m/admin/warehouses 창고 관리
│ │ ├── m/admin/statistics 통계 대시보드
│ │ └── profile 회원정보 수정
│ └── api/m/ 업무 API (orders/items/inbounds/push/notices …)
├── lib/
│ ├── db.ts PostgreSQL Pool (queryRows/queryOne/execute)
│ ├── auth.ts 인증 + 세션
│ ├── push.ts web-push 발송 + 구독 관리
│ ├── notices.ts 공지 테이블 자동 생성
│ ├── momo-proc.ts 매입 진행/결재 분리 마이그레이션
│ └── capture-share.ts 이미지 캡처/공유
├── components/
│ ├── layout/ Header / Sidebar
│ ├── grid/ DataGrid (TanStack Table)
│ ├── ui/ 버튼/입력/SearchableSelect 등
│ └── push-optin.tsx 푸시 알림 켜기/끄기 토글 (localStorage 영속)
└── store/ Zustand (auth/menu/theme)
public/
├── sw.js Service Worker — fetch 캐시 + push/notificationclick
├── manifest.json PWA 매니페스트
├── icon-{192,512}.png PWA 아이콘 (모모 로고)
├── badge-96.png 알림 상태바 단색 배지
└── .well-known/assetlinks.json TWA Digital Asset Links
android/
├── twa-manifest.json Bubblewrap TWA 빌드 설정 (notification delegation ON)
└── README.md APK/AAB 빌드 가이드 (PWABuilder / Bubblewrap)
```
각 디렉토리별 상세는 `*/CLAUDE.md` 참고.
---
## 로컬 개발
```bash
npm install
npm run dev # http://localhost:3000
npm run dev # http://localhost:3000
npm run build # 운영 빌드 검증
npm run lint
```
## 환경변수
### 환경변수 (`.env.development`)
`.env.development`의 DB 접속 정보를 확인. 필수 키:
| 키 | 설명 |
|---|---|
| `DATABASE_URL` | PostgreSQL 접속 (예: `postgresql://momo_app:****@121.156.99.3:5432/distribution`) |
| `NEXTAUTH_URL` | 로컬: `http://localhost:3000` |
| `NEXTAUTH_SECRET` | JWT 서명 시크릿 |
| `AES_KEY` | 16바이트 — 비밀번호 AES 키 (기존 데이터 호환 필요) |
| `MASTER_PWD` | 마스터 비밀번호 (개발 편의) |
| `SMTP_HOST/USER/PASS/FROM` | 거래명세표·계산서 메일 발송 |
| `MOMO_BANK_ACCOUNT`, `MOMO_PHONE` | 거래명세표 공급자 정보 (기준명세표 미설정 폴백) |
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | (선택) 웹푸시 VAPID — 미설정 시 `lib/push.ts` 의 기본키 사용 |
- `DATABASE_URL` — 외부 PostgreSQL 접속
- `NEXTAUTH_SECRET` — JWT 서명 키
- `MASTER_PWD` — 마스터 비밀번호 (개발 편의용)
- `AES_KEY` — 비밀번호 AES 암호화 키 (기존 Java 호환)
`.env.momo.example` 참고.
## 배포 표준
---
- Docker Compose (dev/prod 분리) — 기존 FITO(Java) 배포환경 재사용
- Traefik 리버스 프록시 + `fito.wace.me` 도메인 (entrypoints: web, websecure / certresolver: le)
- 외부 네트워크 `toktork_server_default`
- DB는 외부 서버 공유 (`211.115.91.141:11140/fito`) — 컨테이너 내부 DB 없음
## 배포 — Gitea Actions 자동 배포
### `start.sh` 배포 스크립트 (권장)
`main` 브랜치에 push 하면 [`.gitea/workflows/deploy.yml`](.gitea/workflows/deploy.yml) 이 자동 실행되어 운영 서버에서 `git pull → docker compose build → up -d` 까지 수행.
```bash
# 첫 배포 (서버에서)
cp .env.production.example .env.production
vi .env.production # DATABASE_URL, NEXTAUTH_SECRET, AES_KEY 등 입력
- 운영 서버: `121.156.99.3` (SSH, chpark)
- Compose 파일: [`docker-compose.prod.yml`](docker-compose.prod.yml)
- 컨테이너명: `momo-erp` / 이미지: `momo-erp:latest`
- 리버스 프록시: Traefik (`traefik-net` 외부 네트워크, Let's Encrypt 자동발급)
- 호스트: `momotogether.com`, `www.momotogether.com`
- 영구 스토리지: named volume `momo_data_storage``/data_storage` (업로드 이미지)
- DB: 외부 공유 — 컨테이너 내부 DB 없음
./start.sh prod # git pull → build → 기동 → Traefik 라우팅 확인
> ⚠️ **수동 SSH 빌드 금지** — 자동 워크플로우와 충돌. 배포 검증은 `build-sha.txt` 또는 `docker logs momo-erp` 로 확인.
# 이후 배포 (git commit 후)
./start.sh prod # 자동 git pull + 재빌드
---
# 기타 운영
./start.sh logs prod # 실시간 로그
./start.sh restart prod # 재시작 (git pull 포함)
./start.sh stop prod # 중지
./start.sh status prod # 컨테이너 상태
./start.sh build prod # no-cache 재빌드
./start.sh clean prod # 전체 삭제 (확인 필요)
```
## Android 앱 (TWA)
스크립트는 start.sh 자체가 업데이트되면 새 버전으로 **자동 재실행**하므로 안전합니다.
`com.momotogether.app``momotogether.com` 을 감싸는 Trusted Web Activity.
### 로컬 개발
- **알림 위임(notification delegation) ON** — 웹 푸시가 "삼성 인터넷" 등 브라우저가 아니라 **모모유통 앱 이름·아이콘**으로 표시됨.
- **AAB**: Play 스토어 업로드용
- **APK**: 사이드로드(직접 설치) 테스트용
- 빌드 가이드: [`android/README.md`](android/README.md) (PWABuilder 또는 Bubblewrap)
- ⚠️ 기존 서명키 재사용 필수 — 키가 바뀌면 `assetlinks.json` 지문이 안 맞아 위임 깨짐.
```bash
./start.sh # docker 기반 (localhost:3643, hot reload)
npm run dev # docker 없이 Node 직접 (localhost:3000)
```
서비스워커 갱신 / 페이지 콘텐츠 변경은 **APK 재빌드 불필요** — 서버 배포만으로 앱에도 반영됨.
### 인프라 정보
---
- 컨테이너명: `plm-fito-next` (prod) / `plm-fito-next-dev` (dev)
- 도메인: `https://fito.wace.me`
- 내부 포트: 3000 (Traefik이 외부 80/443 → 3000)
- 파일 저장: 호스트 `./data_storage` (레포 상대경로) ↔ 컨테이너 `/data_storage`
- 이미지: Next.js `output: "standalone"` 기반 multi-stage build
## 코딩 컨벤션
상세 구성은 [CLAUDE.md](CLAUDE.md) 참고.
상세 규칙은 [`.claude/rules/`](.claude/rules/) 와 디렉토리별 `CLAUDE.md` 참고.
- **SQL alias 대문자 유지**: `SELECT col AS "OBJID"` (큰따옴표 필수, 없으면 PG 가 소문자 반환)
- **삭제 플래그**: `COALESCE(is_del,'N') != 'Y'`
- **objid 타입 변환**: `objid::text AS "OBJID"`
- **API 응답**: 목록 `{ RESULTLIST, TOTAL_CNT }`, 단건 `{ success, data }`, 저장 `{ success, objId? }`, 오류 `{ success:false, message }` + 적절한 status code
- **인증 가드**: 모든 API 라우트 첫 줄에 `getSession()` / `requireMomoAdmin()` / `requireMomoUser()`
- **신규 컬럼**: `ensureColumns` 패턴으로 라우트 첫 호출 시 자동 ALTER (운영 무중단)
- **푸시 알림 대상**: 일반 발송은 모든 구독자 / 거래처 전용은 `sendPush(payload, undefined, { generalOnly: true })`
---
## 라이선스
내부용. 외부 배포·재사용 금지.
+37
View File
@@ -0,0 +1,37 @@
# 모모유통 ERP — Android(TWA) 빌드
웹앱(momotogether.com)을 감싸는 **TWA(Trusted Web Activity)** 패키지 빌드 설정입니다.
패키지명 `com.momotogether.app``public/.well-known/assetlinks.json` 의 지문과 짝을 이룹니다.
## 스토어 업로드 형식
- **Google Play 스토어 → AAB** (필수)
- 직접 배포(사이드로드) → APK
## ⚠️ 서명키(중요)
반드시 **기존에 쓰던 keystore 그대로** 사용해야 합니다.
다른 키로 서명하면 `assetlinks.json` 지문이 안 맞아 주소창이 다시 뜨고 **알림 위임(푸시)** 이 깨집니다.
- 기존 지문: `2A:55:B2:9E:03:51:2B:DE:28:E2:A4:34:15:9C:23:1F:21:B6:C0:43:9C:10:3B:6C:E2:D5:46:F7:AF:42:C3:97`
- Play App Signing 을 쓰는 경우, Play Console 의 **앱 서명 인증서 SHA-256** 도 assetlinks 에 포함되어야 합니다.
## 방법 A — PWABuilder (권장, 로컬 툴 불필요)
1. https://www.pwabuilder.com 에서 `https://momotogether.com` 입력
2. **Package For Stores → Android Package**
3. 옵션에서 **Notification delegation(알림 위임) 켜기** ✅ (이게 켜져야 "삼성 인터넷" 대신 앱 이름으로 알림이 옵니다)
4. **기존 서명키 업로드**(처음 만들 때 받은 keystore) — 새로 만들지 말 것
5. **versionCode 를 현재 게시 버전보다 +1**
6. 생성된 **.aab** 를 Play Console 에 새 버전으로 업로드
## 방법 B — Bubblewrap CLI (이 폴더의 twa-manifest.json 사용)
JDK 17 + Android SDK 필요.
```bash
npm i -g @bubblewrap/cli
cd android
# 기존 keystore 를 ./android.keystore 로 복사 (signingKey.path 와 일치)
bubblewrap build # app-release-bundle.aab(스토어용) + app-release-signed.apk(사이드로드용) 생성
```
- `twa-manifest.json``enableNotifications: true` 가 알림 위임 활성화 키입니다.
- 새 버전 낼 때마다 `appVersionCode` 를 올리세요.
## 설치 후 (사용자)
홈화면 설치 → 첫 실행 시 **알림 권한 허용** → 출고요청 화면의 **알림 스위치 ON**.
그러면 새 품목 등록 시 "모모유통" 이름·아이콘으로 푸시가 옵니다.
+40
View File
@@ -0,0 +1,40 @@
{
"packageId": "com.momotogether.app",
"host": "momotogether.com",
"name": "모모유통 ERP",
"launcherName": "모모ERP",
"display": "standalone",
"themeColor": "#1f2937",
"themeColorDark": "#1f2937",
"navigationColor": "#1f2937",
"navigationColorDark": "#1f2937",
"navigationDividerColor": "#1f2937",
"navigationDividerColorDark": "#1f2937",
"backgroundColor": "#ffffff",
"enableNotifications": true,
"startUrl": "/m/login",
"iconUrl": "https://momotogether.com/icon-512.png",
"maskableIconUrl": "https://momotogether.com/icon-512.png",
"monochromeIconUrl": "https://momotogether.com/badge-96.png",
"shortcuts": [],
"generatorApp": "bubblewrap-cli",
"webManifestUrl": "https://momotogether.com/manifest.json",
"fallbackType": "customtabs",
"features": {},
"alphaDependencies": { "enabled": false },
"enableSiteSettingsShortcut": true,
"isChromeOSOnly": false,
"isMetaQuest": false,
"fullScopeUrl": "https://momotogether.com/",
"minSdkVersion": 21,
"orientation": "portrait",
"fingerprints": [],
"additionalTrustedOrigins": [],
"retainedBundles": [],
"appVersion": "1.0.1",
"appVersionCode": 2,
"signingKey": {
"path": "./android.keystore",
"alias": "android"
}
}
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
set -e
echo "CREATE ROLE pro_search; CREATE ROLE search_user;" | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"
-582482
View File
File diff suppressed because one or more lines are too long
-209
View File
@@ -1,209 +0,0 @@
-- ============================================================================
-- 모모유통 (MOMO) 유통관리 시스템 — 초기 스키마
-- 기존 FITO 테이블과 분리하기 위해 momo_ 접두사 사용
-- 실행: psql $DATABASE_URL -f db/migrations/001_momo_init.sql
-- ============================================================================
BEGIN;
-- 1. 회원 (대리점 + 관리자) ----------------------------------------------------
CREATE TABLE IF NOT EXISTS momo_users (
objid TEXT PRIMARY KEY,
email VARCHAR(200) NOT NULL UNIQUE,
password_hash VARCHAR(200) NOT NULL,
company_name VARCHAR(200) NOT NULL,
ceo_name VARCHAR(100),
biz_no VARCHAR(20),
phone VARCHAR(50),
address VARCHAR(300),
role VARCHAR(20) NOT NULL DEFAULT 'USER', -- USER | ADMIN
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | LOCKED | LEFT
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW(),
regid TEXT,
update_date TIMESTAMP,
update_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_momo_users_email ON momo_users(email);
CREATE INDEX IF NOT EXISTS idx_momo_users_role ON momo_users(role, status);
-- 2. 제조사 ------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS momo_makers (
objid TEXT PRIMARY KEY,
maker_name VARCHAR(200) NOT NULL,
contact VARCHAR(100),
phone VARCHAR(50),
memo TEXT,
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW(),
regid TEXT
);
-- 3. 품목 --------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS momo_items (
objid TEXT PRIMARY KEY,
item_code VARCHAR(50) NOT NULL UNIQUE,
item_name VARCHAR(200) NOT NULL,
item_detail TEXT,
maker_objid TEXT,
unit VARCHAR(20) DEFAULT 'EA',
unit_price NUMERIC(15,2) DEFAULT 0,
cost_price NUMERIC(15,2) DEFAULT 0,
is_tax_free CHAR(1) DEFAULT 'N', -- 'Y' = 면세 (M 접두 품목)
image_url TEXT,
attributes JSONB,
status VARCHAR(20) DEFAULT 'ACTIVE',
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW(),
regid TEXT,
update_date TIMESTAMP,
update_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_momo_items_status ON momo_items(status, is_del);
CREATE INDEX IF NOT EXISTS idx_momo_items_taxfree ON momo_items(is_tax_free);
CREATE INDEX IF NOT EXISTS idx_momo_items_name ON momo_items(item_name);
-- 4. 창고 --------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS momo_warehouses (
objid TEXT PRIMARY KEY,
wh_code VARCHAR(50) NOT NULL UNIQUE,
wh_name VARCHAR(200) NOT NULL,
location VARCHAR(200),
wh_type VARCHAR(20) DEFAULT 'STOCK', -- STOCK | PICKUP_TEAM | MARKET | DELIVERY
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW()
);
-- 5. 재고 (창고×품목) --------------------------------------------------------
CREATE TABLE IF NOT EXISTS momo_stocks (
objid TEXT PRIMARY KEY,
wh_objid TEXT NOT NULL,
item_objid TEXT NOT NULL,
qty NUMERIC(15,2) NOT NULL DEFAULT 0,
update_date TIMESTAMP DEFAULT NOW(),
UNIQUE(wh_objid, item_objid)
);
CREATE INDEX IF NOT EXISTS idx_momo_stocks_item ON momo_stocks(item_objid);
-- 6. 입출고 이력 -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS momo_stock_moves (
objid TEXT PRIMARY KEY,
wh_objid TEXT NOT NULL,
item_objid TEXT NOT NULL,
move_type VARCHAR(20) NOT NULL, -- IN | OUT | ADJ | TRANSFER
qty NUMERIC(15,2) NOT NULL,
ref_type VARCHAR(20), -- ORDER | PROCUREMENT | MANUAL
ref_objid TEXT,
memo TEXT,
regdate TIMESTAMP DEFAULT NOW(),
regid TEXT
);
CREATE INDEX IF NOT EXISTS idx_momo_moves_item ON momo_stock_moves(item_objid, regdate);
CREATE INDEX IF NOT EXISTS idx_momo_moves_ref ON momo_stock_moves(ref_type, ref_objid);
-- 7. 발주서 (대리점 → 모모유통) ---------------------------------------------
CREATE TABLE IF NOT EXISTS momo_orders (
objid TEXT PRIMARY KEY,
order_no VARCHAR(50) NOT NULL UNIQUE,
customer_objid TEXT NOT NULL,
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
approve_user TEXT,
approve_date TIMESTAMP,
ship_date TIMESTAMP,
invoice_no VARCHAR(50),
invoice_date DATE,
total_supply NUMERIC(15,2) DEFAULT 0,
total_vat NUMERIC(15,2) DEFAULT 0,
total_amount NUMERIC(15,2) DEFAULT 0,
total_taxfree NUMERIC(15,2) DEFAULT 0,
total_taxable NUMERIC(15,2) DEFAULT 0,
paid_amount NUMERIC(15,2) DEFAULT 0,
paid_date DATE,
memo TEXT,
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW(),
regid TEXT,
update_date TIMESTAMP,
update_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_momo_orders_cust ON momo_orders(customer_objid, status);
CREATE INDEX IF NOT EXISTS idx_momo_orders_status ON momo_orders(status, order_date);
CREATE TABLE IF NOT EXISTS momo_order_items (
objid TEXT PRIMARY KEY,
order_objid TEXT NOT NULL,
item_objid TEXT NOT NULL,
item_name_snap VARCHAR(200),
unit_price NUMERIC(15,2) NOT NULL,
qty NUMERIC(15,2) NOT NULL,
is_tax_free CHAR(1) NOT NULL DEFAULT 'N',
supply_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
total_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
seq INT,
remark VARCHAR(200)
);
CREATE INDEX IF NOT EXISTS idx_momo_order_items ON momo_order_items(order_objid);
-- 8. 매입처 / 매입발주 -------------------------------------------------------
CREATE TABLE IF NOT EXISTS momo_vendors (
objid TEXT PRIMARY KEY,
vendor_name VARCHAR(200) NOT NULL,
contact VARCHAR(100),
phone VARCHAR(50),
biz_no VARCHAR(20),
is_del CHAR(1) DEFAULT 'N'
);
CREATE TABLE IF NOT EXISTS momo_procurements (
objid TEXT PRIMARY KEY,
proc_no VARCHAR(50) NOT NULL UNIQUE,
vendor_objid TEXT,
proc_date DATE NOT NULL DEFAULT CURRENT_DATE,
status VARCHAR(20) DEFAULT 'OPEN',
total_amount NUMERIC(15,2) DEFAULT 0,
memo TEXT,
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS momo_procurement_items (
objid TEXT PRIMARY KEY,
proc_objid TEXT NOT NULL,
item_objid TEXT NOT NULL,
cost_price NUMERIC(15,2) NOT NULL,
qty NUMERIC(15,2) NOT NULL,
total_amount NUMERIC(15,2) NOT NULL,
received_qty NUMERIC(15,2) DEFAULT 0
);
-- 9. 첨부 / 메일 로그 --------------------------------------------------------
CREATE TABLE IF NOT EXISTS momo_attachments (
objid TEXT PRIMARY KEY,
ref_type VARCHAR(20) NOT NULL,
ref_objid TEXT NOT NULL,
file_name VARCHAR(300) NOT NULL,
file_path TEXT NOT NULL,
mime_type VARCHAR(100),
file_size BIGINT,
regdate TIMESTAMP DEFAULT NOW(),
regid TEXT
);
CREATE INDEX IF NOT EXISTS idx_momo_attach_ref ON momo_attachments(ref_type, ref_objid);
CREATE TABLE IF NOT EXISTS momo_mail_logs (
objid TEXT PRIMARY KEY,
to_email VARCHAR(200) NOT NULL,
subject VARCHAR(300),
body TEXT,
ref_type VARCHAR(20),
ref_objid TEXT,
status VARCHAR(20) DEFAULT 'PENDING',
error_msg TEXT,
sent_at TIMESTAMP,
regdate TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_momo_maillogs_ref ON momo_mail_logs(ref_type, ref_objid);
COMMIT;
-37
View File
@@ -1,37 +0,0 @@
-- ============================================================================
-- 모모유통 시드 데이터 — 초기 관리자, 창고 4개, 샘플 제조사
-- 실행 전 db/migrations/001_momo_init.sql 적용 필요
-- ============================================================================
BEGIN;
-- 초기 관리자 (이메일: admin@momo.com / 비밀번호: admin1234 — bcrypt)
-- bcrypt hash for "admin1234" cost=10 — 운영 시 반드시 비밀번호 변경
INSERT INTO momo_users (objid, email, password_hash, company_name, role, status, regdate)
VALUES (
'MOMOADM00000001',
'admin@momo.com',
'$2b$10$gqkZxYVzQwH8gCWPvfBtFOg/9QDx2iO3p0d8RA7d7j.VhSZqHfqTa',
'모모유통(관리자)',
'ADMIN',
'ACTIVE',
NOW()
)
ON CONFLICT (email) DO NOTHING;
-- 창고
INSERT INTO momo_warehouses (objid, wh_code, wh_name, wh_type, regdate) VALUES
('MOMOWH000000001', 'WH001', '본사창고', 'STOCK', NOW()),
('MOMOWH000000002', 'WH002', '시장픽업', 'MARKET', NOW()),
('MOMOWH000000003', 'WH003', '용차배송', 'DELIVERY', NOW()),
('MOMOWH000000004', 'WH004', '창고픽업팀','PICKUP_TEAM', NOW())
ON CONFLICT (wh_code) DO NOTHING;
-- 샘플 제조사
INSERT INTO momo_makers (objid, maker_name, regdate) VALUES
('MOMOMK000000001', '성부유통', NOW()),
('MOMOMK000000002', '과트', NOW()),
('MOMOMK000000003', '날로유진', NOW())
ON CONFLICT DO NOTHING;
COMMIT;
-63
View File
@@ -1,63 +0,0 @@
-- ============================================================================
-- 모모유통 v2 — 매입발주/입고/정산 메뉴 분리
-- - 매입발주 입고 시 불량/파손 수량 분리
-- - 출고관리 상태값 재정의 (REQUESTED → APPROVED(=출고완료) → PAID → INVOICED)
-- - 매입처에 추가 정보
-- ============================================================================
BEGIN;
-- 매입발주 입고 라인에 정상/불량/파손 수량 분리
ALTER TABLE momo_procurement_items
ADD COLUMN IF NOT EXISTS received_normal NUMERIC(15,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS received_defect NUMERIC(15,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS defect_memo VARCHAR(500);
-- 입고 처리 헤더 (1매입발주 → N입고)
CREATE TABLE IF NOT EXISTS momo_inbounds (
objid TEXT PRIMARY KEY,
inbound_no VARCHAR(50) UNIQUE,
proc_objid TEXT, -- 매입발주 참조 (없어도 단독 입고 가능)
vendor_objid TEXT,
wh_objid TEXT NOT NULL,
inbound_date DATE NOT NULL DEFAULT CURRENT_DATE,
status VARCHAR(20) DEFAULT 'COMPLETED', -- COMPLETED | CANCELLED
total_amount NUMERIC(15,2) DEFAULT 0,
memo TEXT,
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW(),
regid TEXT
);
CREATE INDEX IF NOT EXISTS idx_momo_inbounds_date ON momo_inbounds(inbound_date);
CREATE INDEX IF NOT EXISTS idx_momo_inbounds_proc ON momo_inbounds(proc_objid);
CREATE TABLE IF NOT EXISTS momo_inbound_items (
objid TEXT PRIMARY KEY,
inbound_objid TEXT NOT NULL,
item_objid TEXT NOT NULL,
qty_normal NUMERIC(15,2) NOT NULL DEFAULT 0, -- 입고 정상 수량 → 재고에 +
qty_defect NUMERIC(15,2) NOT NULL DEFAULT 0, -- 불량/파손 (재고 미반영)
cost_price NUMERIC(15,2) NOT NULL DEFAULT 0,
defect_reason VARCHAR(200), -- 파손/유통기한임박/불량 등
total_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
seq INT
);
CREATE INDEX IF NOT EXISTS idx_momo_inbound_items ON momo_inbound_items(inbound_objid);
-- 매입처에 주소/이메일 추가
ALTER TABLE momo_vendors
ADD COLUMN IF NOT EXISTS email VARCHAR(200),
ADD COLUMN IF NOT EXISTS address VARCHAR(300),
ADD COLUMN IF NOT EXISTS regdate TIMESTAMP DEFAULT NOW();
-- 품목에 소비기한 / 본사+지사 구분 (엑셀 요청 7,8번)
-- attributes JSONB 에 자유 키 저장 가능. 별도 컬럼 추가는 생략.
-- 매입처 시드 (없으면)
INSERT INTO momo_vendors (objid, vendor_name, contact, phone)
VALUES
('VND_DEFAULT_001', '도매처A (기본)', '담당자', '02-0000-0000'),
('VND_DEFAULT_002', '도매처B (기본)', '담당자', '02-0000-0000')
ON CONFLICT (objid) DO NOTHING;
COMMIT;
-78
View File
@@ -1,78 +0,0 @@
-- 권한/메뉴 관리 (추후 ERP 확장용)
BEGIN;
CREATE TABLE IF NOT EXISTS momo_roles (
objid TEXT PRIMARY KEY,
role_code VARCHAR(50) NOT NULL UNIQUE,
role_name VARCHAR(100) NOT NULL,
description VARCHAR(300),
is_system CHAR(1) DEFAULT 'N', -- 'Y'면 시스템 기본권한 (수정/삭제 불가)
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS momo_menus (
objid TEXT PRIMARY KEY,
menu_code VARCHAR(50) NOT NULL UNIQUE,
menu_name VARCHAR(100) NOT NULL,
menu_url VARCHAR(200),
icon_name VARCHAR(50),
parent_code VARCHAR(50),
sort_order INT DEFAULT 0,
group_name VARCHAR(50),
is_system CHAR(1) DEFAULT 'N',
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS momo_role_menus (
role_code VARCHAR(50) NOT NULL,
menu_code VARCHAR(50) NOT NULL,
can_read CHAR(1) DEFAULT 'Y',
can_write CHAR(1) DEFAULT 'N',
PRIMARY KEY (role_code, menu_code)
);
-- 시스템 기본 권한 시드
INSERT INTO momo_roles (objid, role_code, role_name, description, is_system) VALUES
('ROLE_USER', 'USER', '거래처', '품목 검색 및 출고 요청만 가능', 'Y'),
('ROLE_ADMIN', 'ADMIN', '관리자', '모든 메뉴 접근 + 승인/정산', 'Y')
ON CONFLICT (role_code) DO NOTHING;
-- 메뉴 마스터 시드 (현재 사이드바와 동일 구조)
INSERT INTO momo_menus (objid, menu_code, menu_name, menu_url, group_name, sort_order, is_system) VALUES
('M_DASH', 'DASHBOARD', '대시보드', '/m/dashboard', '_top', 1, 'Y'),
('M_ITEMS', 'ITEMS', '품목 검색', '/m/items', '주문', 10, 'Y'),
('M_ONEW', 'ORDER_NEW', '출고 요청', '/m/orders/new', '주문', 11, 'Y'),
('M_OLIST', 'ORDER_LIST', '내 출고 이력', '/m/orders', '주문', 12, 'Y'),
('M_AITEM', 'A_ITEM', '품목 관리', '/m/admin/items', '마스터', 20, 'Y'),
('M_AVEND', 'A_VENDOR', '매입처 관리', '/m/admin/vendors', '마스터', 21, 'Y'),
('M_AWH', 'A_WH', '창고 관리', '/m/admin/warehouses', '마스터', 22, 'Y'),
('M_AUSER', 'A_USER', '회원 관리', '/m/admin/users', '마스터', 23, 'Y'),
('M_APROC', 'A_PROC', '매입 발주', '/m/admin/procurements','매입', 30, 'Y'),
('M_AINB', 'A_INBOUND', '입고 처리', '/m/admin/inbounds', '매입', 31, 'Y'),
('M_AINV', 'A_INV', '재고 관리', '/m/admin/inventory', '매입', 32, 'Y'),
('M_AORD', 'A_ORD', '출고 관리', '/m/admin/orders', '출고/정산', 40, 'Y'),
('M_APAY', 'A_PAY', '입금 관리', '/m/admin/payments', '출고/정산', 41, 'Y'),
('M_AINVO', 'A_INVOICE', '계산서 발행', '/m/admin/invoices', '출고/정산', 42, 'Y'),
('M_ASTAT', 'A_STAT_M', '월간 매출', '/m/admin/statistics', '통계', 50, 'Y'),
('M_ASTAD', 'A_STAT_D', '일자별', '/m/admin/statistics/daily', '통계', 51, 'Y'),
('M_ASTAR', 'A_STAT_R', '원가/마진', '/m/admin/statistics/margin', '통계', 52, 'Y'),
('M_AROLE', 'A_ROLE', '권한 관리', '/m/admin/roles', '시스템', 90, 'Y'),
('M_AMENU', 'A_MENU', '메뉴 관리', '/m/admin/menus', '시스템', 91, 'Y')
ON CONFLICT (menu_code) DO NOTHING;
-- USER 권한에 거래처 메뉴 매핑
INSERT INTO momo_role_menus (role_code, menu_code, can_read, can_write) VALUES
('USER', 'DASHBOARD', 'Y','N'),
('USER', 'ITEMS', 'Y','N'),
('USER', 'ORDER_NEW', 'Y','Y'),
('USER', 'ORDER_LIST', 'Y','N')
ON CONFLICT DO NOTHING;
-- ADMIN 권한에 모든 메뉴 매핑
INSERT INTO momo_role_menus (role_code, menu_code, can_read, can_write)
SELECT 'ADMIN', menu_code, 'Y', 'Y' FROM momo_menus
ON CONFLICT DO NOTHING;
COMMIT;
-53
View File
@@ -1,53 +0,0 @@
-- 모모유통 페이지를 FITO menu_info 의 [사용자] 그룹 아래에 대메뉴/소메뉴 2단 구조로 등록
-- 기존 [DASHBOARD] 대메뉴 활용 + 거래처/마스터/매입/출고-정산/통계 5개 신규 대메뉴
BEGIN;
-- 기존 모모 메뉴 정리
DELETE FROM menu_info WHERE objid BETWEEN 9000000 AND 9000599;
-- ===== 신규 대메뉴 (parent = -395553955 [사용자]) =====
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
(9000100, '1', -395553955, '거래처 주문', 'Customer Orders', 600, '', 'active', 'PMS', NOW()),
(9000200, '1', -395553955, '마스터 관리', 'Master', 650, '', 'active', 'PMS', NOW()),
(9000300, '1', -395553955, '매입/입고', 'Purchase', 700, '', 'active', 'PMS', NOW()),
(9000400, '1', -395553955, '출고/정산', 'Outbound', 750, '', 'active', 'PMS', NOW()),
(9000500, '1', -395553955, '통계', 'Statistics', 800, '', 'active', 'PMS', NOW());
-- ===== 기존 [DASHBOARD] 대메뉴(1837127121) 아래 자식 =====
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
(9000001, '1', 1837127121, '대시보드', 'Dashboard', 1, '/m/dashboard', 'active', 'PMS', NOW());
-- 기존 [DASHBOARD] 대메뉴의 다른 자식(예: dashboard.do)은 비활성화
UPDATE menu_info SET status='inactive' WHERE parent_obj_id = 1837127121 AND objid != 9000001;
-- ===== 거래처 주문 (9000100) =====
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
(9000101, '1', 9000100, '품목 검색', 'Items', 10, '/m/items', 'active', 'PMS', NOW()),
(9000102, '1', 9000100, '출고 요청', 'New Order', 11, '/m/orders/new', 'active', 'PMS', NOW()),
(9000103, '1', 9000100, '내 출고 이력', 'My Orders', 12, '/m/orders', 'active', 'PMS', NOW());
-- ===== 마스터 관리 (9000200) =====
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
(9000201, '1', 9000200, '품목 관리', 'Item Master', 10, '/m/admin/items', 'active', 'PMS', NOW()),
(9000202, '1', 9000200, '매입처 관리', 'Vendors', 11, '/m/admin/vendors', 'active', 'PMS', NOW()),
(9000203, '1', 9000200, '창고 관리', 'Warehouses', 12, '/m/admin/warehouses', 'active', 'PMS', NOW());
-- ===== 매입/입고 (9000300) =====
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
(9000301, '1', 9000300, '매입 발주', 'Procurements', 10, '/m/admin/procurements', 'active', 'PMS', NOW()),
(9000302, '1', 9000300, '입고 처리', 'Inbound', 11, '/m/admin/inbounds', 'active', 'PMS', NOW()),
(9000303, '1', 9000300, '재고 관리', 'Inventory', 12, '/m/admin/inventory', 'active', 'PMS', NOW());
-- ===== 출고/정산 (9000400) =====
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
(9000401, '1', 9000400, '출고 관리', 'Outbound', 10, '/m/admin/orders', 'active', 'PMS', NOW()),
(9000402, '1', 9000400, '입금 관리', 'Payments', 11, '/m/admin/payments', 'active', 'PMS', NOW()),
(9000403, '1', 9000400, '계산서 발행', 'Invoices', 12, '/m/admin/invoices', 'active', 'PMS', NOW());
-- ===== 통계 (9000500) =====
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
(9000501, '1', 9000500, '월간 매출', 'Stat Monthly', 10, '/m/admin/statistics', 'active', 'PMS', NOW()),
(9000502, '1', 9000500, '일자별 매출', 'Stat Daily', 11, '/m/admin/statistics/daily', 'active', 'PMS', NOW()),
(9000503, '1', 9000500, '원가/마진', 'Margin', 12, '/m/admin/statistics/margin', 'active', 'PMS', NOW());
COMMIT;
@@ -1,63 +0,0 @@
-- 거래처 가입자에 필요한 추가 정보를 user_info 에 직접 컬럼으로 추가 (스펙 §3.1 B안)
-- supply_mng 와 user_info.partner_objid 연결도 가능하지만, 신규 가입 흐름 단순화 위해 직접 컬럼 추가.
-- 이미 컬럼이 있으면 ADD COLUMN IF NOT EXISTS 로 idempotent.
BEGIN;
ALTER TABLE user_info
ADD COLUMN IF NOT EXISTS biz_no VARCHAR(20),
ADD COLUMN IF NOT EXISTS ceo_name VARCHAR(100);
-- 품목 검색 메뉴(스펙 §5에서 출고 요청과 통합으로 변경됨) 비활성화
UPDATE menu_info SET status = 'inactive' WHERE objid = 9000101;
-- ===== 관리자 admin-panel 의 [메뉴관리] 섹션 복구 =====
-- [관리자] 루트(parent=0, menu_name_kor='관리자') 아래에 [메뉴관리] 섹션 + [메뉴관리] 자식이 status='active' 로 존재해야
-- /api/admin/sidebar-menus 가 노출함. 누락된 경우 idempotent 하게 보장.
DO $$
DECLARE
admin_root_id NUMERIC;
menu_section_id NUMERIC;
BEGIN
SELECT objid INTO admin_root_id FROM menu_info
WHERE parent_obj_id = 0 AND menu_name_kor = '관리자' LIMIT 1;
IF admin_root_id IS NULL THEN
RAISE NOTICE '[admin] 루트가 없어 메뉴관리 복구 스킵';
RETURN;
END IF;
-- 섹션이 존재하면 active 로 보장, 없으면 9000600 으로 신규 등록
SELECT objid INTO menu_section_id FROM menu_info
WHERE parent_obj_id = admin_root_id AND menu_name_kor = '메뉴관리' LIMIT 1;
IF menu_section_id IS NULL THEN
menu_section_id := 9000600;
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, status, system_name, regdate)
VALUES (menu_section_id, '1', admin_root_id, '메뉴관리', 'Menu Management',
10, '', 'active', 'PMS', NOW());
ELSE
UPDATE menu_info SET status = 'active' WHERE objid = menu_section_id;
END IF;
-- 자식: 메뉴관리 (LABEL_TO_TAB 매핑이 '메뉴관리' → 'menu' 이므로 정확히 동일 이름 필수)
-- menu_info.objid 에 unique 제약이 없을 수 있으므로 ON CONFLICT 대신 EXISTS 분기로 idempotent 처리
IF EXISTS (SELECT 1 FROM menu_info WHERE objid = 9000601) THEN
UPDATE menu_info
SET parent_obj_id = menu_section_id,
menu_name_kor = '메뉴관리',
menu_name_eng = 'Menus',
menu_url = '',
status = 'active',
system_name = 'PMS'
WHERE objid = 9000601;
ELSIF NOT EXISTS (
SELECT 1 FROM menu_info
WHERE parent_obj_id = menu_section_id AND menu_name_kor = '메뉴관리'
) THEN
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, status, system_name, regdate)
VALUES (9000601, '1', menu_section_id, '메뉴관리', 'Menus',
10, '', 'active', 'PMS', NOW());
END IF;
END $$;
COMMIT;
-7
View File
@@ -1,7 +0,0 @@
-- 회원가입 주소 입력 항목 추가 (스펙 §1: 이메일/업체명/전화번호/주소 필수)
BEGIN;
ALTER TABLE user_info
ADD COLUMN IF NOT EXISTS address VARCHAR(300);
COMMIT;
-13
View File
@@ -1,13 +0,0 @@
-- 제조사 관리 메뉴를 마스터 관리(9000200) 아래에 추가
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM menu_info WHERE objid = 9000204) THEN
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate)
VALUES (9000204, '1', 9000200, '제조사 관리', 'Makers', 13, '/m/admin/makers', 'active', 'PMS', NOW());
ELSE
UPDATE menu_info
SET parent_obj_id = 9000200, menu_name_kor = '제조사 관리',
menu_url = '/m/admin/makers', status = 'active'
WHERE objid = 9000204;
END IF;
END $$;
@@ -1,97 +0,0 @@
-- 009_items_user_permissions.sql
-- v0.3 (2026-04-27)
-- 1) MASTER_PWD 백도어 제거 (코드 변경, DB 작업 없음)
-- 2) plm_admin → admin 으로 user_id 변경, 비밀번호 '1' (AES 암호화) 재설정
-- 3) 모모유통 임직원 6명 등록 (user_type='A', 비밀번호 'momo2026##')
-- 4) 거래처(user_type='C')는 보존, 그 외 FITO 레거시 인사정보는 일괄 삭제
-- 5) momo_items 에 max_order_qty, is_hidden 컬럼 추가
-- 6) user_info 에 unlimited_qty, view_hidden 컬럼 추가 (거래처 권한)
BEGIN;
-- ─────────────────────────────────────────────────────────────────
-- 1. plm_admin → admin 으로 user_id 변경 + 비밀번호 '1' 재설정
-- ─────────────────────────────────────────────────────────────────
-- 기존 admin user_id 가 이미 있을 수 있으니 먼저 확인 후 처리
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM user_info WHERE user_id = 'plm_admin') THEN
-- 동시에 admin 이 이미 있으면 plm_admin 만 삭제
IF EXISTS (SELECT 1 FROM user_info WHERE user_id = 'admin') THEN
DELETE FROM user_info WHERE user_id = 'plm_admin';
ELSE
UPDATE user_info
SET user_id = 'admin',
user_password = 'i8+4uUD3yNGbj6Lz1er20A==',
user_type = 'A',
user_type_name = '관리자',
status = 'active'
WHERE user_id = 'plm_admin';
END IF;
END IF;
END $$;
-- 그래도 admin 이 없으면 신규 INSERT
INSERT INTO user_info (user_id, user_password, user_name, user_type, user_type_name, status, regdate)
SELECT 'admin', 'i8+4uUD3yNGbj6Lz1er20A==', '시스템 관리자', 'A', '관리자', 'active', NOW()
WHERE NOT EXISTS (SELECT 1 FROM user_info WHERE user_id = 'admin');
-- admin 비밀번호는 항상 '1' 로 재설정 (이미 존재하던 admin 도 통일)
UPDATE user_info
SET user_password = 'i8+4uUD3yNGbj6Lz1er20A==',
user_type = 'A',
user_type_name = '관리자',
status = 'active'
WHERE user_id = 'admin';
-- ─────────────────────────────────────────────────────────────────
-- 2. 모모유통 임직원 6명 등록 (UPSERT)
-- ─────────────────────────────────────────────────────────────────
INSERT INTO user_info
(user_id, user_password, user_name, position_name, cell_phone, email,
user_type, user_type_name, status, regdate)
VALUES
('momo8443','95sOzM8nDQRukpt02Uxuaw==','이상용','대표', '010-6369-8443','momo8443@daum.net', 'A','관리자','active',NOW()),
('momo5826','95sOzM8nDQRukpt02Uxuaw==','이윤정','총괄이사', '010-4082-5826','momo8443@daum.net', 'A','관리자','active',NOW()),
('momo5315','95sOzM8nDQRukpt02Uxuaw==','배연진','경영팀장', '010-6624-5315','momo8443@daum.net', 'A','관리자','active',NOW()),
('momo9431','95sOzM8nDQRukpt02Uxuaw==','강상익','김포지사 총괄', '010-5789-9431','momokimpo@nate.com', 'A','관리자','active',NOW()),
('momo4763','95sOzM8nDQRukpt02Uxuaw==','이효철','물류총괄', '010-4104-4763','momo8443@daum.net', 'A','관리자','active',NOW()),
('momo7529','95sOzM8nDQRukpt02Uxuaw==','유우형','물류팀장', '010-4134-7529','momo8443@daum.net', 'A','관리자','active',NOW())
ON CONFLICT (user_id) DO UPDATE SET
user_password = EXCLUDED.user_password,
user_name = EXCLUDED.user_name,
position_name = EXCLUDED.position_name,
cell_phone = EXCLUDED.cell_phone,
email = EXCLUDED.email,
user_type = EXCLUDED.user_type,
user_type_name = EXCLUDED.user_type_name,
status = EXCLUDED.status;
-- ─────────────────────────────────────────────────────────────────
-- 3. (DISABLED) 과거: 거래처(C) + admin + 모모6인 외 모든 사용자 삭제하던 정리 쿼리.
-- user_type 을 'C' → 'U' 로 통합한 뒤로 'U' 거래처 134명이 매 배포마다 삭제되는
-- 심각한 데이터 손실이 발생해서 비활성화함. 마이그레이션은 반드시 idempotent 해야 하므로
-- destructive 구문은 두지 않는다.
-- ─────────────────────────────────────────────────────────────────
-- ─────────────────────────────────────────────────────────────────
-- 4. momo_items 컬럼 추가: 발주 제한수량 + 숨김처리
-- ─────────────────────────────────────────────────────────────────
ALTER TABLE momo_items
ADD COLUMN IF NOT EXISTS max_order_qty INTEGER,
ADD COLUMN IF NOT EXISTS is_hidden CHAR(1) NOT NULL DEFAULT 'N';
COMMENT ON COLUMN momo_items.max_order_qty IS '1회 발주 최대 수량 (NULL/0 = 제한 없음)';
COMMENT ON COLUMN momo_items.is_hidden IS '숨김 처리 (Y/N) — Y이면 view_hidden 권한 회원에게만 노출';
-- ─────────────────────────────────────────────────────────────────
-- 5. user_info 컬럼 추가: 거래처 특수 권한
-- ─────────────────────────────────────────────────────────────────
ALTER TABLE user_info
ADD COLUMN IF NOT EXISTS unlimited_qty CHAR(1) NOT NULL DEFAULT 'N',
ADD COLUMN IF NOT EXISTS view_hidden CHAR(1) NOT NULL DEFAULT 'N';
COMMENT ON COLUMN user_info.unlimited_qty IS '제한수량 해지 권한 (Y/N) — Y이면 max_order_qty 무시';
COMMENT ON COLUMN user_info.view_hidden IS '숨김처리 보기 권한 (Y/N) — Y이면 is_hidden=Y 품목도 노출';
COMMIT;
-33
View File
@@ -1,33 +0,0 @@
-- 010_delivery_charter.sql
-- v0.4 (2026-04-27)
-- 발주서에 택배비/용차비 라인 + 택배 전용 품목 자동 라인 지원
BEGIN;
-- 1. momo_items: 택배 전용 플래그
ALTER TABLE momo_items
ADD COLUMN IF NOT EXISTS requires_delivery CHAR(1) NOT NULL DEFAULT 'N';
COMMENT ON COLUMN momo_items.requires_delivery
IS '택배 전용 품목 (Y) — 카트에 담기면 택배 라인이 자동으로 추가됨';
-- 2. momo_order_items: 라인 종류 + 라벨
-- kind: 'ITEM'(품목) / 'DELIVERY'(택배비) / 'CHARTER'(용차비)
ALTER TABLE momo_order_items
ADD COLUMN IF NOT EXISTS kind VARCHAR(16) NOT NULL DEFAULT 'ITEM',
ADD COLUMN IF NOT EXISTS extra_label VARCHAR(100);
COMMENT ON COLUMN momo_order_items.kind
IS 'ITEM=품목 / DELIVERY=택배비 / CHARTER=용차비';
COMMENT ON COLUMN momo_order_items.extra_label
IS '택배비/용차비 라인의 담당자명 또는 부가 메모';
-- 기존 가맹 데이터는 ITEM 으로 간주
UPDATE momo_order_items SET kind = 'ITEM' WHERE kind IS NULL;
-- 3. momo_orders: 택배비/용차비 합계 (집계 편의용)
ALTER TABLE momo_orders
ADD COLUMN IF NOT EXISTS total_delivery NUMERIC(15,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_charter NUMERIC(15,2) DEFAULT 0;
COMMENT ON COLUMN momo_orders.total_delivery IS '택배비 라인 합계';
COMMENT ON COLUMN momo_orders.total_charter IS '용차비 라인 합계';
COMMIT;
@@ -1,13 +0,0 @@
-- 011_extra_lines_nullable.sql
-- v0.5 (2026-05-07)
-- 택배(DELIVERY)/용차(CHARTER) 라인은 item_objid 가 없는 가상 라인이므로
-- NOT NULL 제약을 풀어준다.
BEGIN;
ALTER TABLE momo_order_items ALTER COLUMN item_objid DROP NOT NULL;
COMMENT ON COLUMN momo_order_items.item_objid
IS '품목 OBJID. ITEM 라인은 NOT NULL, DELIVERY/CHARTER 라인은 NULL.';
COMMIT;
-73
View File
@@ -1,73 +0,0 @@
-- 012_einvoices.sql
-- v0.6 (2026-05-07)
-- 전자세금계산서 발행 이력 테이블 — 국세청 e-세로 직접 연동 + 향후 다른 어댑터 호환
BEGIN;
CREATE TABLE IF NOT EXISTS momo_einvoices (
objid TEXT PRIMARY KEY,
order_objid TEXT, -- 연결된 발주(있으면)
customer_objid TEXT NOT NULL, -- user_info.user_id (공급받는자)
invoice_kind VARCHAR(20) NOT NULL DEFAULT 'TAX', -- TAX(세금계산서) / TAXFREE(계산서) / RECEIPT(영수)
invoice_type VARCHAR(20) NOT NULL DEFAULT 'NORMAL', -- NORMAL / MODIFIED(수정) / CANCELED(취소)
issue_method VARCHAR(20) NOT NULL DEFAULT 'NTS', -- NTS(국세청 직접) / POPBILL / MANUAL
-- 공급자
supplier_biz_no VARCHAR(20),
supplier_name VARCHAR(200),
supplier_ceo VARCHAR(100),
supplier_address TEXT,
supplier_business VARCHAR(100), -- 업태
supplier_item VARCHAR(100), -- 종목
-- 공급받는자
buyer_biz_no VARCHAR(20),
buyer_name VARCHAR(200),
buyer_ceo VARCHAR(100),
buyer_address TEXT,
buyer_email VARCHAR(200),
buyer_phone VARCHAR(50),
-- 금액
total_supply NUMERIC(15,2) NOT NULL DEFAULT 0,
total_vat NUMERIC(15,2) NOT NULL DEFAULT 0,
total_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
-- 국세청 식별자
nts_invoice_no VARCHAR(40), -- 국세청 승인번호 (24자리)
nts_response_code VARCHAR(10), -- 응답코드
nts_response_msg TEXT,
nts_sent_at TIMESTAMP, -- 국세청 전송 시각
nts_acknowledged CHAR(1) DEFAULT 'N', -- 승인 여부
-- 상태
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
-- DRAFT(작성중) / QUEUED(전송대기) / SENT(전송완료) / ACK(승인완료) / FAIL(실패) / CANCELED(취소)
issue_date DATE NOT NULL DEFAULT CURRENT_DATE,
-- 원본 XML / 응답 (디버깅용)
request_xml TEXT,
response_xml TEXT,
-- 메타
memo TEXT,
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW(),
regid TEXT,
update_date TIMESTAMP,
update_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_order ON momo_einvoices(order_objid);
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_buyer ON momo_einvoices(customer_objid, issue_date);
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_status ON momo_einvoices(status, issue_date);
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_nts ON momo_einvoices(nts_invoice_no);
CREATE TABLE IF NOT EXISTS momo_einvoice_items (
objid TEXT PRIMARY KEY,
einvoice_objid TEXT NOT NULL REFERENCES momo_einvoices(objid) ON DELETE CASCADE,
seq INT NOT NULL,
item_date DATE,
item_name VARCHAR(200) NOT NULL,
spec VARCHAR(100),
qty NUMERIC(15,2),
unit_price NUMERIC(15,2),
supply_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
remark VARCHAR(200)
);
CREATE INDEX IF NOT EXISTS idx_momo_einvoice_items ON momo_einvoice_items(einvoice_objid, seq);
COMMIT;
-25
View File
@@ -1,25 +0,0 @@
-- 013_einvoice_menu.sql
-- v0.6 (2026-05-07)
-- 전자세금계산서 메뉴 등록 (출고/정산 그룹 9000400 아래)
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM menu_info WHERE objid = 9000404) THEN
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, status, system_name, regdate)
VALUES (9000404, '1', 9000400, '전자세금계산서', 'eTax Invoice',
13, '/m/admin/einvoices', 'active', 'PMS', NOW());
ELSE
UPDATE menu_info
SET parent_obj_id = 9000400,
menu_name_kor = '전자세금계산서',
menu_name_eng = 'eTax Invoice',
menu_url = '/m/admin/einvoices',
status = 'active'
WHERE objid = 9000404;
END IF;
END $$;
COMMIT;
-33
View File
@@ -1,33 +0,0 @@
-- 014_menu_reorder.sql
-- v0.6 (2026-05-07)
-- 메뉴 순서 재배치 + 대시보드 위치 이동
--
-- 변경 후 순서:
-- 600 거래처 주문 (9000100)
-- 700 매입/입고 (9000300)
-- 750 출고/정산 (9000400)
-- 800 통계 (9000500) ← 대시보드 자식으로 포함
-- 900 마스터 관리 (9000200) ← 마지막
--
-- 대시보드(9000001) 는 [DASHBOARD] 대메뉴(1837127121) → 통계(9000500) 자식으로 이동
-- [DASHBOARD] 대메뉴 자체는 비활성화
BEGIN;
-- 1. 대메뉴 seq 재조정 — 마스터 관리를 맨 뒤로
UPDATE menu_info SET seq = 900 WHERE objid = 9000200; -- 마스터 관리
-- 거래처 주문(600), 매입/입고(700), 출고/정산(750), 통계(800)는 그대로
-- 2. 대시보드(9000001) 를 통계(9000500) 의 첫 자식으로 이동
UPDATE menu_info
SET parent_obj_id = 9000500,
seq = 5,
menu_name_kor = '대시보드'
WHERE objid = 9000001;
-- 3. 빈 [DASHBOARD] 대메뉴(1837127121) 비활성화
UPDATE menu_info
SET status = 'inactive'
WHERE objid = 1837127121;
COMMIT;
-13
View File
@@ -1,13 +0,0 @@
-- 015_menu_reorder_v2.sql
-- v0.6 (2026-05-07)
-- 대메뉴 순서: 거래처 주문 → 출고/정산 → 매입/입고 → 마스터 관리 → 통계
BEGIN;
UPDATE menu_info SET seq = 600 WHERE objid = 9000100; -- 거래처 주문
UPDATE menu_info SET seq = 650 WHERE objid = 9000400; -- 출고/정산
UPDATE menu_info SET seq = 700 WHERE objid = 9000300; -- 매입/입고
UPDATE menu_info SET seq = 750 WHERE objid = 9000200; -- 마스터 관리
UPDATE menu_info SET seq = 800 WHERE objid = 9000500; -- 통계
COMMIT;
-25
View File
@@ -1,25 +0,0 @@
-- 016_vendor_extend.sql
-- v0.7 (2026-05-07)
-- 매입처 → 공급업체 명칭 변경 + 품목에 공급업체 연결 + 공급업체 정보 보강
BEGIN;
-- 1. 공급업체(momo_vendors) 컬럼 보강
ALTER TABLE momo_vendors
ADD COLUMN IF NOT EXISTS email VARCHAR(200),
ADD COLUMN IF NOT EXISTS address TEXT,
ADD COLUMN IF NOT EXISTS memo TEXT,
ADD COLUMN IF NOT EXISTS regdate TIMESTAMP DEFAULT NOW();
COMMENT ON TABLE momo_vendors IS '공급업체 — 발주를 보낼 도매처/제조처';
COMMENT ON COLUMN momo_vendors.email IS '발주서 메일 발송 받을 주소';
COMMENT ON COLUMN momo_vendors.address IS '공급업체 주소';
-- 2. 품목 ↔ 공급업체 연결 컬럼
ALTER TABLE momo_items
ADD COLUMN IF NOT EXISTS vendor_objid TEXT;
COMMENT ON COLUMN momo_items.vendor_objid IS '주 공급업체 (momo_vendors.objid). 매입 발주 시 자동 채움';
CREATE INDEX IF NOT EXISTS idx_momo_items_vendor ON momo_items(vendor_objid);
COMMIT;
-12
View File
@@ -1,12 +0,0 @@
-- 017_menu_rename_vendor.sql
-- v0.7 (2026-05-07)
-- 메뉴 명칭 변경: "매입처 관리" → "공급업체 관리"
BEGIN;
UPDATE menu_info
SET menu_name_kor = '공급업체 관리',
menu_name_eng = 'Vendors'
WHERE objid = 9000202;
COMMIT;
-25
View File
@@ -1,25 +0,0 @@
-- 018_pivot_menu.sql
-- v0.7 (2026-05-07)
-- 통계 그룹에 "거래처×일자 매출 (피벗)" 메뉴 추가
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM menu_info WHERE objid = 9000504) THEN
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, status, system_name, regdate)
VALUES (9000504, '1', 9000500, '거래처×일자 매출', 'Pivot Stats',
8, '/m/admin/statistics/pivot', 'active', 'PMS', NOW());
ELSE
UPDATE menu_info
SET parent_obj_id = 9000500,
menu_name_kor = '거래처×일자 매출',
menu_name_eng = 'Pivot Stats',
menu_url = '/m/admin/statistics/pivot',
status = 'active'
WHERE objid = 9000504;
END IF;
END $$;
COMMIT;
-67
View File
@@ -1,67 +0,0 @@
-- 019_proc_terms.sql
-- v0.8 (2026-05-08)
-- 1) 매입 발주서 납품조건 4필드 추가
-- 2) 기존 공급업체 데이터 삭제 + 샘플 10개 신규 등록
BEGIN;
-- ─────────────────────────────────────────────────────────────────
-- 1. momo_procurements 납품조건 컬럼
-- ─────────────────────────────────────────────────────────────────
ALTER TABLE momo_procurements
ADD COLUMN IF NOT EXISTS delivery_place TEXT,
ADD COLUMN IF NOT EXISTS delivery_period TEXT,
ADD COLUMN IF NOT EXISTS payment_terms TEXT,
ADD COLUMN IF NOT EXISTS freight_terms TEXT;
COMMENT ON COLUMN momo_procurements.delivery_place IS '납품장소';
COMMENT ON COLUMN momo_procurements.delivery_period IS '납품기간';
COMMENT ON COLUMN momo_procurements.payment_terms IS '대금지불 조건';
COMMENT ON COLUMN momo_procurements.freight_terms IS '운임부담';
-- ─────────────────────────────────────────────────────────────────
-- 2. 공급업체(supply_mng) 초기화 + 샘플 10개
-- supply_mng.objid 는 numeric/bigint — 시퀀스가 있을 수도/없을 수도 있어
-- DO 블록 안에서 MAX(objid)+1 로 안전하게 부여한다.
-- ─────────────────────────────────────────────────────────────────
-- 담당자 테이블 정리 (테이블이 있으면)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'supply_charger') THEN
DELETE FROM supply_charger
WHERE supply_objid::text IN (SELECT objid::text FROM supply_mng);
END IF;
END $$;
-- 기존 공급업체 모두 삭제
DELETE FROM supply_mng;
-- 샘플 10개 — MAX(objid)+1 ~ +10 으로 부여
DO $$
DECLARE
base_id BIGINT;
samples TEXT[][] := ARRAY[
ARRAY['VND-001', '(주)아바텍', '김영수', '02-1234-5678', '101-81-12345', 'avatec@example.com', '서울시 강남구 테헤란로 123'],
ARRAY['VND-002', '대성식품', '이상민', '031-987-6543','129-86-54321', 'daesung@example.com', '경기도 의왕시 벌모루길 46'],
ARRAY['VND-003', '(주)고기파는농부', '박정훈', '02-555-1212', '215-87-66721', 'meatfarmer@example.com', '서울시 송파구 문정동 88-2'],
ARRAY['VND-004', '광이진천 농장', '최수진', '043-532-1010','317-91-12340', 'gwang2@example.com', '충북 진천군 진천읍 광혜원로 12'],
ARRAY['VND-005', '단과일', '강동현', '063-211-3344','404-86-77890', 'danfruit@example.com', '전북 전주시 완산구 단풍로 5'],
ARRAY['VND-006', '봉담수산', '윤소라', '031-220-7788','129-86-22301', 'bongdam@example.com', '경기도 화성시 봉담읍 와우안길 33'],
ARRAY['VND-007', '명일동유기농', '이지호', '02-441-2233', '220-81-33445', 'myungil@example.com', '서울시 강동구 명일로 100'],
ARRAY['VND-008', '울산단과일', '오민재', '052-733-9988','610-81-44567', 'ulsanfruit@example.com', '울산시 남구 삼산로 150'],
ARRAY['VND-009', '농부의아침', '한세영', '031-333-4444','215-87-55667', 'morning@example.com', '경기도 양주시 백석읍 호명로 22'],
ARRAY['VND-010', '초록마을 도매', '정혜민', '02-1577-7234','110-86-99887', 'choroc@example.com', '서울시 마포구 양화로 45']
];
i INT;
BEGIN
SELECT COALESCE(MAX(objid::bigint), 0) INTO base_id FROM supply_mng;
FOR i IN 1..10 LOOP
INSERT INTO supply_mng
(objid, supply_code, supply_name, charge_user_name, supply_tel_no, reg_no, email, supply_address, status, reg_id, reg_date)
VALUES
(base_id + i, samples[i][1], samples[i][2], samples[i][3], samples[i][4], samples[i][5], samples[i][6], samples[i][7], 'active', 'admin', NOW());
END LOOP;
END $$;
COMMIT;
-14
View File
@@ -1,14 +0,0 @@
-- 권한그룹 ↔ 메뉴 매핑 테이블
-- 권한 관리 화면에서 그룹별로 노출 메뉴를 체크박스로 매핑하기 위함
CREATE TABLE IF NOT EXISTS authority_sub_menu (
objid numeric PRIMARY KEY,
master_objid numeric NOT NULL,
menu_objid numeric NOT NULL,
writer varchar(100),
regdate timestamp without time zone DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_authority_sub_menu_master ON authority_sub_menu(master_objid);
CREATE INDEX IF NOT EXISTS idx_authority_sub_menu_menu ON authority_sub_menu(menu_objid);
CREATE UNIQUE INDEX IF NOT EXISTS uq_authority_sub_menu_pair ON authority_sub_menu(master_objid, menu_objid);
-6
View File
@@ -1,6 +0,0 @@
-- 거래처(또는 일반 사용자)의 기준 창고 매핑
-- 출고 승인 시 이 창고에서 재고가 차감된다. NULL 이면 기본 STOCK 창고 사용
ALTER TABLE user_info
ADD COLUMN IF NOT EXISTS default_wh_objid numeric NULL;
CREATE INDEX IF NOT EXISTS idx_user_info_default_wh ON user_info(default_wh_objid);
@@ -1,4 +0,0 @@
-- user_info.default_wh_objid 를 text 로 변환
-- 운영 momo_warehouses.objid 가 text 타입(예: MOMOWH000000001)이라 numeric 으로는 매핑 불가
ALTER TABLE user_info
ALTER COLUMN default_wh_objid TYPE text USING default_wh_objid::text;
-99
View File
@@ -1,99 +0,0 @@
-- dept_info: 회사명 업데이트
UPDATE dept_info SET company_name = '(주)피토', location_name = '(주)피토';
-- user_info: 회사명(user_type_name) 업데이트
UPDATE user_info SET user_type_name = '(주)피토' WHERE user_type_name IN ('우성에스이', '성하에스이', '현준엔지니어링');
-- user_info: 이메일 도메인 업데이트
UPDATE user_info SET email = REPLACE(email, '@wsse.co.kr', '@fito.co.kr') WHERE email LIKE '%@wsse.co.kr';
UPDATE user_info SET email = REPLACE(email, '@sunghase.co.kr', '@fito.co.kr') WHERE email LIKE '%@sunghase.co.kr';
-- 직원 이름 업데이트
UPDATE user_info SET user_name = '강감찬', user_name_eng = 'Kang Gamchan' WHERE user_id = 'kwakdonghun';
UPDATE user_info SET user_name = '김유신', user_name_eng = 'Kim Yusin' WHERE user_id = 'kjh871112';
UPDATE user_info SET user_name = '홍길동', user_name_eng = 'Hong Gildong' WHERE user_id = 'jonghoon.kim';
UPDATE user_info SET user_name = '이순신', user_name_eng = 'Lee Sunsin' WHERE user_id = 'jg.kim';
UPDATE user_info SET user_name = '세종대왕', user_name_eng = 'Sejong Daewang' WHERE user_id = 'sh.park';
UPDATE user_info SET user_name = '장영실', user_name_eng = 'Jang Youngsil' WHERE user_id = 'yrpark';
UPDATE user_info SET user_name = '정약용', user_name_eng = 'Jeong Yagyong' WHERE user_id = 'changhwe.park';
UPDATE user_info SET user_name = '을지문덕', user_name_eng = 'Eulji Mundeok' WHERE user_id = 'hosang.park';
UPDATE user_info SET user_name = '광개토대왕', user_name_eng = 'Gwanggaeto Daewang' WHERE user_id = 'honggyu.park';
UPDATE user_info SET user_name = '사임당', user_name_eng = 'Shin Saimdang' WHERE user_id = 'saito-aki';
UPDATE user_info SET user_name = '유관순', user_name_eng = 'Yu Gwansun' WHERE user_id = 'yc.son';
UPDATE user_info SET user_name = '안중근', user_name_eng = 'An Junggeun' WHERE user_id = 'hjshin';
UPDATE user_info SET user_name = '윤봉길', user_name_eng = 'Yun Bonggil' WHERE user_id = 'gg.yang';
UPDATE user_info SET user_name = '김구', user_name_eng = 'Kim Gu' WHERE user_id = 'okjkha55';
UPDATE user_info SET user_name = '안창호', user_name_eng = 'An Changho' WHERE user_id = 'dc.lee';
UPDATE user_info SET user_name = '이황', user_name_eng = 'Lee Hwang' WHERE user_id = 'mh.lee';
UPDATE user_info SET user_name = '이이', user_name_eng = 'Lee I' WHERE user_id = 'sibaek.lee';
UPDATE user_info SET user_name = '허준', user_name_eng = 'Heo Jun' WHERE user_id = 'leehyeri';
UPDATE user_info SET user_name = '장보고', user_name_eng = 'Jang Bogo' WHERE user_id = 'hj.lim';
UPDATE user_info SET user_name = '대조영', user_name_eng = 'Dae Joyoung' WHERE user_id = 'bg.jang';
UPDATE user_info SET user_name = '왕건', user_name_eng = 'Wang Geon' WHERE user_id = 'woocheol.cho';
UPDATE user_info SET user_name = '최영', user_name_eng = 'Choi Young' WHERE user_id = 'ghh0226';
UPDATE user_info SET user_name = '이성계', user_name_eng = 'Lee Seonggye' WHERE user_id = 'yonggyu.choi';
UPDATE user_info SET user_name = '정몽주', user_name_eng = 'Jeong Mongju' WHERE user_id = 'jw.choi';
UPDATE user_info SET user_name = '김시습', user_name_eng = 'Kim Siseup' WHERE user_id = 'jg.ha';
UPDATE user_info SET user_name = '성삼문', user_name_eng = 'Seong Sammun' WHERE user_id = 'jaewon.heo';
UPDATE user_info SET user_name = '박혁거세', user_name_eng = 'Bak Hyeokgeose' WHERE user_id = 'ms.hong';
UPDATE user_info SET user_name = '김춘추', user_name_eng = 'Kim Chunchu' WHERE user_id = 'sy.kang';
UPDATE user_info SET user_name = '김정호', user_name_eng = 'Kim Jeongho' WHERE user_id = 'sungchan.kang';
UPDATE user_info SET user_name = '연개소문', user_name_eng = 'Yeon Gaesomun' WHERE user_id = 'jonggu.kwak';
UPDATE user_info SET user_name = '계백', user_name_eng = 'Gyebaek' WHERE user_id = 'sb.kim';
UPDATE user_info SET user_name = '선덕여왕', user_name_eng = 'Seondeok Yeowang' WHERE user_id = 'ouksung.kim';
UPDATE user_info SET user_name = '원효대사', user_name_eng = 'Wonhyo Daesa' WHERE user_id = 'jingon.kim';
UPDATE user_info SET user_name = '의자왕', user_name_eng = 'Uija Wang' WHERE user_id = 'hw.kim';
UPDATE user_info SET user_name = '근초고왕', user_name_eng = 'Geunchogo Wang' WHERE user_id = 'ys.moon';
UPDATE user_info SET user_name = '이사부', user_name_eng = 'Isabu' WHERE user_id = 'jongwoo.bae';
UPDATE user_info SET user_name = '석가정', user_name_eng = 'Seok Gajeong' WHERE user_id = 'sg.baek';
UPDATE user_info SET user_name = '최무선', user_name_eng = 'Choi Museon' WHERE user_id = 'ts.song';
UPDATE user_info SET user_name = '문익점', user_name_eng = 'Mun Ikjeom' WHERE user_id = 'dongkyu.shin';
UPDATE user_info SET user_name = '한석봉', user_name_eng = 'Han Seokbong' WHERE user_id = 'mh.shim';
UPDATE user_info SET user_name = '조광조', user_name_eng = 'Jo Gwangjo' WHERE user_id = 'sj.yeon';
UPDATE user_info SET user_name = '송시열', user_name_eng = 'Song Siyeol' WHERE user_id = 'hs.youn';
UPDATE user_info SET user_name = '김홍도', user_name_eng = 'Kim Hongdo' WHERE user_id = 'gy.lee';
UPDATE user_info SET user_name = '신윤복', user_name_eng = 'Shin Yunbok' WHERE user_id = 'dongbae.lee';
UPDATE user_info SET user_name = '정선', user_name_eng = 'Jeong Seon' WHERE user_id = 'SUNGHA';
UPDATE user_info SET user_name = '김만덕', user_name_eng = 'Kim Mandeok' WHERE user_id = 'jongwon.lee';
UPDATE user_info SET user_name = '전봉준', user_name_eng = 'Jeon Bongjun' WHERE user_id = 'hogi.lee';
UPDATE user_info SET user_name = '김좌진', user_name_eng = 'Kim Jwajin' WHERE user_id = 'hongkyu.jeon';
UPDATE user_info SET user_name = '신채호', user_name_eng = 'Shin Chaeho' WHERE user_id = 'sm.jo';
UPDATE user_info SET user_name = '박지원', user_name_eng = 'Park Jiwon' WHERE user_id = 'js.kim';
UPDATE user_info SET user_name = '김대건', user_name_eng = 'Kim Daegeon' WHERE user_id = 'sw.jo';
UPDATE user_info SET user_name = '최치원', user_name_eng = 'Choi Chiwon' WHERE user_id = 'jinyao';
UPDATE user_info SET user_name = '이덕무', user_name_eng = 'Lee Deokmu' WHERE user_id = 'hs.choi';
UPDATE user_info SET user_name = '권율', user_name_eng = 'Gwon Yul' WHERE user_id = 'dohyung.hong';
UPDATE user_info SET user_name = '곽재우', user_name_eng = 'Gwak Jaeu' WHERE user_id = 'kts';
UPDATE user_info SET user_name = '이봉창', user_name_eng = 'Lee Bongchang' WHERE user_id = 'ms.park';
UPDATE user_info SET user_name = '김원봉', user_name_eng = 'Kim Wonbong' WHERE user_id = 'hyunjun.eng';
UPDATE user_info SET user_name = '여운형', user_name_eng = 'Yeo Unhyeong' WHERE user_id = 'br.lee';
UPDATE user_info SET user_name = '조만식', user_name_eng = 'Jo Mansik' WHERE user_id = 'jc.lee';
UPDATE user_info SET user_name = '이회영', user_name_eng = 'Lee Hoeyoung' WHERE user_id = 'hsi1799';
UPDATE user_info SET user_name = '김규식', user_name_eng = 'Kim Gyusik' WHERE user_id = 'sg.yoo';
UPDATE user_info SET user_name = '이범석', user_name_eng = 'Lee Beomseok' WHERE user_id = 'my.won';
UPDATE user_info SET user_name = '지청천', user_name_eng = 'Ji Cheongcheon' WHERE user_id = 'js.ha';
UPDATE user_info SET user_name = '관리자', user_name_eng = 'Admin' WHERE user_id = 'plm_admin';
UPDATE user_info SET user_name = '김소월', user_name_eng = 'Kim Sowol' WHERE user_id = 'jaeho.lee';
UPDATE user_info SET user_name = '한용운', user_name_eng = 'Han Yongun' WHERE user_id = 'jy.kim';
UPDATE user_info SET user_name = '윤동주', user_name_eng = 'Yun Dongju' WHERE user_id = 'wj.lee';
UPDATE user_info SET user_name = '이육사', user_name_eng = 'Lee Yuksa' WHERE user_id = 'su.han';
UPDATE user_info SET user_name = '정지용', user_name_eng = 'Jeong Jiyong' WHERE user_id = 'hy.jeong';
UPDATE user_info SET user_name = '백석', user_name_eng = 'Baek Seok' WHERE user_id = 'ys.choi';
UPDATE user_info SET user_name = '나혜석', user_name_eng = 'Na Hyeseok' WHERE user_id = 'sy.kim';
UPDATE user_info SET user_name = '이광수', user_name_eng = 'Lee Gwangsu' WHERE user_id = 'ys.lim';
UPDATE user_info SET user_name = '방정환', user_name_eng = 'Bang Jeonghwan' WHERE user_id = 'hs.kim';
UPDATE user_info SET user_name = '주시경', user_name_eng = 'Ju Sigyeong' WHERE user_id = 'sy.choi';
UPDATE user_info SET user_name = '최현배', user_name_eng = 'Choi Hyeonbae' WHERE user_id = 'tw.kim';
UPDATE user_info SET user_name = '이상화', user_name_eng = 'Lee Sanghwa' WHERE user_id = 'nm.kim';
UPDATE user_info SET user_name = '김병연', user_name_eng = 'Kim Byeongyeon' WHERE user_id = 'hy.kim';
UPDATE user_info SET user_name = '이상', user_name_eng = 'Lee Sang' WHERE user_id = 'gh.ok';
UPDATE user_info SET user_name = '나운규', user_name_eng = 'Na Ungyu' WHERE user_id = 'ts.jeon';
UPDATE user_info SET user_name = '손기정', user_name_eng = 'Son Gijeong' WHERE user_id = 'sh.kim';
UPDATE user_info SET user_name = '남궁억', user_name_eng = 'Namgung Eok' WHERE user_id = 'jw.jang';
UPDATE user_info SET user_name = '서재필', user_name_eng = 'Seo Jaepil' WHERE user_id = 'jw.lee';
UPDATE user_info SET user_name = '안익태', user_name_eng = 'An Iktae' WHERE user_id = 'hc.si';
UPDATE user_info SET user_name = '홍범도', user_name_eng = 'Hong Beomdo' WHERE user_id = 'ty.kim';
UPDATE user_info SET user_name = '이준', user_name_eng = 'Lee Jun' WHERE user_id = 'ys.park';
UPDATE user_info SET user_name = '민영환', user_name_eng = 'Min Yeonghwan' WHERE user_id = 'jh.lee';
UPDATE user_info SET user_name = '양기탁', user_name_eng = 'Yang Gitak' WHERE user_id = 'ej.kim';
UPDATE user_info SET user_name = '이상재', user_name_eng = 'Lee Sangjae' WHERE user_id = 'hu.na';
+3 -1
View File
@@ -1,5 +1,5 @@
# 운영 배포 (Traefik + momotogether.com)
# 대상 서버: 183.99.177.40 (Traefik v2.11 외부 네트워크 traefik-net 사용)
# 대상 서버: 121.156.99.3 (Traefik v2.11 외부 네트워크 traefik-net 사용)
# 사용: docker compose -f docker-compose.prod.yml up -d --build
services:
momo-erp:
@@ -22,6 +22,8 @@ services:
- ./scripts/deploy.sh:/deploy/deploy.sh:ro
# source 디렉토리를 컨테이너 안에서 git pull 하기 위해 호스트의 소스를 마운트
- $PWD:/deploy/source
# Firebase Admin SDK service account (FCM 발송용) — 호스트의 안전한 위치에서만 마운트
- /home/chpark/momo-erp/firebase-sa.json:/deploy/firebase-sa.json:ro
networks:
- traefik-net
labels:
Binary file not shown.
+797 -4
View File
@@ -10,6 +10,11 @@
"dependencies": {
"@prisma/client": "^7.7.0",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-image": "^3.23.6",
"@tiptap/extension-link": "^3.23.6",
"@tiptap/extension-placeholder": "^3.23.6",
"@tiptap/react": "^3.23.6",
"@tiptap/starter-kit": "^3.23.6",
"@types/nodemailer": "^8.0.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
@@ -29,6 +34,7 @@
"recharts": "^3.8.1",
"sweetalert2": "^11.26.24",
"tailwind-merge": "^3.5.0",
"web-push": "^3.6.7",
"xlsx": "^0.18.5",
"zustand": "^5.0.12"
},
@@ -40,6 +46,7 @@
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
@@ -532,6 +539,34 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT",
"optional": true
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
@@ -2038,6 +2073,460 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tiptap/core": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.6.tgz",
"integrity": "sha512-MRB3pHz4Oxqmcawh0cQ5iOGdY5xtNYp/1CoK7hdTLzw5K0C6/gTC2VvanB1R4INaB6EpBkxG/GiWkVirDRnuXw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.23.6"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.6.tgz",
"integrity": "sha512-2RmnqNqTltZ2k1F7IfjoDNs935Uq4rRDR7d98mqkg3OlDktcQIyBpv0t9dTay6H5bkQeZUuS8ogK2S1E8Edjug==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.6.tgz",
"integrity": "sha512-1LMhjnytdbbhWHSoOwnLxZAOQZWPkKyXVCNmaIk0Mhi4tLPUXptG4qKS5sVYTCveE5H6IBPFrbgBFi5dMI6krA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.6.tgz",
"integrity": "sha512-Mwkyp9LkDHFbqmWRIkp63FinRxFu3ajC4qSb9t4mnHsb4kAdbNLLsGtbFg+le0SWk4CxGwAOwM7SzeJ+6UGqCA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6",
"@tiptap/pm": "3.23.6"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.6.tgz",
"integrity": "sha512-RMRgfXZykr/13X8UBOwvpgysVOo9KchwqMoEbvqQSj4YFfU56iIn59C8sbxiQ1sKfeltUf0wH4fPc0I4iwKqAA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.6"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.6.tgz",
"integrity": "sha512-KG8KXFYyLrtYvT7AZ1WGV61ofx8pDe5g9pH658MERxqQGii+Pyfc6xkz04l7XeBts/7+571UQp/0O7i/z560TA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.6.tgz",
"integrity": "sha512-4kccgcn5yHThxrzsIhJny3EwfEZYIk+BjUCL4uIuzOyWvExtGhZ6JMHVCZeMhI8D1/bX1LNkkAKN5DXPzH4lXQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6",
"@tiptap/pm": "3.23.6"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.6.tgz",
"integrity": "sha512-XDAIgG9KcKumFM9KJWUEUhXPbFIhhl47bfy5GknareWTRKke85rcoj/oxKKO9ihLZr8JfpbXjqnS4SCm5yhYPw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.6.tgz",
"integrity": "sha512-+XWEoRKf3lXxi7Le1aOM2xU1XHwxICGpXjT3m4QaYqUgIpsq8gQEuso6kVg8DnTD7biKQs6+oIQ0o2b/gTW9WA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.6"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.6.tgz",
"integrity": "sha512-2kjuDcEq69lEcECl75xqY5MyzUSh2zcC5aLrpwP1WwhJz5bxsIFHiaps5AP6h9R4A+ZBj5b2haay2Y1wDUU3VA==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.23.6",
"@tiptap/pm": "3.23.6"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.6.tgz",
"integrity": "sha512-wbKmxXsszxWacEkrHucRpSQbiKjz4fmOebD6OVyL9AcrmlbxNk8vcM3iyh/8cVeRy09XY+morM165t/u7/z4IQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.6"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.6.tgz",
"integrity": "sha512-KeUm+tkUfIVSX9QM9XOIhaay0Fn36sLKUo5NVYjN3uJaxFvaZXZmTlxdO85OTdgF2P5sqh9LomrIgliaFRGk4w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.6.tgz",
"integrity": "sha512-A/0jPhxnUh9THSZymlu0OGPZe1wdFdwHAXnRCmqvYUCwJjrG7LCC/ahzmcj1tcNzI9hgHyuYPSfev8RXYrNu/w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.6.tgz",
"integrity": "sha512-hEUlz4H+I64r+TH6LCuNCRgO7JTHncXGmx9+WbU69EOfY8O0ZurcgeJc8HeiAKL+r9YuC1e5YHfFxgCaaC0jlg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6",
"@tiptap/pm": "3.23.6"
}
},
"node_modules/@tiptap/extension-image": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.23.6.tgz",
"integrity": "sha512-vvNGxArvD2dW+XvV0KdYovRVUzCy8QVNulc2r5pV7umnG1E6cCmMkiHiif8J2ePJu2KtysAvJQe0iF+UqueGMw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.6.tgz",
"integrity": "sha512-wol5KdwCPAvpiYhH9PLlvO8ZnJHwZtIboVevrfOGgBcKlXRA3dedR4OAMXHnUtkkzu9KtliLg1+TYzEx4JZG9Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.6.tgz",
"integrity": "sha512-KNZz7z7P2/qbQsx5bPAbSPjrKDg1VHsedGlLHJCr8U2VRD5VgmDLkMpkouP1CsDg15qgyUKv/nDib5KgPpLNWA==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6",
"@tiptap/pm": "3.23.6"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.6.tgz",
"integrity": "sha512-z6vj9+Qht2sjdQkyyHcUpsC/yCIZqTrQiyHDhs/HGKrfvoANyAZGpqdNeKf1wSyjIso+27tQuIH5NDfk8ygyNw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6",
"@tiptap/pm": "3.23.6"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.6.tgz",
"integrity": "sha512-3zzyhdkUWcHVpXuvy6KiIwjh29rbH6gEDEqPQqHLrl1XGnO9pnShC7pSHctlCDjmcx3O4n9cd4QMtVBlUerbiA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.6"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.6.tgz",
"integrity": "sha512-x8bPcLViGzg/RAmQM/XtmfqIwQ/Pv9Q8mkd+OgfUiTqjeJqKwVQmiqbLFNa7zw81+H61M+HDU+qGAaQ3vRIMjw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.6"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.6.tgz",
"integrity": "sha512-1m/wWB/ZtXcmG2vNdiUkCqsOgqv5vBjCv/mVaHhF9OvV+zQS8YDjoWE7zEuT/GgELdT77Xq8lHrn4nCDudB3/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.6"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.6.tgz",
"integrity": "sha512-+7m58LUSncodjrIyXks4RZ3tLNYrvgT77wRR4l3HnM5OABY3GDsDTqi7c1t1yI29NVOSk/DUacqy6UwYAj1DGg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.6.tgz",
"integrity": "sha512-8I6b2aevF74aLgymKMxbDxSLxWA2y+2dh0zZDeI8sRZ2m6WHHes+Kyuuwkq1HIPcR+ZLpbec74cmf6lcL/yvqQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.6"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.6.tgz",
"integrity": "sha512-oF7FEZ37f15aCe5kPgzGDYf/m+hr7VdQ/Ko/Hds/UM9pX7AG1fdtmRrl6wqkRqDM/incZaC/AQR2/Dpo2VCNGQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.6.tgz",
"integrity": "sha512-ipoC2TkIAIOTiF5ByiGgvQB1DqDyfP90wrUB3mohBcgvp7lQnwHszCDGv8dNnmcUek8uXV/uoLu2VXeVQlxjPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.6.tgz",
"integrity": "sha512-P55wGIZGYTVH92Fq0cgI4/O9AhLCaJC3hhxg15RSERP5/YegM9eJHDK/GQ1EE/DvYA+xpYGOV6agKwAUqfA/Iw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.6.tgz",
"integrity": "sha512-X09/Db1teB+ifXzDGVVFmOeQRx7wTAayE9/280spxpsHkHZvJ5bHRvWIzUzviMIjbBz+NPDIKYPK7gMfh9iaig==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6",
"@tiptap/pm": "3.23.6"
}
},
"node_modules/@tiptap/pm": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.6.tgz",
"integrity": "sha512-in5CaMaWlJcH2A1q6GJKFtrodE8WLS3M9tIi/f89jPmIVHJShpodC0KZDNyJkrVBQomYk0DEh86Utm6ASXzQww==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.6.tgz",
"integrity": "sha512-Tw9KZkYqFMk3vaJAEQKqEYIO/iq3cSJe7OUEGBul4k4GaMQeLItLf5EYhUd0GIPXci1WVVPNntKJsHfX25M37w==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.23.6",
"@tiptap/extension-floating-menu": "^3.23.6"
},
"peerDependencies": {
"@tiptap/core": "3.23.6",
"@tiptap/pm": "3.23.6",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.6.tgz",
"integrity": "sha512-gykwtGWrnWCmtql1hid3opac/KV8zQvOAnu3bTqIqcHrn1FusbUwKmNzavSbfGvcktHM3hFjb35W48JyVLyu/A==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.23.6",
"@tiptap/extension-blockquote": "^3.23.6",
"@tiptap/extension-bold": "^3.23.6",
"@tiptap/extension-bullet-list": "^3.23.6",
"@tiptap/extension-code": "^3.23.6",
"@tiptap/extension-code-block": "^3.23.6",
"@tiptap/extension-document": "^3.23.6",
"@tiptap/extension-dropcursor": "^3.23.6",
"@tiptap/extension-gapcursor": "^3.23.6",
"@tiptap/extension-hard-break": "^3.23.6",
"@tiptap/extension-heading": "^3.23.6",
"@tiptap/extension-horizontal-rule": "^3.23.6",
"@tiptap/extension-italic": "^3.23.6",
"@tiptap/extension-link": "^3.23.6",
"@tiptap/extension-list": "^3.23.6",
"@tiptap/extension-list-item": "^3.23.6",
"@tiptap/extension-list-keymap": "^3.23.6",
"@tiptap/extension-ordered-list": "^3.23.6",
"@tiptap/extension-paragraph": "^3.23.6",
"@tiptap/extension-strike": "^3.23.6",
"@tiptap/extension-text": "^3.23.6",
"@tiptap/extension-underline": "^3.23.6",
"@tiptap/extensions": "^3.23.6",
"@tiptap/pm": "^3.23.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -2190,7 +2679,6 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -2202,6 +2690,16 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
@@ -2798,6 +3296,15 @@
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -3008,6 +3515,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -3104,6 +3623,12 @@
"integrity": "sha512-C4FQ1gCLz1YCxmM8HhNPb4D7WQmdrdllkhNReeLwvIVtJKQFKKfwJwmM3yZEBG4P34cLtrgB+FEPr1u553hF7Q==",
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@@ -3162,6 +3687,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -3649,7 +4180,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3792,6 +4322,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/effect": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
@@ -4508,6 +5047,15 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -5052,12 +5600,34 @@
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-status-codes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -5121,6 +5691,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5698,6 +6274,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -6003,6 +6600,12 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -6123,6 +6726,12 @@
"node": ">=8.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -6140,7 +6749,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -6150,7 +6758,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mysql2": {
@@ -6554,6 +7161,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -6956,6 +7569,135 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.7",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz",
"integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7253,6 +7995,12 @@
"node": ">=0.10.0"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7297,6 +8045,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -8263,6 +9031,31 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+7
View File
@@ -12,6 +12,11 @@
"dependencies": {
"@prisma/client": "^7.7.0",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-image": "^3.23.6",
"@tiptap/extension-link": "^3.23.6",
"@tiptap/extension-placeholder": "^3.23.6",
"@tiptap/react": "^3.23.6",
"@tiptap/starter-kit": "^3.23.6",
"@types/nodemailer": "^8.0.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
@@ -31,6 +36,7 @@
"recharts": "^3.8.1",
"sweetalert2": "^11.26.24",
"tailwind-merge": "^3.5.0",
"web-push": "^3.6.7",
"xlsx": "^0.18.5",
"zustand": "^5.0.12"
},
@@ -42,6 +48,7 @@
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
+4 -2
View File
@@ -3,8 +3,10 @@
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.momotogether.erp",
"sha256_cert_fingerprints": ["59:9E:56:6C:AE:A3:B2:AD:DF:60:B1:6E:27:91:CD:60:CC:D3:FE:5F:EB:B7:E3:2F:15:D8:FB:E7:B3:11:AB:6A"]
"package_name": "com.momotogether.app",
"sha256_cert_fingerprints": [
"2A:55:B2:9E:03:51:2B:DE:28:E2:A4:34:15:9C:23:1F:21:B6:C0:43:9C:10:3B:6C:E2:D5:46:F7:AF:42:C3:97"
]
}
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

+41 -2
View File
@@ -1,6 +1,6 @@
// 모모유통 ERP — Service Worker (PWA install criteria 충족용)
const CACHE = 'momo-erp-v1';
const PRECACHE = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png'];
const CACHE = 'momo-erp-v4';
const PRECACHE = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/badge-96.png'];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE)).catch(() => {}));
@@ -25,3 +25,42 @@ self.addEventListener('fetch', (e) => {
fetch(e.request).catch(() => caches.match(e.request))
);
});
// ===== 웹 푸시 =====
// 삼성 인터넷/Samsung Galaxy 에서 상단 배너(heads-up) 가 안 뜨던 문제 해결:
// - vibrate: 패턴 명시 (안드로이드가 high-priority 채널로 분류)
// - requireInteraction: 사용자가 직접 닫을 때까지 유지
// - renotify: 같은 tag 라도 다시 알림
// - silent: false 명시 (Samsung 일부 버전에서 기본값이 true 인 케이스 회피)
self.addEventListener('push', (e) => {
let data = {};
try { data = e.data ? e.data.json() : {}; } catch (_) { data = {}; }
const title = data.title || '모모유통';
const options = {
body: data.body || ' ',
icon: data.icon || '/icon-192.png', // 큰 아이콘 = 모모 로고(초록 M)
badge: data.badge || '/badge-96.png', // 상태바 작은 아이콘 = 흰 M 단색(투명 배경)
image: data.image || undefined, // big picture (알림 확장 영역의 큰 이미지)
tag: data.tag || undefined,
renotify: !!data.tag,
requireInteraction: true,
silent: false,
vibrate: [200, 100, 200],
timestamp: Date.now(),
data: { url: data.url || '/m/orders/new' },
};
e.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
const target = (e.notification.data && e.notification.data.url) || '/m/orders/new';
e.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((cs) => {
for (const c of cs) {
if ('focus' in c) { c.navigate(target); return c.focus(); }
}
if (self.clients.openWindow) return self.clients.openWindow(target);
})
);
});
+9
View File
@@ -6,10 +6,19 @@
set -e
cd /deploy/source 2>/dev/null || cd "$HOME/momo-erp/source"
# 컨테이너 안에서 webhook 가 호출 시: 호스트 chpark(1000) 소유 디렉토리를
# nextjs(1001) 가 git 명령으로 다룸 → git 의 "dubious ownership" 거부 회피
git config --global --add safe.directory "$(pwd)" 2>/dev/null || true
git config --global --add safe.directory '*' 2>/dev/null || true
echo "[$(date)] git fetch + reset --hard origin/main"
git fetch origin
git reset --hard origin/main
# build-sha.txt — 헬스체크가 이 값으로 운영 반영 SHA 검증
git rev-parse HEAD > public/build-sha.txt
echo "[$(date)] ▶ 배포 대상 SHA: $(cat public/build-sha.txt)"
# 업로드 저장은 named volume(momo_data_storage)으로 이전됨 — 호스트 디렉토리 prep 불필요
echo "[$(date)] docker compose up --build"
docker compose -f docker-compose.prod.yml up -d --build
@@ -0,0 +1,5 @@
-- 푸시알림 발송이력 사이드바 메뉴 등록 (멱등)
-- 운영 DB 에서 1회 실행: psql … -f 2026-05-30-notice-history-menu.sql
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_url, seq, status, regdate, writer, system_name)
SELECT 9000298, 2, 9000200, '푸시알림 발송이력', '/m/admin/notice-history', 21, 'active', NOW(), 'admin', 'PMS'
WHERE NOT EXISTS (SELECT 1 FROM menu_info WHERE objid = 9000298);
+1 -1
View File
@@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASE = process.env.E2E_BASE || "http://localhost:3000";
const DB_URL = "postgresql://momo_app:qlalfqjsgh11@183.99.177.40:5432/distribution";
const DB_URL = "postgresql://momo_app:qlalfqjsgh11@121.156.99.3:5432/distribution";
const ADMIN_EMAIL = "admin@momo.com";
const ADMIN_PASS = "admin1234";
+1 -1
View File
@@ -4,7 +4,7 @@
import pg from "pg";
const BASE = process.env.E2E_BASE || "https://momo.junggomoa.com";
const DB_URL = "postgresql://momo_app:qlalfqjsgh11@183.99.177.40:5432/distribution";
const DB_URL = "postgresql://momo_app:qlalfqjsgh11@121.156.99.3:5432/distribution";
const log = (...a) => console.log("[e2e]", ...a);
const fail = (m) => { console.error("[e2e] ✖", m); process.exit(1); };
+17 -5
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { User, Lock, Eye, EyeOff, ArrowRight, Phone, Mail, MapPin } from "lucide-react";
import { User, Lock, Eye, EyeOff, ArrowRight, Phone, Mail, MapPin, Home } from "lucide-react";
import Swal from "sweetalert2";
const SAVE_KEY = "momo_saved_credentials"; // localStorage 키
@@ -56,7 +56,7 @@ export default function LoginPage() {
localStorage.removeItem(SAVE_KEY);
}
} catch { /* ignore */ }
router.push(data.redirectTo || "/dashboard");
router.push(data.redirectTo || "/m/orders/new");
} else {
Swal.fire({
icon: "error",
@@ -100,12 +100,16 @@ export default function LoginPage() {
</div>
<div className="relative z-10">
<div className="flex items-center gap-3">
<Link
href="/"
className="inline-flex items-center gap-3 hover:opacity-80 transition-opacity"
title="홈으로"
>
<img src="/momo-icon.svg" alt="MOMO" className="w-11 h-11" />
<span className="text-white/95 text-sm font-semibold tracking-widest">
MOMO DISTRIBUTION
</span>
</div>
</Link>
</div>
<div className="relative z-10 flex-1 flex flex-col justify-center py-12 lg:py-0">
@@ -136,7 +140,15 @@ export default function LoginPage() {
</div>
{/* 우측: 로그인 폼 */}
<div className="lg:flex-1 flex items-center justify-center px-6 py-16 lg:py-0 bg-slate-50">
<div className="lg:flex-1 flex items-center justify-center px-6 py-16 lg:py-0 bg-slate-50 relative">
{/* 홈으로 — 우측 상단 고정 */}
<Link
href="/"
className="absolute top-5 right-5 inline-flex items-center gap-1.5 px-3 h-9 rounded-lg bg-white border border-slate-200 text-slate-600 text-sm font-semibold hover:text-emerald-700 hover:border-emerald-300 hover:bg-emerald-50 transition shadow-sm"
>
<Home size={14} />
</Link>
<div className="w-full max-w-md">
<div className="mb-10">
<div className="inline-flex items-center gap-2 text-emerald-700 text-xs font-bold tracking-widest mb-3">
+10
View File
@@ -218,6 +218,16 @@ export default function MobileLoginPage() {
</form>
</div>
{/* 앱 설치 안내 링크 */}
<div className="relative z-10 text-center pt-3">
<a
href="/install"
className="inline-flex items-center gap-1.5 text-xs text-emerald-50/90 hover:text-white underline underline-offset-4 decoration-emerald-300/50"
>
📱
</a>
</div>
{/* 푸터 */}
<div className="relative z-10 text-center pb-4 pt-2">
<p className="text-[10px] text-emerald-100/60 tracking-wide">
-138
View File
@@ -1,138 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ApprovalStatusBadge } from "@/components/approval/ApprovalStatusBadge";
import { TARGET_NAME_MAP } from "@/components/approval/TargetLinkMap";
import { cn } from "@/lib/utils";
// approvalList.jsp 대응 - 결재함 (미결재/승인/반려/전체 탭)
type Tab = "PENDING" | "APPROVED" | "REJECTED" | "";
const TABS: { key: Tab; label: string }[] = [
{ key: "PENDING", label: "미결재" },
{ key: "APPROVED", label: "승인" },
{ key: "REJECTED", label: "반려" },
{ key: "", label: "전체" },
];
export default function ApprovalPage() {
const [tab, setTab] = useState<Tab>("PENDING");
const [title, setTitle] = useState("");
const [writerName, setWriterName] = useState("");
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(false);
const openDetail = (row: Record<string, unknown>) => {
const w = 900, h = 700;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(`/approval/form?objId=${row.APPROVAL_OBJID || row.OBJID}`,
"approvalDetail",
`width=${w},height=${h},left=${left},top=${top}`);
};
const columns: GridColumn[] = [
{ title: "결재번호", field: "APPROVAL_NO", width: 110, hozAlign: "center" },
{
title: "대상구분", field: "TYPE_NAME", width: 120, hozAlign: "left",
formatter: (_, row) => (TARGET_NAME_MAP[String(row.TARGET_TYPE)] || String(row.TYPE_NAME ?? row.TARGET_TYPE ?? "-")),
},
{
title: "제목", field: "TITLE", width: 300, hozAlign: "left",
cellClick: openDetail,
},
{ title: "상신일", field: "REGDATE", width: 110, hozAlign: "center" },
{
title: "상신자", field: "WRITER_NAME", width: 180, hozAlign: "left",
formatter: (_, row) => {
const dept = row.DEPT_NAME ? String(row.DEPT_NAME) + " / " : "";
return dept + String(row.WRITER_NAME ?? row.WRITER ?? "");
},
},
{
title: "상태", field: "STATUS_NAME", width: 100, hozAlign: "center",
formatter: (_, row) => <ApprovalStatusBadge status={row.STATUS_NAME || row.APPROVAL_STATUS} />,
},
];
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/approval", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: tab,
title,
writer_name: writerName,
from_date: fromDate,
to_date: toDate,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
} finally { setLoading(false); }
}, [tab, title, writerName, fromDate, toDate]);
useEffect(() => { fetchData(); }, [fetchData]);
// 파일 업로드 팝업 등에서 refresh 콜백으로 쓸 수 있게 전역 등록
useEffect(() => {
(window as unknown as Record<string, unknown>).fn_search = fetchData;
return () => { delete (window as unknown as Record<string, unknown>).fn_search; };
}, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"></h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
{/* 탭 */}
<div className="flex items-center border-b border-gray-200 mb-4">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={cn(
"px-4 py-2 text-sm border-b-2 -mb-px transition-colors",
tab === t.key
? "border-primary text-primary font-semibold"
: "border-transparent text-gray-500 hover:text-gray-800"
)}
>
{t.label}
</button>
))}
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="제목">
<Input value={title} onChange={(e) => setTitle(e.target.value)} className="w-[240px]" />
</SearchField>
<SearchField label="상신자">
<Input value={writerName} onChange={(e) => setWriterName(e.target.value)} className="w-[150px]" />
</SearchField>
<SearchField label="상신일">
<div className="flex items-center gap-1">
<Input type="date" value={fromDate} onChange={(e) => setFromDate(e.target.value)} className="w-[140px]" />
<span className="text-gray-400">~</span>
<Input type="date" value={toDate} onChange={(e) => setToDate(e.target.value)} className="w-[140px]" />
</div>
</SearchField>
</SearchForm>
<DataGrid columns={columns} data={data} loading={loading} />
</div>
);
}
-65
View File
@@ -1,65 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
// bom/bomList.jsp 대응 - BOM 관리
export default function BomPage() {
const [productName, setProductName] = useState("");
const [partNo, setPartNo] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "제품명", field: "PRODUCT_NAME", width: 180, hozAlign: "left",
cellClick: (row) => window.open(`/product/part-register/form?objId=${row.OBJID}`, "bomDetail", "width=1200,height=900") },
{ title: "제품코드", field: "PRODUCT_CODE", width: 120, hozAlign: "left" },
{ title: "Level", field: "BOM_LEVEL", width: 60, hozAlign: "center" },
{ title: "파트번호", field: "PART_NO", width: 130, hozAlign: "left" },
{ title: "파트명", field: "PART_NAME", width: 180, hozAlign: "left" },
{ title: "규격", field: "SPEC", width: 120, hozAlign: "left" },
{ title: "수량", field: "QTY", width: 70, hozAlign: "right", formatter: "money" },
{ title: "단위", field: "UNIT_NAME", width: 60, hozAlign: "center" },
{ title: "재질", field: "MATERIAL_NAME", width: 100, hozAlign: "left" },
{ title: "비고", field: "REMARK", hozAlign: "left" },
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/bom", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ product_name: productName, part_no: partNo }),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [productName, partNo]);
// 페이지 진입 시 자동 로드
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800">BOM </h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="제품명">
<Input value={productName} onChange={(e) => setProductName(e.target.value)} className="w-[180px]" />
</SearchField>
<SearchField label="파트번호">
<Input value={partNo} onChange={(e) => setPartNo(e.target.value)} className="w-[130px]" />
</SearchField>
</SearchForm>
<DataGrid columns={columns} data={data} />
</div>
);
}
-65
View File
@@ -1,65 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
// costMgmt/costMgmtList.jsp 대응 - 원가관리
export default function CostMgmtPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [projectNo, setProjectNo] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120 },
{ title: "제품명", field: "PRODUCT_NAME", width: 180, hozAlign: "left" },
{ title: "목표원가", field: "TARGET_COST", width: 120, hozAlign: "right", formatter: "money" },
{ title: "실적원가", field: "ACTUAL_COST", width: 120, hozAlign: "right", formatter: "money" },
{ title: "차이", field: "DIFF_COST", width: 120, hozAlign: "right", formatter: "money" },
{ title: "달성율", field: "ACHIEVE_RATE", width: 80, hozAlign: "center",
formatter: (_cell, row) => `${row.ACHIEVE_RATE || 0}%` },
{ title: "등록일", field: "REGDATE", width: 100, hozAlign: "center" },
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/cost-mgmt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year, project_no: projectNo }),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [year, projectNo]);
// 페이지 진입 시 자동 로드
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"></h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="프로젝트번호">
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[130px]" />
</SearchField>
</SearchForm>
<DataGrid columns={columns} data={data} />
</div>
);
}
-121
View File
@@ -1,121 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import Swal from "sweetalert2";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CodeSelect } from "@/components/ui/code-select";
// costMgmt/expenseDashBoard.jsp 대응 - 경비관리
export default function CostExpensePage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [projectNo, setProjectNo] = useState("");
const [customerObjid, setCustomerObjid] = useState("");
const [pmUserId, setPmUserId] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [selected, setSelected] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left", frozen: true },
{
title: "프로젝트정보",
columns: [
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
{ title: "프로젝트명", field: "PROJECT_NAME", width: 200, hozAlign: "left" },
{ title: "요청납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
{ title: "셋업지", field: "SETUP", width: 100, hozAlign: "left" },
{ title: "PM", field: "PM_USER_NAME", width: 80, hozAlign: "center" },
],
},
{
title: "경비현황",
columns: [
{ title: "목표가", field: "EXPENSE_COST_GOAL", width: 120, hozAlign: "right", formatter: "money" },
{ title: "발생경비", field: "TOTAL_SETTLE_AMOUNT", width: 130, hozAlign: "right", formatter: "money" },
{ title: "투입율(%)", field: "INPUT_RATE", width: 90, hozAlign: "right" },
{ title: "조립", field: "SETTLE_AMOUNT_ASSEMBLE", width: 120, hozAlign: "right", formatter: "money" },
{ title: "셋업", field: "SETTLE_AMOUNT_SETUP", width: 120, hozAlign: "right", formatter: "money" },
{ title: "외주(Turn-key)", field: "SETTLE_AMOUNT_CS", width: 130, hozAlign: "right", formatter: "money" },
],
},
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/cost/expense", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year, project_no: projectNo, customer_objid: customerObjid, pm_user_id: pmUserId,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [year, projectNo, customerObjid, pmUserId]);
useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => {
(window as unknown as { fn_search?: () => void }).fn_search = fetchData;
return () => { delete (window as unknown as { fn_search?: () => void }).fn_search; };
}, [fetchData]);
const openExpenseApply = () => {
const contractObjid = selected.length === 1 ? String(selected[0].OBJID || "") : "";
if (selected.length > 1) {
Swal.fire({ icon: "warning", title: "한번에 1개의 프로젝트만 선택 가능합니다." });
return;
}
const w = 900, h = 600;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/cost/expense/apply${contractObjid ? `?contractObjid=${encodeURIComponent(contractObjid)}` : ""}`,
"expenseApply",
`width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
);
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"> - </h2>
<div className="flex gap-2">
<Button size="sm" onClick={openExpenseApply}></Button>
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="프로젝트번호">
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[140px]" />
</SearchField>
<SearchField label="고객사">
<CodeSelect codeId="CUSTOMER" value={customerObjid} onChange={setCustomerObjid} className="w-[160px]" />
</SearchField>
<SearchField label="PM">
<CodeSelect codeId="PM_USER" value={pmUserId} onChange={setPmUserId} className="w-[130px]" />
</SearchField>
</SearchForm>
<DataGrid
columns={columns}
data={data}
showCheckbox
onSelectionChange={setSelected}
/>
</div>
);
}
-94
View File
@@ -1,94 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CodeSelect } from "@/components/ui/code-select";
// costMgmt/laborCostMgmtList.jsp 대응 - 노무비관리
export default function CostLaborPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [projectNo, setProjectNo] = useState("");
const [customerObjid, setCustomerObjid] = useState("");
const [product, setProduct] = useState("");
const [pmUserId, setPmUserId] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left", frozen: true },
{
title: "프로젝트정보",
columns: [
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
{ title: "프로젝트명", field: "PROJECT_NAME", width: 200, hozAlign: "left" },
{ title: "고객납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
{ title: "셋업지", field: "SETUP", width: 100, hozAlign: "left" },
{ title: "PM", field: "PM_USER_NAME", width: 80, hozAlign: "center" },
],
},
{
title: "노무비현황",
columns: [
{ title: "목표가", field: "LABOR_COST_GOAL", width: 120, hozAlign: "right", formatter: "money" },
{ title: "실투입노무비", field: "LABOR_COST_ACTUAL", width: 120, hozAlign: "right", formatter: "money" },
{ title: "투입율(%)", field: "LABOR_INPUT_RATE", width: 90, hozAlign: "right" },
{ title: "투입공수(H)", field: "LABOR_HOURS", width: 100, hozAlign: "right" },
],
},
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/cost/labor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year, project_no: projectNo, customer_objid: customerObjid,
product, pm_user_id: pmUserId,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [year, projectNo, customerObjid, product, pmUserId]);
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"> - </h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="프로젝트번호">
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[140px]" />
</SearchField>
<SearchField label="고객사">
<CodeSelect codeId="CUSTOMER" value={customerObjid} onChange={setCustomerObjid} className="w-[160px]" />
</SearchField>
<SearchField label="제품구분">
<CodeSelect codeId="PRODUCT_TYPE" value={product} onChange={setProduct} className="w-[130px]" />
</SearchField>
<SearchField label="PM">
<CodeSelect codeId="PM_USER" value={pmUserId} onChange={setPmUserId} className="w-[130px]" />
</SearchField>
</SearchForm>
<DataGrid columns={columns} data={data} />
</div>
);
}
-95
View File
@@ -1,95 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CodeSelect } from "@/components/ui/code-select";
// costMgmt/materialCostTotaltList.jsp 대응 - 재료비관리
export default function CostMaterialPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [projectNo, setProjectNo] = useState("");
const [customerObjid, setCustomerObjid] = useState("");
const [product, setProduct] = useState("");
const [pmUserId, setPmUserId] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left", frozen: true },
{
title: "프로젝트정보",
columns: [
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
{ title: "프로젝트명", field: "PROJECT_NAME", width: 200, hozAlign: "left" },
{ title: "고객납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
{ title: "셋업지", field: "SETUP", width: 100, hozAlign: "left" },
{ title: "PM", field: "PM_USER_NAME", width: 80, hozAlign: "center" },
],
},
{
title: "재료비현황",
columns: [
{ title: "목표가", field: "MATERIAL_COST_GOAL", width: 120, hozAlign: "right", formatter: "money" },
{ title: "실투입재료비", field: "ALL_TOTAL_PRICE", width: 130, hozAlign: "right", formatter: "money" },
{ title: "투입율(%)", field: "MATERIAL_COST_GOAL_RATE", width: 90, hozAlign: "right" },
{ title: "발주금액", field: "NEW_TOTAL_PRICE", width: 120, hozAlign: "right", formatter: "money" },
{ title: "재발주금액", field: "ALL_TOTAL_PRICE_RE", width: 120, hozAlign: "right", formatter: "money" },
],
},
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/cost/material", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year, project_no: projectNo, customer_objid: customerObjid,
product, pm_user_id: pmUserId,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [year, projectNo, customerObjid, product, pmUserId]);
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"> - </h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="프로젝트번호">
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[140px]" />
</SearchField>
<SearchField label="고객사">
<CodeSelect codeId="CUSTOMER" value={customerObjid} onChange={setCustomerObjid} className="w-[160px]" />
</SearchField>
<SearchField label="제품구분">
<CodeSelect codeId="PRODUCT_TYPE" value={product} onChange={setProduct} className="w-[130px]" />
</SearchField>
<SearchField label="PM">
<CodeSelect codeId="PM_USER" value={pmUserId} onChange={setPmUserId} className="w-[130px]" />
</SearchField>
</SearchForm>
<DataGrid columns={columns} data={data} />
</div>
);
}
-155
View File
@@ -1,155 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import Swal from "sweetalert2";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CodeSelect } from "@/components/ui/code-select";
// costMgmt/costTotaltList.jsp 대응 - 투입원가관리 현황
export default function CostStatusPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [projectNo, setProjectNo] = useState("");
const [customerObjid, setCustomerObjid] = useState("");
const [product, setProduct] = useState("");
const [pmUserId, setPmUserId] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [selected, setSelected] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left", frozen: true },
{
title: "프로젝트정보",
columns: [
{ title: "고객사", field: "CUSTOMER_NAME", width: 140, hozAlign: "left" },
{ title: "프로젝트명", field: "PROJECT_NAME", width: 180, hozAlign: "left" },
{ title: "요청납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
{ title: "셋업지", field: "SETUP", width: 100, hozAlign: "left" },
{ title: "PM", field: "PM_USER_NAME", width: 80, hozAlign: "center" },
],
},
{
title: "투입원가현황",
columns: [
{ title: "수주가", field: "CONTRACT_PRICE", width: 110, hozAlign: "right", formatter: "money" },
{ title: "목표가", field: "TOTAL_COST_GOAL", width: 110, hozAlign: "right", formatter: "money" },
{ title: "실투입원가", field: "TOTAL_COST_ACTUAL", width: 110, hozAlign: "right", formatter: "money" },
{ title: "투입율(%)", field: "TOTAL_INPUT_RATE", width: 80, hozAlign: "right" },
{ title: "MC율(%)", field: "MC_RATE", width: 80, hozAlign: "right" },
],
},
{
title: "재료비현황",
columns: [
{ title: "목표가", field: "MATERIAL_COST_GOAL", width: 110, hozAlign: "right", formatter: "money" },
{ title: "발생재료비", field: "ACCRUAL_MATERIAL_COST", width: 110, hozAlign: "right", formatter: "money" },
{ title: "투입율(%)", field: "MATERIAL_COST_GOAL_RATE", width: 90, hozAlign: "right" },
],
},
{
title: "노무비현황",
columns: [
{ title: "목표가", field: "LABOR_COST_GOAL", width: 110, hozAlign: "right", formatter: "money" },
{ title: "발생노무비", field: "LABOR_COST_ACTUAL", width: 110, hozAlign: "right", formatter: "money" },
{ title: "투입율(%)", field: "LABOR_INPUT_RATE", width: 90, hozAlign: "right" },
],
},
{
title: "경비현황",
columns: [
{ title: "목표가", field: "EXPENSE_COST_GOAL", width: 110, hozAlign: "right", formatter: "money" },
{ title: "발생경비", field: "ACCRUAL_EXPENSE", width: 110, hozAlign: "right", formatter: "money" },
{ title: "투입율(%)", field: "EXPENSE_RATE", width: 90, hozAlign: "right" },
],
},
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/cost/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year, project_no: projectNo, customer_objid: customerObjid,
product, pm_user_id: pmUserId,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [year, projectNo, customerObjid, product, pmUserId]);
useEffect(() => { fetchData(); }, [fetchData]);
// 팝업 저장 후 새로고침
useEffect(() => {
(window as unknown as { fn_search?: () => void }).fn_search = fetchData;
return () => { delete (window as unknown as { fn_search?: () => void }).fn_search; };
}, [fetchData]);
const openGoalPopup = () => {
if (selected.length === 0) {
Swal.fire({ icon: "warning", title: "선택된 내용이 없습니다." });
return;
}
if (selected.length > 1) {
Swal.fire({ icon: "warning", title: "한번에 1개의 내용만 등록 가능합니다." });
return;
}
const contractObjid = String(selected[0].OBJID || "");
const w = 500;
const h = 350;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/cost/goal/form?contractObjid=${encodeURIComponent(contractObjid)}`,
"costGoalForm",
`width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
);
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"> </h2>
<div className="flex gap-2">
<Button size="sm" onClick={openGoalPopup}> </Button>
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="프로젝트번호">
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[140px]" />
</SearchField>
<SearchField label="고객사">
<CodeSelect codeId="CUSTOMER" value={customerObjid} onChange={setCustomerObjid} className="w-[160px]" />
</SearchField>
<SearchField label="제품구분">
<CodeSelect codeId="PRODUCT_TYPE" value={product} onChange={setProduct} className="w-[130px]" />
</SearchField>
<SearchField label="PM">
<CodeSelect codeId="PM_USER" value={pmUserId} onChange={setPmUserId} className="w-[130px]" />
</SearchField>
</SearchForm>
<DataGrid
columns={columns}
data={data}
showCheckbox
onSelectionChange={setSelected}
/>
</div>
);
}
-70
View File
@@ -1,70 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Button } from "@/components/ui/button";
import { CodeSelect } from "@/components/ui/code-select";
// csMgmt/csChartList.jsp 대응 - CS 차트관리
export default function CsChartPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [productCode, setProductCode] = useState("");
const [chartData, setChartData] = useState<Record<string, unknown>[]>([]);
const fetchData = useCallback(async () => {
const res = await fetch("/api/cs/chart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year,
product_code: productCode,
}),
});
if (res.ok) {
const json = await res.json();
setChartData(json.RESULTLIST || []);
}
}, [year, productCode]);
// 페이지 진입 시 자동 로드
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800">CS </h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="제품구분">
<CodeSelect codeId="PRODUCT_TYPE" value={productCode} onChange={setProductCode} className="w-[130px]" />
</SearchField>
</SearchForm>
<div className="bg-white rounded-lg border border-gray-200 p-6" style={{ height: "calc(100vh - 350px)" }}>
{chartData.length > 0 ? (
<div className="text-center text-gray-500">
{/* TODO: Chart rendering - integrate with chart library */}
<p className="text-sm"> {chartData.length} </p>
<p className="text-xs text-gray-400 mt-2"> .</p>
</div>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
.
</div>
)}
</div>
</div>
);
}
-277
View File
@@ -1,277 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { SearchableCodeSelect } from "@/components/ui/searchable-code-select";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { FolderCell } from "@/components/ui/folder-cell";
import { ApprovalButton } from "@/components/approval/ApprovalButton";
import { ApprovalStatusBadge } from "@/components/approval/ApprovalStatusBadge";
import Swal from "sweetalert2";
// asMngList_CS.jsp 대응 - CS등록 및 조치
export default function CsManagePage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [productCd, setProductCd] = useState("");
const [projectNo, setProjectNo] = useState("");
const [warranty, setWarranty] = useState("");
const [recStartDate, setRecStartDate] = useState("");
const [recEndDate, setRecEndDate] = useState("");
const [managerId, setManagerId] = useState("");
const [actStartDate, setActStartDate] = useState("");
const [actEndDate, setActEndDate] = useState("");
const [apprStatus, setApprStatus] = useState("");
const [projectOpts, setProjectOpts] = useState<{ value: string; label: string }[]>([]);
const [userOpts, setUserOpts] = useState<{ value: string; label: string }[]>([]);
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetch("/api/common/project-list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
.then((r) => r.json())
.then((j) =>
setProjectOpts(
(j.RESULTLIST || []).map((r: Record<string, unknown>) => ({
value: String(r.OBJID),
label: String(r.LABEL || r.PROJECT_NO || r.OBJID),
}))
)
)
.catch(() => setProjectOpts([]));
fetch("/api/admin/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
.then((r) => r.json())
.then((j) =>
setUserOpts(
(j.RESULTLIST || []).map((u: Record<string, unknown>) => ({
value: String(u.USER_ID),
label: `${u.USER_NAME || u.USER_ID}${u.DEPT_NAME ? ` (${u.DEPT_NAME})` : ""}`,
}))
)
)
.catch(() => setUserOpts([]));
}, []);
const openFormPopup = (objId = "") => {
const w = 1400, h = 930;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/cs/manage/form${objId ? `?objId=${objId}` : ""}`,
"asMngFormPopUp",
`width=${w},height=${h},left=${left},top=${top}`
);
};
const openFileRegist = (objId: string) => {
const w = 800, h = 500;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/common/files?targetObjId=${encodeURIComponent(objId)}&docType=AS_DOC_01&docTypeName=${encodeURIComponent("CS 조치내역 첨부파일")}`,
"fileAS_DOC_01",
`width=${w},height=${h},left=${left},top=${top}`
);
};
const openApprovalDetail = (approvalObjId: string) => {
if (!approvalObjId) return;
const w = 900, h = 700;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/approval/form?objId=${approvalObjId}`,
"approvalDetailPopup",
`width=${w},height=${h},left=${left},top=${top}`
);
};
const columns: GridColumn[] = [
{
title: "접수 No.", field: "SERVICE_NO", width: 110, hozAlign: "left",
cellClick: (row) => openFormPopup(String(row.OBJID || "")),
},
{ title: "제품구분(기계형식)", field: "PRODUCT_NAME", width: 140, hozAlign: "left" },
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left" },
{ title: "고객사", field: "CUSTOMER_NAME", width: 130, hozAlign: "left" },
{ title: "출고일자", field: "RELEASE_DATE", width: 100, hozAlign: "center" },
{ title: "셋업지", field: "SETUP", width: 120, hozAlign: "left" },
{ title: "유무상", field: "WARRANTY_NAME", width: 80, hozAlign: "left" },
{ title: "CS구분", field: "CATEGORY_NAME", width: 100, hozAlign: "left" },
{ title: "유형", field: "CATEGORY_H_NAME", width: 100, hozAlign: "left" },
{ title: "제목", field: "TITLE", width: 200, hozAlign: "left" },
{ title: "접수일", field: "REC_DT", width: 100, hozAlign: "center" },
{ title: "예상발생비용", field: "PLAN_COST", width: 110, hozAlign: "right", formatter: "money" },
{ title: "등록자", field: "MANAGER_NAME", width: 90, hozAlign: "left" },
{ title: "조치완료일", field: "ACT_DATE", width: 110, hozAlign: "center" },
{
title: "첨부파일", field: "CU03_CNT", width: 80, hozAlign: "center",
formatter: (cell, row) => (
<FolderCell
count={Number(cell) || 0}
onClick={() => openFileRegist(String(row.OBJID || ""))}
/>
),
},
{
title: "상태", field: "APPR_STATUS_NAME", width: 100, hozAlign: "center",
formatter: (_, row) => {
const apv = String(row.APPROVAL_OBJID || "");
return (
<ApprovalStatusBadge
status={row.APPR_STATUS_NAME || row.APPR_STATUS}
onClick={apv ? () => openApprovalDetail(apv) : undefined}
/>
);
},
},
];
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/cs/manage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Year: year,
product_cd: productCd,
project_no: projectNo,
warranty,
rec_start_date: recStartDate,
rec_end_date: recEndDate,
manager_id: managerId,
act_start_date: actStartDate,
act_end_date: actEndDate,
appr_status: apprStatus,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
} finally {
setLoading(false);
}
}, [year, productCd, projectNo, warranty, recStartDate, recEndDate, managerId, actStartDate, actEndDate, apprStatus]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleDelete = async () => {
if (selectedRows.length === 0) {
Swal.fire("알림", "삭제할 항목을 선택하세요.", "warning");
return;
}
const result = await Swal.fire({
title: "삭제 확인",
text: `선택한 ${selectedRows.length}건을 삭제하시겠습니까?`,
icon: "question", showCancelButton: true,
confirmButtonText: "삭제", cancelButtonText: "취소",
});
if (!result.isConfirmed) return;
const res = await fetch("/api/cs/manage/delete", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objIds: selectedRows.map((r) => String(r.OBJID)) }),
});
const json = await res.json();
if (json.success) {
Swal.fire({ icon: "success", title: json.message || "삭제되었습니다.", timer: 1200, showConfirmButton: false });
fetchData();
} else {
Swal.fire("오류", json.message || "삭제 실패", "error");
}
};
// 단건만 결재 허용 + 이미 진행/완료건 필터
const approvalRow = selectedRows.length === 1 ? selectedRows[0] : null;
const approvalStatus = String(approvalRow?.APPR_STATUS_NAME || "");
const canRequestApproval =
!!approvalRow && approvalStatus !== "결재중" && approvalStatus !== "결재완료";
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800">CS관리_CS등록 </h2>
<div className="flex gap-2">
<ApprovalButton
objIds={approvalRow ? [String(approvalRow.OBJID)] : []}
targetType="CSM"
title={approvalRow ? `CS조치내역서 상신 - ${approvalRow.TITLE || approvalRow.SERVICE_NO || ""}` : ""}
description={approvalRow ? String(approvalRow.TITLE || "") : ""}
onSuccess={fetchData}
disabled={!canRequestApproval}
/>
<Button size="sm" onClick={() => openFormPopup("")}></Button>
<Button size="sm" variant="danger" onClick={handleDelete}></Button>
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select
value={year}
onChange={(e) => setYear(e.target.value)}
className="h-9 w-[110px] rounded border border-gray-300 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="제품구분">
<SearchableCodeSelect codeId="0000001" value={productCd} onChange={setProductCd} className="w-[150px]" />
</SearchField>
<SearchField label="프로젝트번호">
<SearchableSelect options={projectOpts} value={projectNo} onChange={setProjectNo} className="w-[220px]" />
</SearchField>
<SearchField label="유/무상">
<SearchableCodeSelect codeId="0000156" value={warranty} onChange={setWarranty} className="w-[120px]" />
</SearchField>
<SearchField label="접수일">
<div className="flex items-center gap-1">
<Input type="date" value={recStartDate} onChange={(e) => setRecStartDate(e.target.value)} className="w-[140px]" />
<span className="text-gray-400">~</span>
<Input type="date" value={recEndDate} onChange={(e) => setRecEndDate(e.target.value)} className="w-[140px]" />
</div>
</SearchField>
<SearchField label="조치담당자">
<SearchableSelect options={userOpts} value={managerId} onChange={setManagerId} className="w-[180px]" />
</SearchField>
<SearchField label="조치완료일">
<div className="flex items-center gap-1">
<Input type="date" value={actStartDate} onChange={(e) => setActStartDate(e.target.value)} className="w-[140px]" />
<span className="text-gray-400">~</span>
<Input type="date" value={actEndDate} onChange={(e) => setActEndDate(e.target.value)} className="w-[140px]" />
</div>
</SearchField>
<SearchField label="상태">
<select
value={apprStatus}
onChange={(e) => setApprStatus(e.target.value)}
className="h-9 w-[130px] rounded border border-gray-300 bg-white px-3 text-sm"
>
<option value=""></option>
<option value="inProcess"></option>
<option value="reject"></option>
<option value="complete"></option>
</select>
</SearchField>
</SearchForm>
<DataGrid
columns={columns}
data={data}
showCheckbox
onSelectionChange={setSelectedRows}
loading={loading}
/>
</div>
);
}
-68
View File
@@ -1,68 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
// customerMng/customerServiceList.jsp 대응 - CS관리
export default function CsPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [customerName, setCustomerName] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "접수번호", field: "CS_NO", width: 120,
cellClick: (row) => window.open(`/cs/manage/form?objId=${row.OBJID}`, "csDetail", "width=900,height=700") },
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
{ title: "제품명", field: "PRODUCT_NAME", width: 150, hozAlign: "left" },
{ title: "접수일", field: "RECEIPT_DATE", width: 100, hozAlign: "center" },
{ title: "유형", field: "CS_TYPE_NAME", width: 100, hozAlign: "center" },
{ title: "내용", field: "DESCRIPTION", hozAlign: "left" },
{ title: "상태", field: "STATUS_NAME", width: 80, hozAlign: "center" },
{ title: "담당자", field: "CHARGER_NAME", width: 90, hozAlign: "center" },
{ title: "완료일", field: "COMPLETE_DATE", width: 100, hozAlign: "center" },
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/cs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year, customer_name: customerName }),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [year, customerName]);
// 페이지 진입 시 자동 로드
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800">CS </h2>
<div className="flex gap-2">
<Button size="sm" onClick={() => window.open("/cs/manage/form", "csForm", "width=900,height=700")}></Button>
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="고객사">
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} className="w-[150px]" />
</SearchField>
</SearchForm>
<DataGrid columns={columns} data={data} />
</div>
);
}
-194
View File
@@ -1,194 +0,0 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Button } from "@/components/ui/button";
import { SearchableCodeSelect } from "@/components/ui/searchable-code-select";
import { SearchableSelect } from "@/components/ui/searchable-select";
interface DynColumn {
GROUP_SEQ: number;
GROUP_CNT: number;
GROUP_NAME: string;
PARENT_CODE_ID: string;
CODE_ID: string;
NAME: string;
COL_NAME: string;
}
// asList_CS.jsp 대응 - CS관리_현황 (제품×프로젝트 대시보드)
export default function CsStatusPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [productCd, setProductCd] = useState("");
const [projectNo, setProjectNo] = useState("");
const [warranty, setWarranty] = useState("");
const [csCategory, setCsCategory] = useState("");
const [categoryH, setCategoryH] = useState("");
const [projectOpts, setProjectOpts] = useState<{ value: string; label: string }[]>([]);
const [categoryHOpts, setCategoryHOpts] = useState<{ value: string; label: string }[]>([]);
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [dynColumns, setDynColumns] = useState<DynColumn[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetch("/api/common/project-list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
.then((r) => r.json())
.then((j) =>
setProjectOpts(
(j.RESULTLIST || []).map((r: Record<string, unknown>) => ({
value: String(r.OBJID),
label: String(r.LABEL || r.PROJECT_NO || r.OBJID),
}))
)
)
.catch(() => setProjectOpts([]));
}, []);
// cs_category(0000970) 선택 시 유형(category_h) 옵션 로드
useEffect(() => {
if (!csCategory) {
setCategoryHOpts([]);
setCategoryH("");
return;
}
fetch("/api/common/code-list", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeId: csCategory }),
})
.then((r) => r.json())
.then((j) =>
setCategoryHOpts(
(j.data || []).map((r: Record<string, unknown>) => ({
value: String(r.code_id || r.CODE_ID),
label: String(r.code_name || r.CODE_NAME),
}))
)
)
.catch(() => setCategoryHOpts([]));
}, [csCategory]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/cs/status", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Year: year,
product_cd: productCd,
project_no: projectNo,
warranty,
cs_category: csCategory,
category_h: categoryH,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
setDynColumns(json.COLUMN_LIST || []);
}
} finally {
setLoading(false);
}
}, [year, productCd, projectNo, warranty, csCategory, categoryH]);
useEffect(() => { fetchData(); }, [fetchData]);
const columns: GridColumn[] = useMemo(() => {
// 동적 컬럼 → GROUP_NAME(2nd-level 부모코드명) 기준으로 묶고, leaf는 NAME 사용
const groupMap = new Map<string, DynColumn[]>();
const groupOrder: string[] = [];
dynColumns.forEach((c) => {
const key = `${c.PARENT_CODE_ID}::${c.GROUP_NAME}`;
if (!groupMap.has(key)) {
groupMap.set(key, []);
groupOrder.push(key);
}
groupMap.get(key)!.push(c);
});
const dynGroupColumns: GridColumn[] = groupOrder.map((key) => {
const group = groupMap.get(key)!;
return {
title: group[0].GROUP_NAME || "유형",
columns: group.map((c) => ({
title: c.NAME,
field: c.COL_NAME,
width: 90,
hozAlign: "right",
headerHozAlign: "center",
formatter: "money",
})),
};
});
// 유형 최상위 묶음 (있을 때만)
const categoryWrapper: GridColumn[] = dynGroupColumns.length
? [{ title: "유형", columns: dynGroupColumns }]
: [];
return [
{ title: "제품구분", field: "PRODUCT_NAME", width: 140, hozAlign: "left" },
{ title: "프로젝트번호", field: "PROJECT_NO", width: 130, hozAlign: "left" },
{
title: "유상",
columns: [
{ title: "건수", field: "WARRANTY1", width: 80, hozAlign: "right", headerHozAlign: "center", formatter: "money" },
{ title: "발생비용", field: "COST1", width: 110, hozAlign: "right", headerHozAlign: "center", formatter: "money" },
],
},
{
title: "무상",
columns: [
{ title: "건수", field: "WARRANTY2", width: 80, hozAlign: "right", headerHozAlign: "center", formatter: "money" },
{ title: "발생비용", field: "COST2", width: 110, hozAlign: "right", headerHozAlign: "center", formatter: "money" },
],
},
...categoryWrapper,
];
}, [dynColumns]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800">CS관리_현황</h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select
value={year}
onChange={(e) => setYear(e.target.value)}
className="h-9 w-[110px] rounded border border-gray-300 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="제품구분">
<SearchableCodeSelect codeId="0000001" value={productCd} onChange={setProductCd} className="w-[150px]" />
</SearchField>
<SearchField label="프로젝트번호">
<SearchableSelect options={projectOpts} value={projectNo} onChange={setProjectNo} className="w-[220px]" />
</SearchField>
<SearchField label="유/무상">
<SearchableCodeSelect codeId="0000156" value={warranty} onChange={setWarranty} className="w-[120px]" />
</SearchField>
<SearchField label="CS구분">
<SearchableCodeSelect codeId="0000970" value={csCategory} onChange={setCsCategory} className="w-[150px]" />
</SearchField>
<SearchField label="유형">
<SearchableSelect options={categoryHOpts} value={categoryH} onChange={setCategoryH} className="w-[150px]" />
</SearchField>
</SearchForm>
<DataGrid columns={columns} data={data} loading={loading} />
</div>
);
}
-576
View File
@@ -1,576 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import {
PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend,
ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid,
} from "recharts";
import { useAuthStore } from "@/store/auth-store";
import { useMenuStore } from "@/store/menu-store";
import { numberWithCommas } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
FolderKanban, AlertCircle,
TrendingUp, BarChart3, Briefcase,
} from "lucide-react";
interface YearGoalRow {
YEAR: string;
CONTRACT_CNT_YEAR_IN?: number;
CONTRACT_CNT_YEAR_OUT?: number;
CONTRACT_CNT_YEAR_RATE?: number;
CONTRACT_COST_YEAR?: string | number;
PRICE?: string | number;
GOAL_RATE?: number;
YEAR_GOAL_OBJID?: string;
}
interface DashboardData {
projectStats: {
CNT_TOTAL?: number; CNT_NOPLAN?: number; CNT_ING?: number;
CNT_DELAY?: number; CNT_END?: number;
ISSUE_TOTAL?: number; ISSUE_MISS?: number;
};
productDist: { CODE: string; NAME: string; CNT: number }[];
supplyDist: { CODE: string; NAME: string; CNT: number }[];
monthlyContract: { MONTH: number; AMOUNT: string }[];
projectList: Record<string, unknown>[];
yearGoalInfo: YearGoalRow[];
}
type Tab = "sales" | "project";
const PIE_COLORS = ["#3b82f6", "#22c55e", "#a855f7", "#f97316", "#ef4444", "#14b8a6", "#eab308"];
export default function DashboardPage() {
const { user } = useAuthStore();
const { topMenus, fetchSideMenus } = useMenuStore();
const [data, setData] = useState<DashboardData | null>(null);
const [year, setYear] = useState(new Date().getFullYear().toString());
const [tab, setTab] = useState<Tab>("sales");
useEffect(() => {
if (topMenus.length > 0) {
const userMenu = topMenus.find((m) => m.MENU_NAME_KOR !== "관리자") || topMenus[0];
fetchSideMenus(userMenu.OBJID);
}
}, [topMenus, fetchSideMenus]);
useEffect(() => {
fetch("/api/dashboard", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year }),
})
.then((r) => r.ok ? r.json() : null)
.then((d) => setData(d))
.catch(() => {});
}, [year]);
return (
<div className="flex flex-col h-[calc(100vh-120px)]">
{/* 헤더 */}
<div className="flex items-center justify-between mb-6 shrink-0">
<h1 className="text-xl font-bold text-gray-800">
Dashboard
<span className="text-sm font-normal text-gray-400 ml-2">
{user?.userName}, .
</span>
</h1>
<div className="flex items-center gap-3">
{/* 탭 */}
<div className="inline-flex bg-gray-100 rounded-lg p-1">
<TabButton active={tab === "sales"} onClick={() => setTab("sales")} icon={TrendingUp} label="영업" />
<TabButton active={tab === "project"} onClick={() => setTab("project")} icon={Briefcase} label="프로젝트" />
</div>
<select
value={year}
onChange={(e) => setYear(e.target.value)}
className="h-9 rounded border border-gray-300 bg-white px-3 text-sm"
>
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
{/* 탭 내용 — 남은 공간 가득 채움 */}
<div className="flex-1 min-h-0">
{tab === "sales" ? (
<SalesTab data={data} year={year} />
) : (
<ProjectTab data={data} year={year} />
)}
</div>
</div>
);
}
function TabButton({ active, onClick, icon: Icon, label }: {
active: boolean; onClick: () => void; icon: React.ElementType; label: string;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
active ? "bg-white text-primary shadow-sm" : "text-gray-500 hover:text-gray-700"
}`}
>
<Icon size={15} />
{label}
</button>
);
}
function SalesTab({ data, year }: { data: DashboardData | null; year: string }) {
const openGoalPopup = () => {
const w = 700, h = 500;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(`/sales/year-goal?year=${year}`, "yearGoalPopup",
`width=${w},height=${h},left=${left},top=${top},menubars=no,scrollbars=yes,resizable=yes`);
};
return (
<div className="flex flex-col gap-4 h-full min-h-0">
{/* 상단: 영업현황 표 (고정 높이) */}
<YearGoalTable info={data?.yearGoalInfo || []} onOpenGoal={openGoalPopup} />
{/* 하단: 3분할 — 제품별 pie / 고객사별 pie / 년도별 combo (남은 공간 가득) */}
<div className="grid grid-cols-3 gap-4 flex-1 min-h-0">
<PieCard title="■ 제품별현황" data={data?.productDist || []} />
<PieCard title="■ 고객사별현황" data={data?.supplyDist || []} />
<YearSalesComboChart info={data?.yearGoalInfo || []} />
</div>
</div>
);
}
function YearGoalTable({ info, onOpenGoal }: { info: YearGoalRow[]; onOpenGoal: () => void }) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-4 shadow-sm shrink-0">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
<TrendingUp size={16} className="text-primary" />
</h3>
<Button size="sm" onClick={onOpenGoal}> </Button>
</div>
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-gray-600 text-white">
<th rowSpan={2} className="border border-gray-300 px-2 py-1 font-semibold"></th>
<th colSpan={2} className="border border-gray-300 px-2 py-1 font-semibold">()</th>
<th rowSpan={2} className="border border-gray-300 px-2 py-1 font-semibold">(%)</th>
<th rowSpan={2} className="border border-gray-300 px-2 py-1 font-semibold">()</th>
<th rowSpan={2} className="border border-gray-300 px-2 py-1 font-semibold">()</th>
<th rowSpan={2} className="border border-gray-300 px-2 py-1 font-semibold">(%)</th>
</tr>
<tr className="bg-gray-600 text-white">
<th className="border border-gray-300 px-2 py-1 font-semibold"></th>
<th className="border border-gray-300 px-2 py-1 font-semibold"></th>
</tr>
</thead>
<tbody>
{info.length === 0 ? (
<tr><td colSpan={7} className="text-center py-4 text-gray-400"> .</td></tr>
) : info.map((row, idx) => (
<tr key={idx} className="hover:bg-gray-50">
<td className="border border-gray-200 px-2 py-1.5 text-center font-medium">{row.YEAR}</td>
<td className="border border-gray-200 px-2 py-1.5 text-center">{row.CONTRACT_CNT_YEAR_IN ?? 0}</td>
<td className="border border-gray-200 px-2 py-1.5 text-center">{row.CONTRACT_CNT_YEAR_OUT ?? 0}</td>
<td className="border border-gray-200 px-2 py-1.5 text-center">{row.CONTRACT_CNT_YEAR_RATE ?? 0}</td>
<td className="border border-gray-200 px-2 py-1.5 text-center">{numberWithCommas(Number(row.CONTRACT_COST_YEAR ?? 0))}</td>
<td className="border border-gray-200 px-2 py-1.5 text-center">{numberWithCommas(Number(row.PRICE ?? 0))}</td>
<td className="border border-gray-200 px-2 py-1.5 text-center">{row.GOAL_RATE ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function PieCard({ title, data }: { title: string; data: { CODE: string; NAME: string; CNT: number }[] }) {
const chartData = data.map((d, i) => ({
name: d.NAME || `코드 ${d.CODE}`,
value: d.CNT,
color: PIE_COLORS[i % PIE_COLORS.length],
}));
const total = chartData.reduce((s, d) => s + d.value, 0);
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm flex flex-col min-h-0">
<h3 className="text-sm font-semibold text-gray-700 mb-3 shrink-0">{title}</h3>
{total === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-gray-400"> .</div>
) : (
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius="75%"
label={({ percent }: { percent?: number }) =>
percent != null && percent >= 0.05 ? `${Math.round(percent * 100)}%` : ""
}
labelLine={false}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(v) => `${v}`} />
<Legend
verticalAlign="bottom"
align="center"
iconSize={10}
wrapperStyle={{ fontSize: "11px" }}
/>
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
function YearSalesComboChart({ info }: { info: YearGoalRow[] }) {
// 과거→현재 순서
const chartData = [...info]
.sort((a, b) => Number(a.YEAR) - Number(b.YEAR))
.map((r) => ({
YEAR: r.YEAR,
영업목표: Number(r.PRICE || 0),
수주금액: Number(r.CONTRACT_COST_YEAR || 0),
달성율: Number(r.GOAL_RATE || 0),
}));
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm flex flex-col min-h-0">
<h3 className="text-sm font-semibold text-gray-700 mb-3 shrink-0"> </h3>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f1f1" />
<XAxis dataKey="YEAR" tick={{ fontSize: 11 }} />
<YAxis yAxisId="left" tick={{ fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} unit="%" />
<Tooltip
formatter={(v, name) =>
name === "달성율" ? `${v}%` : `${numberWithCommas(Number(v))}`
}
/>
<Legend wrapperStyle={{ fontSize: "11px" }} iconSize={10} />
<Bar yAxisId="left" dataKey="영업목표" fill="#3b82f6" barSize={20} />
<Bar yAxisId="left" dataKey="수주금액" fill="#ef4444" barSize={20} />
<Line yAxisId="right" type="monotone" dataKey="달성율" stroke="#f97316" strokeWidth={2}
dot={{ r: 5, fill: "#f97316", stroke: "#fff", strokeWidth: 2 }} />
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
);
}
type StatusFilter = "all" | "noplan" | "ing" | "delay" | "end";
const FILTER_LABELS: Record<StatusFilter, string> = {
all: "전체",
noplan: "계획미수립",
ing: "진행중",
delay: "지연",
end: "종료",
};
function ProjectTab({ data, year }: { data: DashboardData | null; year: string }) {
const stats = data?.projectStats || {};
const allProjects = (data?.projectList || []) as Record<string, unknown>[];
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [projectFilter, setProjectFilter] = useState<string>("");
const [selectedIdx, setSelectedIdx] = useState(0);
const projectList = allProjects.filter((p) => {
const s = String(p.STATUS_TITLE || "");
if (statusFilter !== "all") {
if (statusFilter === "noplan" && s !== "계획미수립") return false;
if (statusFilter === "ing" && s !== "진행중") return false;
if (statusFilter === "delay" && s !== "지연") return false;
if (statusFilter === "end" && s !== "종료") return false;
}
if (projectFilter && String(p.OBJID) !== projectFilter) return false;
return true;
});
const selected = projectList[selectedIdx] || projectList[0];
const toggleFilter = (f: StatusFilter) => {
setSelectedIdx(0);
setStatusFilter((cur) => (cur === f ? "all" : f));
};
const openProjectSchedule = () => {
// 프로젝트 일정 전체 보기 → 프로젝트 관리 > 종합현황 페이지로 이동
window.location.href = `/project/total?year=${year}`;
};
return (
<div className="flex flex-col gap-5 h-full">
{/* 상단 프로젝트현황 카드 — 원본 스타일 (5개 숫자 가로 + 컨트롤) */}
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm shrink-0">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
<FolderKanban size={16} className="text-primary" />
</h3>
<Button size="sm" onClick={openProjectSchedule}> </Button>
</div>
<div className="flex items-start gap-4">
{/* 좌측: 년도/프로젝트번호 셀렉트 */}
<div className="flex flex-col gap-2 min-w-[240px] pr-4 border-r border-gray-100">
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-20"></label>
<div className="flex-1 text-sm font-medium">{year}</div>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-20"></label>
<select value={projectFilter} onChange={(e) => setProjectFilter(e.target.value)}
className="flex-1 h-8 rounded border border-gray-300 bg-white px-2 text-xs">
<option value=""></option>
{allProjects.map((p) => (
<option key={String(p.OBJID)} value={String(p.OBJID)}>{String(p.PROJECT_NO || "")}</option>
))}
</select>
</div>
</div>
{/* 우측: 5개 숫자 가로 배치 */}
<div className="flex-1 grid grid-cols-5 gap-2">
<CountBadge label="전체" value={Number(stats.CNT_TOTAL || 0)} color="blue"
active={statusFilter === "all"} onClick={() => setStatusFilter("all")} />
<CountBadge label="계획미수립" value={Number(stats.CNT_NOPLAN || 0)} color="blue"
active={statusFilter === "noplan"} onClick={() => toggleFilter("noplan")} />
<CountBadge label="진행중" value={Number(stats.CNT_ING || 0)} color="blue"
active={statusFilter === "ing"} onClick={() => toggleFilter("ing")} />
<CountBadge label="지연" value={Number(stats.CNT_DELAY || 0)} color="red"
active={statusFilter === "delay"} onClick={() => toggleFilter("delay")} />
<CountBadge label="종료" value={Number(stats.CNT_END || 0)} color="blue"
active={statusFilter === "end"} onClick={() => toggleFilter("end")} />
</div>
</div>
</div>
{/* 프로젝트 리스트 — 전체 너비, 원본 10컬럼 구조 */}
<div className="flex-1 bg-white rounded-xl border border-gray-200 p-4 flex flex-col min-h-0 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 mb-3 shrink-0 flex items-center gap-2">
<FolderKanban size={16} className="text-primary" />
{statusFilter !== "all" && (
<span className="text-xs font-normal text-primary">[{FILTER_LABELS[statusFilter]}]</span>
)} · {projectList.length}
{statusFilter !== "all" && (
<button onClick={() => setStatusFilter("all")}
className="ml-auto text-[10px] text-gray-400 hover:text-gray-700">
×
</button>
)}
</h3>
<div className="flex-1 overflow-auto min-h-0 border border-gray-100 rounded">
<table className="w-full text-sm border-collapse">
<thead className="sticky top-0 bg-gray-600 text-white z-10">
<tr>
<th className="px-2 py-2 font-semibold text-xs w-12"></th>
<th className="px-2 py-2 font-semibold text-xs"></th>
<th className="px-2 py-2 font-semibold text-xs"></th>
<th className="px-2 py-2 font-semibold text-xs"></th>
<th className="px-2 py-2 font-semibold text-xs"></th>
<th className="px-2 py-2 font-semibold text-xs"></th>
<th className="px-2 py-2 font-semibold text-xs"></th>
<th className="px-2 py-2 font-semibold text-xs">(%)</th>
<th className="px-2 py-2 font-semibold text-xs"></th>
<th className="px-2 py-2 font-semibold text-xs"></th>
</tr>
</thead>
<tbody>
{projectList.length === 0 ? (
<tr><td colSpan={10} className="text-center py-10 text-gray-400"> .</td></tr>
) : projectList.map((pjt, idx) => {
const statusTitle = String(pjt.STATUS_TITLE || "");
const statusColor =
statusTitle === "종료" ? "text-green-600" :
statusTitle === "지연" ? "text-red-500" :
statusTitle === "계획미수립" ? "text-gray-500" :
"text-blue-600";
const isSelected = idx === selectedIdx;
return (
<tr key={idx}
className={`border-b border-gray-100 cursor-pointer ${isSelected ? "bg-primary/10" : "hover:bg-gray-50"}`}
onClick={() => setSelectedIdx(idx)}>
<td className="px-2 py-2 text-center">
<input type="radio" checked={isSelected} onChange={() => setSelectedIdx(idx)} className="pointer-events-none" />
</td>
<td className="px-2 py-2 text-center text-xs">{String(pjt.CUSTOMER_NAME || "")}</td>
<td className="px-2 py-2 text-center text-xs">{String(pjt.PRODUCT_NAME || "")}</td>
<td className="px-2 py-2 text-center">
<span className="inline-block px-2 py-0.5 bg-gray-700 text-white rounded text-[11px]">{String(pjt.PROJECT_NO || "")}</span>
</td>
<td className="px-2 py-2 text-center text-xs">{String(pjt.CONTRACT_DEL_DATE || "-")}</td>
<td className="px-2 py-2 text-center text-xs">{String(pjt.SETUP || "")}</td>
<td className="px-2 py-2 text-center text-xs">{String(pjt.MANUFACTURE_PLANT_NAME || "")}</td>
<td className="px-2 py-2 text-right text-xs">{Number(pjt.SETUP_RATE || 0).toFixed(1)}</td>
<td className="px-2 py-2 text-center text-xs">{String(pjt.SETUP_DONE_DATE || "")}</td>
<td className={`px-2 py-2 text-center text-xs font-semibold ${statusColor}`}>{statusTitle}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* 선택된 프로젝트 상세 (이슈 + 투입원가) */}
<div className="shrink-0">
<ProjectDetailPanel project={selected} />
</div>
</div>
);
}
function ProjectDetailPanel({ project }: { project: Record<string, unknown> | undefined }) {
if (!project) {
return (
<div className="flex-1 bg-white rounded-xl border border-gray-200 p-10 flex items-center justify-center shadow-sm">
<div className="text-sm text-gray-400"> </div>
</div>
);
}
const issueTotal = Number(project.ISSUE_CNT || 0);
const issueDone = Number(project.ISSUE_DONE_CNT || 0);
const issueMiss = Number(project.ISSUE_MISS_CNT || 0);
const issueRate = issueTotal > 0 ? Math.round((issueDone / issueTotal) * 100) : 0;
// 투입원가 항목별 (원본 dashboard.jsp fn_getProjectCostStatusList 이식)
const contractPrice = Number(project.CONTRACT_PRICE || 0);
const materialGoal = Number(project.MATERIAL_COST_GOAL || 0);
const materialActual = Number(project.ACCRUAL_MATERIAL_COST || 0);
const laborGoal = Number(project.LABOR_COST_GOAL || 0);
const laborActual = Number(project.LABOR_COST_ACTUAL || 0);
const expenseGoal = Number(project.EXPENSE_COST_GOAL || 0);
const expenseActual = Number(project.ACCRUAL_EXPENSE || 0);
const totalGoalBase = materialGoal + laborGoal + expenseGoal;
const totalActualBase = materialActual + laborActual + expenseActual;
// 관리비 = 전체의 10%
const mgmtGoal = Math.round(totalGoalBase * 0.1);
const mgmtActual = Math.round(totalActualBase * 0.1);
const totalGoal = totalGoalBase + mgmtGoal;
const totalActual = totalActualBase + mgmtActual;
// 각 항목 투입율(%) — 재료비는 수주가 기준, 나머지는 목표 기준 (원본 로직)
const materialRate = contractPrice > 0 ? Math.round((materialActual / contractPrice) * 1000) / 10 : 0;
const laborRate = laborGoal > 0 ? Math.round((laborActual / laborGoal) * 1000) / 10 : 0;
const expenseRate = expenseGoal > 0 ? Math.round((expenseActual / expenseGoal) * 1000) / 10 : 0;
const mgmtRate = mgmtGoal > 0 ? Math.round((mgmtActual / mgmtGoal) * 1000) / 10 : 0;
const totalRateCost = totalGoal > 0 ? Math.round((totalActual / totalGoal) * 1000) / 10 : 0;
return (
<>
{/* 이슈 + 투입원가 2분할 (가로 배치) */}
<div className="grid grid-cols-2 gap-4">
{/* 이슈 (Quality) */}
<div className="bg-white rounded-xl border border-gray-200 p-5 flex flex-col shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2 shrink-0">
<AlertCircle size={16} className="text-red-500" />
(Quality)
</h3>
<div className="flex-1 grid grid-cols-2 gap-3 content-center">
<MiniStat label="발생" value={issueTotal} color="text-gray-800" />
<MiniStat label="조치" value={issueDone} color="text-green-600" />
<MiniStat label="미결" value={issueMiss} color="text-red-500" />
<MiniStat label="조치율" value={`${issueRate}%`} color="text-primary" />
</div>
</div>
{/* 투입원가현황 — 원본 dashboard.jsp 5행 테이블 */}
<div className="bg-white rounded-xl border border-gray-200 p-5 flex flex-col shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2 shrink-0">
<BarChart3 size={16} className="text-primary" />
</h3>
<div className="flex-1 overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-gray-600 text-white">
<th className="border border-gray-300 px-2 py-1.5 font-semibold">()</th>
<th className="border border-gray-300 px-2 py-1.5 font-semibold"></th>
<th className="border border-gray-300 px-2 py-1.5 font-semibold">()</th>
<th className="border border-gray-300 px-2 py-1.5 font-semibold">()</th>
<th className="border border-gray-300 px-2 py-1.5 font-semibold">(%)</th>
</tr>
</thead>
<tbody className="text-gray-700">
<tr>
<td rowSpan={5} className="border border-gray-300 px-2 py-2 text-right align-middle">{numberWithCommas(contractPrice)}</td>
<td className="border border-gray-300 px-2 py-1.5 text-center"></td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(materialGoal)}</td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(materialActual)}</td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{materialRate}</td>
</tr>
<tr>
<td className="border border-gray-300 px-2 py-1.5 text-center"></td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(laborGoal)}</td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(laborActual)}</td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{laborRate}</td>
</tr>
<tr>
<td className="border border-gray-300 px-2 py-1.5 text-center"></td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(expenseGoal)}</td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(expenseActual)}</td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{expenseRate}</td>
</tr>
<tr>
<td className="border border-gray-300 px-2 py-1.5 text-center"></td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(mgmtGoal)}</td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(mgmtActual)}</td>
<td className="border border-gray-300 px-2 py-1.5 text-right">{mgmtRate}</td>
</tr>
<tr style={{ backgroundColor: "#efb3b3" }}>
<td className="border border-gray-300 px-2 py-1.5 text-center font-semibold"></td>
<td className="border border-gray-300 px-2 py-1.5 text-right font-semibold">{numberWithCommas(totalGoal)}</td>
<td className="border border-gray-300 px-2 py-1.5 text-right font-semibold">{numberWithCommas(totalActual)}</td>
<td className="border border-gray-300 px-2 py-1.5 text-right font-semibold">{totalRateCost}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</>
);
}
function MiniStat({ label, value, color }: { label: string; value: number | string; color: string }) {
return (
<div className="bg-gray-50 rounded-lg p-4 text-center">
<div className="text-xs text-gray-500 mb-1">{label}</div>
<div className={`text-2xl font-bold ${color}`}>{value}</div>
</div>
);
}
function CountBadge({ label, value, color, active, onClick }: {
label: string; value: number; color: "blue" | "red";
active?: boolean; onClick?: () => void;
}) {
const numColor = value > 0 ? (color === "red" ? "text-red-500" : "text-blue-600") : "text-gray-300";
const bg = active ? (color === "red" ? "bg-red-50" : "bg-blue-50") : "hover:bg-gray-50";
return (
<button type="button" onClick={onClick}
className={`flex flex-col items-center justify-center rounded-lg py-2 transition-colors ${bg}`}>
<div className={`text-3xl font-bold ${numColor}`}>{numberWithCommas(value)}</div>
<div className="text-xs text-gray-500 mt-0.5">({label})</div>
</button>
);
}
-352
View File
@@ -1,352 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { SearchableCodeSelect } from "@/components/ui/searchable-code-select";
import Swal from "sweetalert2";
type Option = { value: string; label: string };
// 원본: purchaseOrder/deliveryMngAcceptanceList.jsp
// 입고관리 > 입고결과등록
export default function AcceptancePage() {
const currentYear = new Date().getFullYear();
const [year, setYear] = useState("");
const [customerProjectName, setCustomerProjectName] = useState("");
const [projectNo, setProjectNo] = useState("");
const [unitCode, setUnitCode] = useState("");
const [purchaseOrderNo, setPurchaseOrderNo] = useState("");
const [type, setType] = useState("");
const [searchPartSpec, setSearchPartSpec] = useState("");
const [partnerObjid, setPartnerObjid] = useState("");
const [salesMngUserId, setSalesMngUserId] = useState("");
const [deliveryStartDate, setDeliveryStartDate] = useState("");
const [deliveryEndDate, setDeliveryEndDate] = useState("");
const [regStartDate, setRegStartDate] = useState("");
const [regEndDate, setRegEndDate] = useState("");
const [deliveryStatus, setDeliveryStatus] = useState("");
const [searchPartName, setSearchPartName] = useState("");
const [searchPartNo, setSearchPartNo] = useState("");
const [poClientId, setPoClientId] = useState("");
const [supplyOptions, setSupplyOptions] = useState<Option[]>([]);
const [userOptions, setUserOptions] = useState<Option[]>([]);
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
useEffect(() => {
fetch("/api/admin/supply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
})
.then((r) => r.json())
.then((d) =>
setSupplyOptions(
(d.RESULTLIST || []).map((r: Record<string, unknown>) => ({
value: String(r.OBJID),
label: String(r.SUPPLY_NAME),
})),
),
)
.catch(() => {});
fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
})
.then((r) => r.json())
.then((d) =>
setUserOptions(
(d.RESULTLIST || []).map((r: Record<string, unknown>) => ({
value: String(r.USER_ID),
label: String(r.USER_NAME),
})),
),
)
.catch(() => {});
}, []);
// 발주서 상세 팝업 열기 (발주번호 셀 클릭)
const openOrderForm = (objId: string) => {
const w = 1460;
const h = 1050;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/order/list/form?objId=${objId}&action=view`,
`orderForm_${objId}`,
`width=${w},height=${h},left=${left},top=${top}`,
);
};
// 입고결과 팝업 (view) — 입고결과 셀 클릭
const openAcceptanceViewPopup = (objId: string, status: string) => {
const w = 1560;
const h = 1050;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/delivery/acceptance/form?objId=${objId}&delivery_status=${encodeURIComponent(status)}`,
"deliveryAcceptancePopUp",
`width=${w},height=${h},left=${left},top=${top}`,
);
};
const columns: GridColumn[] = [
{ title: "고객사", field: "CUSTOMER_NAME", width: 110, hozAlign: "left" },
{ title: "프로젝트명", field: "CUSTOMER_PROJECT_NAME", width: 150, hozAlign: "left" },
{ title: "유닛명", field: "UNIT_NAME", width: 200, hozAlign: "left" },
{ title: "프로젝트번호", field: "PROJECT_NO", width: 100, hozAlign: "center" },
{
title: "발주번호",
field: "PURCHASE_ORDER_NO",
width: 100,
hozAlign: "center",
cellClick: (row) => openOrderForm(String(row.OBJID || "")),
},
{ title: "동시", field: "MULTI_YN_MAKED", width: 50, hozAlign: "center" },
{ title: "발주서_제목", field: "TITLE", width: 150, hozAlign: "left" },
{ title: "입고요청일", field: "DELIVERY_DATE", width: 85, hozAlign: "center" },
{ title: "구매/제작업체명", field: "PARTNER_NAME", width: 120, hozAlign: "left" },
{ title: "구매담당", field: "SALES_MNG_USER_NAME", width: 78, hozAlign: "center" },
{ title: "발주일", field: "REGDATE", width: 78, hozAlign: "center" },
{ title: "발주수량", field: "TOTAL_PO_QTY", width: 78, hozAlign: "right", formatter: "money" },
{ title: "입고일", field: "CUR_DELIVERY_DATE", width: 78, hozAlign: "center" },
{ title: "입고자", field: "CUR_RECEIVER_NAME", width: 70, hozAlign: "center" },
{ title: "입고수량", field: "TOTAL_DELIVERY_QTY", width: 75, hozAlign: "right", formatter: "money" },
{ title: "미입고수량", field: "NON_DELIVERY_QTY", width: 85, hozAlign: "right", formatter: "money" },
{
title: "입고결과",
field: "DELIVERY_STATUS",
width: 75,
hozAlign: "center",
cellClick: (row) =>
openAcceptanceViewPopup(String(row.OBJID || ""), String(row.DELIVERY_STATUS || "")),
},
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/delivery/acceptance", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year,
customer_project_name: customerProjectName,
project_nos: projectNo ? [projectNo] : [],
unit_code: unitCode,
purchase_order_no: purchaseOrderNo,
type,
SEARCH_PART_SPEC: searchPartSpec,
partner_objid: partnerObjid,
sales_mng_user_ids: salesMngUserId ? [salesMngUserId] : [],
delivery_start_date: deliveryStartDate,
delivery_end_date: deliveryEndDate,
reg_start_date: regStartDate,
reg_end_date: regEndDate,
delivery_status: deliveryStatus,
SEARCH_PART_NAME: searchPartName,
SEARCH_PART_NO: searchPartNo,
po_client_id: poClientId,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
} else {
const j = await res.json().catch(() => ({}));
Swal.fire("오류", j.message || "조회 실패", "error");
}
}, [
year, customerProjectName, projectNo, unitCode, purchaseOrderNo, type,
searchPartSpec, partnerObjid, salesMngUserId, deliveryStartDate, deliveryEndDate,
regStartDate, regEndDate, deliveryStatus, searchPartName, searchPartNo, poClientId,
]);
// 입고결과등록: 원본 가드 로직 동일
// - 미선택: "선택된 데이터가 없습니다."
// - 2건이상: "한건씩 등록 가능합니다."
// - MULTI_YN='Y' AND MULTI_MASTER_YN='N': "동시발주건은 마스터건으로 수입검사해야 합니다."
const handleAcceptanceRegister = () => {
if (selectedRows.length === 0) {
Swal.fire("알림", "선택된 데이터가 없습니다.", "warning");
return;
}
if (selectedRows.length > 1) {
Swal.fire("알림", "한건씩 등록 가능합니다.", "warning");
return;
}
const row = selectedRows[0];
const multiYn = String(row.MULTI_YN || "");
const multiMasterYn = String(row.MULTI_MASTER_YN || "");
if (multiYn === "Y" && multiMasterYn === "N") {
Swal.fire("알림", "동시발주건은 마스터건으로 수입검사해야 합니다.", "warning");
return;
}
const w = 1560;
const h = 1050;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/delivery/acceptance/form?objId=${row.OBJID}&action=regist`,
"deliveryAcceptancePopUp",
`width=${w},height=${h},left=${left},top=${top}`,
);
};
// 부적합등록: 원본 가드 로직 동일
// - 미선택/복수선택 체크 + MULTI_MASTER_YN='N': "동시발주건은 마스터건으로 부적합 등록해야 합니다."
const handleDefectRegister = () => {
if (selectedRows.length === 0) {
Swal.fire("알림", "선택된 데이터가 없습니다.", "warning");
return;
}
if (selectedRows.length > 1) {
Swal.fire("알림", "한건씩 등록 가능합니다.", "warning");
return;
}
const row = selectedRows[0];
const multiMasterYn = String(row.MULTI_MASTER_YN || "");
if (multiMasterYn === "N") {
Swal.fire("알림", "동시발주건은 마스터건으로 부적합 등록해야 합니다.", "warning");
return;
}
const w = 1260;
const h = 1050;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/delivery/defect/form?objId=${row.OBJID}`,
"InvalidFormPopUp",
`width=${w},height=${h},left=${left},top=${top}`,
);
};
useEffect(() => {
fetchData();
// 최초 1회만
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800">_입고결과등록</h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
<Button size="sm" onClick={handleAcceptanceRegister}></Button>
<Button size="sm" variant="secondary" onClick={handleDefectRegister}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select
value={year}
onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from({ length: 5 }, (_, i) => currentYear - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="프로젝트명">
<Input value={customerProjectName} onChange={(e) => setCustomerProjectName(e.target.value)} className="w-[150px]" />
</SearchField>
<SearchField label="프로젝트번호">
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[140px]" placeholder="프로젝트 OBJID" />
</SearchField>
<SearchField label="유닛명">
<Input value={unitCode} onChange={(e) => setUnitCode(e.target.value)} className="w-[130px]" placeholder="유닛 OBJID" />
</SearchField>
<SearchField label="발주No.">
<Input value={purchaseOrderNo} onChange={(e) => setPurchaseOrderNo(e.target.value)} className="w-[120px]" />
</SearchField>
<SearchField label="부품구분">
<SearchableCodeSelect codeId="0001068" value={type} onChange={setType} className="w-[130px]" />
</SearchField>
<SearchField label="규격">
<Input value={searchPartSpec} onChange={(e) => setSearchPartSpec(e.target.value)} className="w-[120px]" />
</SearchField>
<SearchField label="공급업체">
<SearchableSelect options={supplyOptions} value={partnerObjid} onChange={setPartnerObjid} className="w-[170px]" />
</SearchField>
<SearchField label="구매담당자">
<SearchableSelect options={userOptions} value={salesMngUserId} onChange={setSalesMngUserId} className="w-[150px]" />
</SearchField>
<SearchField label="입고요청일">
<div className="flex items-center gap-1">
<input
type="date"
value={deliveryStartDate}
onChange={(e) => setDeliveryStartDate(e.target.value)}
className="h-9 rounded border border-gray-300 bg-white px-2 text-sm w-[130px]"
/>
<span>~</span>
<input
type="date"
value={deliveryEndDate}
onChange={(e) => setDeliveryEndDate(e.target.value)}
className="h-9 rounded border border-gray-300 bg-white px-2 text-sm w-[130px]"
/>
</div>
</SearchField>
<SearchField label="발주일">
<div className="flex items-center gap-1">
<input
type="date"
value={regStartDate}
onChange={(e) => setRegStartDate(e.target.value)}
className="h-9 rounded border border-gray-300 bg-white px-2 text-sm w-[130px]"
/>
<span>~</span>
<input
type="date"
value={regEndDate}
onChange={(e) => setRegEndDate(e.target.value)}
className="h-9 rounded border border-gray-300 bg-white px-2 text-sm w-[130px]"
/>
</div>
</SearchField>
<SearchField label="입고결과">
<select
value={deliveryStatus}
onChange={(e) => setDeliveryStatus(e.target.value)}
className="h-9 w-[110px] rounded border border-gray-300 bg-white px-3 text-sm"
>
<option value=""></option>
<option value="입고중"></option>
<option value="입고완료"></option>
<option value="지연"></option>
</select>
</SearchField>
<SearchField label="품명">
<Input value={searchPartName} onChange={(e) => setSearchPartName(e.target.value)} className="w-[130px]" />
</SearchField>
<SearchField label="품번">
<Input value={searchPartNo} onChange={(e) => setSearchPartNo(e.target.value)} className="w-[130px]" />
</SearchField>
<SearchField label="발주처">
<SearchableSelect options={supplyOptions} value={poClientId} onChange={setPoClientId} className="w-[170px]" />
</SearchField>
</SearchForm>
<DataGrid
columns={columns}
data={data}
showCheckbox
onSelectionChange={setSelectedRows}
/>
</div>
);
}
-108
View File
@@ -1,108 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CodeSelect } from "@/components/ui/code-select";
import Swal from "sweetalert2";
// 입고관리 > 부적합리스트
export default function DefectPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [customerCd, setCustomerCd] = useState("");
const [projectNo, setProjectNo] = useState("");
const [partnerObjid, setPartnerObjid] = useState("");
const [defectType, setDefectType] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "부적합번호", field: "DEFECT_NO", width: 130, hozAlign: "left",
cellClick: (row) => {
window.open(`/delivery/defect/form?objId=${row.OBJID}`, "defectForm", "width=1000,height=800");
},
},
{ title: "발주No", field: "PURCHASE_ORDER_NO", width: 140, hozAlign: "left" },
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
{ title: "프로젝트번호", field: "PROJECT_NO", width: 130, hozAlign: "left" },
{ title: "공급업체", field: "PARTNER_NAME", width: 150, hozAlign: "left" },
{ title: "Part No", field: "PART_NO", width: 120, hozAlign: "left" },
{ title: "품명", field: "PART_NAME", width: 150, hozAlign: "left" },
{ title: "부적합유형", field: "DEFECT_TYPE_NAME", width: 100, hozAlign: "center" },
{ title: "부적합수량", field: "DEFECT_QTY", width: 90, hozAlign: "right", formatter: "money" },
{ title: "부적합내용", field: "DEFECT_CONTENT", width: 250, hozAlign: "left" },
{ title: "처리상태", field: "STATUS_NAME", width: 80, hozAlign: "center" },
{ title: "등록일", field: "REG_DATE", width: 100, hozAlign: "center" },
{ title: "등록자", field: "REG_USER_NAME", width: 80, hozAlign: "center" },
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/delivery/defect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year,
customer_cd: customerCd,
project_no: projectNo,
partner_objid: partnerObjid,
defect_type: defectType,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [year, customerCd, projectNo, partnerObjid, defectType]);
const handleExcelDownload = () => {
Swal.fire("알림", "Excel 다운로드 기능은 준비 중입니다.", "info");
};
// 페이지 진입 시 자동 로드
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"></h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
<Button size="sm" variant="secondary" onClick={handleExcelDownload}>Excel Download</Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="고객사">
<CodeSelect codeId="CUSTOMER" value={customerCd} onChange={setCustomerCd} className="w-[160px]" />
</SearchField>
<SearchField label="프로젝트번호">
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} placeholder="프로젝트번호" className="w-[150px]" />
</SearchField>
<SearchField label="공급업체">
<CodeSelect codeId="PARTNER" value={partnerObjid} onChange={setPartnerObjid} className="w-[160px]" />
</SearchField>
<SearchField label="부적합유형">
<CodeSelect codeId="DEFECT_TYPE" value={defectType} onChange={setDefectType} className="w-[130px]" />
</SearchField>
</SearchForm>
<DataGrid
columns={columns}
data={data}
showCheckbox
onSelectionChange={setSelectedRows}
/>
</div>
);
}
-108
View File
@@ -1,108 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CodeSelect } from "@/components/ui/code-select";
import Swal from "sweetalert2";
// 입고관리 > 단가관리
export default function PricePage() {
const [partnerObjid, setPartnerObjid] = useState("");
const [partName, setPartName] = useState("");
const [partNo, setPartNo] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "공급업체", field: "PARTNER_NAME", width: 180, hozAlign: "left" },
{ title: "Part No", field: "PART_NO", width: 120, hozAlign: "left" },
{ title: "품명", field: "PART_NAME", width: 150, hozAlign: "left" },
{ title: "규격", field: "SPEC", width: 150, hozAlign: "left" },
{ title: "재질", field: "MATERIAL", width: 100, hozAlign: "left" },
{ title: "단위", field: "UNIT_NAME", width: 60, hozAlign: "center" },
{ title: "단가", field: "UNIT_PRICE", width: 100, hozAlign: "right", formatter: "money" },
{ title: "적용시작일", field: "START_DATE", width: 100, hozAlign: "center" },
{ title: "적용종료일", field: "END_DATE", width: 100, hozAlign: "center" },
{ title: "등록일", field: "REG_DATE", width: 100, hozAlign: "center" },
{ title: "비고", field: "REMARK", width: 200, hozAlign: "left" },
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/delivery/price", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
partner_objid: partnerObjid,
part_name: partName,
part_no: partNo,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [partnerObjid, partName, partNo]);
const handleDelete = async () => {
if (selectedRows.length === 0) {
Swal.fire("알림", "삭제할 항목을 선택하세요.", "warning");
return;
}
const result = await Swal.fire({
title: "삭제 확인",
text: `선택한 ${selectedRows.length}건을 삭제하시겠습니까?`,
icon: "question",
showCancelButton: true,
confirmButtonText: "삭제",
cancelButtonText: "취소",
});
if (result.isConfirmed) {
Swal.fire("완료", "삭제되었습니다.", "success");
fetchData();
}
};
const handleExcelDownload = () => {
Swal.fire("알림", "Excel 다운로드 기능은 준비 중입니다.", "info");
};
// 페이지 진입 시 자동 로드
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"></h2>
<div className="flex gap-2">
<Button size="sm" onClick={() => {/* TODO: open register form */}}></Button>
<Button size="sm" variant="danger" onClick={handleDelete}></Button>
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
<Button size="sm" variant="secondary" onClick={handleExcelDownload}>Excel Download</Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="공급업체">
<CodeSelect codeId="PARTNER" value={partnerObjid} onChange={setPartnerObjid} className="w-[160px]" />
</SearchField>
<SearchField label="Part No">
<Input value={partNo} onChange={(e) => setPartNo(e.target.value)} placeholder="Part No" className="w-[150px]" />
</SearchField>
<SearchField label="품명">
<Input value={partName} onChange={(e) => setPartName(e.target.value)} placeholder="품명" className="w-[150px]" />
</SearchField>
</SearchForm>
<DataGrid
columns={columns}
data={data}
showCheckbox
onSelectionChange={setSelectedRows}
/>
</div>
);
}
-196
View File
@@ -1,196 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
import Swal from "sweetalert2";
type Option = { value: string; label: string };
// 원본: purchaseOrder/deliveryMngStatus.jsp
// 입고관리 > 현황 — 프로젝트 BOM별 발주/입고/부적합 집계 리포트
export default function DeliveryStatusPage() {
const currentYear = new Date().getFullYear();
const [year, setYear] = useState("");
const [customerObjid, setCustomerObjid] = useState("");
const [projectNo, setProjectNo] = useState(""); // 단일 project objid
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [customerOptions, setCustomerOptions] = useState<Option[]>([]);
const [projectOptions, setProjectOptions] = useState<Option[]>([]);
useEffect(() => {
fetch("/api/common/supply-list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
})
.then((r) => r.json())
.then((d) =>
setCustomerOptions(
(d.RESULTLIST || []).map((r: Record<string, unknown>) => ({
value: String(r.OBJID),
label: String(r.SUPPLY_NAME),
})),
),
)
.catch(() => {});
fetch("/api/common/project-list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
})
.then((r) => r.json())
.then((d) =>
setProjectOptions(
(d.RESULTLIST || []).map((r: Record<string, unknown>) => ({
value: String(r.OBJID),
label: String(r.LABEL || r.PROJECT_NO || r.CUSTOMER_PROJECT_NAME || r.OBJID),
})),
),
)
.catch(() => {});
}, []);
// 구매BOM 팝업
const openBomPopup = (bomReportObjId: string) => {
if (!bomReportObjId) return;
const w = 1600;
const h = 900;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/purchase/bom/form?parentObjId=${bomReportObjId}&actType=view`,
`bomReport_${bomReportObjId}`,
`width=${w},height=${h},left=${left},top=${top}`,
);
};
const columns: GridColumn[] = [
{
title: "프로젝트정보",
headerHozAlign: "center",
frozen: true,
columns: [
{ title: "고객사", field: "CUSTOMER_NAME", width: 110, hozAlign: "left" },
{ title: "프로젝트명", field: "CUSTOMER_PROJECT_NAME", width: 140, hozAlign: "left" },
{ title: "유닛명", field: "UNIT_PART_NAME", width: 200, hozAlign: "left" },
{ title: "프로젝트번호", field: "PROJECT_NO", width: 100, hozAlign: "left" },
],
},
{
title: "발주내역",
headerHozAlign: "center",
columns: [
{
title: "구매BOM",
field: "TOTAL_BOM_PART_CNT",
width: 80,
hozAlign: "center",
cellClick: (row) => openBomPopup(String(row.BOM_REPORT_OBJID || "")),
formatter: (cell) => (Number(cell) > 0 ? "조회" : "-"),
},
{ title: "전체품목수", field: "TOTAL_BOM_PART_CNT", width: 100, hozAlign: "right", formatter: "money" },
{ title: "발주품목수", field: "TOTAL_PO_PART_CNT", width: 100, hozAlign: "right", formatter: "money" },
{ title: "발주율(%)", field: "PO_RATE", width: 90, hozAlign: "right" },
{ title: "미발주품수", field: "NON_PO_PART_CNT", width: 90, hozAlign: "right", formatter: "money" },
{ title: "총수량", field: "TOTAL_BOM_PART_QTY_SUM", width: 90, hozAlign: "right", formatter: "money" },
],
},
{
title: "입고현황",
headerHozAlign: "center",
columns: [
{ title: "발주수량(신)", field: "TOTAL_PO_NEW_QTY", width: 95, hozAlign: "right", formatter: "money" },
{ title: "발주수량(재)", field: "TOTAL_PO_RE_QTY", width: 95, hozAlign: "right", formatter: "money" },
{ title: "입고수량", field: "DELIVERY_QTY", width: 90, hozAlign: "right", formatter: "money" },
{ title: "미입고수량", field: "NON_DELIVERY_QTY", width: 90, hozAlign: "right", formatter: "money" },
],
},
{
title: "수입검사결과(불량현황)",
headerHozAlign: "center",
columns: [
{ title: "부적합수량", field: "TOTAL_DEFECT_QTY", width: 90, hozAlign: "right", formatter: "money" },
{ title: "불량률(%)", field: "DELIVERY_RATE", width: 90, hozAlign: "right" },
{ title: "설계오류", field: "DEFECT_QTY_1", width: 80, hozAlign: "right", formatter: "money" },
{ title: "제작불량", field: "DEFECT_QTY_2", width: 80, hozAlign: "right", formatter: "money" },
{ title: "구매오류", field: "DEFECT_QTY_3", width: 80, hozAlign: "right", formatter: "money" },
{ title: "오품반입", field: "DEFECT_QTY_4", width: 80, hozAlign: "right", formatter: "money" },
{ title: "손실비용", field: "TOTAL_DEFECT_PRICE", width: 90, hozAlign: "right", formatter: "money" },
],
},
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/delivery/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Year: year,
customer_objid: customerObjid,
project_nos: projectNo ? [projectNo] : [],
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
} else {
const j = await res.json().catch(() => ({}));
Swal.fire("오류", j.message || "조회 실패", "error");
}
}, [year, customerObjid, projectNo]);
useEffect(() => {
fetchData();
// 최초 1회만
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800">_현황</h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select
value={year}
onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from({ length: 5 }, (_, i) => currentYear - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="고객사">
<SearchableSelect
options={customerOptions}
value={customerObjid}
onChange={setCustomerObjid}
className="w-[180px]"
/>
</SearchField>
<SearchField label="프로젝트번호">
<SearchableSelect
options={projectOptions}
value={projectNo}
onChange={setProjectNo}
className="w-[240px]"
/>
</SearchField>
</SearchForm>
<DataGrid columns={columns} data={data} />
</div>
);
}
-100
View File
@@ -1,100 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CodeSelect } from "@/components/ui/code-select";
import { ApprovalButton } from "@/components/approval/ApprovalButton";
// fundMgmt/fundExpenseFormList.jsp 대응 - 경비신청서관리
export default function FundExpenseFormPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [writerName, setWriterName] = useState("");
const [statusCode, setStatusCode] = useState("");
const [expenseDateFrom, setExpenseDateFrom] = useState("");
const [expenseDateTo, setExpenseDateTo] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "신청번호", field: "EXPENSE_FORM_NO", width: 140, hozAlign: "left",
cellClick: (row) => window.open(`/fund/expense-form/form?objId=${row.OBJID}`, "expenseFormDetail", "width=1000,height=700") },
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left" },
{ title: "프로젝트명", field: "PROJECT_NAME", width: 200, hozAlign: "left" },
{ title: "신청자", field: "WRITER_NAME", width: 80, hozAlign: "center" },
{ title: "신청일", field: "EXPENSE_DATE", width: 100, hozAlign: "center" },
{ title: "경비구분", field: "EXPENSE_TYPE_NAME", width: 100, hozAlign: "center" },
{ title: "신청금액", field: "EXPENSE_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
{ title: "사용처", field: "EXPENSE_PLACE", width: 150, hozAlign: "left" },
{ title: "상태", field: "STATUS_NAME", width: 80, hozAlign: "center" },
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/fund/expense-form", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year,
writer_name: writerName,
status_code: statusCode,
expense_date_from: expenseDateFrom,
expense_date_to: expenseDateTo,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [year, writerName, statusCode, expenseDateFrom, expenseDateTo]);
// 페이지 진입 시 자동 로드
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"></h2>
<div className="flex gap-2">
<Button size="sm" onClick={() => window.open("/fund/expense-form/form", "expenseFormNew", "width=1000,height=700")}></Button>
<ApprovalButton
objIds={selectedRows.map((r) => String(r.OBJID))}
targetType="EXPENSE_APPLY"
title={`경비 결재 요청 (${selectedRows.length}건)`}
description={selectedRows.map((r) => `${r.EXPENSE_ID} - ${r.BUS_TITLE}`).join("\n")}
onSuccess={fetchData}
disabled={selectedRows.length === 0}
/>
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="신청자">
<Input value={writerName} onChange={(e) => setWriterName(e.target.value)} className="w-[120px]" />
</SearchField>
<SearchField label="상태">
<CodeSelect codeId="EXPENSE_STATUS" value={statusCode} onChange={setStatusCode} className="w-[120px]" />
</SearchField>
<SearchField label="신청일(From)">
<Input type="date" value={expenseDateFrom} onChange={(e) => setExpenseDateFrom(e.target.value)} className="w-[140px]" />
</SearchField>
<SearchField label="신청일(To)">
<Input type="date" value={expenseDateTo} onChange={(e) => setExpenseDateTo(e.target.value)} className="w-[140px]" />
</SearchField>
</SearchForm>
<DataGrid columns={columns} data={data} showCheckbox
onSelectionChange={setSelectedRows} />
</div>
);
}
-85
View File
@@ -1,85 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CodeSelect } from "@/components/ui/code-select";
// fundMgmt/fundInvoiceList.jsp 대응 - 거래명세서관리
export default function FundInvoicePage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [customerName, setCustomerName] = useState("");
const [invoiceDateFrom, setInvoiceDateFrom] = useState("");
const [invoiceDateTo, setInvoiceDateTo] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "거래명세서번호", field: "INVOICE_NO", width: 140, hozAlign: "left",
cellClick: (row) => window.open(`/fund/invoice/form?objId=${row.OBJID}`, "invoiceDetail", "width=1000,height=700") },
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left" },
{ title: "프로젝트명", field: "PROJECT_NAME", width: 200, hozAlign: "left" },
{ title: "발행일", field: "INVOICE_DATE", width: 100, hozAlign: "center" },
{ title: "공급가액", field: "SUPPLY_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
{ title: "세액", field: "TAX_AMOUNT", width: 100, hozAlign: "right", formatter: "money" },
{ title: "합계", field: "TOTAL_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
{ title: "상태", field: "STATUS_NAME", width: 80, hozAlign: "center" },
{ title: "등록자", field: "WRITER_NAME", width: 80, hozAlign: "center" },
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/fund/invoice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year,
customer_name: customerName,
invoice_date_from: invoiceDateFrom,
invoice_date_to: invoiceDateTo,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [year, customerName, invoiceDateFrom, invoiceDateTo]);
// 페이지 진입 시 자동 로드
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"></h2>
<div className="flex gap-2">
<Button size="sm" onClick={() => window.open("/fund/invoice/form", "invoiceForm", "width=1000,height=700")}></Button>
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="고객사">
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} className="w-[150px]" />
</SearchField>
<SearchField label="발행일(From)">
<Input type="date" value={invoiceDateFrom} onChange={(e) => setInvoiceDateFrom(e.target.value)} className="w-[140px]" />
</SearchField>
<SearchField label="발행일(To)">
<Input type="date" value={invoiceDateTo} onChange={(e) => setInvoiceDateTo(e.target.value)} className="w-[140px]" />
</SearchField>
</SearchForm>
<DataGrid columns={columns} data={data} />
</div>
);
}
-101
View File
@@ -1,101 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CodeSelect } from "@/components/ui/code-select";
import Swal from "sweetalert2";
// 자재관리 > 자금관리
export default function FundPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [month, setMonth] = useState("");
const [partnerObjid, setPartnerObjid] = useState("");
const [paymentStatus, setPaymentStatus] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "공급업체", field: "PARTNER_NAME", width: 180, hozAlign: "left" },
{ title: "발주금액", field: "ORDER_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
{ title: "입고금액", field: "DELIVERY_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
{ title: "지급예정금액", field: "PAYMENT_PLAN_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
{ title: "지급완료금액", field: "PAYMENT_DONE_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
{ title: "미지급금액", field: "UNPAID_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
{ title: "지급예정일", field: "PAYMENT_PLAN_DATE", width: 100, hozAlign: "center" },
{ title: "지급상태", field: "PAYMENT_STATUS_NAME", width: 90, hozAlign: "center" },
{ title: "비고", field: "REMARK", width: 200, hozAlign: "left" },
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/inventory/fund", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year,
month,
partner_objid: partnerObjid,
payment_status: paymentStatus,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [year, month, partnerObjid, paymentStatus]);
const handleExcelDownload = () => {
Swal.fire("알림", "Excel 다운로드 기능은 준비 중입니다.", "info");
};
// 페이지 진입 시 자동 로드
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"></h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
<Button size="sm" variant="secondary" onClick={handleExcelDownload}>Excel Download</Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="월">
<select value={month} onChange={(e) => setMonth(e.target.value)}
className="h-9 w-[80px] rounded border border-gray-300 bg-white px-3 text-sm">
<option value=""></option>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={String(m).padStart(2, "0")}>{m}</option>
))}
</select>
</SearchField>
<SearchField label="공급업체">
<CodeSelect codeId="PARTNER" value={partnerObjid} onChange={setPartnerObjid} className="w-[160px]" />
</SearchField>
<SearchField label="지급상태">
<CodeSelect codeId="PAYMENT_STATUS" value={paymentStatus} onChange={setPaymentStatus} className="w-[130px]" />
</SearchField>
</SearchForm>
<DataGrid
columns={columns}
data={data}
showCheckbox
onSelectionChange={setSelectedRows}
/>
</div>
);
}
-43
View File
@@ -1,43 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { Button } from "@/components/ui/button";
// 재고 입출고 이력 팝업
export default function InventoryHistoryPage() {
const searchParams = useSearchParams();
const objId = searchParams.get("objId") || "";
const [data, setData] = useState<Record<string, unknown>[]>([]);
const fetchData = useCallback(async () => {
if (!objId) return;
const res = await fetch("/api/inventory/history", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent_objid: objId }),
});
if (res.ok) { const json = await res.json(); setData(json.RESULTLIST || []); }
}, [objId]);
useEffect(() => { fetchData(); }, [fetchData]);
const columns: GridColumn[] = [
{ title: "유형", field: "TYPE", width: 80, hozAlign: "center" },
{ title: "수량", field: "QTY", width: 100, hozAlign: "right", formatter: "money" },
{ title: "일자", field: "HIST_DATE", width: 110, hozAlign: "center" },
{ title: "위치", field: "LOCATION_NAME", width: 100, hozAlign: "center" },
{ title: "등록자", field: "WRITER_NAME", width: 90, hozAlign: "center" },
{ title: "비고", field: "REMARK", hozAlign: "left" },
];
return (
<div className="p-4">
<h2 className="text-lg font-bold text-gray-800 mb-3"> </h2>
<DataGrid columns={columns} data={data} />
<div className="flex justify-end mt-3">
<Button variant="secondary" onClick={() => window.close()}></Button>
</div>
</div>
);
}
-407
View File
@@ -1,407 +0,0 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { SearchableCodeSelect } from "@/components/ui/searchable-code-select";
import { SearchableSelect } from "@/components/ui/searchable-select";
import Swal from "sweetalert2";
// 자재관리 > 자재리스트 (원본 /inventoryMng/inventoryMngNewList.do)
export default function InventoryListPage() {
const [projectNos, setProjectNos] = useState<string[]>([]);
const [unitCode, setUnitCode] = useState("");
const [partNo, setPartNo] = useState("");
const [partName, setPartName] = useState("");
const [partType, setPartType] = useState("");
const [location, setLocation] = useState("");
const [projectOptions, setProjectOptions] = useState<{ value: string; label: string }[]>([]);
const [unitOptions, setUnitOptions] = useState<{ value: string; label: string }[]>([]);
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(false);
// 프로젝트 옵션 로드
useEffect(() => {
fetch("/api/common/project-list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
})
.then((r) => r.json())
.then((j) => {
const list = (j.RESULTLIST || []) as Array<Record<string, unknown>>;
setProjectOptions(
list.map((r) => ({
value: String(r.OBJID || ""),
label: String(r.LABEL || r.PROJECT_NO || ""),
})),
);
})
.catch(() => setProjectOptions([]));
}, []);
// 프로젝트 선택 변경 시 유닛 로드 (단일/다중 모두 대응 — 첫 번째 프로젝트 기준)
useEffect(() => {
setUnitCode("");
if (projectNos.length === 0) {
setUnitOptions([]);
return;
}
const first = projectNos[0];
fetch("/api/common/unit-list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contract_objid: first }),
})
.then((r) => r.json())
.then((j) => {
const list = (j.RESULTLIST || []) as Array<Record<string, unknown>>;
setUnitOptions(
list.map((r) => ({
value: String(r.OBJID || ""),
label: String(r.UNIT_NAME || ""),
})),
);
})
.catch(() => setUnitOptions([]));
}, [projectNos]);
const openHistoryPopup = useCallback((objId: string) => {
const w = 730;
const h = 400;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/inventory/request/history?objId=${encodeURIComponent(objId)}`,
"inventoryRequestHistoryPopUp",
`width=${w},height=${h},left=${left},top=${top}`,
);
}, []);
const columns: GridColumn[] = useMemo(
() => [
{
title: "자재목록",
headerHozAlign: "center",
columns: [
{ title: "프로젝트번호", field: "PROJECT_NO", width: 100, hozAlign: "left", headerHozAlign: "center" },
{ title: "유닛명", field: "UNIT_NAME", width: 200, hozAlign: "left", headerHozAlign: "center" },
{ title: "품번", field: "PART_NO", width: 180, hozAlign: "left", headerHozAlign: "center" },
{ title: "품명", field: "PART_NAME", width: 180, hozAlign: "left", headerHozAlign: "center" },
{ title: "재질", field: "MATERIAL", width: 180, hozAlign: "left", headerHozAlign: "center" },
{ title: "사양(규격)", field: "SPEC", width: 200, hozAlign: "left", headerHozAlign: "center" },
{ title: "PART구분", field: "PART_TYPE_NAME", width: 120, hozAlign: "center", headerHozAlign: "center" },
{ title: "보유수량", field: "USE_CNT", width: 100, hozAlign: "center", headerHozAlign: "center", formatter: "money" },
{ title: "보유수량(전체)", field: "USE_CNT_ALL", width: 120, hozAlign: "center", headerHozAlign: "center", formatter: "money" },
{ title: "Location", field: "LOCATION_NAME", width: 120, hozAlign: "left", headerHozAlign: "center" },
],
},
{
title: "불출이력",
headerHozAlign: "center",
columns: [
{
title: "불출이력",
field: "REQUEST_QTY",
width: 80,
hozAlign: "center",
headerHozAlign: "center",
formatter: (cell, row) => {
const v = Number(cell || 0);
if (v === 0) return "0";
return (
<a
href="#"
onClick={(e) => {
e.preventDefault();
openHistoryPopup(String(row.OBJID || ""));
}}
className="text-blue-600 underline"
>
{v.toLocaleString()}
</a>
);
},
},
{ title: "비고", field: "REMARK", width: 200, hozAlign: "left", headerHozAlign: "center" },
],
},
],
[openHistoryPopup],
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/inventory/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
project_nos: projectNos.join(","),
unit_code: unitCode,
part_no: partNo,
part_name: partName,
part_type: partType,
location,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
} finally {
setLoading(false);
}
}, [projectNos, unitCode, partNo, partName, partType, location]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 등록 팝업 (inventoryFormPopUp)
const handleRegister = () => {
const w = 850;
const h = 500;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
"/inventory/list/form",
"inventoryFormPopUp",
`width=${w},height=${h},left=${left},top=${top}`,
);
};
// 자재이동 (materialMoveFormPopUp)
const handleMove = () => {
if (selectedRows.length === 0) {
Swal.fire("알림", "선택된 데이터가 없습니다.", "warning");
return;
}
for (const r of selectedRows) {
if (Number(r.USE_CNT || 0) === 0) {
alert("보유수량이 0일 경우 이동이 불가능합니다.");
return;
}
}
const checkArr = selectedRows.map((r) => String(r.OBJID)).join(",");
const w = 1600;
const h = 700;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/inventory/move/form?checkArr=${encodeURIComponent(checkArr)}`,
"materialMoveFormPopUp",
`width=${w},height=${h},left=${left},top=${top}`,
);
};
// 불출의뢰 (materialRequestFormPopUp)
const handleRequest = () => {
if (selectedRows.length === 0) {
Swal.fire("알림", "선택된 데이터가 없습니다.", "warning");
return;
}
for (const r of selectedRows) {
if (Number(r.USE_CNT || 0) === 0) {
alert("보유수량이 0일 경우 불출의뢰가 불가능합니다.");
return;
}
}
const checkArr = selectedRows.map((r) => String(r.OBJID)).join(",");
const w = 1800;
const h = 700;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/inventory/request/form?checkArr=${encodeURIComponent(checkArr)}`,
"inventoryRequestPopUp",
`width=${w},height=${h},left=${left},top=${top}`,
);
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800">_자재리스트</h2>
<div className="flex gap-2">
<Button size="sm" onClick={handleRegister}>
</Button>
<Button size="sm" variant="secondary" onClick={handleMove}>
</Button>
<Button size="sm" variant="secondary" onClick={handleRequest}>
</Button>
<Button size="sm" variant="secondary" onClick={fetchData}>
</Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="프로젝트번호">
<MultiSelect
options={projectOptions}
value={projectNos}
onChange={setProjectNos}
placeholder="선택"
className="w-[300px]"
/>
</SearchField>
<SearchField label="유닛명">
<SearchableSelect
options={unitOptions}
value={unitCode}
onChange={setUnitCode}
placeholder="선택"
className="w-[230px]"
/>
</SearchField>
<SearchField label="품번">
<Input
value={partNo}
onChange={(e) => setPartNo(e.target.value)}
placeholder="품번"
className="w-[150px]"
/>
</SearchField>
<SearchField label="품명">
<Input
value={partName}
onChange={(e) => setPartName(e.target.value)}
placeholder="품명"
className="w-[170px]"
/>
</SearchField>
<SearchField label="PART 구분">
<SearchableCodeSelect
codeId="0000062"
value={partType}
onChange={setPartType}
className="w-[120px]"
/>
</SearchField>
<SearchField label="Location">
<SearchableCodeSelect
codeId="0000262"
value={location}
onChange={setLocation}
className="w-[230px]"
/>
</SearchField>
</SearchForm>
<DataGrid
columns={columns}
data={data}
showCheckbox
loading={loading}
onSelectionChange={setSelectedRows}
/>
</div>
);
}
// 간단한 다중 선택 — 선택된 라벨을 태그로 표시 + 드롭다운
function MultiSelect({
options,
value,
onChange,
placeholder,
className,
}: {
options: { value: string; label: string }[];
value: string[];
onChange: (v: string[]) => void;
placeholder?: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const filtered = options.filter((o) => {
if (!search) return true;
const s = search.toLowerCase();
return o.label.toLowerCase().includes(s) || o.value.toLowerCase().includes(s);
});
const toggle = (v: string) => {
if (value.includes(v)) onChange(value.filter((x) => x !== v));
else onChange([...value, v]);
};
const selectedLabels = value
.map((v) => options.find((o) => o.value === v)?.label || v)
.join(", ");
return (
<div className={`relative ${className || ""}`}>
<button
type="button"
onClick={() => setOpen((p) => !p)}
className="h-9 w-full text-left rounded border border-gray-300 bg-white px-2 text-sm truncate pr-6"
title={selectedLabels}
>
{selectedLabels || <span className="text-gray-400">{placeholder || "선택"}</span>}
</button>
{value.length > 0 && (
<button
type="button"
onClick={() => onChange([])}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700 text-sm"
tabIndex={-1}
>
×
</button>
)}
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded shadow max-h-64 overflow-y-auto">
<div className="sticky top-0 bg-white border-b p-1">
<input
autoFocus
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="검색"
className="w-full px-2 py-1 text-sm border rounded"
/>
</div>
{filtered.length === 0 ? (
<div className="p-2 text-xs text-gray-400 text-center"> </div>
) : (
filtered.map((o) => {
const selected = value.includes(o.value);
return (
<button
key={o.value}
type="button"
onClick={() => toggle(o.value)}
className={`w-full text-left px-2 py-1.5 text-sm hover:bg-blue-50 flex items-center gap-2 ${
selected ? "bg-blue-50" : ""
}`}
>
<input
type="checkbox"
checked={selected}
readOnly
className="pointer-events-none"
/>
<span className="truncate">{o.label}</span>
</button>
);
})
)}
</div>
</>
)}
</div>
);
}
-125
View File
@@ -1,125 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { CodeSelect } from "@/components/ui/code-select";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
// inventoryMngInputList.jsp 대응 - 재고관리(입고)
export default function InventoryPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [projectNo, setProjectNo] = useState("");
const [partNo, setPartNo] = useState("");
const [partName, setPartName] = useState("");
const [spec, setSpec] = useState("");
const [clsCd, setClsCd] = useState("");
const [cauCd, setCauCd] = useState("");
const [location, setLocation] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [totalCount, setTotalCount] = useState(0);
// inventoryMngInputList.jsp columns 대응
const columns: GridColumn[] = [
{ title: "프로젝트번호", field: "PROJECT_NO", width: 100, hozAlign: "left" },
{ title: "유닛명", field: "UNIT_NAME", hozAlign: "left" },
{ title: "파트번호", field: "PART_NO", width: 130, hozAlign: "left" },
{ title: "파트명", field: "PART_NAME", width: 150, hozAlign: "left" },
{ title: "규격", field: "SPEC", width: 110, hozAlign: "left" },
{ title: "업체", field: "MAKER", width: 100, hozAlign: "left" },
{ title: "재고구분", field: "CLS_CD_NAME", width: 90, hozAlign: "center" },
{ title: "발생사유", field: "CAU_CD_NAME", width: 100, hozAlign: "center" },
{ title: "발생수량", field: "QTY", width: 90, hozAlign: "right", formatter: "money" },
{ title: "위치", field: "LOCATION_NAME", width: 100, hozAlign: "left" },
{ title: "금액(원)", field: "PRICE", width: 100, hozAlign: "right", formatter: "money" },
{ title: "등록일", field: "REG_DATE", width: 90, hozAlign: "center" },
{ title: "등록자", field: "WRITER_NAME", width: 90, hozAlign: "center" },
{ title: "총입고수량", field: "INPUT_QTY", width: 90, hozAlign: "center",
cellClick: (row) => openHistoryPopup(String(row.OBJID)) },
{ title: "최종입고일", field: "INPUT_DATE", width: 90, hozAlign: "center" },
{ title: "잔여수량", field: "REMAIN_QTY", width: 90, hozAlign: "center",
cellClick: (row) => openHistoryPopup(String(row.OBJID)) },
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/inventory", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year, project_no: projectNo, part_no: partNo, part_name: partName,
spec, cls_cd: clsCd, cau_cd: cauCd, location,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
setTotalCount(json.TOTAL_CNT || 0);
}
}, [year, projectNo, partNo, partName, spec, clsCd, cauCd, location]);
const openHistoryPopup = (objId: string) => {
window.open(`/inventory/history?objId=${objId}`, "inventoryHistory", "width=600,height=500");
};
const openInputPopup = () => {
window.open("/inventory/input-form", "inventoryInput", "width=850,height=330");
};
// 페이지 진입 시 자동 로드
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"> ()</h2>
<div className="flex gap-2">
<Button size="sm" onClick={openInputPopup}></Button>
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select
value={year}
onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="파트번호">
<Input value={partNo} onChange={(e) => setPartNo(e.target.value)} className="w-[120px]" />
</SearchField>
<SearchField label="파트명">
<Input value={partName} onChange={(e) => setPartName(e.target.value)} className="w-[120px]" />
</SearchField>
<SearchField label="규격">
<Input value={spec} onChange={(e) => setSpec(e.target.value)} className="w-[100px]" />
</SearchField>
<SearchField label="재고구분">
<CodeSelect codeId="0001576" value={clsCd} onChange={setClsCd} placeholder="전체" className="w-[120px]" />
</SearchField>
<SearchField label="위치">
<CodeSelect codeId="0000262" value={location} onChange={setLocation} placeholder="전체" className="w-[120px]" />
</SearchField>
</SearchForm>
<DataGrid
columns={columns}
data={data}
showCheckbox
/>
</div>
);
}
@@ -1,138 +0,0 @@
"use client";
import { useState, useEffect, useCallback, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { numberWithCommas } from "@/lib/utils";
// 입출고 History 팝업 (원본 /inventoryMng/inventoryRequestHistoryPopUp.do)
function HistoryPage() {
const searchParams = useSearchParams();
const objId = searchParams.get("objId") || searchParams.get("partId") || "";
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const fetchData = useCallback(async () => {
if (!objId) return;
const res = await fetch("/api/inventory/request/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objId }),
});
if (res.ok) {
const json = await res.json();
setRows(json.RESULTLIST || []);
}
}, [objId]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openTarget = (id: string, gubun: string) => {
if (gubun === "출고") {
const w = 1500;
const h = 700;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
const params = new URLSearchParams({
INVENTORY_REQUEST_MASTER_OBJID: id,
action: "view",
});
window.open(
`/inventory/request/detail?${params.toString()}`,
"inventoryRequestPopUp",
`width=${w},height=${h},left=${left},top=${top}`,
);
} else if (gubun === "입고") {
const w = 1260;
const h = 1050;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
window.open(
`/delivery/acceptance/form?objId=${encodeURIComponent(id)}&actionType=view`,
"deliveryAcceptancePopUp",
`width=${w},height=${h},left=${left},top=${top}`,
);
}
};
return (
<div className="p-3 bg-white min-h-screen">
<div className="mb-2">
<h2 className="text-base font-bold"> </h2>
</div>
<div className="border border-gray-300 overflow-x-auto max-h-[calc(100vh-120px)]">
<table className="w-full text-xs">
<thead className="bg-gray-100 text-center sticky top-0">
<tr>
<th className="border px-2 py-1.5"></th>
<th className="border px-2 py-1.5"></th>
<th className="border px-2 py-1.5"></th>
<th className="border px-2 py-1.5"></th>
<th className="border px-2 py-1.5"></th>
<th className="border px-2 py-1.5">Location</th>
<th className="border px-2 py-1.5">Sub_Location</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-4 text-gray-400">
.
</td>
</tr>
) : (
rows.map((r, i) => {
const gubun = String(r.GUBUN || "");
const rowObjId = String(r.OBJID || "");
return (
<tr key={i} className="border-b">
<td className="border px-2 py-1">{String(r.PROJECT_NO || "")}</td>
<td className="border px-2 py-1">{String(r.PART_NO || "")}</td>
<td className="border px-2 py-1">{String(r.PART_NAME || "")}</td>
<td className="border px-2 py-1 text-center">
{gubun === "입고" || gubun === "출고" ? (
<a
href="#"
onClick={(e) => {
e.preventDefault();
openTarget(rowObjId, gubun);
}}
className="text-blue-600 underline"
>
{gubun}
</a>
) : (
<span>{gubun}</span>
)}
</td>
<td className="border px-2 py-1 text-right">
{numberWithCommas(String(r.RECEIPT_QTY || ""))}
</td>
<td className="border px-2 py-1">{String(r.LOCATION_NAME || "")}</td>
<td className="border px-2 py-1">{String(r.SUB_LOCATION_NAME || "")}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="flex justify-center mt-3 pt-2 border-t">
<Button variant="secondary" onClick={() => window.close()}>
</Button>
</div>
</div>
);
}
export default function Page() {
return (
<Suspense fallback={<div className="p-8 text-center text-gray-400"> ...</div>}>
<HistoryPage />
</Suspense>
);
}
-341
View File
@@ -1,341 +0,0 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { ExcelDownloadButton } from "@/components/ui/excel-download-button";
import Swal from "sweetalert2";
// 자재관리 > 불출의뢰서 (원본 /inventoryMng/materialRequestList.do)
export default function InventoryRequestPage() {
const [partNo, setPartNo] = useState("");
const [partName, setPartName] = useState("");
const [requestStartDate, setRequestStartDate] = useState("");
const [requestEndDate, setRequestEndDate] = useState("");
const [requestUser, setRequestUser] = useState("");
const [receptionStatus, setReceptionStatus] = useState("");
const [receptionUser, setReceptionUser] = useState("");
const [receptionStartDate, setReceptionStartDate] = useState("");
const [receptionEndDate, setReceptionEndDate] = useState("");
const [outStatus, setOutStatus] = useState("");
const [userOptions, setUserOptions] = useState<{ value: string; label: string }[]>([]);
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
})
.then((r) => r.json())
.then((j) => {
const list = (j.RESULTLIST || []) as Array<Record<string, unknown>>;
setUserOptions(
list.map((r) => ({
value: String(r.USER_ID || ""),
label: String(r.USER_NAME || r.USER_ID || ""),
})),
);
})
.catch(() => setUserOptions([]));
}, []);
const openDetailPopup = useCallback(
(objId: string, outStatusTitle: string, receptionStatusTitle: string) => {
const w = 1800;
const h = 700;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
const params = new URLSearchParams({
INVENTORY_REQUEST_MASTER_OBJID: objId,
action: "view",
OUTSTATUS: outStatusTitle,
RECEPTION_STATUS: receptionStatusTitle,
});
window.open(
`/inventory/request/detail?${params.toString()}`,
"inventoryRequestPopUp",
`width=${w},height=${h},left=${left},top=${top}`,
);
},
[],
);
const columns: GridColumn[] = useMemo(
() => [
{
title: "자재불출번호",
field: "INVENTORY_OUT_NO",
width: 140,
hozAlign: "left",
headerHozAlign: "center",
formatter: (cell, row) => (
<a
href="#"
onClick={(e) => {
e.preventDefault();
openDetailPopup(
String(row.OBJID || ""),
String(row.OUTSTATUS_TITLE || ""),
String(row.RECEPTION_STATUS_TITLE || ""),
);
}}
className="text-blue-600 underline"
>
{String(cell || "")}
</a>
),
},
{ title: "품번", field: "PART_NO_ARR", width: 480, hozAlign: "left", headerHozAlign: "center" },
{ title: "품명", field: "PART_NAME_ARR", width: 480, hozAlign: "left", headerHozAlign: "center" },
{ title: "불출의뢰일", field: "REQUEST_DATE", width: 120, hozAlign: "left", headerHozAlign: "center" },
{ title: "의뢰자", field: "REQUEST_USER_NAME", width: 100, hozAlign: "left", headerHozAlign: "center" },
{ title: "상태", field: "RECEPTION_STATUS_TITLE", width: 100, hozAlign: "left", headerHozAlign: "center" },
{ title: "접수자", field: "RECEPTION_USER_NAME", width: 100, hozAlign: "center", headerHozAlign: "center" },
{ title: "접수일", field: "RECEPTION_DATE", width: 100, hozAlign: "center", headerHozAlign: "center" },
{ title: "불출상태", field: "OUTSTATUS_TITLE", width: 100, hozAlign: "center", headerHozAlign: "center" },
],
[openDetailPopup],
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/inventory/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
part_no: partNo,
part_name: partName,
request_start_date: requestStartDate,
request_end_date: requestEndDate,
request_user: requestUser,
reception_status: receptionStatus,
reception_user: receptionUser,
reception_start_date: receptionStartDate,
reception_end_date: receptionEndDate,
out_status: outStatus,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
} finally {
setLoading(false);
}
}, [
partNo,
partName,
requestStartDate,
requestEndDate,
requestUser,
receptionStatus,
receptionUser,
receptionStartDate,
receptionEndDate,
outStatus,
]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 접수 (미접수만 가능)
const handleReceipt = async () => {
if (selectedRows.length === 0) {
Swal.fire("선택된 데이터가 없습니다.");
return;
}
const checkedArr = selectedRows
.filter((r) => String(r.RECEPTION_STATUS_TITLE) === "미접수")
.map((r) => String(r.OBJID));
if (checkedArr.length === 0) {
Swal.fire("선택된 데이터가 없습니다.");
return;
}
const confirmed = await Swal.fire({
title: "선택된 데이터를 접수하시겠습니까?",
icon: "warning",
showCancelButton: true,
confirmButtonText: "확인",
cancelButtonText: "취소",
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
});
if (!confirmed.isConfirmed) return;
const res = await fetch("/api/inventory/request/receipt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ checkArr: checkedArr.join(",") }),
});
const data = await res.json();
Swal.fire(data.message || "접수되었습니다.");
if (data.success) fetchData();
};
// 자재불출 (단일 선택, 접수 상태만)
const handleAccept = () => {
if (selectedRows.length === 0) {
Swal.fire("선택된 데이터가 없습니다.");
return;
}
if (selectedRows.length > 1) {
Swal.fire("한번에 1개의 내용만 불출가능합니다.");
return;
}
const row = selectedRows[0];
const receptionStatusTitle = String(row.RECEPTION_STATUS_TITLE || "");
const outStatusTitle = String(row.OUTSTATUS_TITLE || "");
if (receptionStatusTitle !== "접수") {
Swal.fire("접수한 데이터만 불출가능합니다.");
return;
}
if (outStatusTitle === "완료") {
Swal.fire("불출완료된 데이터는 불출가능합니다.");
return;
}
const w = 1800;
const h = 700;
const left = (window.screen.width - w) / 2;
const top = (window.screen.height - h) / 2;
const params = new URLSearchParams({
INVENTORY_REQUEST_MASTER_OBJID: String(row.OBJID || ""),
OUTSTATUS: outStatusTitle,
RECEPTION_STATUS: receptionStatusTitle,
});
window.open(
`/inventory/request/detail?${params.toString()}`,
"inventoryRequestPopUp",
`width=${w},height=${h},left=${left},top=${top}`,
);
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800">_불출의뢰서</h2>
<div className="flex gap-2">
<Button size="sm" onClick={handleAccept}>
</Button>
<Button size="sm" variant="secondary" onClick={handleReceipt}>
</Button>
<Button size="sm" variant="secondary" onClick={fetchData}>
</Button>
<ExcelDownloadButton data={data} columns={columns} filename="자재관리_불출의뢰서" />
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="품번">
<Input
value={partNo}
onChange={(e) => setPartNo(e.target.value)}
placeholder="품번"
className="w-[150px]"
/>
</SearchField>
<SearchField label="품명">
<Input
value={partName}
onChange={(e) => setPartName(e.target.value)}
placeholder="품명"
className="w-[150px]"
/>
</SearchField>
<SearchField label="불출의뢰일">
<div className="flex items-center gap-1">
<Input
type="date"
value={requestStartDate}
onChange={(e) => setRequestStartDate(e.target.value)}
className="w-[130px]"
/>
<span>~</span>
<Input
type="date"
value={requestEndDate}
onChange={(e) => setRequestEndDate(e.target.value)}
className="w-[130px]"
/>
</div>
</SearchField>
<SearchField label="의뢰자">
<SearchableSelect
options={userOptions}
value={requestUser}
onChange={setRequestUser}
className="w-[130px]"
/>
</SearchField>
<SearchField label="접수상태">
<select
value={receptionStatus}
onChange={(e) => setReceptionStatus(e.target.value)}
className="h-9 w-[130px] rounded border border-gray-300 bg-white px-3 text-sm"
>
<option value=""></option>
<option value="reception"></option>
<option value="AA"></option>
</select>
</SearchField>
<SearchField label="접수자">
<SearchableSelect
options={userOptions}
value={receptionUser}
onChange={setReceptionUser}
className="w-[130px]"
/>
</SearchField>
<SearchField label="접수일">
<div className="flex items-center gap-1">
<Input
type="date"
value={receptionStartDate}
onChange={(e) => setReceptionStartDate(e.target.value)}
className="w-[130px]"
/>
<span>~</span>
<Input
type="date"
value={receptionEndDate}
onChange={(e) => setReceptionEndDate(e.target.value)}
className="w-[130px]"
/>
</div>
</SearchField>
<SearchField label="불출상태">
<select
value={outStatus}
onChange={(e) => setOutStatus(e.target.value)}
className="h-9 w-[130px] rounded border border-gray-300 bg-white px-3 text-sm"
>
<option value=""></option>
<option value="complete"></option>
<option value="NG"></option>
</select>
</SearchField>
</SearchForm>
<DataGrid
columns={columns}
data={data}
showCheckbox
loading={loading}
onSelectionChange={setSelectedRows}
/>
</div>
);
}
-92
View File
@@ -1,92 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CodeSelect } from "@/components/ui/code-select";
import Swal from "sweetalert2";
// 자재관리 > 현황
export default function InventoryStatusPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [projectNo, setProjectNo] = useState("");
const [locationCd, setLocationCd] = useState("");
const [partTypeCd, setPartTypeCd] = useState("");
const [data, setData] = useState<Record<string, unknown>[]>([]);
const columns: GridColumn[] = [
{ title: "프로젝트번호", field: "PROJECT_NO", width: 130, hozAlign: "left" },
{ title: "유닛명", field: "UNIT_NAME", width: 120, hozAlign: "center" },
{ title: "부품구분", field: "PART_TYPE_NAME", width: 90, hozAlign: "center" },
{ title: "위치", field: "LOCATION_NAME", width: 100, hozAlign: "center" },
{ title: "총보유수량", field: "TOTAL_QTY", width: 100, hozAlign: "right", formatter: "money" },
{ title: "불출수량", field: "REQUEST_QTY", width: 90, hozAlign: "right", formatter: "money" },
{ title: "잔여수량", field: "REMAIN_QTY", width: 90, hozAlign: "right", formatter: "money" },
{ title: "입고수량", field: "DELIVERY_QTY", width: 90, hozAlign: "right", formatter: "money" },
{ title: "이동수량", field: "MOVE_QTY", width: 90, hozAlign: "right", formatter: "money" },
];
const fetchData = useCallback(async () => {
const res = await fetch("/api/inventory/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
year,
project_no: projectNo,
location_cd: locationCd,
part_type_cd: partTypeCd,
}),
});
if (res.ok) {
const json = await res.json();
setData(json.RESULTLIST || []);
}
}, [year, projectNo, locationCd, partTypeCd]);
const handleExcelDownload = () => {
Swal.fire("알림", "Excel 다운로드 기능은 준비 중입니다.", "info");
};
// 페이지 진입 시 자동 로드
useEffect(() => { fetchData(); }, [fetchData]);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-800"></h2>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={fetchData}></Button>
<Button size="sm" variant="secondary" onClick={handleExcelDownload}>Excel Download</Button>
</div>
</div>
<SearchForm onSearch={fetchData}>
<SearchField label="년도">
<select value={year} onChange={(e) => setYear(e.target.value)}
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</SearchField>
<SearchField label="프로젝트번호">
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} placeholder="프로젝트번호" className="w-[150px]" />
</SearchField>
<SearchField label="위치">
<CodeSelect codeId="LOCATION" value={locationCd} onChange={setLocationCd} className="w-[130px]" />
</SearchField>
<SearchField label="부품구분">
<CodeSelect codeId="PART_TYPE" value={partTypeCd} onChange={setPartTypeCd} className="w-[130px]" />
</SearchField>
</SearchForm>
<DataGrid
columns={columns}
data={data}
/>
</div>
);
}
+3
View File
@@ -7,6 +7,7 @@ import { useMenuStore } from "@/store/menu-store";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { Loading } from "@/components/ui/loading";
import { NativePushAutoRegister } from "@/components/native-push-auto-register";
export default function MainLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
@@ -24,6 +25,8 @@ export default function MainLayout({ children }: { children: React.ReactNode })
return (
<div className="flex h-screen overflow-hidden">
{/* 로그인 직후 자동으로 안드로이드 알림 권한 + FCM 토큰 등록 (Capacitor 앱만, UI 없음) */}
<NativePushAutoRegister />
{/* 사이드바 — 데스크탑은 정상, 모바일은 오버레이로 등장 */}
<div
className={
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Building2, MapPin, TrendingUp, RefreshCcw } from "lucide-react";
interface BranchRow {
BRANCH: string; BRANCH_NAME: string;
REVENUE: number; COST: number; MARGIN: number; ORDER_CNT: number;
HQ_FEE_RATE: number; HQ_FEE_AMOUNT: number; NET_TO_BRANCH: number;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
export default function BranchFeePage() {
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [rows, setRows] = useState<BranchRow[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/m/admin/branch-fee", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year, month }),
});
setRows((await res.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
}, [year, month]);
useEffect(() => { load(); }, [load]);
// 합계
const totalRevenue = rows.reduce((a, r) => a + r.REVENUE, 0);
const totalMargin = rows.reduce((a, r) => a + r.MARGIN, 0);
const totalHqFee = rows.reduce((a, r) => a + r.HQ_FEE_AMOUNT, 0);
const totalNet = rows.reduce((a, r) => a + r.NET_TO_BRANCH, 0);
return (
<div className="space-y-4">
<div className="flex items-end justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<Building2 size={20} className="text-emerald-700" />
</h1>
<p className="text-xs text-slate-500 mt-0.5">
<b> </b> . (HQ) . 20% .
</p>
</div>
<div className="flex items-center gap-2">
<select value={year} onChange={(e) => setYear(Number(e.target.value))}
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
<select value={month} onChange={(e) => setMonth(Number(e.target.value))}
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
<button onClick={load} disabled={loading}
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50">
<RefreshCcw size={14} />
</button>
</div>
</div>
{/* 합계 카드 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-500 mb-1"> </div>
<div className="text-xl font-bold text-slate-800 tabular-nums">{fmt(totalRevenue)}</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-500 mb-1"> </div>
<div className="text-xl font-bold text-emerald-700 tabular-nums">{fmt(totalMargin)}</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="text-[11px] text-amber-700 mb-1 font-semibold"> ( 20%)</div>
<div className="text-xl font-bold text-amber-700 tabular-nums">{fmt(totalHqFee)}</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-500 mb-1"> ( )</div>
<div className="text-xl font-bold text-slate-800 tabular-nums">{fmt(totalNet)}</div>
</div>
</div>
{/* 지사별 표 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3">()</th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3"> </th>
<th className="text-right px-4 py-3 bg-amber-50 text-amber-700"> (20%)</th>
<th className="text-right px-4 py-3"> </th>
</tr>
</thead>
<tbody className="tabular-nums">
{rows.length === 0 ? (
<tr><td colSpan={7} className="text-center py-12 text-slate-400">{loading ? "조회 중..." : "데이터가 없습니다."}</td></tr>
) : rows.map((r) => (
<tr key={r.BRANCH} className="border-t border-slate-100">
<td className="px-4 py-3">
<div className="inline-flex items-center gap-1.5">
<MapPin size={14} className="text-sky-700" />
<span className="font-semibold">{r.BRANCH_NAME}</span>
<span className="text-xs text-slate-400">({r.BRANCH})</span>
</div>
</td>
<td className="px-4 py-3 text-right">{fmt(r.ORDER_CNT)}</td>
<td className="px-4 py-3 text-right">{fmt(r.REVENUE)}</td>
<td className="px-4 py-3 text-right text-slate-500">{fmt(r.COST)}</td>
<td className="px-4 py-3 text-right font-semibold text-emerald-700">{fmt(r.MARGIN)}</td>
<td className="px-4 py-3 text-right bg-amber-50/60 font-bold text-amber-700">
{fmt(r.HQ_FEE_AMOUNT)}
</td>
<td className="px-4 py-3 text-right font-bold">{fmt(r.NET_TO_BRANCH)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="text-[11px] text-slate-500 flex items-start gap-1.5">
<TrendingUp size={12} className="mt-0.5 text-slate-400" />
<div>
<b>(HQ) </b> . = × 20%.
/ (APPROVED) (/ ) .
</div>
</div>
</div>
);
}
@@ -0,0 +1,269 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { RefreshCcw, CalendarDays, Download, Search } from "lucide-react";
import { downloadXlsx } from "@/lib/xlsx-export";
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
interface ItemRow {
OBJID: string;
ITEM_CODE: string;
ITEM_NAME: string;
UNIT: string;
UNIT_PRICE: string | number;
IS_TAX_FREE: string;
SALE_START_DATE: string | null;
SALE_END_DATE: string | null;
VENDOR_NAME: string | null;
STOCK: Record<string, number>; // wh_code → 현재고
ORDER: Record<string, number>; // wh_code → 발주수량
TOTAL_STOCK: number;
TOTAL_ORDER: number;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
const today = () => new Date().toISOString().slice(0, 10);
export default function DailyOrderInventoryPage() {
const [startDate, setStartDate] = useState<string>(today());
const [endDate, setEndDate] = useState<string>(today());
const [keyword, setKeyword] = useState("");
const [warehouses, setWarehouses] = useState<Wh[]>([]);
const [items, setItems] = useState<ItemRow[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/m/admin/daily-order-inventory", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ startDate, endDate, keyword }),
});
if (res.ok) {
const j = await res.json();
setWarehouses(j.WAREHOUSES ?? []);
setItems(j.ITEMS ?? []);
} else {
setWarehouses([]);
setItems([]);
}
} finally {
setLoading(false);
}
}, [startDate, endDate, keyword]);
useEffect(() => { load(); }, [load]);
const totalOrderQty = items.reduce((a, r) => a + Number(r.TOTAL_ORDER || 0), 0);
const totalStockQty = items.reduce((a, r) => a + Number(r.TOTAL_STOCK || 0), 0);
const onExport = () => {
if (items.length === 0 || warehouses.length === 0) return;
type Row = Record<string, string | number>;
const data: Row[] = [];
for (const w of warehouses) {
const orderRow: Row = { WH: `${w.WH_NAME} (${w.WH_CODE})`, KIND: "발주수량" };
const stockRow: Row = { WH: `${w.WH_NAME} (${w.WH_CODE})`, KIND: "재고수량" };
for (const it of items) {
orderRow[it.ITEM_NAME] = Number(it.ORDER[w.WH_CODE] ?? 0);
stockRow[it.ITEM_NAME] = Number(it.STOCK[w.WH_CODE] ?? 0);
}
data.push(orderRow, stockRow);
}
const cols = [
{ header: "창고", key: "WH" },
{ header: "분류", key: "KIND" },
...items.map((it) => ({ header: it.ITEM_NAME, key: it.ITEM_NAME })),
];
const range = startDate === endDate ? startDate : `${startDate}_${endDate}`;
downloadXlsx(`일자별발주재고_${range}`, data, cols);
};
return (
<div className="space-y-4">
<div className="flex items-end justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<CalendarDays size={20} className="text-emerald-700" />
/
</h1>
<p className="text-xs text-slate-500 mt-0.5">
<b> </b> , .
= (REQUESTED는 default / APPROVED ). = ( ).
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
<input
type="date"
value={startDate}
max={endDate || undefined}
onChange={(e) => setStartDate(e.target.value)}
className="h-9 px-3 rounded border border-slate-300 text-sm"
/>
<span className="text-slate-400 text-sm">~</span>
<input
type="date"
value={endDate}
min={startDate || undefined}
onChange={(e) => setEndDate(e.target.value)}
className="h-9 px-3 rounded border border-slate-300 text-sm"
/>
<button
onClick={() => { const t = today(); setStartDate(t); setEndDate(t); }}
className="h-9 px-3 rounded border border-slate-300 bg-white text-slate-700 text-xs font-semibold"
>
</button>
<button
onClick={() => {
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - 6);
setStartDate(start.toISOString().slice(0, 10));
setEndDate(end.toISOString().slice(0, 10));
}}
className="h-9 px-3 rounded border border-slate-300 bg-white text-slate-700 text-xs font-semibold"
>
7
</button>
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") load(); }}
placeholder="품목명/코드"
className="h-9 pl-8 pr-3 rounded border border-slate-300 text-sm w-48"
/>
</div>
<button
onClick={load}
disabled={loading}
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50"
>
<RefreshCcw size={14} />
</button>
<button
onClick={onExport}
disabled={items.length === 0}
className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800 disabled:opacity-40"
>
<Download size={14} />
</button>
</div>
</div>
{/* 요약 카드 */}
<div className="grid grid-cols-3 sm:grid-cols-3 gap-3">
<SummaryCard label="판매가능 품목" value={`${fmt(items.length)}`} color="slate" />
<SummaryCard label="발주수량 합계" value={`${fmt(totalOrderQty)}`} color="rose" />
<SummaryCard label="재고수량 합계" value={`${fmt(totalStockQty)}`} color="emerald" />
</div>
{/* 매트릭스: 헤더=품목, 행=창고 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="text-sm border-collapse">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2 border-b border-slate-200 sticky left-0 bg-slate-50 z-10 min-w-[120px]"></th>
<th className="text-center px-3 py-2 border-b border-slate-200 sticky left-[120px] bg-slate-50 z-10 min-w-[90px]"></th>
{items.map((it) => (
<th key={it.OBJID} className="text-right px-3 py-2 border-b border-slate-200 min-w-[110px] whitespace-nowrap">
<div>{it.ITEM_NAME}</div>
<div className="text-[10px] text-slate-400 font-mono font-normal">{it.ITEM_CODE}</div>
{it.SALE_END_DATE && (
<div className="text-[10px] text-rose-600 font-semibold tabular-nums">~ {it.SALE_END_DATE}</div>
)}
</th>
))}
</tr>
</thead>
<tbody className="tabular-nums">
{items.length === 0 || warehouses.length === 0 ? (
<tr>
<td colSpan={2 + items.length} className="text-center py-12 text-slate-400">
{loading ? "조회 중..." : "데이터가 없습니다."}
</td>
</tr>
) : [
/* 전체 합계 — 모든 창고의 발주수량/재고수량 합 (상단 강조 행) */
<tr key="__total-order" className="bg-emerald-50/70 border-y-2 border-emerald-300 font-bold">
<td className="px-3 py-2 align-top sticky left-0 bg-emerald-50/70" rowSpan={2}>
<div className="text-emerald-800"> </div>
<div className="text-[10px] text-emerald-600 font-normal"> </div>
</td>
<td className="px-3 py-1.5 text-center text-[11px] text-rose-700 bg-rose-50/60 sticky left-[120px]"></td>
{items.map((it) => {
const v = Number(it.TOTAL_ORDER || 0);
return (
<td key={it.OBJID} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-rose-700"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
<tr key="__total-stock" className="bg-emerald-50/70 border-b-2 border-emerald-300 font-bold">
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-100/60 sticky left-[120px]"></td>
{items.map((it) => {
const v = Number(it.TOTAL_STOCK || 0);
const negative = v < 0;
return (
<td key={it.OBJID} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600" : v === 0 ? "text-slate-300" : "text-emerald-700"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
/* 창고별 — 각 창고 발주수량/재고수량 두 줄 */
...warehouses.flatMap((w) => [
<tr key={`${w.WH_CODE}-order`} className="border-t border-slate-100 hover:bg-slate-50/60">
<td className="px-3 py-2 align-top font-semibold sticky left-0 bg-white" rowSpan={2}>
{w.WH_NAME}
<div className="text-[10px] text-slate-400 font-mono">{w.WH_CODE}</div>
</td>
<td className="px-3 py-1.5 text-center text-[11px] text-rose-700 bg-rose-50/40 sticky left-[120px]"></td>
{items.map((it) => {
const v = Number(it.ORDER[w.WH_CODE] ?? 0);
return (
<td key={it.OBJID} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-rose-700 font-semibold"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
<tr key={`${w.WH_CODE}-stock`} className="border-b border-slate-100">
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-50/40 font-semibold sticky left-[120px]"></td>
{items.map((it) => {
const v = Number(it.STOCK[w.WH_CODE] ?? 0);
const negative = v < 0;
return (
<td key={it.OBJID} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600 font-bold" : v === 0 ? "text-slate-300" : "text-emerald-700 font-semibold"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
]),
]}
</tbody>
</table>
</div>
</div>
);
}
function SummaryCard({ label, value, color }: { label: string; value: string; color: "slate" | "rose" | "emerald" }) {
const cls = {
slate: "bg-slate-50 border-slate-200 text-slate-800",
rose: "bg-rose-50 border-rose-200 text-rose-800",
emerald: "bg-emerald-50 border-emerald-200 text-emerald-800",
}[color];
return (
<div className={`rounded-xl border ${cls} p-4`}>
<div className="text-xs font-semibold opacity-80 mb-1">{label}</div>
<div className="text-lg sm:text-xl font-bold tabular-nums">{value}</div>
</div>
);
}
+72 -5
View File
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import { FileText, Send, Download, RefreshCcw, AlertCircle } from "lucide-react";
import Swal from "sweetalert2";
import { downloadXlsx } from "@/lib/xlsx-export";
import { SearchableSelect } from "@/components/ui/searchable-select";
interface Einvoice {
OBJID: string;
@@ -38,6 +39,11 @@ const fmt = (n: number | string | null | undefined) => Number(n || 0).toLocaleSt
const STATUS_LABEL: Record<string, string> = {
DRAFT: "작성중", QUEUED: "전송대기", SENT: "전송완료", ACK: "승인완료", FAIL: "실패", CANCELED: "취소",
};
// 발주(출고) 진행 상태 한글 — 발행 가능 발주 목록용
const ORDER_STATUS_LABEL: Record<string, string> = {
REQUESTED: "출고요청", APPROVED: "출고완료", SHIPPED: "출고완료",
PAID: "입금완료", INVOICED: "계산서발행", CANCELLED: "취소", CANCELED: "취소",
};
const STATUS_COLOR: Record<string, string> = {
DRAFT: "bg-slate-100 text-slate-600",
QUEUED: "bg-amber-100 text-amber-700",
@@ -53,9 +59,13 @@ function defaultRange() {
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
}
interface Customer { USER_ID: string; USER_NAME: string }
export default function EinvoicesPage() {
const [[from, to], setRange] = useState(defaultRange());
const [statusFilter, setStatusFilter] = useState("");
const [customerFilter, setCustomerFilter] = useState("");
const [customers, setCustomers] = useState<Customer[]>([]);
const [list, setList] = useState<Einvoice[]>([]);
const [pending, setPending] = useState<PendingOrder[]>([]);
const [busy, setBusy] = useState(false);
@@ -63,10 +73,37 @@ export default function EinvoicesPage() {
const load = useCallback(async () => {
const res = await fetch("/api/m/einvoices/list", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dateFrom: from, dateTo: to, status: statusFilter || undefined }),
body: JSON.stringify({
dateFrom: from,
dateTo: to,
status: statusFilter || undefined,
customerObjid: customerFilter || undefined,
}),
});
setList((await res.json()).RESULTLIST ?? []);
}, [from, to, statusFilter]);
}, [from, to, statusFilter, customerFilter]);
const loadCustomers = useCallback(async () => {
const res = await fetch("/api/m/customers/list", {
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
});
setCustomers((await res.json()).RESULTLIST ?? []);
}, []);
// 면세/과세/합계 합산
const summary = useMemo(() => {
let taxFreeAmount = 0, taxableSupply = 0, taxableVat = 0, total = 0;
for (const e of list) {
total += Number(e.TOTAL_AMOUNT) || 0;
if (e.INVOICE_KIND === "TAXFREE") {
taxFreeAmount += Number(e.TOTAL_SUPPLY) || 0;
} else {
taxableSupply += Number(e.TOTAL_SUPPLY) || 0;
taxableVat += Number(e.TOTAL_VAT) || 0;
}
}
return { taxFreeAmount, taxableSupply, taxableVat, taxableTotal: taxableSupply + taxableVat, total };
}, [list]);
const loadPending = useCallback(async () => {
// 발주 + 발행이력 동시 조회 후 이미 발행된 건은 제외
@@ -88,6 +125,7 @@ export default function EinvoicesPage() {
}, []);
useEffect(() => { load(); loadPending(); }, [load, loadPending]);
useEffect(() => { loadCustomers(); }, [loadCustomers]);
const issueFromOrder = async (orderObjid: string, kind: "TAX" | "TAXFREE" = "TAX") => {
const ok = await Swal.fire({
@@ -196,7 +234,7 @@ export default function EinvoicesPage() {
<td className="px-3 py-2">{o.ORDER_DATE}</td>
<td className="px-3 py-2">{o.COMPANY_NAME}</td>
<td className="px-3 py-2 text-right tabular-nums font-bold">{fmt(o.TOTAL_AMOUNT)}</td>
<td className="px-3 py-2 text-center text-[11px] text-slate-500">{o.STATUS}</td>
<td className="px-3 py-2 text-center text-[11px] text-slate-500">{ORDER_STATUS_LABEL[o.STATUS] ?? o.STATUS}</td>
<td className="px-3 py-2 text-right">
<button
onClick={() => issueFromOrder(o.OBJID, "TAX")}
@@ -217,12 +255,20 @@ export default function EinvoicesPage() {
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-4 py-2.5 bg-slate-50 border-b border-slate-200 text-sm font-bold text-slate-700 flex flex-wrap items-center gap-2 justify-between">
<span> ({list.length})</span>
<div className="flex gap-2 items-center text-xs font-normal">
<div className="flex gap-2 items-center text-xs font-normal flex-wrap">
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])}
className="h-8 px-2 rounded border border-slate-200" />
<span className="text-slate-400">~</span>
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])}
className="h-8 px-2 rounded border border-slate-200" />
<div className="min-w-[180px]">
<SearchableSelect
options={[{ value: "", label: "전체 거래처" }, ...customers.map((c) => ({ value: c.USER_ID, label: c.USER_NAME }))]}
value={customerFilter}
onChange={setCustomerFilter}
placeholder="거래처"
/>
</div>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="h-8 px-2 rounded border border-slate-200 bg-white">
<option value=""> </option>
@@ -271,6 +317,27 @@ export default function EinvoicesPage() {
</tr>
))}
</tbody>
{list.length > 0 && (
<tfoot className="bg-slate-50 border-t-2 border-slate-300 font-bold text-[11px]">
<tr>
<td colSpan={4} className="px-3 py-2 text-right text-slate-600"> </td>
<td className="px-3 py-2 text-right tabular-nums text-violet-700" colSpan={3}>{fmt(summary.taxFreeAmount)}</td>
<td colSpan={2}></td>
</tr>
<tr>
<td colSpan={4} className="px-3 py-2 text-right text-slate-600"> ( + )</td>
<td className="px-3 py-2 text-right tabular-nums text-rose-700">{fmt(summary.taxableSupply)}</td>
<td className="px-3 py-2 text-right tabular-nums text-rose-700">{fmt(summary.taxableVat)}</td>
<td className="px-3 py-2 text-right tabular-nums text-rose-700">{fmt(summary.taxableTotal)}</td>
<td colSpan={2}></td>
</tr>
<tr className="border-t border-slate-300">
<td colSpan={4} className="px-3 py-2 text-right text-slate-700"> </td>
<td colSpan={3} className="px-3 py-2 text-right tabular-nums text-emerald-700 text-sm">{fmt(summary.total)}</td>
<td colSpan={2}></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
@@ -0,0 +1,138 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AlertTriangle, AlertCircle, Clock, RefreshCcw } from "lucide-react";
interface Row {
INBOUND_OBJID: string; INBOUND_NO: string;
INBOUND_DATE: string; EXPIRY_DATE: string; DAYS_LEFT: number;
WH_NAME: string | null; VENDOR_NAME: string | null;
COMPLETED_BY: string | null; MEMO: string | null; TOTAL_AMOUNT: number;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
export default function ExpiryAlertsPage() {
const [days, setDays] = useState(30);
const [rows, setRows] = useState<Row[]>([]);
const [counts, setCounts] = useState({ expired: 0, urgent: 0, soon: 0 });
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/m/admin/expiry-alerts", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ days }),
});
const j = await res.json();
setRows(j.RESULTLIST ?? []);
setCounts({ expired: j.EXPIRED_CNT ?? 0, urgent: j.URGENT_CNT ?? 0, soon: j.SOON_CNT ?? 0 });
} finally { setLoading(false); }
}, [days]);
useEffect(() => { load(); }, [load]);
const rowStyle = (daysLeft: number) => {
if (daysLeft < 0) return "bg-rose-50 border-l-4 border-l-rose-500";
if (daysLeft <= 7) return "bg-amber-50 border-l-4 border-l-amber-500";
return "bg-white";
};
const badge = (daysLeft: number) => {
if (daysLeft < 0) return <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-bold"> {Math.abs(daysLeft)} </span>;
if (daysLeft === 0) return <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-bold"> </span>;
if (daysLeft <= 7) return <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold">D-{daysLeft}</span>;
return <span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">D-{daysLeft}</span>;
};
return (
<div className="space-y-4">
<div className="flex items-end justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<AlertTriangle size={20} className="text-amber-600" />
</h1>
<p className="text-xs text-slate-500 mt-0.5">
. / D-7 / D-30 . .
</p>
</div>
<div className="flex items-center gap-2">
<select value={days} onChange={(e) => setDays(Number(e.target.value))}
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
<option value={7}>7 </option>
<option value={14}>14 </option>
<option value={30}>30 </option>
<option value={60}>60 </option>
<option value={90}>90 </option>
</select>
<button onClick={load} disabled={loading}
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50">
<RefreshCcw size={14} />
</button>
</div>
</div>
{/* 알림 카드 */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-rose-50 border border-rose-200 rounded-xl p-4">
<div className="text-[11px] text-rose-700 font-semibold mb-1 flex items-center gap-1">
<AlertCircle size={14} />
</div>
<div className="text-2xl font-bold text-rose-700 tabular-nums">{counts.expired}</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="text-[11px] text-amber-700 font-semibold mb-1 flex items-center gap-1">
<AlertTriangle size={14} /> 7
</div>
<div className="text-2xl font-bold text-amber-700 tabular-nums">{counts.urgent}</div>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-600 font-semibold mb-1 flex items-center gap-1">
<Clock size={14} /> 30
</div>
<div className="text-2xl font-bold text-slate-700 tabular-nums">{counts.soon}</div>
</div>
</div>
{/* 리스트 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-center px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
</tr>
</thead>
<tbody className="tabular-nums">
{rows.length === 0 ? (
<tr><td colSpan={8} className="text-center py-12 text-slate-400">
{loading ? "조회 중..." : "임박한 유통기한이 없습니다."}
</td></tr>
) : rows.map((r) => (
<tr key={r.INBOUND_OBJID} className={`border-t border-slate-100 ${rowStyle(Number(r.DAYS_LEFT))}`}>
<td className="px-3 py-2 font-semibold">{r.INBOUND_NO}
<div className="text-[10px] text-slate-400"> {r.INBOUND_DATE}</div></td>
<td className="px-3 py-2 font-mono">{r.EXPIRY_DATE}</td>
<td className="px-3 py-2 text-center">{badge(Number(r.DAYS_LEFT))}</td>
<td className="px-3 py-2">{r.WH_NAME ?? "-"}</td>
<td className="px-3 py-2">{r.VENDOR_NAME ?? "-"}</td>
<td className="px-3 py-2 text-xs">{r.COMPLETED_BY ?? "-"}</td>
<td className="px-3 py-2 text-[10px] text-slate-500 whitespace-pre-line max-w-[300px] truncate" title={r.MEMO ?? ""}>
{r.MEMO ?? ""}
</td>
<td className="px-3 py-2 text-right font-semibold">{fmt(Number(r.TOTAL_AMOUNT))}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+34 -16
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Trash2, Plus } from "lucide-react";
import Swal from "sweetalert2";
import { SearchableSelect } from "@/components/ui/searchable-select";
interface Vendor { OBJID: string; VENDOR_NAME: string }
interface Wh { OBJID: string; WH_NAME: string }
@@ -102,24 +103,37 @@ export default function NewInboundPage() {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-slate-600"> ( )</label>
<select value={procObjid} onChange={(e) => onProcChange(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
<option value=""> ( )</option>
{procs.map((p) => <option key={p.OBJID} value={p.OBJID}>{p.PROC_NO} {p.VENDOR_NAME}</option>)}
</select>
<div className="mt-1">
<SearchableSelect
options={procs.map((p) => ({ value: p.OBJID, label: `${p.PROC_NO}${p.VENDOR_NAME}` }))}
value={procObjid}
onChange={onProcChange}
placeholder="단독 입고 (매입발주 없이)"
emptyLabel="단독 입고 (매입발주 없이)"
/>
</div>
</div>
<div>
<label className="text-xs font-semibold text-slate-600"></label>
<select value={vendorObjid} onChange={(e) => setVendorObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
<option value=""></option>
{vendors.map((v) => <option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>)}
</select>
<div className="mt-1">
<SearchableSelect
options={vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))}
value={vendorObjid}
onChange={setVendorObjid}
placeholder="선택"
/>
</div>
</div>
<div>
<label className="text-xs font-semibold text-slate-600"> *</label>
<select value={whObjid} onChange={(e) => setWhObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
<option value=""></option>
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
</select>
<div className="mt-1">
<SearchableSelect
options={whs.map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
value={whObjid}
onChange={setWhObjid}
placeholder="선택"
/>
</div>
</div>
<div>
<label className="text-xs font-semibold text-slate-600"></label>
@@ -131,10 +145,14 @@ export default function NewInboundPage() {
<div className="bg-white border rounded-xl p-5">
<h3 className="font-bold mb-3"> ( + )</h3>
<div className="grid grid-cols-12 gap-2 mb-3">
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="col-span-4 h-10 px-3 rounded-lg border border-slate-200">
<option value=""> </option>
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
</select>
<div className="col-span-4">
<SearchableSelect
options={items.map((i) => ({ value: i.OBJID, label: i.ITEM_NAME }))}
value={pickItem}
onChange={setPickItem}
placeholder="품목 선택"
/>
</div>
<input type="number" min={0} value={qtyN} onChange={(e) => setQtyN(Number(e.target.value))} placeholder="정상" className="col-span-1 h-10 px-2 rounded-lg border border-slate-200 text-right" />
<input type="number" min={0} value={qtyD} onChange={(e) => setQtyD(Number(e.target.value))} placeholder="불량" className="col-span-1 h-10 px-2 rounded-lg border border-slate-200 text-right" />
<input type="text" value={defectReason} onChange={(e) => setDefectReason(e.target.value)} placeholder="불량사유" className="col-span-3 h-10 px-3 rounded-lg border border-slate-200" />
+128 -19
View File
@@ -3,11 +3,12 @@
import { useEffect, useState, useCallback } from "react";
import { Save, RefreshCcw, CheckCircle2, Clock } from "lucide-react";
import Swal from "sweetalert2";
import { Loading } from "@/components/ui/loading";
interface ProcRow {
OBJID: string; PROC_NO: string; PROC_DATE: string;
VENDOR_OBJID: string | null; VENDOR_NAME: string | null;
STATUS: string; TOTAL_AMOUNT: number; LINE_CNT: number;
STATUS: string; IS_PAID?: boolean; TOTAL_AMOUNT: number; LINE_CNT: number;
TOTAL_QTY: number; RECEIVED_QTY: number;
}
interface ProcDetail { OBJID: string; PROC_NO: string; PROC_DATE: string; STATUS: string; VENDOR_NAME: string | null }
@@ -19,6 +20,7 @@ interface ProcLine {
interface Warehouse { OBJID: string; WH_NAME: string }
const fmt = (n: number | string | undefined) => Number(n || 0).toLocaleString("ko-KR");
// 진행상태 (입금/결재와 무관)
const STATUS_LABEL: Record<string, string> = {
REQUESTED: "발주요청", PARTIAL: "입고중", RECEIVED: "입고완료", OPEN: "작성중", CANCELLED: "취소",
};
@@ -26,13 +28,13 @@ const STATUS_COLOR: Record<string, string> = {
OPEN: "bg-slate-100 text-slate-600 border-slate-200",
REQUESTED: "bg-amber-100 text-amber-700 border-amber-200",
PARTIAL: "bg-orange-100 text-orange-700 border-orange-200",
RECEIVED: "bg-emerald-100 text-emerald-700 border-emerald-200",
RECEIVED: "bg-emerald-100 text-emerald-800 border-emerald-300",
CANCELLED: "bg-rose-100 text-rose-600 border-rose-200",
};
export default function InboundsPage() {
const [list, setList] = useState<ProcRow[]>([]);
const [statusFilter, setStatusFilter] = useState("OPEN_OR_PARTIAL");
const [statusFilter, setStatusFilter] = useState("INBOUNDABLE");
const [activeId, setActiveId] = useState("");
const [detail, setDetail] = useState<{ proc: ProcDetail; items: ProcLine[] } | null>(null);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
@@ -41,6 +43,23 @@ export default function InboundsPage() {
// 라인별 입력 (창고/입고수량/불량수량)
const [inputs, setInputs] = useState<Record<string, { whObjid: string; qtyNormal: number; qtyDefect: number }>>({});
// 입고 체크리스트
const [checklist, setChecklist] = useState({
qtyMatch: false, // 1) 발주수량/입고수량 일치
cartonMatch: false, // 2) 1카톤 N개 일치
cartonSize: "", // 카톤 단위
expiryDate: "", // 3) 소비기한
completedBy: "", // 4) 물류팀 입고 최종완료자
remark: "", // 5) 특이건 메모
});
// 물류팀 4명 — 임직원(user_type='A') 중 momo4763/momo7529 외 2명까지
const LOGISTICS = [
{ id: "momo4763", name: "이효철 (물류총괄)" },
{ id: "momo7529", name: "유우형 (물류팀장)" },
{ id: "momo9431", name: "강상익 (김포지사 총괄)" },
{ id: "momo5315", name: "배연진 (경영팀장)" },
];
const load = useCallback(async () => {
const body: Record<string, unknown> = {};
// 입고 화면은 REQUESTED + PARTIAL 만 보이게
@@ -49,7 +68,9 @@ export default function InboundsPage() {
});
const j = await res.json();
let rows: ProcRow[] = j.RESULTLIST ?? [];
if (statusFilter === "OPEN_OR_PARTIAL") {
// 입고 처리 대상: 진행상태 REQUESTED(발주요청) / PARTIAL(입고중) 만.
// 입금(결재)은 입고와 무관 — paid 여부와 상관없이 진행상태로만 판단.
if (statusFilter === "INBOUNDABLE") {
rows = rows.filter((r) => r.STATUS === "REQUESTED" || r.STATUS === "PARTIAL");
} else if (statusFilter && statusFilter !== "ALL") {
rows = rows.filter((r) => r.STATUS === statusFilter);
@@ -148,6 +169,15 @@ export default function InboundsPage() {
});
if (!ok.isConfirmed) return;
// 체크리스트 텍스트화 — memo 에 저장 (스키마 변경 없이)
const checklistMemo = [
`[수량 일치] ${checklist.qtyMatch ? "Y ✓" : "N"}`,
`[카톤 일치] ${checklist.cartonMatch ? `Y ✓ (1카톤 ${checklist.cartonSize || "?"}개)` : "N"}`,
`[소비기한] ${checklist.expiryDate || "-"}`,
`[입고완료자] ${checklist.completedBy || "-"}`,
`[특이사항] ${checklist.remark || "-"}`,
].join("\n");
setBusy(true);
let successCnt = 0, failCnt = 0;
const errors: string[] = [];
@@ -159,6 +189,9 @@ export default function InboundsPage() {
procObjid: detail.proc.OBJID,
whObjid,
lines: whLines,
memo: checklistMemo,
expiryDate: checklist.expiryDate || undefined,
completedBy: checklist.completedBy || undefined,
}),
});
const j = await res.json();
@@ -182,15 +215,16 @@ export default function InboundsPage() {
return (
<div className="space-y-3">
{busy && <Loading message="입고 처리 중..." />}
<div className="flex items-end justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-bold"> </h1>
<p className="text-xs text-slate-500 mt-0.5"> , / . .</p>
<p className="text-xs text-slate-500 mt-0.5"> ( ). , / . .</p>
</div>
<div className="flex gap-2">
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
<option value="OPEN_OR_PARTIAL"> ( + )</option>
<option value="INBOUNDABLE"> ( + )</option>
<option value="ALL"></option>
<option value="REQUESTED"></option>
<option value="PARTIAL"></option>
@@ -212,15 +246,14 @@ export default function InboundsPage() {
<table className="w-full text-xs">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="text-left px-2 py-2"></th>
<th className="text-left px-2 py-2"></th>
<th className="text-left px-2 py-2"> / </th>
<th className="text-center px-2 py-2">//</th>
<th className="text-center px-2 py-2"></th>
</tr>
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={4} className="text-center py-12 text-slate-400"> .</td></tr>
<tr><td colSpan={3} className="text-center py-12 text-slate-400"> .</td></tr>
) : list.map((p) => {
const total = Number(p.TOTAL_QTY);
const recv = Number(p.RECEIVED_QTY);
@@ -230,8 +263,12 @@ export default function InboundsPage() {
onClick={() => setActiveId(p.OBJID)}
style={{ cursor: "pointer" }}
className={`border-t border-slate-100 ${activeId === p.OBJID ? "bg-emerald-50" : "hover:bg-slate-50"}`}>
<td className="px-2 py-2 font-semibold">{p.PROC_NO}<div className="text-slate-400 text-[10px]">{p.PROC_DATE}</div></td>
<td className="px-2 py-2 truncate max-w-[120px]">{p.VENDOR_NAME ?? <span className="text-slate-300"></span>}</td>
<td className="px-2 py-2">
<div className="font-bold text-[13px] truncate max-w-[170px]" title={p.VENDOR_NAME ?? ""}>
{p.VENDOR_NAME ?? <span className="text-slate-300 font-normal"></span>}
</div>
<div className="text-[10px] text-slate-500">{p.PROC_DATE} · {p.PROC_NO}</div>
</td>
<td className="px-2 py-2 text-center text-[11px] tabular-nums">
<span className="text-slate-700">{fmt(total)}</span>
<span className="text-slate-300 mx-0.5">/</span>
@@ -245,6 +282,11 @@ export default function InboundsPage() {
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold border ${STATUS_COLOR[p.STATUS] ?? "bg-slate-100 text-slate-500 border-slate-200"}`}>
{STATUS_LABEL[p.STATUS] ?? p.STATUS}
</span>
<div className="mt-0.5">
<span className={`inline-block px-1 py-0.5 rounded text-[9px] font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
{p.IS_PAID ? "입금완료" : "미입금"}
</span>
</div>
</td>
</tr>
);
@@ -258,17 +300,17 @@ export default function InboundsPage() {
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
<span> </span>
{detail && (detail.proc.STATUS === "REQUESTED" || detail.proc.STATUS === "PARTIAL") && (
<button onClick={submitInbound} disabled={busy}
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
<Save size={12} />
</button>
)}
{detail && detail.proc.STATUS === "RECEIVED" && (
<span className="text-[11px] text-emerald-700 inline-flex items-center gap-1">
<CheckCircle2 size={12} />
</span>
)}
{detail && ["REQUESTED", "PARTIAL"].includes(detail.proc.STATUS) && (
<button onClick={submitInbound} disabled={busy}
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
<Save size={12} />
</button>
)}
</div>
<div className="flex-1 lg:overflow-auto p-4">
{!detail ? (
@@ -279,6 +321,9 @@ export default function InboundsPage() {
warehouses={warehouses}
inputs={inputs}
onUpdate={updateInput}
checklist={checklist}
onChecklistChange={(patch) => setChecklist((p) => ({ ...p, ...patch }))}
logistics={LOGISTICS}
/>
)}
</div>
@@ -288,13 +333,22 @@ export default function InboundsPage() {
);
}
function InboundForm({ detail, warehouses, inputs, onUpdate }: {
interface Checklist {
qtyMatch: boolean; cartonMatch: boolean; cartonSize: string;
expiryDate: string; completedBy: string; remark: string;
}
function InboundForm({ detail, warehouses, inputs, onUpdate, checklist, onChecklistChange, logistics }: {
detail: { proc: ProcDetail; items: ProcLine[] };
warehouses: Warehouse[];
inputs: Record<string, { whObjid: string; qtyNormal: number; qtyDefect: number }>;
onUpdate: (lineObjid: string, patch: Partial<{ whObjid: string; qtyNormal: number; qtyDefect: number }>) => void;
checklist: Checklist;
onChecklistChange: (patch: Partial<Checklist>) => void;
logistics: { id: string; name: string }[];
}) {
const editable = detail.proc.STATUS === "REQUESTED" || detail.proc.STATUS === "PARTIAL";
// 입고 입력 허용: 진행상태 발주요청 / 입고중 만 (입금 여부 무관).
// 입고완료(RECEIVED)는 더 받을 게 없어 읽기전용, OPEN/CANCELLED 도 불가.
const editable = ["REQUESTED", "PARTIAL"].includes(detail.proc.STATUS);
return (
<div className="text-[12px]">
<div className="flex items-center justify-between mb-3">
@@ -384,10 +438,65 @@ function InboundForm({ detail, warehouses, inputs, onUpdate }: {
</table>
{editable && (
<>
<div className="mt-3 text-[11px] text-slate-500">
+ <b> </b> . 0 .<br />
<span className="inline-block px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold"></span> , .
</div>
{/* 입고 체크리스트 — memo 컬럼에 함께 저장 */}
<div className="mt-4 border border-emerald-200 bg-emerald-50/40 rounded-lg p-3 space-y-2">
<div className="font-bold text-[12px] text-emerald-800 mb-1">📋 </div>
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
<input type="checkbox" className="w-4 h-4 accent-emerald-600"
checked={checklist.qtyMatch}
onChange={(e) => onChecklistChange({ qtyMatch: e.target.checked })} />
<span>1) ?</span>
</label>
<div className="flex items-center gap-2 text-[12px]">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" className="w-4 h-4 accent-emerald-600"
checked={checklist.cartonMatch}
onChange={(e) => onChecklistChange({ cartonMatch: e.target.checked })} />
<span>2) 1</span>
</label>
<input type="number" min={0}
value={checklist.cartonSize}
onChange={(e) => onChecklistChange({ cartonSize: e.target.value })}
placeholder="개수"
className="w-20 h-7 px-2 border border-slate-300 rounded text-[11px] text-right tabular-nums" />
<span> ?</span>
</div>
<div className="flex items-center gap-2 text-[12px]">
<span className="w-44">3) </span>
<input type="date" value={checklist.expiryDate}
onChange={(e) => onChecklistChange({ expiryDate: e.target.value })}
className="h-7 px-2 border border-slate-300 rounded text-[11px]" />
</div>
<div className="flex items-center gap-2 text-[12px]">
<span className="w-44">4) </span>
<select value={checklist.completedBy}
onChange={(e) => onChecklistChange({ completedBy: e.target.value })}
className="h-7 px-2 border border-slate-300 rounded text-[11px] bg-white">
<option value="">-- --</option>
{logistics.map((p) => <option key={p.id} value={p.name}>{p.name}</option>)}
</select>
</div>
<div className="text-[12px]">
<div className="mb-1">5) </div>
<textarea rows={2}
value={checklist.remark}
onChange={(e) => onChecklistChange({ remark: e.target.value })}
placeholder="특이건이 있으면 입력"
className="w-full px-2 py-1 border border-slate-300 rounded text-[11px] resize-none" />
</div>
</div>
</>
)}
</div>
);
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { SearchableSelect } from "@/components/ui/searchable-select";
interface Move {
OBJID: string;
@@ -13,7 +14,9 @@ interface Move {
MOVE_TYPE_NAME: string;
QTY: number;
REF_TYPE: string;
REF_TYPE_LABEL?: string;
REF_OBJID: string;
COUNTER_WH_NAME?: string | null;
MEMO: string;
REGID: string;
REGDATE: string;
@@ -89,11 +92,13 @@ export default function InventoryHistoryPage() {
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
<div>
<label className="block text-[11px] font-semibold text-slate-500 mb-1"></label>
<select value={whFilter} onChange={(e) => setWhFilter(e.target.value)}
className="w-full h-9 px-2 rounded-lg border border-slate-200 text-sm bg-white">
<option value=""> </option>
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
</select>
<SearchableSelect
options={whs.map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
value={whFilter}
onChange={setWhFilter}
placeholder="전체 창고"
emptyLabel="전체 창고"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-500 mb-1"> </label>
@@ -186,7 +191,14 @@ export default function InventoryHistoryPage() {
{m.MOVE_TYPE === "OUT" ? "-" : "+"}{fmt(m.QTY)}
</span>
</td>
<td className="px-3 py-2 text-xs text-slate-500">{m.REF_TYPE || "-"}</td>
<td className="px-3 py-2 text-xs text-slate-700">
{m.REF_TYPE_LABEL || m.REF_TYPE || "-"}
{m.REF_TYPE === "TRANSFER" && m.COUNTER_WH_NAME && (
<span className="ml-1 text-slate-500">
{m.MOVE_TYPE === "OUT" ? `${m.COUNTER_WH_NAME}` : `${m.COUNTER_WH_NAME}`}
</span>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-500 max-w-[200px] truncate">{m.MEMO || "-"}</td>
<td className="px-3 py-2 text-xs text-slate-500">{m.REGID || "-"}</td>
<td className="px-3 py-2 text-center text-xs text-slate-500">{m.REGDATE}</td>
@@ -215,7 +227,7 @@ export default function InventoryHistoryPage() {
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${MOVE_TYPE_BADGE[m.MOVE_TYPE] || "bg-slate-100 text-slate-600"}`}>
{m.MOVE_TYPE_NAME}
</span>
{m.REF_TYPE && <span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-500 font-mono">{m.REF_TYPE}</span>}
{m.REF_TYPE && <span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">{m.REF_TYPE_LABEL || m.REF_TYPE}{m.REF_TYPE === "TRANSFER" && m.COUNTER_WH_NAME && (m.MOVE_TYPE === "OUT" ? `${m.COUNTER_WH_NAME}` : `${m.COUNTER_WH_NAME}`)}</span>}
</div>
<div className="font-bold text-sm text-slate-900 truncate">{m.ITEM_NAME}</div>
<div className="text-[10px] text-slate-400 font-mono">{m.ITEM_CODE}</div>
+342 -67
View File
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Plus, Search, Trash2, History, ArrowRightLeft, Package } from "lucide-react";
import { useEffect, useMemo, useState, useCallback } from "react";
import { Plus, Search, Trash2, History, ArrowRightLeft, Package, LayoutGrid, Columns3 } from "lucide-react";
import { useRouter } from "next/navigation";
import Swal from "sweetalert2";
import { SearchableSelect } from "@/components/ui/searchable-select";
interface Stock { OBJID: string; WH_OBJID: string; WH_CODE: string; WH_NAME: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT: string; IS_TAX_FREE: string; QTY: number; UPDATE_DATE: string }
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
@@ -21,6 +22,7 @@ export default function InventoryPage() {
const [keyword, setKeyword] = useState("");
const [inboundOpen, setInboundOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState<{ itemObjid: string; whObjid: string; itemName: string; whName: string } | null>(null);
// 입고 폼
const [inboundWh, setInboundWh] = useState("");
@@ -34,13 +36,36 @@ export default function InventoryPage() {
const [trItem, setTrItem] = useState("");
const [trQty, setTrQty] = useState(1);
const load = async () => {
// 매트릭스 보기 모드 — 기본 '품목 가로' (헤더=품목, 행=창고). 토글로 '창고 가로'.
const [viewMode, setViewMode] = useState<"by-item" | "by-wh">("by-item");
// list 평면 → 매트릭스 pivot
const matrix = useMemo(() => {
const itemSet = new Map<string, { OBJID: string; CODE: string; NAME: string; UNIT: string; IS_TAX_FREE: string }>();
const whSet = new Map<string, { OBJID: string; CODE: string; NAME: string }>();
const cell: Record<string, Record<string, { qty: number; updateDate: string }>> = {};
// cell[itemObjid][whObjid] = { qty, updateDate }
for (const s of list) {
if (!itemSet.has(s.ITEM_OBJID)) itemSet.set(s.ITEM_OBJID, {
OBJID: s.ITEM_OBJID, CODE: s.ITEM_CODE, NAME: s.ITEM_NAME, UNIT: s.UNIT, IS_TAX_FREE: s.IS_TAX_FREE,
});
if (!whSet.has(s.WH_OBJID)) whSet.set(s.WH_OBJID, { OBJID: s.WH_OBJID, CODE: s.WH_CODE, NAME: s.WH_NAME });
if (!cell[s.ITEM_OBJID]) cell[s.ITEM_OBJID] = {};
cell[s.ITEM_OBJID][s.WH_OBJID] = { qty: Number(s.QTY), updateDate: s.UPDATE_DATE };
}
// 창고 7개 — list 에 없는 창고도 헤더에 포함시키려면 whs 사용
const allWhs = [...whs].sort((a, b) => a.WH_CODE.localeCompare(b.WH_CODE));
const itemList = Array.from(itemSet.values()).sort((a, b) => a.NAME.localeCompare(b.NAME));
return { items: itemList, warehouses: allWhs, cell };
}, [list, whs]);
const load = useCallback(async () => {
const res = await fetch("/api/m/inventory/list", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ whObjid: whFilter || undefined, keyword: keyword || undefined }),
});
setList((await res.json()).RESULTLIST ?? []);
};
}, [whFilter, keyword]);
const loadMeta = async () => {
const w = await (await fetch("/api/m/warehouses/list", { method: "POST" })).json();
setWhs(w.RESULTLIST ?? []);
@@ -48,10 +73,15 @@ export default function InventoryPage() {
setItems(i.RESULTLIST ?? []);
};
useEffect(() => { loadMeta(); load(); }, []); // eslint-disable-line
useEffect(() => { loadMeta(); }, []);
useEffect(() => { load(); }, [load]);
const addLine = () => {
if (!pickItem) return;
if (!pickQty || pickQty === 0) {
Swal.fire({ icon: "warning", title: "수량을 입력하세요.", text: "양수=입고, 음수=차감" });
return;
}
const it = items.find((x) => x.OBJID === pickItem);
if (!it) return;
setLines([...lines, { itemObjid: it.OBJID, itemName: it.ITEM_NAME, qty: pickQty }]);
@@ -145,10 +175,13 @@ export default function InventoryPage() {
{/* 검색 영역 — 모바일 1열 / sm 2열 / lg 옆으로 */}
<div className="bg-white border border-slate-200 rounded-xl p-2.5">
<div className="grid grid-cols-1 sm:grid-cols-[1fr_2fr_auto] gap-2">
<select value={whFilter} onChange={(e) => setWhFilter(e.target.value)} className="h-9 px-3 rounded-lg border border-slate-200 text-sm bg-white">
<option value=""> </option>
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
</select>
<SearchableSelect
options={whs.map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
value={whFilter}
onChange={setWhFilter}
placeholder="전체 창고"
emptyLabel="전체 창고"
/>
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load()} placeholder="품목명 / 코드" className="w-full h-9 pl-8 pr-3 rounded-lg border border-slate-200 text-sm" />
@@ -157,39 +190,141 @@ export default function InventoryPage() {
</div>
</div>
{/* 데스크톱 테이블 */}
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-center px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-left px-4 py-3"> </th>
</tr>
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={6} className="text-center py-12 text-slate-400"> . .</td></tr>
) : list.map((s) => (
<tr key={s.OBJID} className="border-t border-slate-100">
<td className="px-4 py-3">{s.WH_NAME}</td>
<td className="px-4 py-3 font-mono text-xs">{s.ITEM_CODE}</td>
<td className="px-4 py-3 font-semibold">{s.ITEM_NAME}</td>
<td className="px-4 py-3 text-center">
{s.IS_TAX_FREE === "Y"
? <span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span>
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[10px] font-bold"></span>}
</td>
<td className="px-4 py-3 text-right tabular-nums font-bold">{fmt(s.QTY)} {s.UNIT}</td>
<td className="px-4 py-3 text-slate-500 text-xs">{s.UPDATE_DATE}</td>
{/* 데스크톱 — 매트릭스 토글 (품목 가로 ↔ 창고 가로) */}
<div className="hidden sm:block">
<div className="flex items-center justify-end mb-2">
<div className="inline-flex rounded border border-slate-300 overflow-hidden">
<button onClick={() => setViewMode("by-item")}
className={`h-8 px-3 text-xs font-semibold inline-flex items-center gap-1 ${viewMode === "by-item" ? "bg-emerald-700 text-white" : "bg-white text-slate-600 hover:bg-slate-50"}`}>
<LayoutGrid size={13} />
</button>
<button onClick={() => setViewMode("by-wh")}
className={`h-8 px-3 text-xs font-semibold inline-flex items-center gap-1 border-l border-slate-300 ${viewMode === "by-wh" ? "bg-emerald-700 text-white" : "bg-white text-slate-600 hover:bg-slate-50"}`}>
<Columns3 size={13} />
</button>
</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
{matrix.items.length === 0 ? (
<div className="text-center py-12 text-slate-400"> . .</div>
) : viewMode === "by-item" ? (
/* 품목 가로 (기본): 헤더=품목, 행=창고. 상단에 품목별 전체 합계 강조 행. */
<table className="text-sm border-collapse">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2 border-b border-slate-200 sticky left-0 bg-slate-50 z-10 min-w-[140px]"></th>
{matrix.items.map((it) => (
<th key={it.OBJID} className="text-right px-3 py-2 border-b border-slate-200 min-w-[100px] whitespace-nowrap">
<div>{it.NAME}</div>
<div className="text-[10px] text-slate-400 font-mono font-normal">{it.CODE}</div>
</th>
))}
</tr>
))}
</tbody>
</table>
{/* 전체 합계 행 — 헤더 바로 아래, sticky */}
<tr className="bg-emerald-50 border-b-2 border-emerald-300">
<th className="text-left px-3 py-2 sticky left-0 bg-emerald-50 z-10 text-emerald-800 font-bold">
</th>
{matrix.items.map((it) => {
const total = matrix.warehouses.reduce(
(sum, w) => sum + (matrix.cell[it.OBJID]?.[w.OBJID]?.qty ?? 0), 0
);
const cls = total < 0
? "text-rose-700 font-extrabold bg-rose-50"
: total === 0
? "text-emerald-300"
: "text-emerald-800 font-bold";
return (
<th key={it.OBJID} className={`px-3 py-2 text-right tabular-nums ${cls}`}>
{total === 0 ? "-" : `${fmt(total)} ${it.UNIT}`}
</th>
);
})}
</tr>
</thead>
<tbody className="tabular-nums">
{matrix.warehouses.map((w) => (
<tr key={w.OBJID} className="border-t border-slate-100 hover:bg-slate-50/60">
<td className="px-3 py-2 font-semibold sticky left-0 bg-white">
{w.WH_NAME}
<div className="text-[10px] text-slate-400 font-mono">{w.WH_CODE}</div>
</td>
{matrix.items.map((it) => {
const c = matrix.cell[it.OBJID]?.[w.OBJID];
const qty = c ? c.qty : 0;
const cls = qty < 0
? "text-rose-700 font-extrabold bg-rose-50"
: qty === 0
? "text-slate-300"
: "text-slate-800 font-semibold";
return (
<td key={it.OBJID} className={`px-3 py-2 text-right tabular-nums ${cls}`}>
{qty === 0 ? "-" : (
<button
onClick={() => setHistoryOpen({ itemObjid: it.OBJID, whObjid: w.OBJID, itemName: it.NAME, whName: w.WH_NAME })}
className="hover:underline hover:text-emerald-700"
title="재고 이력"
>
{fmt(qty)} {it.UNIT}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
) : (
/* 창고 가로: 헤더=창고, 행=품목 */
<table className="w-full text-sm border-collapse">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2 border-b border-slate-200 min-w-[200px]"></th>
{matrix.warehouses.map((w) => (
<th key={w.OBJID} className="text-right px-3 py-2 border-b border-slate-200 min-w-[100px]">
{w.WH_NAME}
</th>
))}
</tr>
</thead>
<tbody className="tabular-nums">
{matrix.items.map((it) => (
<tr key={it.OBJID} className="border-t border-slate-100 hover:bg-slate-50/60">
<td className="px-3 py-2">
<span className="font-semibold">{it.NAME}</span>
<span className="ml-2 text-[10px] text-slate-400 font-mono">{it.CODE}</span>
{it.IS_TAX_FREE === "Y"
? <span className="ml-2 px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[9px] font-bold"></span>
: <span className="ml-2 px-1 py-0.5 rounded bg-rose-100 text-rose-700 text-[9px] font-bold"></span>}
</td>
{matrix.warehouses.map((w) => {
const c = matrix.cell[it.OBJID]?.[w.OBJID];
const qty = c ? c.qty : 0;
const cls = qty < 0
? "text-rose-700 font-extrabold bg-rose-50"
: qty === 0
? "text-slate-300"
: "text-slate-800 font-semibold";
return (
<td key={w.OBJID} className={`px-3 py-2 text-right tabular-nums ${cls}`}>
{qty === 0 ? "-" : (
<button
onClick={() => setHistoryOpen({ itemObjid: it.OBJID, whObjid: w.OBJID, itemName: it.NAME, whName: w.WH_NAME })}
className="hover:underline hover:text-emerald-700"
title="재고 이력"
>
{fmt(qty)} {it.UNIT}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
@@ -215,29 +350,57 @@ export default function InventoryPage() {
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[9px] font-bold"></span>}
</div>
</div>
<div className="mt-2 pt-2 border-t border-slate-100 text-[10px] text-slate-400">
· {s.UPDATE_DATE}
<div className="mt-2 pt-2 border-t border-slate-100 text-[10px] text-slate-400 flex items-center justify-between">
<span> · {s.UPDATE_DATE}</span>
<button onClick={() => setHistoryOpen({ itemObjid: s.ITEM_OBJID, whObjid: s.WH_OBJID, itemName: s.ITEM_NAME, whName: s.WH_NAME })}
className="inline-flex items-center gap-1 h-6 px-2 rounded bg-slate-100 text-slate-700 text-[10px] font-bold">
<History size={10} />
</button>
</div>
</div>
))}
</div>
{/* 재고이력 모달 */}
{historyOpen && (
<StockHistoryModal
itemObjid={historyOpen.itemObjid}
whObjid={historyOpen.whObjid}
itemName={historyOpen.itemName}
whName={historyOpen.whName}
onClose={() => setHistoryOpen(null)}
/>
)}
{/* 입고 모달 */}
{inboundOpen && (
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4" onClick={() => setInboundOpen(false)}>
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-t-xl sm:rounded-xl max-w-2xl w-full p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
<h3 className="font-bold mb-4"> </h3>
<div className="space-y-3">
<select value={inboundWh} onChange={(e) => setInboundWh(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
<option value=""> </option>
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
</select>
<SearchableSelect
options={whs.map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
value={inboundWh}
onChange={setInboundWh}
placeholder="창고 선택"
/>
<div className="grid grid-cols-[1fr_80px_auto] gap-2">
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 bg-white min-w-0">
<option value=""> </option>
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
</select>
<input type="number" min={1} value={pickQty} onChange={(e) => setPickQty(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200" />
<div className="min-w-0">
<SearchableSelect
options={items.map((i) => ({ value: i.OBJID, label: i.ITEM_NAME }))}
value={pickItem}
onChange={setPickItem}
placeholder="품목 선택"
/>
</div>
<input
type="number"
step={1}
value={pickQty}
onChange={(e) => setPickQty(Number(e.target.value))}
className="h-10 px-3 rounded-lg border border-slate-200"
title="양수=입고, 음수=차감"
/>
<button onClick={addLine} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div>
<div className="border border-slate-200 rounded-lg max-h-60 overflow-y-auto">
@@ -252,7 +415,9 @@ export default function InventoryPage() {
{lines.map((ln, i) => (
<tr key={i} className="border-t border-slate-100">
<td className="px-3 py-2">{ln.itemName}</td>
<td className="px-3 py-2 text-right">{ln.qty}</td>
<td className={`px-3 py-2 text-right font-semibold tabular-nums ${ln.qty < 0 ? "text-rose-600" : "text-emerald-700"}`}>
{ln.qty > 0 ? `+${ln.qty}` : ln.qty}
</td>
<td className="px-3 py-2 text-right">
<button onClick={() => setLines(lines.filter((_, j) => j !== i))} className="text-slate-400 hover:text-rose-500"><Trash2 size={12} /></button>
</td>
@@ -280,26 +445,31 @@ export default function InventoryPage() {
<div className="space-y-3">
<div>
<label className="block text-[11px] font-semibold text-slate-500 mb-1"> (A)</label>
<select value={trFrom} onChange={(e) => { setTrFrom(e.target.value); setTrItem(""); }} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
<option value=""></option>
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
</select>
<SearchableSelect
options={whs.map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
value={trFrom}
onChange={(v) => { setTrFrom(v); setTrItem(""); }}
placeholder="선택"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-500 mb-1"> (B)</label>
<select value={trTo} onChange={(e) => setTrTo(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
<option value=""></option>
{whs.filter((w) => w.OBJID !== trFrom).map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
</select>
<SearchableSelect
options={whs.filter((w) => w.OBJID !== trFrom).map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
value={trTo}
onChange={setTrTo}
placeholder="선택"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-500 mb-1"> {trFrom && <span className="text-slate-400">( )</span>}</label>
<select value={trItem} onChange={(e) => setTrItem(e.target.value)} disabled={!trFrom} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white disabled:bg-slate-50">
<option value="">{trFrom ? "선택" : "출발창고 먼저 선택"}</option>
{trItemsInFrom.map((s) => (
<option key={s.ITEM_OBJID} value={s.ITEM_OBJID}>{s.ITEM_NAME} ( {fmt(s.QTY)} {s.UNIT})</option>
))}
</select>
<SearchableSelect
options={trItemsInFrom.map((s) => ({ value: s.ITEM_OBJID, label: `${s.ITEM_NAME} (재고 ${fmt(s.QTY)} ${s.UNIT})` }))}
value={trItem}
onChange={setTrItem}
disabled={!trFrom}
placeholder={trFrom ? "선택" : "출발창고 먼저 선택"}
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-500 mb-1">
@@ -318,3 +488,108 @@ export default function InventoryPage() {
</div>
);
}
interface MoveRow {
OBJID: string;
MOVE_TYPE: string;
MOVE_TYPE_NAME?: string;
QTY: number;
REF_TYPE: string;
REF_TYPE_LABEL?: string;
REF_OBJID: string;
COUNTER_WH_NAME?: string | null;
MEMO: string | null;
REGID: string | null;
REGDATE: string;
}
const MOVE_LABEL: Record<string, string> = { IN: "입고", OUT: "출고", ADJ: "조정", TRANSFER: "이동" };
const MOVE_COLOR: Record<string, string> = {
IN: "bg-emerald-100 text-emerald-700",
OUT: "bg-rose-100 text-rose-700",
ADJ: "bg-amber-100 text-amber-700",
TRANSFER: "bg-blue-100 text-blue-700",
};
function StockHistoryModal({
itemObjid, whObjid, itemName, whName, onClose,
}: { itemObjid: string; whObjid: string; itemName: string; whName: string; onClose: () => void }) {
const [moves, setMoves] = useState<MoveRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
const res = await fetch("/api/m/inventory/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemObjid, whObjid }),
});
const j = await res.json();
if (!cancelled) {
setMoves(j.RESULTLIST ?? []);
setLoading(false);
}
})();
return () => { cancelled = true; };
}, [itemObjid, whObjid]);
return (
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-3" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
<div>
<h3 className="font-bold text-slate-900">{itemName} · </h3>
<p className="text-[11px] text-slate-500 mt-0.5">{whName}</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-700 p-1"></button>
</div>
<div className="overflow-y-auto p-3">
{loading ? (
<div className="text-center py-12 text-slate-400 text-sm"> ...</div>
) : moves.length === 0 ? (
<div className="text-center py-12 text-slate-400 text-sm"> .</div>
) : (
<table className="w-full text-xs">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-2 py-2"></th>
<th className="text-center px-2 py-2"></th>
<th className="text-right px-2 py-2"></th>
<th className="text-left px-2 py-2"></th>
<th className="text-left px-2 py-2"></th>
<th className="text-left px-2 py-2"></th>
</tr>
</thead>
<tbody>
{moves.map((m) => (
<tr key={m.OBJID} className="border-t border-slate-100">
<td className="px-2 py-2 whitespace-nowrap">{m.REGDATE}</td>
<td className="px-2 py-2 text-center">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${MOVE_COLOR[m.MOVE_TYPE] ?? "bg-slate-100 text-slate-600"}`}>
{MOVE_LABEL[m.MOVE_TYPE] ?? m.MOVE_TYPE}
</span>
</td>
<td className={`px-2 py-2 text-right tabular-nums font-bold ${Number(m.QTY) < 0 ? "text-rose-600" : "text-emerald-700"}`}>
{Number(m.QTY) > 0 ? "+" : ""}{fmt(m.QTY)}
</td>
<td className="px-2 py-2 text-[11px] text-slate-700">
{m.REF_TYPE_LABEL || m.REF_TYPE || "-"}
{m.REF_TYPE === "TRANSFER" && m.COUNTER_WH_NAME && (
<span className="ml-1 text-slate-500">
{m.MOVE_TYPE === "OUT" ? `${m.COUNTER_WH_NAME}` : `${m.COUNTER_WH_NAME}`}
</span>
)}
</td>
<td className="px-2 py-2 text-[11px] truncate max-w-[150px]">{m.MEMO || "-"}</td>
<td className="px-2 py-2 text-[11px] text-slate-500">{m.REGID || "-"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
+214 -54
View File
@@ -1,67 +1,217 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo, useCallback } from "react";
import Swal from "sweetalert2";
import { SearchableSelect } from "@/components/ui/searchable-select";
interface Order {
OBJID: string;
ORDER_NO: string;
ORDER_DATE: string;
COMPANY_NAME: string;
CUSTOMER_OBJID?: string;
STATUS: string;
TOTAL_AMOUNT: number;
TOTAL_TAXFREE?: number;
TOTAL_TAXABLE?: number;
TOTAL_VAT?: number;
INVOICE_NO: string | null;
INVOICE_DATE: string | null;
}
interface Customer { USER_ID: string; USER_NAME: string }
interface Order { OBJID: string; ORDER_NO: string; ORDER_DATE: string; COMPANY_NAME: string; STATUS: string; TOTAL_AMOUNT: number; INVOICE_NO: string | null; INVOICE_DATE: string | null }
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
const STATUS_LABEL: Record<string, string> = { APPROVED: "출고완료", PAID: "입금완료", INVOICED: "계산서발행" };
// 계산서 발행 대상 — 입금완료(PAID) 이후만. 출고완료(APPROVED)/출고요청 은 노출 안 함.
const STATUS_LABEL: Record<string, string> = {
PAID: "입금완료",
INVOICED: "계산서발행",
};
function defaultRange() {
const e = new Date(), s = new Date();
s.setDate(s.getDate() - 30);
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
}
export default function InvoicesPage() {
const [list, setList] = useState<Order[]>([]);
const [all, setAll] = useState<Order[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const load = async () => {
const res = await fetch("/api/m/orders/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) });
const all = ((await res.json()).RESULTLIST ?? []) as Order[];
setList(all.filter((o) => ["APPROVED", "PAID", "INVOICED"].includes(o.STATUS)));
};
useEffect(() => { load(); }, []);
const [[from, to], setRange] = useState(defaultRange());
const [customerFilter, setCustomerFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const loadCustomers = useCallback(async () => {
const res = await fetch("/api/m/customers/list", {
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
});
setCustomers((await res.json()).RESULTLIST ?? []);
}, []);
const loadAll = useCallback(async () => {
const res = await fetch("/api/m/orders/list", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}),
});
const rows = ((await res.json()).RESULTLIST ?? []) as Order[];
// 입금완료(PAID) + 계산서발행(INVOICED) 만 표시. 출고완료(APPROVED) 는 발행 대상 아님.
setAll(rows.filter((o) => ["PAID", "INVOICED"].includes(o.STATUS)));
}, []);
useEffect(() => { loadAll(); loadCustomers(); }, [loadAll, loadCustomers]);
// 클라이언트 사이드 필터 — 입력하면 즉시 반영 (조회 버튼 불필요)
const list = useMemo(() => {
return all.filter((o) => {
if (from && o.ORDER_DATE && o.ORDER_DATE < from) return false;
if (to && o.ORDER_DATE && o.ORDER_DATE > to) return false;
if (customerFilter && o.CUSTOMER_OBJID !== customerFilter) return false;
if (statusFilter && o.STATUS !== statusFilter) return false;
return true;
});
}, [all, from, to, customerFilter, statusFilter]);
// 선택 합산
const selectedSum = useMemo(() => {
let taxFree = 0, taxable = 0, vat = 0, total = 0;
for (const o of list) {
if (!selected.has(o.OBJID)) continue;
taxFree += Number(o.TOTAL_TAXFREE) || 0;
taxable += Number(o.TOTAL_TAXABLE) || 0;
vat += Number(o.TOTAL_VAT) || 0;
total += Number(o.TOTAL_AMOUNT) || 0;
}
return { taxFree, taxable, vat, total, count: [...selected].filter((id) => list.some((o) => o.OBJID === id)).length };
}, [list, selected]);
// 전체 합산 (필터 적용된 list)
const listSum = useMemo(() => {
let taxFree = 0, taxable = 0, vat = 0, total = 0;
for (const o of list) {
taxFree += Number(o.TOTAL_TAXFREE) || 0;
taxable += Number(o.TOTAL_TAXABLE) || 0;
vat += Number(o.TOTAL_VAT) || 0;
total += Number(o.TOTAL_AMOUNT) || 0;
}
return { taxFree, taxable, vat, total };
}, [list]);
const issue = async () => {
const targets = list.filter((o) => selected.has(o.OBJID) && !o.INVOICE_NO);
if (targets.length === 0) return Swal.fire({ icon: "warning", title: "발행 대상 없음", text: "이미 발행된 건은 제외됩니다." });
// 발행 가능 = 선택됨 + 입금완료(PAID) + 아직 미발행 (INVOICE_NO 없음)
const targets = list.filter((o) => selected.has(o.OBJID) && o.STATUS === "PAID" && !o.INVOICE_NO);
if (targets.length === 0) return Swal.fire({ icon: "warning", title: "발행 대상 없음", text: "입금완료된 미발행 건만 발행할 수 있습니다." });
const sum = targets.reduce((a, o) => a + Number(o.TOTAL_AMOUNT), 0);
const r = await Swal.fire({
icon: "question", title: `계산서 ${targets.length}건 발행`,
text: `합계 ₩${fmt(targets.reduce((a, o) => a + Number(o.TOTAL_AMOUNT), 0))}`,
showCancelButton: true, confirmButtonText: "발행", confirmButtonColor: "#0f766e",
icon: "question",
title: `계산서 ${targets.length}건 발행`,
text: `합계 ₩${fmt(sum)}`,
showCancelButton: true,
confirmButtonText: "발행",
confirmButtonColor: "#0f766e",
});
if (!r.isConfirmed) return;
const res = await fetch("/api/m/orders/invoice", {
method: "POST", headers: { "Content-Type": "application/json" },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objids: targets.map((o) => o.OBJID) }),
});
if ((await res.json()).success) {
Swal.fire({ icon: "success", title: "계산서 발행 완료", timer: 1500, showConfirmButton: false });
setSelected(new Set()); load();
setSelected(new Set());
loadAll();
}
};
const toggle = (id: string) => {
const s = new Set(selected);
s.has(id) ? s.delete(id) : s.add(id);
if (s.has(id)) s.delete(id);
else s.add(id);
setSelected(s);
};
const toggleAll = () => {
const issuable = list.filter((o) => !o.INVOICE_NO).map((o) => o.OBJID);
if (issuable.every((id) => selected.has(id))) {
// 모두 선택돼있으면 해제
const s = new Set(selected);
issuable.forEach((id) => s.delete(id));
setSelected(s);
} else {
setSelected(new Set([...selected, ...issuable]));
}
};
const unissued = list.filter((o) => !o.INVOICE_NO).length;
const selectedTotal = list.filter((o) => selected.has(o.OBJID)).reduce((a, o) => a + Number(o.TOTAL_AMOUNT), 0);
return (
<div className="space-y-4">
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-xl sm:text-2xl font-bold"> </h1>
<p className="text-xs sm:text-sm text-slate-500 mt-1"> {unissued} · {selected.size} ({fmt(selectedTotal)})</p>
<p className="text-xs sm:text-sm text-slate-500 mt-1">
{list.length} ( {unissued}) · {selectedSum.count}
</p>
</div>
<button
onClick={issue}
disabled={selected.size === 0}
disabled={selectedSum.count === 0}
className="h-10 px-3 sm:px-4 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold disabled:opacity-50 hover:bg-emerald-800"
>
{selected.size}
{selectedSum.count}
</button>
</div>
{/* 조회 조건 — 입력 즉시 필터 적용 (조회 버튼 없음) */}
<div className="bg-white border border-slate-200 rounded-xl p-3 flex flex-wrap gap-2 items-center text-xs">
<span className="text-slate-500 font-semibold mr-1"></span>
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])}
className="h-9 px-2 rounded border border-slate-200" />
<span className="text-slate-400">~</span>
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])}
className="h-9 px-2 rounded border border-slate-200" />
<div className="min-w-[200px]">
<SearchableSelect
options={[{ value: "", label: "전체 거래처" }, ...customers.map((c) => ({ value: c.USER_ID, label: c.USER_NAME }))]}
value={customerFilter}
onChange={setCustomerFilter}
placeholder="거래처"
/>
</div>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-2 rounded border border-slate-200 bg-white">
<option value=""> </option>
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
{(customerFilter || statusFilter) && (
<button
onClick={() => { setCustomerFilter(""); setStatusFilter(""); setRange(defaultRange()); }}
className="h-9 px-3 rounded border border-slate-200 text-slate-600 text-xs"
>
</button>
)}
</div>
{/* 합계 요약 — 필터 결과 + 선택 합산 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3">
<div className="text-[11px] text-slate-500 font-semibold mb-1.5"> ({list.length})</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div><div className="text-violet-600"></div><div className="font-bold tabular-nums">{fmt(listSum.taxFree)}</div></div>
<div><div className="text-rose-600"></div><div className="font-bold tabular-nums">{fmt(listSum.taxable + listSum.vat)}</div></div>
<div><div className="text-slate-700"></div><div className="font-bold tabular-nums text-emerald-700">{fmt(listSum.total)}</div></div>
</div>
</div>
<div className="bg-emerald-50 border border-emerald-300 rounded-lg p-3">
<div className="text-[11px] text-emerald-700 font-semibold mb-1.5"> ({selectedSum.count})</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div><div className="text-violet-700"></div><div className="font-bold tabular-nums">{fmt(selectedSum.taxFree)}</div></div>
<div><div className="text-rose-700"></div><div className="font-bold tabular-nums">{fmt(selectedSum.taxable + selectedSum.vat)}</div></div>
<div><div className="text-slate-800"></div><div className="font-bold tabular-nums text-emerald-800">{fmt(selectedSum.total)}</div></div>
</div>
</div>
</div>
{/* 모바일: 카드 리스트 */}
<div className="space-y-2 sm:hidden">
{list.length === 0 ? (
@@ -87,25 +237,24 @@ export default function InvoicesPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1.5">
<div className="min-w-0">
<div className="font-semibold text-sm truncate">{o.ORDER_NO}</div>
<div className="text-[11px] text-slate-500 truncate">{o.ORDER_DATE} · {o.COMPANY_NAME}</div>
<div className="font-bold text-base truncate">{o.COMPANY_NAME}</div>
<div className="text-[11px] text-slate-500 flex items-center gap-1.5">
<span>{o.ORDER_DATE}</span>
<span className="text-slate-300">·</span>
<span className="font-semibold text-slate-600">{o.ORDER_NO}</span>
</div>
</div>
<span className={`shrink-0 px-2 py-0.5 rounded text-[10px] font-semibold ${o.STATUS === "INVOICED" ? "bg-violet-100 text-violet-700" : "bg-amber-100 text-amber-700"}`}>
{STATUS_LABEL[o.STATUS]}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div>
<div className="text-slate-400"></div>
<div className="font-bold tabular-nums">{fmt(o.TOTAL_AMOUNT)}</div>
</div>
<div>
<div className="text-slate-400"></div>
<div className="font-mono text-[11px] truncate">{o.INVOICE_NO || <span className="text-slate-300"></span>}</div>
</div>
<div className="grid grid-cols-3 gap-2 text-[11px]">
<div><div className="text-slate-400"></div><div className="tabular-nums">{fmt(o.TOTAL_TAXFREE ?? 0)}</div></div>
<div><div className="text-slate-400"></div><div className="tabular-nums">{fmt((Number(o.TOTAL_TAXABLE) || 0) + (Number(o.TOTAL_VAT) || 0))}</div></div>
<div><div className="text-slate-400"></div><div className="font-bold tabular-nums">{fmt(o.TOTAL_AMOUNT)}</div></div>
</div>
{o.INVOICE_DATE && (
<div className="text-[10px] text-slate-500 mt-1"> {o.INVOICE_DATE}</div>
{o.INVOICE_NO && (
<div className="text-[10px] text-slate-500 mt-1"> {o.INVOICE_NO} {o.INVOICE_DATE && `· ${o.INVOICE_DATE}`}</div>
)}
</div>
</div>
@@ -116,38 +265,49 @@ export default function InvoicesPage() {
{/* 데스크탑: 표 */}
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[800px]">
<table className="w-full text-sm min-w-[900px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="px-3 py-3 w-10"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-center px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="px-3 py-3 w-10">
<input
type="checkbox"
className="w-4 h-4 accent-emerald-600"
checked={unissued > 0 && list.filter((o) => !o.INVOICE_NO).every((o) => selected.has(o.OBJID))}
onChange={toggleAll}
/>
</th>
<th className="text-left px-4 py-1.5"></th>
<th className="text-left px-4 py-1.5"></th>
<th className="text-left px-4 py-1.5"></th>
<th className="text-right px-4 py-1.5"></th>
<th className="text-right px-4 py-1.5"></th>
<th className="text-right px-4 py-1.5"></th>
<th className="text-center px-4 py-1.5"></th>
<th className="text-left px-4 py-1.5"></th>
<th className="text-left px-4 py-1.5"></th>
</tr>
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={8} className="text-center py-12 text-slate-400"> .</td></tr>
<tr><td colSpan={10} className="text-center py-12 text-slate-400"> .</td></tr>
) : list.map((o) => (
<tr key={o.OBJID} className="border-t border-slate-100">
<tr key={o.OBJID} className={`border-t border-slate-100 ${selected.has(o.OBJID) ? "bg-emerald-50/40" : ""}`}>
<td className="px-3 py-3 text-center">
{!o.INVOICE_NO && <input type="checkbox" checked={selected.has(o.OBJID)} onChange={() => toggle(o.OBJID)} className="w-4 h-4 accent-emerald-600" />}
</td>
<td className="px-4 py-3 font-semibold">{o.ORDER_NO}</td>
<td className="px-4 py-3">{o.ORDER_DATE}</td>
<td className="px-4 py-3">{o.COMPANY_NAME}</td>
<td className="px-4 py-3 text-right tabular-nums font-bold">{fmt(o.TOTAL_AMOUNT)}</td>
<td className="px-4 py-3 text-center">
<td className="px-3 py-1.5 font-semibold">{o.ORDER_NO}</td>
<td className="px-4 py-1.5">{o.ORDER_DATE}</td>
<td className="px-4 py-1.5">{o.COMPANY_NAME}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-violet-700">{fmt(o.TOTAL_TAXFREE ?? 0)}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-rose-700">{fmt((Number(o.TOTAL_TAXABLE) || 0) + (Number(o.TOTAL_VAT) || 0))}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-bold text-emerald-700">{fmt(o.TOTAL_AMOUNT)}</td>
<td className="px-3 py-1.5 text-center">
<span className={`px-2 py-1 rounded text-xs font-semibold ${o.STATUS === "INVOICED" ? "bg-violet-100 text-violet-700" : "bg-amber-100 text-amber-700"}`}>
{STATUS_LABEL[o.STATUS]}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs">{o.INVOICE_NO || "-"}</td>
<td className="px-4 py-3 text-xs text-slate-500">{o.INVOICE_DATE || "-"}</td>
<td className="px-3 py-1.5 font-mono text-xs">{o.INVOICE_NO || "-"}</td>
<td className="px-3 py-1.5 text-xs text-slate-500">{o.INVOICE_DATE || "-"}</td>
</tr>
))}
</tbody>
+246 -39
View File
@@ -19,10 +19,14 @@ interface Item {
STOCK_QTY: number;
ATTRIBUTES: Record<string, unknown> | null;
MAX_ORDER_QTY: number | null;
LIMIT_QTY: number | null;
IS_HIDDEN: string;
REQUIRES_DELIVERY: string;
VENDOR_OBJID?: string;
VENDOR_NAME?: string;
SALE_START_DATE?: string | null;
SALE_END_DATE?: string | null;
IS_ALWAYS_SALE?: string;
}
interface Vendor { OBJID: string; VENDOR_NAME: string }
@@ -38,21 +42,40 @@ interface ItemAttributes {
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
// API 응답 "YYYY-MM-DD HH:MM" → datetime-local 입력값 "YYYY-MM-DDTHH:MM"
const toLocal = (s?: string | null) => (s ? String(s).replace(" ", "T").slice(0, 16) : "");
export default function AdminItemsPage() {
const [items, setItems] = useState<Item[]>([]);
const [vendors, setVendors] = useState<Vendor[]>([]);
const [keyword, setKeyword] = useState("");
const [filterStatus, setFilterStatus] = useState("");
const [filterVendor, setFilterVendor] = useState("");
const [editing, setEditing] = useState<Partial<Item> | null>(null);
const [attrs, setAttrs] = useState<ItemAttributes>({});
const [uploading, setUploading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const fileRef = useRef<HTMLInputElement>(null);
const toggleSel = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelAll = () => {
setSelectedIds((prev) => {
if (prev.size === items.length) return new Set();
return new Set(items.map((it) => it.OBJID));
});
};
const loadItems = async () => {
const res = await fetch("/api/m/items/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keyword, status: filterStatus || undefined }),
body: JSON.stringify({ keyword, vendorObjid: filterVendor || undefined }),
});
setItems((await res.json()).RESULTLIST ?? []);
};
@@ -80,7 +103,7 @@ export default function AdminItemsPage() {
};
const openNew = () => {
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null, REQUIRES_DELIVERY: "N" });
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null, LIMIT_QTY: null, REQUIRES_DELIVERY: "N" });
setAttrs({});
};
@@ -101,9 +124,13 @@ export default function AdminItemsPage() {
status: editing.STATUS || "ACTIVE",
attributes: Object.keys(attrs).length > 0 ? attrs : null,
maxOrderQty: editing.MAX_ORDER_QTY ?? null,
limitQty: editing.LIMIT_QTY ?? null,
isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N",
requiresDelivery: editing.REQUIRES_DELIVERY === "Y" ? "Y" : "N",
vendorObjid: editing.VENDOR_OBJID || null,
saleStartDate: editing.SALE_START_DATE || null,
saleEndDate: editing.SALE_END_DATE || null,
isAlwaysSale: editing.IS_ALWAYS_SALE === "Y" ? "Y" : "N",
};
const res = await fetch("/api/m/items/save", {
method: "POST",
@@ -159,6 +186,10 @@ export default function AdminItemsPage() {
return (
<div className="space-y-4">
<BulkSaleRangeBar
selectedIds={selectedIds}
onApplied={() => { setSelectedIds(new Set()); loadItems(); }}
/>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-800"> </h1>
<button
@@ -180,15 +211,14 @@ export default function AdminItemsPage() {
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</div>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none bg-white"
>
<option value=""> </option>
<option value="ACTIVE"></option>
<option value="INACTIVE"></option>
</select>
<div className="min-w-[200px]">
<SearchableSelect
options={[{ value: "", label: "전체 공급업체" }, ...vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))]}
value={filterVendor}
onChange={setFilterVendor}
placeholder="공급업체"
/>
</div>
<button
onClick={loadItems}
className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"
@@ -244,27 +274,44 @@ export default function AdminItemsPage() {
<table className="w-full text-sm min-w-[900px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="px-2 py-1.5 w-8">
<input
type="checkbox"
checked={items.length > 0 && selectedIds.size === items.length}
onChange={toggleSelAll}
className="cursor-pointer"
/>
</th>
<th className="px-3 py-3 w-14"></th>
<th className="text-left px-3 py-3"></th>
<th className="text-left px-3 py-3"></th>
<th className="text-center px-3 py-3"></th>
<th className="text-right px-3 py-3"></th>
<th className="text-right px-3 py-3"></th>
<th className="text-right px-3 py-3"></th>
<th className="text-center px-3 py-3"></th>
<th className="text-right px-3 py-3 w-[70px]"></th>
<th className="text-left px-3 py-1.5"></th>
<th className="text-left px-3 py-1.5"></th>
<th className="text-center px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5"></th>
<th className="text-center px-3 py-1.5 whitespace-nowrap"></th>
<th className="text-center px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5 w-[80px] whitespace-nowrap"></th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={9} className="text-center py-12 text-slate-400">
<td colSpan={11} className="text-center py-12 text-slate-400">
. .
</td>
</tr>
) : (
items.map((it) => (
<tr key={it.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-2 py-2 text-center">
<input
type="checkbox"
checked={selectedIds.has(it.OBJID)}
onChange={() => toggleSel(it.OBJID)}
className="cursor-pointer"
/>
</td>
<td className="px-3 py-2">
<div className="w-10 h-10 bg-slate-50 rounded overflow-hidden flex items-center justify-center">
{it.IMAGE_URL ? (
@@ -275,7 +322,7 @@ export default function AdminItemsPage() {
</div>
</td>
<td className="px-3 py-2 font-mono text-xs text-slate-500">{it.ITEM_CODE}</td>
<td className="px-3 py-2 font-semibold text-slate-800">{it.ITEM_NAME}</td>
<td className="px-3 py-2 font-semibold text-slate-800 max-w-[260px] truncate" title={it.ITEM_NAME}>{it.ITEM_NAME}</td>
<td className="px-3 py-2 text-center">
{it.IS_TAX_FREE === "Y" ? (
<span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span>
@@ -290,12 +337,18 @@ export default function AdminItemsPage() {
{fmt(it.STOCK_QTY)}
</span>
</td>
<td className="px-3 py-2 text-center text-[11px] tabular-nums whitespace-nowrap">
{it.IS_ALWAYS_SALE === "Y" ? (
<span className="inline-block px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 font-bold"></span>
) : (it.SALE_START_DATE || it.SALE_END_DATE) ? (
<span className="text-slate-600">{it.SALE_START_DATE ?? "—"} <span className="text-slate-300">~</span> {it.SALE_END_DATE ?? "—"}</span>
) : (
<span className="inline-block px-2 py-0.5 rounded bg-rose-100 text-rose-600 font-bold"></span>
)}
</td>
<td className="px-3 py-2 text-center">
<span className={`text-[10px] px-1.5 py-0.5 rounded ${it.STATUS === "ACTIVE" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-500"}`}>
{it.STATUS === "ACTIVE" ? "사용" : "중지"}
</span>
{it.IS_HIDDEN === "Y" && (
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold"></span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold"></span>
)}
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 font-bold">{it.MAX_ORDER_QTY}</span>
@@ -304,11 +357,11 @@ export default function AdminItemsPage() {
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold"></span>
)}
</td>
<td className="px-3 py-2 text-right">
<button onClick={() => openEdit(it)} className="text-slate-400 hover:text-emerald-700 p-1">
<td className="px-3 py-2 text-right whitespace-nowrap">
<button onClick={() => openEdit(it)} className="text-slate-400 hover:text-emerald-700 p-1" title="수정">
<Pencil size={14} />
</button>
<button onClick={() => onDelete(it.OBJID)} className="text-slate-400 hover:text-rose-600 p-1 ml-1">
<button onClick={() => onDelete(it.OBJID)} className="text-slate-400 hover:text-rose-600 p-1 ml-1" title="삭제">
<Trash2 size={14} />
</button>
</td>
@@ -408,16 +461,6 @@ export default function AdminItemsPage() {
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm tabular-nums text-right focus:border-emerald-500 outline-none"
/>
</Field>
<Field label="상태">
<select
value={editing.STATUS ?? "ACTIVE"}
onChange={(e) => setEditing({ ...editing, STATUS: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white focus:border-emerald-500 outline-none"
>
<option value="ACTIVE"></option>
<option value="INACTIVE"></option>
</select>
</Field>
<Field label="발주 제한수량 (1회 최대)">
<input
type="number" min={0}
@@ -430,6 +473,22 @@ export default function AdminItemsPage() {
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</Field>
<Field label="한정 수량 (이번 마감 사이클 누적 상한)">
<input
type="number" min={0}
value={editing.LIMIT_QTY ?? ""}
onChange={(e) => {
const v = e.target.value;
setEditing({ ...editing, LIMIT_QTY: v === "" ? null : Number(v) });
}}
placeholder="공란 = 제한 없음"
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
<p className="text-[10px] text-slate-400 mt-1 leading-tight">
+ .<br/>
마감: 저번주 ~ , 마감: 저번주 ~ .
</p>
</Field>
<Field label="숨김 처리">
<div className="flex gap-2 h-10">
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
@@ -470,6 +529,44 @@ export default function AdminItemsPage() {
</label>
</div>
</Field>
<div className="sm:col-span-2">
<Field label="상시 판매">
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={editing.IS_ALWAYS_SALE === "Y"}
onChange={(e) => setEditing({
...editing,
IS_ALWAYS_SALE: e.target.checked ? "Y" : "N",
// 상시 체크 시 날짜 비우기 (요청 정책)
SALE_START_DATE: e.target.checked ? null : editing.SALE_START_DATE,
SALE_END_DATE: e.target.checked ? null : editing.SALE_END_DATE,
})}
className="w-4 h-4 accent-emerald-600"
/>
<span className="text-sm"> ( )</span>
</label>
<p className="text-[11px] text-slate-500 mt-1"> + .</p>
</Field>
</div>
<Field label="판매 시작일시 (분 단위)">
<input
type="datetime-local"
value={toLocal(editing.SALE_START_DATE)}
disabled={editing.IS_ALWAYS_SALE === "Y"}
onChange={(e) => setEditing({ ...editing, SALE_START_DATE: e.target.value || null })}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none disabled:bg-slate-100 disabled:text-slate-400"
/>
</Field>
<Field label="판매 종료일시 (분 단위)">
<input
type="datetime-local"
value={toLocal(editing.SALE_END_DATE)}
disabled={editing.IS_ALWAYS_SALE === "Y"}
onChange={(e) => setEditing({ ...editing, SALE_END_DATE: e.target.value || null })}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none disabled:bg-slate-100 disabled:text-slate-400"
/>
</Field>
<div className="sm:col-span-2">
<Field label="상세 설명">
<textarea
@@ -604,3 +701,113 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
</div>
);
}
function BulkSaleRangeBar({
selectedIds,
onApplied,
}: {
selectedIds: Set<string>;
onApplied: () => void;
}) {
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [busy, setBusy] = useState(false);
const apply = async (mode: "apply" | "clear" | "always") => {
if (selectedIds.size === 0) {
Swal.fire({ icon: "warning", title: "품목을 선택하세요" });
return;
}
if (mode === "apply" && !from && !to) {
Swal.fire({ icon: "warning", title: "시작일 또는 종료일을 입력하세요" });
return;
}
setBusy(true);
try {
const res = await fetch("/api/m/items/bulk-sale-range", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
objids: Array.from(selectedIds),
saleStartDate: from || null,
saleEndDate: to || null,
clear: mode === "clear",
alwaysSale: mode === "always",
}),
});
const j = await res.json();
if (j.success) {
const title = mode === "clear" ? "미노출 처리됨"
: mode === "always" ? "상시 판매로 설정됨"
: "판매기간 일괄 적용 완료";
Swal.fire({
icon: "success",
title,
text: `${j.count}개 품목 적용`,
timer: 1500,
showConfirmButton: false,
});
setFrom("");
setTo("");
onApplied();
} else {
Swal.fire({ icon: "error", title: "실패", text: j.message });
}
} finally {
setBusy(false);
}
};
return (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg px-4 py-3 flex flex-wrap items-end gap-3">
<div>
<label className="block text-[11px] font-semibold text-emerald-800 mb-1"> </label>
<input
type="datetime-local"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="h-9 px-2 rounded-md border border-emerald-300 text-sm bg-white"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-emerald-800 mb-1"> </label>
<input
type="datetime-local"
value={to}
onChange={(e) => setTo(e.target.value)}
className="h-9 px-2 rounded-md border border-emerald-300 text-sm bg-white"
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
disabled={busy}
onClick={() => apply("apply")}
className="h-9 px-3 rounded-md bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-50"
>
{selectedIds.size}
</button>
<button
type="button"
disabled={busy}
onClick={() => apply("always")}
className="h-9 px-3 rounded-md bg-emerald-600 text-white text-sm font-bold hover:bg-emerald-700 disabled:opacity-50"
title="선택한 품목을 상시 판매로 설정 (날짜는 모두 초기화)"
>
</button>
<button
type="button"
disabled={busy}
onClick={() => apply("clear")}
className="h-9 px-3 rounded-md bg-white border border-emerald-300 text-emerald-700 text-sm font-semibold hover:bg-emerald-100 disabled:opacity-50"
>
</button>
</div>
<div className="text-[11px] text-emerald-700/80 ml-auto">
· <b></b>= ( ) · <b></b>=( )
</div>
</div>
);
}
-265
View File
@@ -1,265 +0,0 @@
"use client";
import { useEffect, useState, FormEvent } from "react";
import { Plus, Search, Pencil, Trash2, Factory } from "lucide-react";
import Swal from "sweetalert2";
interface Maker {
OBJID: string;
MAKER_NAME: string;
CONTACT: string;
PHONE: string;
MEMO: string;
REGDATE: string;
}
export default function AdminMakersPage() {
const [makers, setMakers] = useState<Maker[]>([]);
const [keyword, setKeyword] = useState("");
const [editing, setEditing] = useState<Partial<Maker> | null>(null);
const [saving, setSaving] = useState(false);
const load = async () => {
const res = await fetch("/api/m/makers/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keyword }),
});
setMakers((await res.json()).RESULTLIST ?? []);
};
useEffect(() => {
load();
}, []); // eslint-disable-line
const onSave = async (e: FormEvent) => {
e.preventDefault();
if (!editing) return;
setSaving(true);
try {
const isNew = !editing.OBJID;
const res = await fetch("/api/m/makers/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
objid: editing.OBJID,
actionType: isNew ? "regist" : "update",
makerName: editing.MAKER_NAME,
contact: editing.CONTACT,
phone: editing.PHONE,
memo: editing.MEMO,
}),
});
const j = await res.json();
if (j.success) {
Swal.fire({ icon: "success", title: j.message, timer: 1200, showConfirmButton: false });
setEditing(null);
load();
} else {
Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
}
} finally {
setSaving(false);
}
};
const onDelete = async (objid: string, name: string) => {
const ok = await Swal.fire({
icon: "warning",
title: `"${name}" 삭제`,
text: "삭제하시겠습니까?",
showCancelButton: true,
confirmButtonText: "삭제",
cancelButtonText: "취소",
confirmButtonColor: "#dc2626",
});
if (!ok.isConfirmed) return;
const res = await fetch("/api/m/makers/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objids: [objid] }),
});
const j = await res.json();
if (j.success) {
Swal.fire({ icon: "success", title: j.message, timer: 1200, showConfirmButton: false });
load();
} else {
Swal.fire({ icon: "error", title: "오류", text: j.message });
}
};
const set = (k: keyof Maker) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setEditing((prev) => prev ? { ...prev, [k]: e.target.value } : prev);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-slate-800"> </h1>
<p className="text-xs sm:text-sm text-slate-500 mt-1"> {makers.length}</p>
</div>
<button
onClick={() => setEditing({ MAKER_NAME: "", CONTACT: "", PHONE: "", MEMO: "" })}
className="h-10 px-3 sm:px-4 inline-flex items-center gap-1.5 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800"
>
<Plus size={16} />
</button>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && load()}
placeholder="제조사명 검색"
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</div>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold shrink-0"></button>
</div>
{/* 모바일: 카드 */}
<div className="grid grid-cols-1 sm:hidden gap-2">
{makers.length === 0 ? (
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center text-slate-400"> .</div>
) : makers.map((m) => (
<div key={m.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 shadow-sm">
<div className="flex items-start gap-2">
<Factory size={16} className="text-emerald-700 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-sm truncate">{m.MAKER_NAME}</span>
<div className="shrink-0 flex gap-1">
<button onClick={() => setEditing(m)} className="text-slate-400 hover:text-emerald-700 p-1.5"><Pencil size={14} /></button>
<button onClick={() => onDelete(m.OBJID, m.MAKER_NAME)} className="text-slate-400 hover:text-rose-600 p-1.5"><Trash2 size={14} /></button>
</div>
</div>
<div className="text-[11px] text-slate-600 space-y-0.5 mt-0.5">
<div>📞 {m.PHONE || "-"} {m.CONTACT && `· ${m.CONTACT}`}</div>
{m.MEMO && <div className="text-slate-500">📝 {m.MEMO}</div>}
<div className="text-slate-400 text-[10px]"> {m.REGDATE}</div>
</div>
</div>
</div>
</div>
))}
</div>
{/* 데스크탑: 표 */}
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[700px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-center px-4 py-3 w-[100px]"></th>
<th className="text-right px-4 py-3 w-[80px]"></th>
</tr>
</thead>
<tbody>
{makers.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-12 text-slate-400">
. .
</td>
</tr>
) : (
makers.map((m) => (
<tr key={m.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-4 py-3 font-semibold text-slate-800">{m.MAKER_NAME}</td>
<td className="px-4 py-3 text-slate-600">{m.CONTACT || "-"}</td>
<td className="px-4 py-3 text-slate-600">{m.PHONE || "-"}</td>
<td className="px-4 py-3 text-slate-500 text-xs">{m.MEMO || "-"}</td>
<td className="px-4 py-3 text-center text-slate-500 text-xs">{m.REGDATE}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => setEditing(m)} className="text-slate-400 hover:text-emerald-700 p-1"><Pencil size={14} /></button>
<button onClick={() => onDelete(m.OBJID, m.MAKER_NAME)} className="text-slate-400 hover:text-rose-600 p-1 ml-1"><Trash2 size={14} /></button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 등록/수정 모달 */}
{editing && (
<div
className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4"
onClick={() => setEditing(null)}
>
<form
onSubmit={onSave}
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6"
>
<h3 className="text-lg font-bold mb-5 text-slate-800">
{editing.OBJID ? "제조사 수정" : "제조사 등록"}
</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
<span className="text-rose-500">*</span>
</label>
<input
required
value={editing.MAKER_NAME ?? ""}
onChange={set("MAKER_NAME")}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5"></label>
<input
value={editing.CONTACT ?? ""}
onChange={set("CONTACT")}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5"></label>
<input
value={editing.PHONE ?? ""}
onChange={set("PHONE")}
placeholder="010-0000-0000"
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5"></label>
<textarea
rows={3}
value={editing.MEMO ?? ""}
onChange={set("MEMO")}
className="w-full px-3 py-2 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none resize-none"
/>
</div>
</div>
<div className="flex gap-2 justify-end mt-6 pt-4 border-t border-slate-100">
<button
type="button"
onClick={() => setEditing(null)}
className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold hover:bg-slate-50"
>
</button>
<button
type="submit"
disabled={saving}
className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-60"
>
{saving ? "저장 중..." : "저장"}
</button>
</div>
</form>
</div>
)}
</div>
);
}
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { History, Users, ExternalLink, ChevronDown, ChevronRight } from "lucide-react";
interface HistoryRow {
OBJID: string; TITLE: string; BODY: string | null;
IMAGE_URL: string | null; URL: string | null;
REGDATE: string; REGID: string;
SENT_COUNT: number; FAILED_COUNT: number;
RECIPIENT_COUNT: number;
RECIPIENT_USER_IDS: string[];
GROUP_NAMES: string[];
RECIPIENT_NAMES: string[];
}
export default function NoticeHistoryPage() {
const [list, setList] = useState<HistoryRow[]>([]);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const load = useCallback(async () => {
setLoading(true);
try {
const r = await fetch("/api/m/admin/notice-history/list", { method: "POST" });
setList((await r.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const toggle = (id: string) => {
setExpanded((p) => {
const n = new Set(p);
if (n.has(id)) n.delete(id); else n.add(id);
return n;
});
};
return (
<div className="space-y-3">
<div>
<h1 className="text-xl sm:text-2xl font-bold inline-flex items-center gap-2">
<History size={18} className="text-emerald-700" />
</h1>
<p className="text-xs text-slate-500 mt-0.5"> ··/ . · .</p>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
<span> {list.length}</span>
<button onClick={load} disabled={loading}
className="text-[11px] px-2 py-0.5 rounded border border-slate-300 bg-white hover:bg-slate-100 disabled:opacity-50">
</button>
</div>
<div className="divide-y divide-slate-100 max-h-[calc(100vh-220px)] overflow-y-auto">
{list.length === 0 ? (
<div className="text-center py-10 text-slate-400 text-sm">{loading ? "불러오는 중…" : "발송 이력이 없습니다."}</div>
) : list.map((n) => {
const open = expanded.has(n.OBJID);
return (
<div key={n.OBJID} className="px-3 py-2">
<button onClick={() => toggle(n.OBJID)} className="w-full flex items-center gap-3 text-left">
<span className="text-slate-400 shrink-0">{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}</span>
{n.IMAGE_URL && (
// eslint-disable-next-line @next/next/no-img-element
<img src={n.IMAGE_URL} alt="" className="w-10 h-10 rounded object-cover shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-bold truncate">{n.TITLE}</div>
<div className="text-[11px] text-slate-500 truncate flex items-center gap-1.5">
<span>{n.REGDATE}</span>
<span className="text-slate-300">·</span>
<span> <b className="text-slate-700">{n.RECIPIENT_COUNT < 0 ? "전체" : n.RECIPIENT_COUNT}</b></span>
<span className="text-slate-300">·</span>
<span className="text-emerald-700"> {n.SENT_COUNT}</span>
{n.FAILED_COUNT > 0 && <><span className="text-slate-300">/</span><span className="text-rose-600"> {n.FAILED_COUNT}</span></>}
{n.GROUP_NAMES && n.GROUP_NAMES.length > 0 && (
<>
<span className="text-slate-300">·</span>
<span className="text-violet-700 inline-flex items-center gap-0.5"><Users size={11} />{n.GROUP_NAMES.join(", ")}</span>
</>
)}
</div>
</div>
<a href={`/m/notices/${n.OBJID}`} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}
className="shrink-0 text-slate-400 hover:text-emerald-700 inline-flex items-center gap-0.5 text-[11px]" title="공지 페이지 열기">
<ExternalLink size={11} />
</a>
</button>
{open && (
<div className="mt-2 pl-7 space-y-2">
{n.BODY && (
// 본문은 Tiptap 에디터의 HTML (이미지/서식 포함) — 관리자만 작성 가능해 XSS 위험 낮음
<div
className="bg-slate-50 rounded p-3 text-xs leading-relaxed text-slate-700 border border-slate-100 notice-body-html [&_img]:max-w-full [&_img]:rounded [&_img]:my-1 [&_p]:my-1 [&_h2]:text-sm [&_h2]:font-bold [&_h2]:my-1 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_a]:text-emerald-700 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 [&_blockquote]:pl-2 [&_blockquote]:text-slate-500"
dangerouslySetInnerHTML={{ __html: n.BODY }}
/>
)}
<div>
<div className="text-[11px] font-bold text-slate-600 mb-1"> {n.RECIPIENT_NAMES?.length ?? 0}</div>
<div className="flex flex-wrap gap-1">
{n.RECIPIENT_COUNT < 0 ? (
<span className="text-[11px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold"> </span>
) : n.RECIPIENT_NAMES?.length === 0 ? (
<span className="text-[11px] text-slate-400"> ()</span>
) : (
n.RECIPIENT_NAMES.map((nm, i) => (
<span key={i} className="text-[11px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-700">
{nm || n.RECIPIENT_USER_IDS?.[i]}
</span>
))
)}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
+462
View File
@@ -0,0 +1,462 @@
"use client";
// 푸시알림 게시판 — 권한 관리 화면(admin-panel/AuthManagement)과 동일한 3-패널 패턴.
// 좌측 : 수신자 그룹 목록 [+ 생성]
// 우측 상단 : 그룹 멤버 / [추가/제거] / 전체 사용자 풀 ← 권한있는/권한없는 직원
// 우측 하단 : 컨텐츠(제목/리치 본문/외부링크) + 발송
// - 본문은 Tiptap 리치 에디터 (이미지 복붙/드래그 자동 업로드, 볼드/리스트/링크 등)
// 발송이력은 별도 메뉴(/m/admin/notice-history)로 분리됨 — 본 화면에서 제거.
import { useEffect, useState, useCallback, useMemo } from "react";
import Swal from "sweetalert2";
import { Send, X, Bell, Users, Shield, Upload } from "lucide-react";
import { RichEditor } from "@/components/rich-editor";
interface Group {
OBJID: string; NAME: string; DESCRIPTION: string | null;
REGDATE: string; MEMBER_CNT: number; MEMBER_IDS: string[];
}
interface AllUser {
USER_ID: string;
USER_NAME: string;
USER_TYPE: string;
IS_ADMIN: boolean;
DEPT_NAME: string;
SUBSCRIBED: boolean;
}
export default function AdminNoticesPage() {
// ===== 좌측: 그룹 목록 =====
const [groups, setGroups] = useState<Group[]>([]);
const [groupQuery, setGroupQuery] = useState("");
const [activeGroup, setActiveGroup] = useState<Group | null>(null);
// ===== 우측 상단: 멤버 양쪽 패널 =====
const [allUsers, setAllUsers] = useState<AllUser[]>([]);
const [memberQ, setMemberQ] = useState("");
const [availQ, setAvailQ] = useState("");
const [chkMember, setChkMember] = useState<Set<string>>(new Set());
const [chkAvail, setChkAvail] = useState<Set<string>>(new Set());
// ===== 우측 하단: 작성 폼 =====
const [title, setTitle] = useState("");
const [bodyText, setBodyText] = useState("");
const [imageUrl, setImageUrl] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [uploading, setUploading] = useState(false);
const [sending, setSending] = useState(false);
// 그룹 외 추가 옵션 — 활성 그룹 없이 발송하고 싶을 때
const [sendAll, setSendAll] = useState(false);
const loadGroups = useCallback(async () => {
const r = await fetch("/api/m/admin/notices/groups/list", { method: "POST" });
const j = await r.json();
const list = (j.RESULTLIST ?? []) as Group[];
setGroups(list);
// 활성 그룹이 갱신되도록 sync
setActiveGroup((cur) => (cur ? (list.find((g) => g.OBJID === cur.OBJID) ?? null) : null));
}, []);
const loadAllUsers = useCallback(async () => {
const r = await fetch("/api/m/admin/notices/all-users", { method: "POST" });
setAllUsers((await r.json()).RESULTLIST ?? []);
}, []);
useEffect(() => { loadGroups(); loadAllUsers(); }, [loadGroups, loadAllUsers]);
// 활성 그룹이 바뀌면 체크박스 초기화
useEffect(() => { setChkMember(new Set()); setChkAvail(new Set()); }, [activeGroup?.OBJID]);
const filteredGroups = useMemo(() => groups.filter((g) =>
!groupQuery || g.NAME.toLowerCase().includes(groupQuery.toLowerCase()) || (g.DESCRIPTION ?? "").toLowerCase().includes(groupQuery.toLowerCase())
), [groups, groupQuery]);
// 활성 그룹 멤버 / 풀
const memberSet = useMemo(() => new Set(activeGroup?.MEMBER_IDS ?? []), [activeGroup]);
const members = useMemo(() => allUsers.filter((u) => memberSet.has(u.USER_ID)), [allUsers, memberSet]);
const available = useMemo(() => allUsers.filter((u) => !memberSet.has(u.USER_ID)), [allUsers, memberSet]);
const filteredMembers = useMemo(() => members.filter((u) => !memberQ || u.USER_NAME.includes(memberQ) || u.USER_ID.includes(memberQ) || (u.DEPT_NAME ?? "").includes(memberQ)), [members, memberQ]);
const filteredAvail = useMemo(() => available.filter((u) => !availQ || u.USER_NAME.includes(availQ) || u.USER_ID.includes(availQ) || (u.DEPT_NAME ?? "").includes(availQ)), [available, availQ]);
// ===== 그룹 생성/수정/삭제 (SweetAlert) =====
const onCreate = async () => {
const r = await Swal.fire({
title: "수신자 그룹 생성",
html: `
<input id="sw_name" class="swal2-input" placeholder="그룹명 (예: 본사 거래처)">
<input id="sw_desc" class="swal2-input" placeholder="설명 (선택)">
`,
showCancelButton: true, confirmButtonText: "생성", confirmButtonColor: "#0f766e",
preConfirm: () => ({
name: (document.getElementById("sw_name") as HTMLInputElement).value.trim(),
description: (document.getElementById("sw_desc") as HTMLInputElement).value.trim(),
}),
});
if (!r.isConfirmed || !r.value?.name) return;
const sv = await fetch("/api/m/admin/notices/groups/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: r.value.name, description: r.value.description }),
});
const sj = await sv.json();
if (!sj.success) { Swal.fire({ icon: "error", title: "생성 실패", text: sj.message }); return; }
await loadGroups();
// 새로 만든 그룹을 활성화
setActiveGroup({ OBJID: sj.objId, NAME: r.value.name, DESCRIPTION: r.value.description || null, REGDATE: "", MEMBER_CNT: 0, MEMBER_IDS: [] });
};
const onRename = async (g: Group) => {
const r = await Swal.fire({
title: "그룹 수정", icon: "info",
html: `
<input id="sw_name" class="swal2-input" value="${g.NAME.replace(/"/g, "&quot;")}">
<input id="sw_desc" class="swal2-input" placeholder="설명 (선택)" value="${(g.DESCRIPTION ?? "").replace(/"/g, "&quot;")}">
`,
showCancelButton: true, showDenyButton: true, denyButtonText: "삭제", confirmButtonText: "저장", confirmButtonColor: "#0f766e",
preConfirm: () => ({
name: (document.getElementById("sw_name") as HTMLInputElement).value.trim(),
description: (document.getElementById("sw_desc") as HTMLInputElement).value.trim(),
}),
});
if (r.isDenied) {
const ok = await Swal.fire({ icon: "warning", title: `"${g.NAME}" 삭제?`, showCancelButton: true, confirmButtonColor: "#dc2626", confirmButtonText: "삭제" });
if (!ok.isConfirmed) return;
const res = await fetch("/api/m/admin/notices/groups/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: g.OBJID, action: "delete" }),
});
if ((await res.json()).success) {
if (activeGroup?.OBJID === g.OBJID) setActiveGroup(null);
loadGroups();
}
return;
}
if (!r.isConfirmed || !r.value?.name) return;
const res = await fetch("/api/m/admin/notices/groups/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: g.OBJID, name: r.value.name, description: r.value.description }),
});
if ((await res.json()).success) loadGroups();
};
// ===== 멤버 추가/제거 =====
const saveMembers = async (newIds: string[]) => {
if (!activeGroup) return false;
const r = await fetch("/api/m/admin/notices/groups/members/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ groupObjid: activeGroup.OBJID, userIds: newIds }),
});
const j = await r.json();
if (!j.success) { Swal.fire({ icon: "error", title: "저장 실패", text: j.message }); return false; }
return true;
};
const addSelected = async () => {
if (!activeGroup) return Swal.fire({ icon: "warning", title: "그룹을 선택하세요" });
if (chkAvail.size === 0) return Swal.fire({ icon: "warning", title: "추가할 사용자를 선택하세요" });
const newIds = Array.from(new Set([...(activeGroup.MEMBER_IDS ?? []), ...Array.from(chkAvail)]));
if (await saveMembers(newIds)) { setChkAvail(new Set()); loadGroups(); }
};
const removeSelected = async () => {
if (!activeGroup) return Swal.fire({ icon: "warning", title: "그룹을 선택하세요" });
if (chkMember.size === 0) return Swal.fire({ icon: "warning", title: "제거할 사용자를 선택하세요" });
const newIds = (activeGroup.MEMBER_IDS ?? []).filter((id) => !chkMember.has(id));
if (await saveMembers(newIds)) { setChkMember(new Set()); loadGroups(); }
};
// ===== 이미지 업로드 =====
const onUpload = async (file: File) => {
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/m/items/upload-image", { method: "POST", body: fd });
const j = await r.json();
if (j.success) setImageUrl(j.url);
else Swal.fire({ icon: "error", title: "업로드 실패", text: j.message });
} finally { setUploading(false); }
};
// ===== 발송 =====
const targetUserIds = useMemo(() => sendAll ? [] : (activeGroup?.MEMBER_IDS ?? []), [sendAll, activeGroup]);
const targetCount = sendAll ? -1 : targetUserIds.length;
const send = async () => {
if (!title.trim()) return Swal.fire({ icon: "warning", title: "제목을 입력하세요" });
if (!sendAll && targetUserIds.length === 0) return Swal.fire({ icon: "warning", title: "그룹(또는 멤버)을 선택하거나 [전체 구독자] 옵션을 켜세요" });
const targetLabel = sendAll ? "전체 구독자" : `${activeGroup?.NAME} (${targetUserIds.length}명)`;
const ok = await Swal.fire({
icon: "question", title: `${targetLabel} 에게 발송`, text: `제목: ${title}`,
showCancelButton: true, confirmButtonText: "발송", cancelButtonText: "취소", confirmButtonColor: "#0f766e",
});
if (!ok.isConfirmed) return;
setSending(true);
try {
const sv = await fetch("/api/m/admin/notices/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body: bodyText, imageUrl, url: linkUrl }),
});
const svj = await sv.json();
if (!svj.success) { Swal.fire({ icon: "error", title: "저장 실패", text: svj.message }); return; }
const noticeObjid = svj.objId;
const groupNames = activeGroup && !sendAll ? [activeGroup.NAME] : [];
// 푸시 본문은 plain text — HTML 태그 제거 + 공백 압축
const plainBody = bodyText.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
// 첨부 이미지가 없으면 본문 HTML 의 첫 <img src=""> 추출해서 big picture 로 사용
const firstImgInBody = (() => {
const m = bodyText.match(/<img[^>]+src=["']([^"']+)["']/i);
return m ? m[1] : undefined;
})();
const sendRes = await fetch("/api/m/admin/notices/send-push", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
noticeObjid, title, message: plainBody,
url: linkUrl || `/m/notices/${noticeObjid}`,
userIds: sendAll ? undefined : targetUserIds,
sendAll,
groupNames,
imageUrl: imageUrl || firstImgInBody,
}),
});
const sj = await sendRes.json();
if (sj.success) {
await Swal.fire({ icon: "success", title: "발송 완료", text: `성공 ${sj.sent} · 실패 ${sj.failed}`, timer: 1800, showConfirmButton: false });
setTitle(""); setBodyText(""); setImageUrl(""); setLinkUrl(""); setSendAll(false);
} else {
Swal.fire({ icon: "error", title: "발송 실패", text: sj.message });
}
} finally { setSending(false); }
};
return (
<div className="space-y-3">
<div>
<h1 className="text-xl sm:text-2xl font-bold inline-flex items-center gap-2">
<Bell size={18} className="text-emerald-700" />
</h1>
<p className="text-xs text-slate-500 mt-0.5"> <b> </b> , / , [] . <b> </b> .</p>
</div>
{/* ===== 상단: 좌측 그룹 목록 + 우측 멤버 양쪽 패널 ===== */}
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-3">
{/* 좌측: 수신자 그룹 목록 */}
<div className="bg-white border border-slate-200 rounded-xl flex flex-col">
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between">
<div className="text-sm font-bold text-slate-700 inline-flex items-center gap-1.5">
<Shield size={14} className="text-emerald-700" /> ({groups.length})
</div>
<button onClick={onCreate} className="inline-flex items-center gap-1 h-7 px-2.5 rounded bg-emerald-600 text-white text-[11px] font-bold hover:bg-emerald-700">
<span>+ </span>
</button>
</div>
<div className="px-3 py-2 border-b border-slate-100">
<input value={groupQuery} onChange={(e) => setGroupQuery(e.target.value)}
placeholder="검색..." className="w-full h-8 px-2 text-xs border border-slate-200 rounded" />
</div>
<div className="flex-1 overflow-auto max-h-[40vh] lg:max-h-[calc(100vh-540px)] divide-y divide-slate-100">
{filteredGroups.length === 0 ? (
<div className="p-6 text-center text-xs text-slate-400"> .</div>
) : filteredGroups.map((g) => (
<button
key={g.OBJID}
onClick={() => setActiveGroup(g)}
onDoubleClick={() => onRename(g)}
className={`w-full text-left px-3 py-2 hover:bg-slate-50 transition-colors ${
activeGroup?.OBJID === g.OBJID ? "bg-emerald-50/70 border-l-4 border-l-emerald-600" : ""
}`}
title="더블클릭: 수정/삭제"
>
<div className="text-sm font-bold text-slate-800 truncate">{g.NAME}</div>
<div className="text-[10px] text-slate-500 truncate">
{g.MEMBER_CNT}{g.DESCRIPTION ? ` · ${g.DESCRIPTION}` : ""}
</div>
</button>
))}
</div>
</div>
{/* 우측: 멤버 양쪽 패널 */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-3">
{/* 그룹 멤버 (= 권한있는 직원) */}
<div className="bg-white border border-slate-200 rounded-xl flex flex-col">
<div className="px-3 py-2 border-b border-slate-100 text-sm font-bold text-slate-700 inline-flex items-center gap-1.5">
<Users size={14} className="text-emerald-700" /> ({members.length})
</div>
<div className="p-2 flex items-center gap-2 border-b border-slate-100">
<label className="text-xs inline-flex items-center gap-1.5">
<input type="checkbox"
checked={filteredMembers.length > 0 && filteredMembers.every((u) => chkMember.has(u.USER_ID))}
onChange={(e) => setChkMember(e.target.checked ? new Set(filteredMembers.map((u) => u.USER_ID)) : new Set())}
className="w-4 h-4 accent-emerald-600" />
</label>
<input value={memberQ} onChange={(e) => setMemberQ(e.target.value)}
placeholder="검색" className="ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" />
</div>
<div className="flex-1 overflow-auto max-h-[40vh]">
<table className="w-full text-xs">
<thead className="bg-slate-50 sticky top-0">
<tr><th className="w-8 p-1.5"></th><th className="text-left p-1.5"></th><th className="text-left p-1.5"></th><th className="text-left p-1.5">ID</th></tr>
</thead>
<tbody>
{filteredMembers.map((u) => (
<tr key={u.USER_ID} className="border-t hover:bg-slate-50">
<td className="text-center p-1.5">
<input type="checkbox" checked={chkMember.has(u.USER_ID)}
onChange={(e) => { const s = new Set(chkMember); if (e.target.checked) s.add(u.USER_ID); else s.delete(u.USER_ID); setChkMember(s); }}
className="w-4 h-4 accent-emerald-600" />
</td>
<td className="p-1.5">{u.DEPT_NAME || "-"}</td>
<td className="p-1.5 font-semibold">
{u.USER_NAME}
{u.IS_ADMIN && <span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-bold"></span>}
{u.SUBSCRIBED && <span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-emerald-100 text-emerald-700 font-bold"></span>}
</td>
<td className="p-1.5 text-slate-400 font-mono">{u.USER_ID}</td>
</tr>
))}
{filteredMembers.length === 0 && (
<tr><td colSpan={4} className="p-6 text-center text-slate-400">
{activeGroup ? "멤버가 없습니다. 오른쪽 풀에서 추가하세요." : "왼쪽에서 수신자 그룹을 선택하세요"}
</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* 추가/제거 버튼 */}
<div className="flex flex-row lg:flex-col items-center justify-center gap-3 px-2 lg:px-4 min-w-[120px]">
<button type="button" onClick={addSelected} disabled={!activeGroup}
className="h-12 w-full lg:w-32 rounded-lg bg-emerald-600 text-white text-sm font-bold hover:bg-emerald-700 active:bg-emerald-800 disabled:opacity-40 disabled:cursor-not-allowed shadow-sm transition-colors">
</button>
<button type="button" onClick={removeSelected} disabled={!activeGroup}
className="h-12 w-full lg:w-32 rounded-lg border border-slate-300 bg-white text-slate-700 text-sm font-bold hover:bg-rose-50 hover:border-rose-300 hover:text-rose-700 active:bg-rose-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
</button>
</div>
{/* 전체 사용자 풀 (= 권한없는 직원) */}
<div className="bg-white border border-slate-200 rounded-xl flex flex-col">
<div className="px-3 py-2 border-b border-slate-100 text-sm font-bold text-slate-700 inline-flex items-center gap-1.5">
<Users size={14} className="text-slate-400" /> ({available.length})
</div>
<div className="p-2 flex items-center gap-2 border-b border-slate-100">
<label className="text-xs inline-flex items-center gap-1.5">
<input type="checkbox"
checked={filteredAvail.length > 0 && filteredAvail.every((u) => chkAvail.has(u.USER_ID))}
onChange={(e) => setChkAvail(e.target.checked ? new Set(filteredAvail.map((u) => u.USER_ID)) : new Set())}
className="w-4 h-4 accent-emerald-600" />
</label>
<input value={availQ} onChange={(e) => setAvailQ(e.target.value)}
placeholder="검색" className="ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" />
</div>
<div className="flex-1 overflow-auto max-h-[40vh]">
<table className="w-full text-xs">
<thead className="bg-slate-50 sticky top-0">
<tr><th className="w-8 p-1.5"></th><th className="text-left p-1.5"></th><th className="text-left p-1.5"></th><th className="text-left p-1.5">ID</th></tr>
</thead>
<tbody>
{filteredAvail.map((u) => (
<tr key={u.USER_ID} className="border-t hover:bg-slate-50">
<td className="text-center p-1.5">
<input type="checkbox" checked={chkAvail.has(u.USER_ID)}
onChange={(e) => { const s = new Set(chkAvail); if (e.target.checked) s.add(u.USER_ID); else s.delete(u.USER_ID); setChkAvail(s); }}
className="w-4 h-4 accent-emerald-600" />
</td>
<td className="p-1.5">{u.DEPT_NAME || "-"}</td>
<td className="p-1.5 font-semibold">
{u.USER_NAME}
{u.IS_ADMIN && <span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-bold"></span>}
{u.SUBSCRIBED && <span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-emerald-100 text-emerald-700 font-bold"></span>}
</td>
<td className="p-1.5 text-slate-400 font-mono">{u.USER_ID}</td>
</tr>
))}
{filteredAvail.length === 0 && (
<tr><td colSpan={4} className="p-6 text-center text-slate-400">
{activeGroup ? "추가 가능한 사용자가 없습니다." : "왼쪽에서 수신자 그룹을 선택하세요"}
</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/* ===== 하단: 컨텐츠 작성 + 발송 ===== */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-sm font-bold text-slate-700 inline-flex items-center gap-1.5 mb-3">
<Send size={14} className="text-emerald-700" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-3">
<div>
<label className="text-xs font-semibold text-slate-600"> *</label>
<input value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="예: 오늘의 특가" maxLength={60}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm mt-1" />
<div className="text-[10px] text-slate-400 mt-0.5"> (60 )</div>
</div>
<div>
<label className="text-xs font-semibold text-slate-600"></label>
<div className="mt-1">
<RichEditor
value={bodyText}
onChange={setBodyText}
placeholder="알림 내용 + 공지 페이지 본문이 됩니다. 이미지는 그냥 복사해서 붙여넣으세요 (Ctrl+V)."
minHeight="200px"
/>
</div>
<div className="text-[10px] text-slate-400 mt-0.5"> 240() . · .</div>
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-semibold text-slate-600"> ()</label>
<div className="flex items-center gap-2 mt-1">
<label className="inline-flex items-center gap-1 h-9 px-3 rounded-lg border border-slate-300 bg-white text-xs font-semibold cursor-pointer hover:bg-slate-50">
<Upload size={14} />
<input type="file" accept="image/*" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onUpload(f); }} />
</label>
{imageUrl && (
<button type="button" onClick={() => setImageUrl("")} className="text-rose-500 hover:text-rose-700" title="이미지 제거"><X size={14} /></button>
)}
{uploading && <span className="text-xs text-slate-400"> </span>}
</div>
{imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={imageUrl} alt="첨부" className="mt-2 max-h-56 rounded border border-slate-200" />
)}
</div>
<div>
<label className="text-xs font-semibold text-slate-600"> ()</label>
<input value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://… (입력 시 푸시 탭하면 해당 URL 로 이동)"
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm mt-1" />
<div className="text-[10px] text-slate-400 mt-0.5"> (+) .</div>
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 pt-3 mt-3 border-t border-slate-100">
<div className="flex items-center gap-3 text-xs text-slate-600">
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={sendAll} onChange={(e) => setSendAll(e.target.checked)} className="w-4 h-4 accent-emerald-600" />
<span> ( )</span>
</label>
{!sendAll && (
<span>
: <b className="text-emerald-700">{activeGroup?.NAME ?? "(선택 없음)"}</b>
{activeGroup && <span className="ml-1 text-slate-500">· {targetUserIds.length}</span>}
</span>
)}
</div>
<button onClick={send} disabled={sending}
className="inline-flex items-center gap-2 px-5 h-11 rounded-xl bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-50">
<Send size={14} /> {sending ? "발송 중…" : sendAll ? "전체 구독자에게 발송" : `${targetCount < 0 ? "전체" : targetCount}명에게 발송`}
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+29 -23
View File
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from "react";
import { RefreshCcw } from "lucide-react";
import Swal from "sweetalert2";
import { makeSwalDraggable } from "@/lib/swal-draggable";
interface Order { OBJID: string; ORDER_NO: string; ORDER_DATE: string; COMPANY_NAME: string; STATUS: string; TOTAL_AMOUNT: number; PAID_AMOUNT: number }
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
@@ -49,8 +50,8 @@ export default function PaymentsPage() {
}
}, [dateFrom, dateTo, keyword, payFilter]);
// 최초 1회만 자동 로드. 검색 조건 변경은 [조회] 버튼으로
useEffect(() => { load(); }, []); // eslint-disable-line
// 검색 조건 변경 시 즉시 갱신
useEffect(() => { load(); }, [load]);
const onPay = async (o: Order) => {
const remain = Number(o.TOTAL_AMOUNT) - Number(o.PAID_AMOUNT || 0);
@@ -58,6 +59,7 @@ export default function PaymentsPage() {
title: `${o.COMPANY_NAME} 입금 등록`,
html: `미입금 ₩${fmt(remain)}`, input: "number", inputValue: remain,
showCancelButton: true, confirmButtonText: "입금 처리", confirmButtonColor: "#0f766e",
didOpen: () => makeSwalDraggable(),
});
if (!r.isConfirmed) return;
const amt = Number(r.value);
@@ -153,12 +155,16 @@ export default function PaymentsPage() {
const paid = o.STATUS === "PAID" || o.STATUS === "INVOICED";
return (
<div key={o.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 shadow-sm">
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="font-semibold text-sm">{o.ORDER_NO}</div>
<div className="text-[11px] text-slate-500">{o.ORDER_DATE} · {o.COMPANY_NAME}</div>
<div className="flex items-start justify-between gap-2 mb-1.5">
<div className="min-w-0">
<div className="font-bold text-base truncate">{o.COMPANY_NAME}</div>
<div className="text-[11px] text-slate-500 flex items-center gap-1.5">
<span>{o.ORDER_DATE}</span>
<span className="text-slate-300">·</span>
<span className="font-semibold text-slate-600">{o.ORDER_NO}</span>
</div>
</div>
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${paid ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"}`}>
<span className={`shrink-0 px-2 py-0.5 rounded text-[10px] font-semibold ${paid ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"}`}>
{STATUS_LABEL[o.STATUS]}
</span>
</div>
@@ -192,14 +198,14 @@ export default function PaymentsPage() {
<table className="w-full text-sm min-w-[800px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-center px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-left px-4 py-1.5"></th>
<th className="text-left px-4 py-1.5"></th>
<th className="text-left px-4 py-1.5"></th>
<th className="text-right px-4 py-1.5"></th>
<th className="text-right px-4 py-1.5"></th>
<th className="text-right px-4 py-1.5"></th>
<th className="text-center px-4 py-1.5"></th>
<th className="text-right px-4 py-1.5"></th>
</tr>
</thead>
<tbody>
@@ -209,18 +215,18 @@ export default function PaymentsPage() {
const remain = Number(o.TOTAL_AMOUNT) - Number(o.PAID_AMOUNT || 0);
return (
<tr key={o.OBJID} className="border-t border-slate-100">
<td className="px-4 py-3 font-semibold">{o.ORDER_NO}</td>
<td className="px-4 py-3">{o.ORDER_DATE}</td>
<td className="px-4 py-3">{o.COMPANY_NAME}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmt(o.TOTAL_AMOUNT)}</td>
<td className="px-4 py-3 text-right tabular-nums text-emerald-700">{fmt(o.PAID_AMOUNT || 0)}</td>
<td className={`px-4 py-3 text-right tabular-nums font-bold ${remain > 0 ? "text-rose-700" : "text-slate-400"}`}>{fmt(remain)}</td>
<td className="px-4 py-3 text-center">
<td className="px-3 py-1.5 font-semibold">{o.ORDER_NO}</td>
<td className="px-4 py-1.5">{o.ORDER_DATE}</td>
<td className="px-4 py-1.5">{o.COMPANY_NAME}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{fmt(o.TOTAL_AMOUNT)}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-emerald-700">{fmt(o.PAID_AMOUNT || 0)}</td>
<td className={`px-3 py-1.5 text-right tabular-nums font-bold ${remain > 0 ? "text-rose-700" : "text-slate-400"}`}>{fmt(remain)}</td>
<td className="px-3 py-1.5 text-center">
<span className={`px-2 py-1 rounded text-xs font-semibold ${o.STATUS === "PAID" || o.STATUS === "INVOICED" ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"}`}>
{STATUS_LABEL[o.STATUS]}
</span>
</td>
<td className="px-4 py-3 text-right">
<td className="px-3 py-1.5 text-right">
{o.STATUS !== "INVOICED" && (
<button onClick={() => onPay(o)} className="text-xs px-3 h-8 rounded-md bg-emerald-700 text-white hover:bg-emerald-800 font-semibold">
@@ -0,0 +1,387 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import Swal from "sweetalert2";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Loading } from "@/components/ui/loading";
import { makeSwalDraggable } from "@/lib/swal-draggable";
interface Proc {
OBJID: string;
PROC_NO: string;
PROC_DATE: string;
VENDOR_OBJID: string;
VENDOR_NAME: string;
VENDOR_CONTACT: string;
TOTAL_AMOUNT: number;
TOTAL_QTY: number;
RECEIVED_QTY: number;
RECEIVED_AMOUNT: number;
PENDING_AMOUNT: number;
STATUS: string;
IS_PAID: boolean;
PAYMENT_TERMS: string | null;
PAID_DATE: string | null;
PAID_AMOUNT: number | null;
PAID_METHOD: string | null;
PAID_MEMO: string | null;
}
interface Vendor { OBJID: string; VENDOR_NAME: string }
const fmt = (n: number | string | null | undefined) => Number(n || 0).toLocaleString("ko-KR");
// 진행상태 (입금과 무관)
const STATUS_LABEL: Record<string, string> = {
OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료", CANCELLED: "취소",
};
const STATUS_COLOR: Record<string, string> = {
OPEN: "bg-slate-100 text-slate-600",
REQUESTED: "bg-amber-100 text-amber-700",
PARTIAL: "bg-sky-100 text-sky-700",
RECEIVED: "bg-blue-100 text-blue-700",
CANCELLED: "bg-rose-100 text-rose-600",
};
function defaultRange() {
const e = new Date(), s = new Date();
s.setDate(s.getDate() - 60);
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
}
export default function ProcPaymentsPage() {
const [list, setList] = useState<Proc[]>([]);
const [vendors, setVendors] = useState<Vendor[]>([]);
const [[from, to], setRange] = useState(defaultRange());
const [vendorFilter, setVendorFilter] = useState("");
const [payFilter, setPayFilter] = useState(""); // "" | PAID | UNPAID
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const res = await fetch("/api/m/admin/proc-payments/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dateFrom: from || undefined,
dateTo: to || undefined,
vendorObjid: vendorFilter || undefined,
payStatus: payFilter || undefined,
}),
});
setList((await res.json()).RESULTLIST ?? []);
}, [from, to, vendorFilter, payFilter]);
const loadVendors = useCallback(async () => {
const res = await fetch("/api/m/vendors/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
setVendors((await res.json()).RESULTLIST ?? []);
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => { loadVendors(); }, [loadVendors]);
const summary = useMemo(() => {
let requested = 0, requestedAmt = 0, paid = 0, paidAmt = 0;
for (const p of list) {
if (p.IS_PAID) { paid++; paidAmt += Number(p.PAID_AMOUNT || p.TOTAL_AMOUNT) || 0; }
else { requested++; requestedAmt += Number(p.TOTAL_AMOUNT) || 0; }
}
return { requested, requestedAmt, paid, paidAmt };
}, [list]);
const onPay = async (p: Proc) => {
// 입금 금액 기본값 = 실제 입고된 만큼(입고금액). 부분입고면 미입고분 만큼 줄어든 금액이 제안됨.
// 입고 전(RECEIVED_AMOUNT=0) 이면 발주금액으로 폴백.
const suggested = Number(p.RECEIVED_AMOUNT) > 0 ? Number(p.RECEIVED_AMOUNT) : Number(p.TOTAL_AMOUNT);
const result = await Swal.fire({
title: "입금 처리",
html: `
<div class="text-left text-sm space-y-1">
<div><b></b> ${p.PROC_NO}</div>
<div><b></b> ${p.VENDOR_NAME ?? "-"}</div>
<div><b></b> ${fmt(p.TOTAL_AMOUNT)} <span class="text-slate-400">(${fmt(p.TOTAL_QTY)})</span></div>
<div><b></b> <span class="text-emerald-700">${fmt(p.RECEIVED_AMOUNT)}</span> <span class="text-slate-400">(${fmt(p.RECEIVED_QTY)})</span></div>
<div><b></b> <span class="text-rose-600">${fmt(p.PENDING_AMOUNT)}</span></div>
</div>
<div class="mt-3 space-y-2 text-left">
<input id="sw-amount" class="swal2-input" placeholder="입금 금액 (기본=입고금액)" value="${suggested}" type="number" />
<input id="sw-method" class="swal2-input" placeholder="입금 방법 (예: 계좌이체)" />
<input id="sw-memo" class="swal2-input" placeholder="메모 (선택)" />
</div>
`,
showCancelButton: true,
confirmButtonText: "입금 완료",
confirmButtonColor: "#0f766e",
cancelButtonText: "취소",
focusConfirm: false,
didOpen: () => makeSwalDraggable(),
preConfirm: () => {
const a = (document.getElementById("sw-amount") as HTMLInputElement)?.value;
const m = (document.getElementById("sw-method") as HTMLInputElement)?.value;
const memo = (document.getElementById("sw-memo") as HTMLInputElement)?.value;
return { amount: Number(a) || suggested, method: m, memo };
},
});
if (!result.isConfirmed || !result.value) return;
setBusy(true);
try {
const res = await fetch("/api/m/admin/proc-payments/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: p.OBJID, ...result.value }),
});
const j = await res.json();
if (j.success) {
await Swal.fire({ icon: "success", title: "입금 처리 완료", timer: 1200, showConfirmButton: false });
load();
} else {
Swal.fire({ icon: "error", title: "처리 실패", text: j.message });
}
} finally {
setBusy(false);
}
};
// 입금완료(PAID) 건 수정 — 입금액/입금일/방법/메모 수정 또는 입금 취소
const onEdit = async (p: Proc) => {
const today = new Date().toISOString().slice(0, 10);
const result = await Swal.fire({
title: "입금 정보 수정",
html: `
<div class="text-left text-sm space-y-1">
<div><b></b> ${p.PROC_NO}</div>
<div><b></b> ${p.VENDOR_NAME ?? "-"}</div>
<div><b></b> ${fmt(p.TOTAL_AMOUNT)} <span class="text-slate-400">(${fmt(p.TOTAL_QTY)})</span></div>
<div><b></b> <span class="text-emerald-700">${fmt(p.RECEIVED_AMOUNT)}</span> <span class="text-slate-400">(${fmt(p.RECEIVED_QTY)})</span></div>
<div><b></b> <span class="text-rose-600">${fmt(p.PENDING_AMOUNT)}</span></div>
</div>
<div class="mt-3 space-y-2 text-left">
<label class="text-xs text-slate-500"></label>
<input id="sw-date" class="swal2-input" type="date" value="${p.PAID_DATE || today}" />
<label class="text-xs text-slate-500"> (=)</label>
<input id="sw-amount" class="swal2-input" type="number" value="${p.PAID_AMOUNT ?? (Number(p.RECEIVED_AMOUNT) > 0 ? Number(p.RECEIVED_AMOUNT) : Number(p.TOTAL_AMOUNT))}" />
<input id="sw-method" class="swal2-input" placeholder="입금 방법 (예: 계좌이체)" value="${p.PAID_METHOD ?? ""}" />
<input id="sw-memo" class="swal2-input" placeholder="메모 (선택)" value="${p.PAID_MEMO ?? ""}" />
</div>
`,
showCancelButton: true,
showDenyButton: true,
confirmButtonText: "저장",
denyButtonText: "입금 취소",
cancelButtonText: "닫기",
confirmButtonColor: "#0f766e",
denyButtonColor: "#dc2626",
focusConfirm: false,
didOpen: () => makeSwalDraggable(),
preConfirm: () => ({
paidDate: (document.getElementById("sw-date") as HTMLInputElement)?.value || undefined,
amount: Number((document.getElementById("sw-amount") as HTMLInputElement)?.value) || Number(p.TOTAL_AMOUNT),
method: (document.getElementById("sw-method") as HTMLInputElement)?.value,
memo: (document.getElementById("sw-memo") as HTMLInputElement)?.value,
}),
});
if (result.isConfirmed && result.value) {
setBusy(true);
try {
const res = await fetch("/api/m/admin/proc-payments/update", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: p.OBJID, action: "edit", ...result.value }),
});
const j = await res.json();
if (j.success) { await Swal.fire({ icon: "success", title: "수정 완료", timer: 1200, showConfirmButton: false }); load(); }
else Swal.fire({ icon: "error", title: "수정 실패", text: j.message });
} finally { setBusy(false); }
} else if (result.isDenied) {
const ok = await Swal.fire({
icon: "warning", title: "입금을 취소하시겠습니까?",
text: "입금완료를 해제하고 입금 정보를 지웁니다. (입고 진행 상태로 되돌아갑니다)",
showCancelButton: true, confirmButtonText: "입금 취소", cancelButtonText: "닫기",
confirmButtonColor: "#dc2626",
});
if (!ok.isConfirmed) return;
setBusy(true);
try {
const res = await fetch("/api/m/admin/proc-payments/update", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: p.OBJID, action: "cancel" }),
});
const j = await res.json();
if (j.success) { await Swal.fire({ icon: "success", title: "입금 취소됨", timer: 1200, showConfirmButton: false }); load(); }
else Swal.fire({ icon: "error", title: "취소 실패", text: j.message });
} finally { setBusy(false); }
}
};
return (
<div className="space-y-3">
{busy && <Loading message="저장 중..." />}
<div>
<h1 className="text-xl sm:text-2xl font-bold"> </h1>
<p className="text-xs text-slate-500 mt-0.5"> . .</p>
</div>
{/* 조회조건 — 입력 즉시 반영, 모바일에서도 한 줄 (가로 스크롤) */}
<div className="bg-white border border-slate-200 rounded-xl p-2 flex gap-1.5 items-center flex-nowrap overflow-x-auto text-xs max-w-full">
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])}
className="h-8 px-2 rounded border border-slate-200 shrink-0 w-[130px]" />
<span className="text-slate-400 shrink-0">~</span>
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])}
className="h-8 px-2 rounded border border-slate-200 shrink-0 w-[130px]" />
<div className="shrink-0 w-[150px]">
<SearchableSelect
options={[{ value: "", label: "전체 공급업체" }, ...vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))]}
value={vendorFilter}
onChange={setVendorFilter}
placeholder="공급업체"
/>
</div>
<select value={payFilter} onChange={(e) => setPayFilter(e.target.value)}
className="h-8 px-2 rounded border border-slate-200 bg-white shrink-0">
<option value=""> </option>
<option value="UNPAID"></option>
<option value="PAID"></option>
</select>
</div>
{/* 합계 카드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="text-[11px] text-amber-700 font-semibold mb-1.5"> ({summary.requested})</div>
<div className="text-lg font-bold tabular-nums text-amber-700">{fmt(summary.requestedAmt)}</div>
</div>
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
<div className="text-[11px] text-emerald-700 font-semibold mb-1.5"> ({summary.paid})</div>
<div className="text-lg font-bold tabular-nums text-emerald-700">{fmt(summary.paidAmt)}</div>
</div>
</div>
{/* 모바일: 카드 */}
<div className="space-y-2 sm:hidden">
{list.length === 0 ? (
<div className="bg-white border border-slate-200 rounded-xl p-6 text-center text-slate-400 text-sm"> .</div>
) : list.map((p) => (
<div key={p.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 shadow-sm">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0">
<div className="font-bold text-base truncate">{p.VENDOR_NAME ?? "-"}</div>
<div className="text-[11px] text-slate-500 flex items-center gap-1.5">
<span>{p.PROC_DATE}</span>
<span className="text-slate-300">·</span>
<span className="font-semibold text-slate-600">{p.PROC_NO}</span>
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[p.STATUS] ?? "bg-slate-100 text-slate-500"}`}>
{STATUS_LABEL[p.STATUS] || p.STATUS}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
{p.IS_PAID ? "입금완료" : "미입금"}
</span>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-[11px] py-1.5 mb-2 border-t border-slate-100">
<div>
<div className="text-slate-400"></div>
<div className="font-bold tabular-nums">{fmt(p.TOTAL_AMOUNT)}</div>
<div className="text-[10px] text-slate-400 tabular-nums">{fmt(p.TOTAL_QTY)}</div>
</div>
<div>
<div className="text-emerald-700/80"></div>
<div className="font-bold text-emerald-700 tabular-nums">{fmt(p.RECEIVED_AMOUNT)}</div>
<div className="text-[10px] text-emerald-700/70 tabular-nums">{fmt(p.RECEIVED_QTY)}</div>
</div>
<div>
<div className="text-rose-600/80"></div>
<div className={`font-bold tabular-nums ${Number(p.PENDING_AMOUNT) > 0 ? "text-rose-600" : "text-slate-300"}`}>{fmt(p.PENDING_AMOUNT)}</div>
<div className="text-[10px] text-rose-600/70 tabular-nums">{fmt(Number(p.TOTAL_QTY) - Number(p.RECEIVED_QTY))}</div>
</div>
</div>
{!p.IS_PAID ? (
<button onClick={() => onPay(p)} disabled={busy}
className="w-full h-8 rounded bg-emerald-700 text-white text-xs font-bold disabled:opacity-50"> </button>
) : (
<div className="flex items-center justify-between gap-2">
<div className="text-[11px] text-emerald-700 min-w-0 truncate">
{p.PAID_DATE} · {fmt(p.PAID_AMOUNT)} {p.PAID_METHOD && `· ${p.PAID_METHOD}`}
</div>
<button onClick={() => onEdit(p)} disabled={busy}
className="shrink-0 h-7 px-2 rounded border border-slate-300 bg-white text-slate-600 text-[11px] font-bold disabled:opacity-50"></button>
</div>
)}
</div>
))}
</div>
{/* 데스크탑: 표 */}
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600 text-xs">
<tr>
<th className="text-left px-3 py-1.5"></th>
<th className="text-left px-3 py-1.5"></th>
<th className="text-left px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5 text-emerald-700"></th>
<th className="text-right px-3 py-1.5 text-rose-600"></th>
<th className="text-center px-3 py-1.5"></th>
<th className="text-center px-3 py-1.5"></th>
<th className="text-left px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5"></th>
<th className="text-center px-3 py-2.5 w-[120px]"></th>
</tr>
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={11} className="text-center py-10 text-slate-400"> .</td></tr>
) : list.map((p) => (
<tr key={p.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2.5 font-semibold">{p.PROC_NO}</td>
<td className="px-3 py-1.5">{p.PROC_DATE}</td>
<td className="px-3 py-1.5">{p.VENDOR_NAME ?? "-"}</td>
<td className="px-3 py-2.5 text-right tabular-nums">
<div className="font-bold text-slate-800 text-sm">{fmt(p.TOTAL_AMOUNT)}</div>
<div className="text-[11px] text-slate-400">{fmt(p.TOTAL_QTY)}</div>
</td>
<td className="px-3 py-2.5 text-right tabular-nums">
<div className="font-bold text-emerald-700 text-sm">{fmt(p.RECEIVED_AMOUNT)}</div>
<div className="text-[11px] text-emerald-700/70">{fmt(p.RECEIVED_QTY)}</div>
</td>
<td className="px-3 py-2.5 text-right tabular-nums">
<div className={`font-bold text-sm ${Number(p.PENDING_AMOUNT) > 0 ? "text-rose-600" : "text-slate-300"}`}>{fmt(p.PENDING_AMOUNT)}</div>
<div className={`text-[11px] ${Number(p.PENDING_AMOUNT) > 0 ? "text-rose-600/70" : "text-slate-300"}`}>{fmt(Number(p.TOTAL_QTY) - Number(p.RECEIVED_QTY))}</div>
</td>
<td className="px-3 py-2.5 text-center">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[p.STATUS] ?? "bg-slate-100 text-slate-500"}`}>
{STATUS_LABEL[p.STATUS] || p.STATUS}
</span>
</td>
<td className="px-3 py-2.5 text-center">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
{p.IS_PAID ? "입금완료" : "미입금"}
</span>
</td>
<td className="px-3 py-2.5 text-[11px] text-slate-600">{p.PAID_DATE || "-"}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-emerald-700">{p.PAID_AMOUNT ? `${fmt(p.PAID_AMOUNT)}` : "-"}</td>
<td className="px-3 py-2.5 text-center">
{!p.IS_PAID ? (
<button onClick={() => onPay(p)} disabled={busy}
className="h-7 px-2 rounded bg-emerald-700 text-white text-[11px] font-bold disabled:opacity-50">
</button>
) : (
<button onClick={() => onEdit(p)} disabled={busy}
className="h-7 px-2 rounded border border-slate-300 bg-white text-slate-600 text-[11px] font-bold hover:bg-slate-50 disabled:opacity-50">
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Trash2, Plus } from "lucide-react";
import Swal from "sweetalert2";
import { SearchableSelect } from "@/components/ui/searchable-select";
interface Vendor { OBJID: string; VENDOR_NAME: string }
interface Item { OBJID: string; ITEM_CODE: string; ITEM_NAME: string; COST_PRICE: number }
@@ -58,10 +59,14 @@ export default function NewProcPage() {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-slate-600"> *</label>
<select value={vendorObjid} onChange={(e) => setVendorObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
<option value=""></option>
{vendors.map((v) => <option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>)}
</select>
<div className="mt-1">
<SearchableSelect
value={vendorObjid}
onChange={setVendorObjid}
options={vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))}
placeholder="공급업체 검색/선택"
/>
</div>
</div>
<div>
<label className="text-xs font-semibold text-slate-600"></label>
@@ -73,10 +78,14 @@ export default function NewProcPage() {
<div className="bg-white border rounded-xl p-5">
<h3 className="font-bold mb-3"> </h3>
<div className="flex gap-2 mb-3">
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="flex-1 h-10 px-3 rounded-lg border border-slate-200">
<option value=""> </option>
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
</select>
<div className="flex-1">
<SearchableSelect
options={items.map((i) => ({ value: i.OBJID, label: i.ITEM_NAME }))}
value={pickItem}
onChange={setPickItem}
placeholder="품목 선택"
/>
</div>
<input type="number" min={1} value={pickQty} onChange={(e) => setPickQty(Number(e.target.value))} placeholder="수량" className="w-24 h-10 px-3 rounded-lg border border-slate-200" />
<input type="number" min={0} value={pickPrice} onChange={(e) => setPickPrice(Number(e.target.value))} placeholder="단가" className="w-32 h-10 px-3 rounded-lg border border-slate-200" />
<button onClick={addLine} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-bold flex items-center gap-1"><Plus size={14} /></button>
+160 -50
View File
@@ -4,11 +4,12 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { Plus, Send, Search, RefreshCcw, X, Download, Image as ImageIcon } from "lucide-react";
import Swal from "sweetalert2";
import { captureAndShare } from "@/lib/capture-share";
import { SearchableSelect } from "@/components/ui/searchable-select";
interface ProcRow {
OBJID: string; PROC_NO: string; PROC_DATE: string;
VENDOR_OBJID: string | null; VENDOR_NAME: string | null;
STATUS: string; TOTAL_AMOUNT: number; LINE_CNT: number; MEMO?: string;
STATUS: string; IS_PAID?: boolean; TOTAL_AMOUNT: number; LINE_CNT: number; MEMO?: string;
}
interface ProcDetail {
OBJID: string; PROC_NO: string; PROC_DATE: string; STATUS: string;
@@ -18,7 +19,9 @@ interface ProcDetail {
DELIVERY_PERIOD?: string;
PAYMENT_TERMS?: string;
FREIGHT_TERMS?: string;
BRANCH?: string;
}
interface StatementBranch { CODE: string; NAME: string; IS_DEFAULT: string; SORT_ORDER: number }
interface ProcLine {
OBJID: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
UNIT: string; QTY: number; COST_PRICE: number; TOTAL_AMOUNT: number;
@@ -32,41 +35,64 @@ interface Item {
}
const fmt = (n: number | string | undefined | null) => Number(n || 0).toLocaleString("ko-KR");
// 진행상태 (결재/입금과 무관) — 입금완료는 별도 결재 배지로 표시
const STATUS_LABEL: Record<string, string> = {
OPEN: "작성중", REQUESTED: "발주요청", RECEIVED: "입고완료", CANCELLED: "취소",
OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료", CANCELLED: "취소",
};
const STATUS_COLOR: Record<string, string> = {
OPEN: "bg-slate-100 text-slate-600 border-slate-200",
REQUESTED: "bg-amber-100 text-amber-700 border-amber-200",
PARTIAL: "bg-sky-100 text-sky-700 border-sky-200",
RECEIVED: "bg-emerald-100 text-emerald-700 border-emerald-200",
CANCELLED: "bg-rose-100 text-rose-600 border-rose-200",
CANCELED: "bg-rose-100 text-rose-600 border-rose-200",
};
// 기본 한 달 전 ~ 오늘
function defaultRange() {
const e = new Date(), s = new Date();
s.setDate(s.getDate() - 30);
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return [fmt(s), fmt(e)];
}
export default function ProcurementsPage() {
const [list, setList] = useState<ProcRow[]>([]);
const [statusFilter, setStatusFilter] = useState("");
const [vendorFilter, setVendorFilter] = useState("");
const [[dateFrom, dateTo], setRange] = useState(defaultRange());
const [activeId, setActiveId] = useState("");
const [detail, setDetail] = useState<{ proc: ProcDetail; items: ProcLine[] } | null>(null);
const [vendors, setVendors] = useState<Vendor[]>([]);
const [branches, setBranches] = useState<StatementBranch[]>([]);
const [busy, setBusy] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const load = useCallback(async () => {
const res = await fetch("/api/m/procurements/list", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: statusFilter || undefined }),
body: JSON.stringify({
status: statusFilter || undefined,
vendorObjid: vendorFilter || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
}),
});
const j = await res.json();
const rows: ProcRow[] = j.RESULTLIST ?? [];
setList(rows);
if (rows.length && !rows.some((r) => r.OBJID === activeId)) setActiveId(rows[0].OBJID);
if (!rows.length) { setActiveId(""); setDetail(null); }
}, [statusFilter, activeId]);
}, [statusFilter, vendorFilter, dateFrom, dateTo, activeId]);
const loadVendors = async () => {
const r = await fetch("/api/m/vendors/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
setVendors((await r.json()).RESULTLIST ?? []);
};
const loadBranches = async () => {
const r = await fetch("/api/m/admin/statement-branches/list", { method: "POST" });
setBranches((await r.json()).RESULTLIST ?? []);
};
const loadDetail = useCallback(async () => {
if (!activeId) { setDetail(null); return; }
@@ -78,7 +104,7 @@ export default function ProcurementsPage() {
if (j.success) setDetail({ proc: j.proc, items: j.items });
}, [activeId]);
useEffect(() => { loadVendors(); }, []);
useEffect(() => { loadVendors(); loadBranches(); }, []);
useEffect(() => { load(); }, [load]);
useEffect(() => { loadDetail(); }, [loadDetail]);
@@ -93,7 +119,7 @@ export default function ProcurementsPage() {
}
};
const updateHeader = async (patch: { vendorObjid?: string | null; memo?: string }) => {
const updateHeader = async (patch: Record<string, unknown>) => {
if (!detail) return;
const res = await fetch("/api/m/procurements/update-header", {
method: "POST", headers: { "Content-Type": "application/json" },
@@ -156,6 +182,39 @@ export default function ProcurementsPage() {
} finally { setBusy(false); }
};
// 작성중(OPEN) 발주서 삭제 — 라인 포함 hard delete
const deleteProc = async () => {
if (!detail) return;
if (detail.proc.STATUS !== "OPEN") {
Swal.fire({ icon: "warning", title: "작성중 상태만 삭제 가능합니다." });
return;
}
const ok = await Swal.fire({
icon: "warning",
title: `발주서 ${detail.proc.PROC_NO} 를 삭제하시겠습니까?`,
text: "작성중인 라인까지 모두 삭제됩니다.",
showCancelButton: true,
confirmButtonColor: "#dc2626",
confirmButtonText: "삭제",
});
if (!ok.isConfirmed) return;
setBusy(true);
try {
const res = await fetch("/api/m/procurements/delete", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: detail.proc.OBJID }),
});
const j = await res.json();
if (j.success) {
Swal.fire({ icon: "success", title: "삭제되었습니다", timer: 1200, showConfirmButton: false });
setActiveId("");
load();
} else {
Swal.fire({ icon: "error", title: "삭제 실패", text: j.message });
}
} finally { setBusy(false); }
};
return (
<div className="space-y-3">
<div className="flex items-end justify-between flex-wrap gap-2">
@@ -163,22 +222,32 @@ export default function ProcurementsPage() {
<h1 className="text-xl font-bold"> </h1>
<p className="text-xs text-slate-500 mt-0.5"> . [+ ] , [ ] .</p>
</div>
<div className="flex gap-2">
<div className="flex gap-1.5 items-center flex-nowrap overflow-x-auto pb-1 max-w-full">
<input type="date" value={dateFrom} onChange={(e) => setRange([e.target.value, dateTo])}
className="h-8 px-2 rounded border border-slate-300 text-xs shrink-0 w-[130px]" />
<span className="text-slate-400 text-xs shrink-0">~</span>
<input type="date" value={dateTo} onChange={(e) => setRange([dateFrom, e.target.value])}
className="h-8 px-2 rounded border border-slate-300 text-xs shrink-0 w-[130px]" />
<div className="shrink-0 w-[150px]">
<SearchableSelect
options={[{ value: "", label: "전체 공급업체" }, ...vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))]}
value={vendorFilter}
onChange={setVendorFilter}
placeholder="공급업체"
/>
</div>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
className="h-8 px-2 rounded border border-slate-300 bg-white text-xs shrink-0">
<option value=""> </option>
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<button onClick={load} className="h-9 px-3 rounded bg-white border border-slate-300 text-sm font-semibold inline-flex items-center gap-1">
<RefreshCcw size={14} />
</button>
<button onClick={createNew} className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800">
<Plus size={14} />
<button onClick={createNew} className="h-8 px-2 rounded bg-emerald-700 text-white text-xs font-bold inline-flex items-center gap-1 hover:bg-emerald-800 shrink-0">
<Plus size={12} />
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-3">
<div className="grid grid-cols-1 lg:grid-cols-[480px_1fr] gap-3">
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
({list.length})
@@ -191,12 +260,13 @@ export default function ProcurementsPage() {
<th className="text-left px-2 py-2"></th>
<th className="text-left px-2 py-2"></th>
<th className="text-right px-2 py-2"></th>
<th className="text-center px-2 py-2"></th>
<th className="text-center px-2 py-2"></th>
<th className="text-center px-2 py-2"></th>
</tr>
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={5} className="text-center py-12 text-slate-400"> .</td></tr>
<tr><td colSpan={6} className="text-center py-12 text-slate-400"> .</td></tr>
) : list.map((p) => (
<tr key={p.OBJID}
onClick={() => setActiveId(p.OBJID)}
@@ -210,6 +280,11 @@ export default function ProcurementsPage() {
{STATUS_LABEL[p.STATUS] ?? p.STATUS}
</span>
</td>
<td className="px-2 py-2 text-center">
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
{p.IS_PAID ? "입금완료" : "미입금"}
</span>
</td>
</tr>
))}
</tbody>
@@ -221,10 +296,16 @@ export default function ProcurementsPage() {
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
<span></span>
{detail && detail.proc.STATUS === "OPEN" && (
<button onClick={sendOrder} disabled={busy}
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
<Send size={12} />
</button>
<div className="flex items-center gap-2">
<button onClick={deleteProc} disabled={busy}
className="inline-flex items-center gap-1 h-8 px-3 rounded border border-rose-300 bg-white text-rose-700 text-xs font-bold hover:bg-rose-50 disabled:opacity-50">
</button>
<button onClick={sendOrder} disabled={busy}
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
<Send size={12} />
</button>
</div>
)}
{detail && detail.proc.STATUS === "REQUESTED" && (
<span className="text-[11px] text-amber-700"> </span>
@@ -237,7 +318,9 @@ export default function ProcurementsPage() {
<ProcurementForm
detail={detail}
vendors={vendors}
branches={branches}
onSetVendor={(v) => updateHeader({ vendorObjid: v || null })}
onSetBranch={(b) => updateHeader({ branch: b })}
onSetMemo={(m) => updateHeader({ memo: m })}
onSetTerm={(field, val) => updateHeader({ [field]: val })}
onAddPicker={() => setPickerOpen(true)}
@@ -277,17 +360,21 @@ export default function ProcurementsPage() {
);
}
function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, onAddPicker, onUpdateLine, onDeleteLine }: {
function ProcurementForm({ detail, vendors, branches, onSetVendor, onSetBranch, onSetMemo, onSetTerm, onAddPicker, onUpdateLine, onDeleteLine }: {
detail: { proc: ProcDetail; items: ProcLine[] };
vendors: Vendor[];
branches: StatementBranch[];
onSetVendor: (id: string) => void;
onSetBranch: (code: string) => void;
onSetMemo: (m: string) => void;
onSetTerm: (field: "deliveryPlace" | "deliveryPeriod" | "paymentTerms" | "freightTerms", val: string) => void;
onAddPicker: () => void;
onUpdateLine: (line: { objid?: string; itemObjid?: string; qty: number; costPrice: number }) => void;
onDeleteLine: (objid: string) => void;
}) {
const editable = detail.proc.STATUS === "OPEN";
// OPEN(작성중) / REQUESTED(발주요청) 만 수정 가능.
// RECEIVED(입고완료) / PARTIAL(부분입고) / PAID(입금완료) / CANCELLED 는 수정 불가.
const editable = ["OPEN", "REQUESTED"].includes(detail.proc.STATUS);
const formRef = useRef<HTMLDivElement>(null);
const handleCapture = async () => {
@@ -330,16 +417,16 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
</a>
</div>
<div ref={formRef} className="bg-white p-3">
<div ref={formRef} className="bg-white p-3 font-bold [&_*]:font-bold">
<div className="text-center">
<h2 className="text-2xl font-bold tracking-[0.4em] text-slate-900"> </h2>
</div>
<table className="text-[11px] mt-3 border border-slate-400" style={{borderCollapse:'collapse',width:'auto'}}>
<table className="text-[13px] mt-3 border border-slate-400" style={{borderCollapse:'collapse',width:'auto'}}>
<tbody>
<tr>
<th className="border border-slate-400 bg-slate-100 px-2 py-1 w-[100px] text-center"></th>
<td className="border border-slate-400 px-3 py-1 font-semibold w-[200px]"></td>
<td className="border border-slate-400 px-3 py-1 font-semibold w-[260px]"></td>
</tr>
<tr>
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center"></th>
@@ -349,16 +436,40 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center"></th>
<td className="border border-slate-400 px-3 py-1">{detail.proc.PROC_DATE}</td>
</tr>
<tr>
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center"></th>
<td className="border border-slate-400 px-3 py-1">
{editable ? (
<select
value={detail.proc.BRANCH ?? branches.find((b) => b.IS_DEFAULT === "Y")?.CODE ?? "HQ"}
onChange={(e) => onSetBranch(e.target.value)}
className="h-7 px-2 rounded border border-slate-300 text-[13px] bg-white"
>
{branches.length === 0 ? (
<option value="HQ"> (HQ)</option>
) : branches.map((b) => (
<option key={b.CODE} value={b.CODE}>{b.NAME} ({b.CODE}){b.IS_DEFAULT === "Y" ? " ★" : ""}</option>
))}
</select>
) : (
<span className="font-semibold">
{branches.find((b) => b.CODE === (detail.proc.BRANCH ?? "HQ"))?.NAME ?? (detail.proc.BRANCH ?? "본사")}
</span>
)}
</td>
</tr>
<tr>
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center"></th>
<td className="border border-slate-400 px-3 py-1">
{editable ? (
<select value={detail.proc.VENDOR_OBJID ?? ""}
onChange={(e) => onSetVendor(e.target.value)}
className="h-7 px-2 rounded border border-slate-300 text-[11px] bg-white">
<option value="">-- --</option>
{vendors.map((v) => <option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>)}
</select>
<div className="max-w-[280px]">
<SearchableSelect
value={detail.proc.VENDOR_OBJID ?? ""}
onChange={onSetVendor}
options={vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))}
placeholder="공급업체 검색/선택"
/>
</div>
) : (
<span className="font-semibold">{detail.proc.VENDOR_NAME ?? "-"}</span>
)}
@@ -367,9 +478,9 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
</tbody>
</table>
<p className="mt-3 font-semibold text-[12px]">1. </p>
<p className="mt-3 font-semibold text-[14px]">1. </p>
{editable && (
<div className="flex gap-2 mb-2 text-[11px] flex-wrap">
<div className="flex gap-2 mb-2 text-[13px] flex-wrap">
<button onClick={onAddPicker}
className="inline-flex items-center gap-1 h-7 px-3 rounded bg-emerald-100 text-emerald-800 font-bold hover:bg-emerald-200">
<Plus size={12} />
@@ -377,11 +488,11 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
<span className="self-center text-slate-400"> , , </span>
</div>
)}
<table className="w-full text-[11px] border border-slate-400" style={{borderCollapse:'collapse'}}>
<table className="w-full text-[13px] border border-slate-400" style={{borderCollapse:'collapse'}}>
<thead className="bg-slate-100">
<tr>
<th className="border border-slate-400 px-1 py-1 w-8">#</th>
<th className="border border-slate-400 px-2 py-1 text-left"></th>
<th className="border border-slate-400 px-2 py-1 text-left w-[220px]"></th>
<th className="border border-slate-400 px-1 py-1 w-12"></th>
<th className="border border-slate-400 px-1 py-1 w-16"></th>
<th className="border border-slate-400 px-1 py-1 w-20"></th>
@@ -399,9 +510,9 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
))}
</tbody>
</table>
<div className="text-right text-[11px] mt-1 text-slate-500">(V.A.T , 단위: )</div>
<div className="text-right text-[13px] mt-1 text-slate-500">(V.A.T , 단위: )</div>
<table className="ml-auto text-[12px] tabular-nums mt-3">
<table className="ml-auto text-[14px] tabular-nums mt-3">
<tbody>
<tr className="border-t-2 border-slate-900 font-bold">
<td className="px-3 py-1.5"></td>
@@ -410,14 +521,13 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
</tbody>
</table>
<p className="mt-4 font-semibold text-[12px]">2. </p>
<ol className="text-[11px] mt-1 space-y-1 leading-relaxed list-decimal pl-5">
<li> , 1 3/1000 .</li>
<p className="mt-4 font-semibold text-[14px]">2. </p>
<ol className="text-[13px] mt-1 space-y-1 leading-relaxed list-decimal pl-5">
<li> , .</li>
<li> , .</li>
</ol>
<table className="text-[11px] mt-2 border border-slate-400 w-full" style={{borderCollapse:'collapse'}}>
<table className="text-[13px] mt-2 border border-slate-400 w-full" style={{borderCollapse:'collapse'}}>
<tbody>
<tr>
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center font-semibold w-[80px]">4) </th>
@@ -425,7 +535,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
{editable ? (
<input defaultValue={detail.proc.DELIVERY_PLACE ?? ""}
onBlur={(e) => onSetTerm("deliveryPlace", e.target.value)}
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
className="w-full h-7 px-2 text-[13px] outline-none border-0 bg-transparent" />
) : (
<span>{detail.proc.DELIVERY_PLACE || "-"}</span>
)}
@@ -435,7 +545,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
{editable ? (
<input defaultValue={detail.proc.PAYMENT_TERMS ?? ""}
onBlur={(e) => onSetTerm("paymentTerms", e.target.value)}
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
className="w-full h-7 px-2 text-[13px] outline-none border-0 bg-transparent" />
) : (
<span>{detail.proc.PAYMENT_TERMS || "-"}</span>
)}
@@ -447,7 +557,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
{editable ? (
<input defaultValue={detail.proc.DELIVERY_PERIOD ?? ""}
onBlur={(e) => onSetTerm("deliveryPeriod", e.target.value)}
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
className="w-full h-7 px-2 text-[13px] outline-none border-0 bg-transparent" />
) : (
<span>{detail.proc.DELIVERY_PERIOD || "-"}</span>
)}
@@ -457,7 +567,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
{editable ? (
<input defaultValue={detail.proc.FREIGHT_TERMS ?? ""}
onBlur={(e) => onSetTerm("freightTerms", e.target.value)}
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
className="w-full h-7 px-2 text-[13px] outline-none border-0 bg-transparent" />
) : (
<span>{detail.proc.FREIGHT_TERMS || "-"}</span>
)}
@@ -466,27 +576,27 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
</tbody>
</table>
<ol className="text-[11px] mt-2 space-y-1 leading-relaxed list-decimal pl-5" start={8}>
<ol className="text-[13px] mt-2 space-y-1 leading-relaxed list-decimal pl-5" start={8}>
<li> FAX 3 .</li>
<li> , .</li>
</ol>
<p className="mt-4 font-semibold text-[12px]">3. </p>
<p className="mt-4 font-semibold text-[14px]">3. </p>
{editable ? (
<textarea
defaultValue={detail.proc.MEMO ?? ""}
onBlur={(e) => onSetMemo(e.target.value)}
rows={2}
placeholder="추가로 전달할 사항이 있으면 입력"
className="w-full mt-1 px-3 py-2 rounded border border-slate-300 text-[11px] resize-y"
className="w-full mt-1 px-3 py-2 rounded border border-slate-300 text-[13px] resize-y"
/>
) : (
<div className="mt-1 px-3 py-2 rounded border border-slate-200 bg-slate-50 text-[11px] whitespace-pre-wrap min-h-[40px]">
<div className="mt-1 px-3 py-2 rounded border border-slate-200 bg-slate-50 text-[13px] whitespace-pre-wrap min-h-[40px]">
{detail.proc.MEMO || <span className="text-slate-400"></span>}
</div>
)}
<div className="mt-6 text-center text-[11px] text-slate-600">
<div className="mt-6 text-center text-[13px] text-slate-600">
<p> .</p>
<p className="mt-2">{detail.proc.PROC_DATE.replace(/-/g, ".") + "."}</p>
<p className="mt-3 font-semibold"> : {process.env.NEXT_PUBLIC_MOMO_NAME ?? "(주)모모유통"}</p>
@@ -0,0 +1,203 @@
"use client";
import { useEffect, useState, FormEvent } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import Swal from "sweetalert2";
interface Branch {
CODE: string;
NAME: string;
BANK_ACCOUNT: string;
PHONE: string | null;
EMAIL: string | null;
CEO_NAME: string | null;
BIZ_NO: string | null;
ADDRESS: string | null;
IS_DEFAULT: string;
SORT_ORDER: number;
}
export default function StatementBranchesPage() {
const [list, setList] = useState<Branch[]>([]);
const [editing, setEditing] = useState<Partial<Branch> | null>(null);
const [isNew, setIsNew] = useState(false);
const load = async () => {
const res = await fetch("/api/m/admin/statement-branches/list", { method: "POST" });
setList((await res.json()).RESULTLIST ?? []);
};
useEffect(() => { load(); }, []);
const openNew = () => {
setIsNew(true);
setEditing({ CODE: "", NAME: "", BANK_ACCOUNT: "", IS_DEFAULT: "N", SORT_ORDER: list.length + 1 });
};
const openEdit = (b: Branch) => { setIsNew(false); setEditing({ ...b }); };
const save = async (e: FormEvent) => {
e.preventDefault();
if (!editing) return;
if (!editing.CODE || !editing.NAME || !editing.BANK_ACCOUNT) {
Swal.fire({ icon: "warning", title: "코드/이름/계좌번호는 필수" });
return;
}
const res = await fetch("/api/m/admin/statement-branches/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
actionType: isNew ? "regist" : "update",
code: editing.CODE, name: editing.NAME, bankAccount: editing.BANK_ACCOUNT,
phone: editing.PHONE ?? "", email: editing.EMAIL ?? "",
ceoName: editing.CEO_NAME ?? "", bizNo: editing.BIZ_NO ?? "", address: editing.ADDRESS ?? "",
isDefault: editing.IS_DEFAULT ?? "N", sortOrder: String(editing.SORT_ORDER ?? 0),
}),
});
const j = await res.json();
if (j.success) {
await Swal.fire({ icon: "success", title: "저장 완료", timer: 1200, showConfirmButton: false });
setEditing(null); load();
} else {
Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
}
};
const del = async (b: Branch) => {
if (b.IS_DEFAULT === "Y") {
Swal.fire({ icon: "warning", title: "기본 명세표는 삭제 불가" });
return;
}
const ok = await Swal.fire({ icon: "question", title: `${b.NAME} (${b.CODE}) 삭제?`, showCancelButton: true });
if (!ok.isConfirmed) return;
const res = await fetch("/api/m/admin/statement-branches/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ actionType: "delete", code: b.CODE, name: b.NAME, bankAccount: b.BANK_ACCOUNT }),
});
if ((await res.json()).success) load();
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"> </h1>
<p className="text-xs text-slate-500 mt-0.5">/ () . .</p>
</div>
<button onClick={openNew} className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800">
<Plus size={14} />
</button>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600 text-xs">
<tr>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"> </th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-center w-[100px]"></th>
</tr>
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={7} className="text-center py-12 text-slate-400"> .</td></tr>
) : list.map((b) => (
<tr key={b.CODE} className="border-t border-slate-100">
<td className="px-3 py-2 font-mono font-semibold text-emerald-700">{b.CODE}</td>
<td className="px-3 py-2 font-semibold">{b.NAME}</td>
<td className="px-3 py-2 text-xs">{b.BANK_ACCOUNT}</td>
<td className="px-3 py-2 text-xs">{b.PHONE}</td>
<td className="px-3 py-2 text-xs text-blue-700">{b.EMAIL}</td>
<td className="px-3 py-2 text-center">
{b.IS_DEFAULT === "Y" && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold"></span>}
</td>
<td className="px-3 py-2 text-center">
<button onClick={() => openEdit(b)} className="text-emerald-700 hover:text-emerald-800 mr-2"><Pencil size={14} /></button>
<button onClick={() => del(b)} className="text-rose-500 hover:text-rose-600"><Trash2 size={14} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{editing && (
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-3" onClick={() => setEditing(null)}>
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-lg p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="font-bold text-lg mb-4">{isNew ? "기준 명세표 등록" : `기준 명세표 수정 (${editing.CODE})`}</h3>
<div className="space-y-3">
<Field label="코드 *">
<input required disabled={!isNew} value={editing.CODE ?? ""}
onChange={(e) => setEditing({ ...editing, CODE: e.target.value.toUpperCase() })}
placeholder="HQ / KIMPO 등" className="w-full h-9 px-3 rounded border border-slate-200 text-sm font-mono disabled:bg-slate-100" />
</Field>
<Field label="이름 *">
<input required value={editing.NAME ?? ""}
onChange={(e) => setEditing({ ...editing, NAME: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
<Field label="결제 계좌번호 *">
<input required value={editing.BANK_ACCOUNT ?? ""}
onChange={(e) => setEditing({ ...editing, BANK_ACCOUNT: e.target.value })}
placeholder="예: 기업은행 434-115361-01-016 (이상용)"
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="전화">
<input value={editing.PHONE ?? ""} onChange={(e) => setEditing({ ...editing, PHONE: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
<Field label="이메일">
<input type="email" value={editing.EMAIL ?? ""} onChange={(e) => setEditing({ ...editing, EMAIL: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="대표자">
<input value={editing.CEO_NAME ?? ""} onChange={(e) => setEditing({ ...editing, CEO_NAME: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
<Field label="사업자등록번호">
<input value={editing.BIZ_NO ?? ""} onChange={(e) => setEditing({ ...editing, BIZ_NO: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
</div>
<Field label="주소">
<input value={editing.ADDRESS ?? ""} onChange={(e) => setEditing({ ...editing, ADDRESS: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" checked={editing.IS_DEFAULT === "Y"}
onChange={(e) => setEditing({ ...editing, IS_DEFAULT: e.target.checked ? "Y" : "N" })}
className="w-4 h-4 accent-emerald-600" />
</label>
<Field label="정렬순">
<input type="number" value={editing.SORT_ORDER ?? 0}
onChange={(e) => setEditing({ ...editing, SORT_ORDER: Number(e.target.value) })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
</div>
</div>
<div className="flex gap-2 justify-end mt-5">
<button type="button" onClick={() => setEditing(null)} className="h-10 px-4 rounded-lg border border-slate-200 text-sm font-semibold"></button>
<button type="submit" className="h-10 px-5 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800"></button>
</div>
</form>
</div>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<div className="text-[11px] font-semibold text-slate-600 mb-1">{label}</div>
{children}
</label>
);
}
@@ -18,6 +18,7 @@ function defaultRange() {
export default function DailyStatsPage() {
const [[from, to], setRange] = useState(defaultRange());
const [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
@@ -26,12 +27,12 @@ export default function DailyStatsPage() {
try {
const res = await fetch("/api/m/statistics/daily", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dateFrom: from, dateTo: to }),
body: JSON.stringify({ dateFrom: from, dateTo: to, branch }),
});
setRows((await res.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => { load(); }, [from, to, branch]); // eslint-disable-line
const total = rows.reduce((a, r) => a + Number(r.TOTAL), 0);
const totalFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
@@ -78,6 +79,11 @@ export default function DailyStatsPage() {
<div className="flex gap-2 flex-wrap items-end">
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])} className="h-10 px-3 rounded-lg border border-slate-200 text-sm" />
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])} className="h-10 px-3 rounded-lg border border-slate-200 text-sm" />
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
<option value="ALL"> ( )</option>
<option value="HQ"> </option>
<option value="KIMPO"> </option>
</select>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div>
@@ -88,58 +94,66 @@ export default function DailyStatsPage() {
<Card label="총 매출 (VAT)" value={`${fmt(total)}`} color="emerald" />
</div>
<div className="bg-white border rounded-xl p-4">
<h3 className="font-bold text-slate-700 mb-3 text-sm"> </h3>
<div className="w-full h-72 sm:h-80">
{loading ? (
<div className="h-full flex items-center justify-center text-slate-400"> ...</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-slate-400"> .</div>
) : (
<ResponsiveContainer>
<ComposedChart data={chartData} margin={{ top: 10, right: 20, bottom: 0, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="day" tick={{ fontSize: 10 }} />
<YAxis yAxisId="left" tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
<Tooltip
formatter={(v, name) => name === "건수" ? `${Number(v)}` : `${fmt(Number(v))}`}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar yAxisId="left" dataKey="면세" stackId="a" fill="#8b5cf6" />
<Bar yAxisId="left" dataKey="과세" stackId="a" fill="#f43f5e" />
<Line yAxisId="right" type="monotone" dataKey="건수" stroke="#0ea5e9" strokeWidth={2} dot={{ r: 3 }} />
</ComposedChart>
</ResponsiveContainer>
)}
{/* 좌: 일자별 리스트 / 우: 추이 차트 — 50/50 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="bg-white border rounded-xl overflow-hidden flex flex-col" style={{ maxHeight: "calc(100vh - 320px)" }}>
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
({rows.length})
</div>
<div className="flex-1 overflow-y-auto overflow-x-auto">
<table className="w-full text-sm min-w-[480px]">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
</tr>
</thead>
<tbody className="tabular-nums">
{rows.length === 0 ? (
<tr><td colSpan={5} className="text-center py-12 text-slate-400">{loading ? "조회 중..." : "선택한 기간의 매출 데이터가 없습니다."}</td></tr>
) : rows.map((r) => (
<tr key={r.DAY} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 font-semibold">{r.DAY}</td>
<td className="px-3 py-2 text-right">{r.ORDER_CNT}</td>
<td className="px-3 py-2 text-right text-violet-700">{fmt(r.TAX_FREE)}</td>
<td className="px-3 py-2 text-right text-rose-700">{fmt(r.TAXABLE)}</td>
<td className="px-3 py-2 text-right font-bold">{fmt(r.TOTAL)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className="bg-white border rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[600px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={5} className="text-center py-12 text-slate-400"> .</td></tr>
) : rows.map((r) => (
<tr key={r.DAY} className="border-t border-slate-100">
<td className="px-4 py-2.5 font-semibold">{r.DAY}</td>
<td className="px-4 py-2.5 text-right">{r.ORDER_CNT}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-violet-700">{fmt(r.TAX_FREE)}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-rose-700">{fmt(r.TAXABLE)}</td>
<td className="px-4 py-2.5 text-right tabular-nums font-bold">{fmt(r.TOTAL)}</td>
</tr>
))}
</tbody>
</table>
<div className="bg-white border rounded-xl p-4 flex flex-col" style={{ maxHeight: "calc(100vh - 320px)" }}>
<h3 className="font-bold text-slate-700 mb-3 text-sm shrink-0"> </h3>
<div className="flex-1 min-h-0">
{loading ? (
<div className="h-full flex items-center justify-center text-slate-400"> ...</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-slate-400"> .</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 10, right: 20, bottom: 0, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="day" tick={{ fontSize: 10 }} />
<YAxis yAxisId="left" tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
<Tooltip
formatter={(v, name) => name === "건수" ? `${Number(v)}` : `${fmt(Number(v))}`}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar yAxisId="left" dataKey="면세" stackId="a" fill="#8b5cf6" />
<Bar yAxisId="left" dataKey="과세" stackId="a" fill="#f43f5e" />
<Line yAxisId="right" type="monotone" dataKey="건수" stroke="#0ea5e9" strokeWidth={2} dot={{ r: 3 }} />
</ComposedChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
</div>
);
@@ -13,6 +13,7 @@ const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
export default function MarginPage() {
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
@@ -21,12 +22,12 @@ export default function MarginPage() {
try {
const res = await fetch("/api/m/statistics/margin", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year, month }),
body: JSON.stringify({ year, month, branch }),
});
setRows((await res.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => { load(); }, [year, month, branch]); // eslint-disable-line
const totalRev = rows.reduce((a, r) => a + Number(r.REVENUE), 0);
const totalCost = rows.reduce((a, r) => a + Number(r.COST), 0);
@@ -82,6 +83,11 @@ export default function MarginPage() {
<select value={month} onChange={(e) => setMonth(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => <option key={m} value={m}>{m}</option>)}
</select>
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
<option value="ALL"> ( )</option>
<option value="HQ"> </option>
<option value="KIMPO"> </option>
</select>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div>
@@ -92,62 +98,70 @@ export default function MarginPage() {
<Card label="마진율" value={`${marginPct}%`} color="violet" />
</div>
<div className="bg-white border rounded-xl p-4">
<h3 className="font-bold text-slate-700 mb-3 text-sm"> TOP 10 </h3>
<div className="w-full h-72 sm:h-80">
{loading ? (
<div className="h-full flex items-center justify-center text-slate-400"> ...</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-slate-400"> .</div>
) : (
<ResponsiveContainer>
<BarChart data={chartData} margin={{ top: 10, right: 20, bottom: 30, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} interval={0} angle={-25} textAnchor="end" height={50} />
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} />
<Tooltip
formatter={(v) => `${fmt(Number(v))}`}
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="원가" fill="#f59e0b" />
<Bar dataKey="마진" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
<div className="bg-white border rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[700px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={6} className="text-center py-12 text-slate-400"> .</td></tr>
) : rows.map((r) => {
const pct = Number(r.REVENUE) ? ((Number(r.MARGIN) / Number(r.REVENUE)) * 100).toFixed(1) : "0.0";
return (
<tr key={r.ITEM_CODE} className="border-t border-slate-100">
<td className="px-4 py-2.5 font-semibold">{r.ITEM_NAME}</td>
<td className="px-4 py-2.5 text-right">{fmt(r.QTY)}</td>
<td className="px-4 py-2.5 text-right tabular-nums">{fmt(r.REVENUE)}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-amber-700">{fmt(r.COST)}</td>
<td className="px-4 py-2.5 text-right tabular-nums font-bold text-emerald-700">{fmt(r.MARGIN)}</td>
<td className="px-4 py-2.5 text-right tabular-nums">{pct}%</td>
{/* 좌: 품목 리스트 / 우: 차트 — 50/50, 화면 높이 내 스크롤 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="bg-white border rounded-xl overflow-hidden flex flex-col" style={{ maxHeight: "calc(100vh - 280px)" }}>
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
({rows.length})
</div>
<div className="flex-1 overflow-y-auto overflow-x-auto">
<table className="w-full text-sm min-w-[560px]">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2">%</th>
</tr>
);
})}
</tbody>
</table>
</thead>
<tbody className="tabular-nums">
{rows.length === 0 ? (
<tr><td colSpan={6} className="text-center py-12 text-slate-400">{loading ? "조회 중..." : "데이터가 없습니다."}</td></tr>
) : rows.map((r) => {
const pct = Number(r.REVENUE) ? ((Number(r.MARGIN) / Number(r.REVENUE)) * 100).toFixed(1) : "0.0";
return (
<tr key={r.ITEM_CODE} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 font-semibold">{r.ITEM_NAME}</td>
<td className="px-3 py-2 text-right">{fmt(r.QTY)}</td>
<td className="px-3 py-2 text-right">{fmt(r.REVENUE)}</td>
<td className="px-3 py-2 text-right text-amber-700">{fmt(r.COST)}</td>
<td className="px-3 py-2 text-right font-bold text-emerald-700">{fmt(r.MARGIN)}</td>
<td className="px-3 py-2 text-right">{pct}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className="bg-white border rounded-xl p-4 flex flex-col" style={{ maxHeight: "calc(100vh - 280px)" }}>
<h3 className="font-bold text-slate-700 mb-3 text-sm shrink-0"> TOP 10 </h3>
<div className="flex-1 min-h-0">
{loading ? (
<div className="h-full flex items-center justify-center text-slate-400"> ...</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-slate-400"> .</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 10, right: 20, bottom: 30, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} interval={0} angle={-25} textAnchor="end" height={50} />
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} />
<Tooltip
formatter={(v) => `${fmt(Number(v))}`}
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="원가" fill="#f59e0b" />
<Bar dataKey="마진" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
</div>
);
+66 -53
View File
@@ -20,6 +20,7 @@ const COLORS = ["#10b981", "#0ea5e9", "#8b5cf6", "#f59e0b", "#ef4444", "#14b8a6"
export default function StatisticsPage() {
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
const [rows, setRows] = useState<MonthlyRow[]>([]);
const [loading, setLoading] = useState(false);
@@ -28,12 +29,12 @@ export default function StatisticsPage() {
try {
const res = await fetch("/api/m/statistics/monthly", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year, month }),
body: JSON.stringify({ year, month, branch }),
});
setRows((await res.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => { load(); }, [year, month, branch]); // eslint-disable-line
const grandTotal = rows.reduce((a, r) => a + Number(r.TOTAL), 0);
const grandFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
@@ -85,6 +86,11 @@ export default function StatisticsPage() {
<select value={month} onChange={(e) => setMonth(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => <option key={m} value={m}>{m}</option>)}
</select>
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
<option value="ALL"> ( )</option>
<option value="HQ"> </option>
<option value="KIMPO"> </option>
</select>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div>
@@ -94,59 +100,66 @@ export default function StatisticsPage() {
<Card label="총 매출 (VAT포함)" value={fmt(grandTotal)} color="emerald" />
</div>
{/* 차트 */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="font-bold text-slate-700 mb-3 text-sm"> (TOP 15)</h3>
<div className="w-full h-72 sm:h-80">
{loading ? (
<div className="h-full flex items-center justify-center text-slate-400"> ...</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-slate-400"> .</div>
) : (
<ResponsiveContainer>
<BarChart data={chartData} margin={{ top: 10, right: 10, bottom: 30, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} interval={0} angle={-25} textAnchor="end" height={50} />
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} />
<Tooltip
formatter={(v) => `${fmt(Number(v))}`}
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
cursor={{ fill: "rgba(16, 185, 129, 0.05)" }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="면세" stackId="a" fill="#8b5cf6" />
<Bar dataKey="과세" stackId="a" fill="#f43f5e">
{chartData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} fillOpacity={0.7} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
{/* 좌: 업체별 리스트 / 우: 차트 — 50/50 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col" style={{ maxHeight: "calc(100vh - 320px)" }}>
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
({rows.length})
</div>
<div className="flex-1 overflow-y-auto overflow-x-auto">
<table className="w-full text-sm min-w-[480px]">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"> </th>
</tr>
</thead>
<tbody className="tabular-nums">
{rows.length === 0 ? (
<tr><td colSpan={4} className="text-center py-12 text-slate-400">{loading ? "조회 중..." : "선택한 월의 매출 데이터가 없습니다."}</td></tr>
) : rows.map((r) => (
<tr key={r.COMPANY_NAME} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 font-semibold">{r.COMPANY_NAME}</td>
<td className="px-3 py-2 text-right text-violet-700">{fmt(r.TAX_FREE)}</td>
<td className="px-3 py-2 text-right text-rose-700">{fmt(r.TAXABLE)}</td>
<td className="px-3 py-2 text-right font-bold">{fmt(r.TOTAL)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[600px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"> </th>
<th className="text-right px-4 py-3"> </th>
<th className="text-right px-4 py-3"> </th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={4} className="text-center py-12 text-slate-400"> .</td></tr>
) : rows.map((r) => (
<tr key={r.COMPANY_NAME} className="border-t border-slate-100">
<td className="px-4 py-3 font-semibold">{r.COMPANY_NAME}</td>
<td className="px-4 py-3 text-right tabular-nums text-violet-700">{fmt(r.TAX_FREE)}</td>
<td className="px-4 py-3 text-right tabular-nums text-rose-700">{fmt(r.TAXABLE)}</td>
<td className="px-4 py-3 text-right tabular-nums font-bold">{fmt(r.TOTAL)}</td>
</tr>
))}
</tbody>
</table>
<div className="bg-white border border-slate-200 rounded-xl p-4 flex flex-col" style={{ maxHeight: "calc(100vh - 320px)" }}>
<h3 className="font-bold text-slate-700 mb-3 text-sm shrink-0"> (TOP 15)</h3>
<div className="flex-1 min-h-0">
{loading ? (
<div className="h-full flex items-center justify-center text-slate-400"> ...</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-slate-400"> .</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 10, right: 10, bottom: 30, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} interval={0} angle={-25} textAnchor="end" height={50} />
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} />
<Tooltip
formatter={(v) => `${fmt(Number(v))}`}
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
cursor={{ fill: "rgba(16, 185, 129, 0.05)" }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="면세" stackId="a" fill="#8b5cf6" />
<Bar dataKey="과세" stackId="a" fill="#f43f5e">
{chartData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} fillOpacity={0.7} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
</div>
);
@@ -18,6 +18,7 @@ const fmt = (n: number | undefined | null) => Number(n || 0).toLocaleString("ko-
export default function PivotStatsPage() {
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
const [data, setData] = useState<PivotResp | null>(null);
const [loading, setLoading] = useState(false);
@@ -26,14 +27,14 @@ export default function PivotStatsPage() {
try {
const res = await fetch("/api/m/statistics/monthly-pivot", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year, month }),
body: JSON.stringify({ year, month, branch }),
});
const j = await res.json();
if (j.success) setData(j);
} finally { setLoading(false); }
}, [year, month]);
}, [year, month, branch]);
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => { load(); }, [load]);
const dayLabel = (d: string) => {
const [, mm, dd] = d.split("-");
@@ -96,6 +97,12 @@ export default function PivotStatsPage() {
className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
{Array.from({ length: 12 }, (_, i) => i + 1).map((mm) => <option key={mm} value={mm}>{mm}</option>)}
</select>
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")}
className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
<option value="ALL"> ( )</option>
<option value="HQ"> </option>
<option value="KIMPO"> </option>
</select>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div>
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ArrowRightLeft, RefreshCcw, Download } from "lucide-react";
import { downloadXlsx } from "@/lib/xlsx-export";
interface Row {
OBJID: string; MOVED_AT: string;
ITEM_CODE: string; ITEM_NAME: string; UNIT: string;
COST_PRICE: number; QTY: number; AMOUNT: number;
FROM_WH: string; FROM_CODE: string;
TO_WH: string; TO_CODE: string;
MEMO: string | null; REGID: string; REG_USER_NAME: string | null;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
const todayStr = () => new Date().toISOString().slice(0, 10);
const monthAgo = () => { const d = new Date(); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 10); };
export default function TransfersPage() {
const [dateFrom, setDateFrom] = useState(monthAgo());
const [dateTo, setDateTo] = useState(todayStr());
const [rows, setRows] = useState<Row[]>([]);
const [totals, setTotals] = useState({ qty: 0, amount: 0 });
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/m/admin/transfers", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dateFrom, dateTo }),
});
const j = await res.json();
setRows(j.RESULTLIST ?? []);
setTotals({ qty: Number(j.TOTAL_QTY ?? 0), amount: Number(j.TOTAL_AMOUNT ?? 0) });
} finally { setLoading(false); }
}, [dateFrom, dateTo]);
useEffect(() => { load(); }, [load]);
const onExport = () => {
if (rows.length === 0) return;
downloadXlsx(`창고이동_${dateFrom}_${dateTo}`, rows, [
{ header: "이동일시", key: "MOVED_AT" },
{ header: "품목코드", key: "ITEM_CODE" },
{ header: "품목명", key: "ITEM_NAME" },
{ header: "단위", key: "UNIT" },
{ header: "수량", key: (r) => Number(r.QTY) },
{ header: "단가", key: (r) => Number(r.COST_PRICE) },
{ header: "금액", key: (r) => Number(r.AMOUNT) },
{ header: "출발창고", key: (r) => `${r.FROM_WH ?? ""} (${r.FROM_CODE ?? ""})` },
{ header: "도착창고", key: (r) => `${r.TO_WH ?? ""} (${r.TO_CODE ?? ""})` },
{ header: "메모", key: (r) => r.MEMO ?? "" },
{ header: "이동자ID", key: "REGID" },
{ header: "이동자", key: (r) => r.REG_USER_NAME ?? "" },
]);
};
return (
<div className="space-y-4">
<div className="flex items-end justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<ArrowRightLeft size={20} className="text-sky-700" />
( )
</h1>
<p className="text-xs text-slate-500 mt-0.5">
<b> </b> <b> </b>(///) . × (cost_price) = .
</p>
</div>
<div className="flex items-center gap-2">
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
className="h-9 px-3 rounded border border-slate-300 text-sm" />
<span className="text-slate-400">~</span>
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
className="h-9 px-3 rounded border border-slate-300 text-sm" />
<button onClick={load} disabled={loading}
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50">
<RefreshCcw size={14} />
</button>
<button onClick={onExport} disabled={rows.length === 0}
className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800 disabled:opacity-50">
<Download size={14} />
</button>
</div>
</div>
{/* 합계 카드 */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-500 mb-1"> </div>
<div className="text-xl font-bold tabular-nums">{fmt(rows.length)}</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-500 mb-1"> </div>
<div className="text-xl font-bold tabular-nums">{fmt(totals.qty)}</div>
</div>
<div className="bg-sky-50 border border-sky-200 rounded-xl p-4">
<div className="text-[11px] text-sky-700 mb-1 font-semibold"> ( )</div>
<div className="text-xl font-bold text-sky-700 tabular-nums">{fmt(totals.amount)}</div>
</div>
</div>
{/* 리스트 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600 text-xs">
<tr>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-left px-3 py-2"> </th>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
</tr>
</thead>
<tbody className="tabular-nums">
{rows.length === 0 ? (
<tr><td colSpan={8} className="text-center py-12 text-slate-400">
{loading ? "조회 중..." : "이동 내역이 없습니다."}
</td></tr>
) : rows.map((r) => (
<tr key={r.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 text-xs font-mono">{r.MOVED_AT}</td>
<td className="px-3 py-2">
<div className="font-semibold">{r.ITEM_NAME}</div>
<div className="text-[10px] text-slate-400">{r.ITEM_CODE}</div>
</td>
<td className="px-3 py-2 text-right font-semibold">{fmt(Number(r.QTY))} {r.UNIT}</td>
<td className="px-3 py-2 text-right text-slate-500">{fmt(Number(r.COST_PRICE))}</td>
<td className="px-3 py-2 text-right font-bold text-sky-700">{fmt(Number(r.AMOUNT))}</td>
<td className="px-3 py-2 text-xs">
<span className="text-slate-700">{r.FROM_WH}</span>
<span className="text-slate-300 mx-1"></span>
<span className="text-emerald-700 font-semibold">{r.TO_WH}</span>
</td>
<td className="px-3 py-2 text-xs">
<div className="font-semibold">{r.REG_USER_NAME ?? "-"}</div>
<div className="text-[10px] text-slate-400 font-mono">{r.REGID}</div>
</td>
<td className="px-3 py-2 text-[10px] text-slate-500 max-w-[200px] truncate" title={r.MEMO ?? ""}>
{r.MEMO ?? ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+32 -9
View File
@@ -7,15 +7,38 @@ import Swal from "sweetalert2";
interface Warehouse {
OBJID: string; WH_CODE: string; WH_NAME: string; LOCATION: string; WH_TYPE: string;
}
// 본사/김포 각각 창고/용차/시장 + 김포지사 자체 = 총 7가지
const TYPE_LABEL: Record<string, string> = {
STOCK: "본사 창고", PICKUP_TEAM: "창고픽업팀", MARKET: "시장픽업", DELIVERY: "용차배송",
HQ_STOCK: "본사 창고",
HQ_CHARTER: "본사 용차",
HQ_MARKET: "본사 시장",
KIMPO_BRANCH:"김포지사",
KIMPO_STOCK: "김포 창고",
KIMPO_CHARTER:"김포 용차",
KIMPO_MARKET:"김포 시장",
// ↓ 옛 enum (기존 데이터 표시 유지)
STOCK: "본사 창고",
KIMPO: "김포 창고",
PICKUP_TEAM: "창고픽업팀",
MARKET: "시장픽업",
DELIVERY: "용차배송",
};
const TYPE_COLOR: Record<string, string> = {
STOCK: "bg-emerald-100 text-emerald-700",
PICKUP_TEAM: "bg-sky-100 text-sky-700",
MARKET: "bg-amber-100 text-amber-700",
DELIVERY: "bg-orange-100 text-orange-700",
HQ_STOCK: "bg-emerald-100 text-emerald-700",
HQ_CHARTER: "bg-orange-100 text-orange-700",
HQ_MARKET: "bg-amber-100 text-amber-700",
KIMPO_BRANCH: "bg-teal-100 text-teal-700",
KIMPO_STOCK: "bg-teal-100 text-teal-700",
KIMPO_CHARTER:"bg-orange-100 text-orange-700",
KIMPO_MARKET: "bg-amber-100 text-amber-700",
STOCK: "bg-emerald-100 text-emerald-700",
KIMPO: "bg-teal-100 text-teal-700",
PICKUP_TEAM: "bg-sky-100 text-sky-700",
MARKET: "bg-amber-100 text-amber-700",
DELIVERY: "bg-orange-100 text-orange-700",
};
// 신규 카테고리 (select 옵션). 옛 enum 은 데이터 표시만 유지, 신규 추가는 새 enum 만.
const NEW_TYPE_ORDER = ["HQ_STOCK", "HQ_CHARTER", "HQ_MARKET", "KIMPO_BRANCH", "KIMPO_STOCK", "KIMPO_CHARTER", "KIMPO_MARKET"];
export default function WarehousesPage() {
const [list, setList] = useState<Warehouse[]>([]);
@@ -35,7 +58,7 @@ export default function WarehousesPage() {
body: JSON.stringify({
objid: editing.OBJID, actionType: editing.OBJID ? "update" : "regist",
whName: editing.WH_NAME,
location: editing.LOCATION, whType: editing.WH_TYPE || "STOCK",
location: editing.LOCATION, whType: editing.WH_TYPE || "HQ_STOCK",
}),
});
if ((await res.json()).success) {
@@ -51,7 +74,7 @@ export default function WarehousesPage() {
<h1 className="text-xl sm:text-2xl font-bold"> </h1>
<p className="text-xs sm:text-sm text-slate-500 mt-1"> {list.length}</p>
</div>
<button onClick={() => setEditing({ WH_TYPE: "STOCK" })}
<button onClick={() => setEditing({ WH_TYPE: "HQ_STOCK" })}
className="h-10 px-3 sm:px-4 inline-flex items-center gap-1.5 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800">
<Plus size={16} />
</button>
@@ -128,8 +151,8 @@ export default function WarehousesPage() {
/>
</div>
<input required placeholder="이름" value={editing.WH_NAME ?? ""} onChange={(e) => setEditing({ ...editing, WH_NAME: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
<select value={editing.WH_TYPE ?? "STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
{Object.entries(TYPE_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
<select value={editing.WH_TYPE ?? "HQ_STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
{NEW_TYPE_ORDER.map((k) => <option key={k} value={k}>{TYPE_LABEL[k]}</option>)}
</select>
<input placeholder="위치 (선택)" value={editing.LOCATION ?? ""} onChange={(e) => setEditing({ ...editing, LOCATION: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
</div>
@@ -0,0 +1,252 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { RefreshCcw, Warehouse, Download, LayoutGrid, Columns3 } from "lucide-react";
import { downloadXlsx } from "@/lib/xlsx-export";
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
interface ItemRow {
ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
STOCK: Record<string, number>; // wh_code → 현재고
AVAILABLE: Record<string, number>; // wh_code → 여유분(현재고 - 진행중)
TOTAL_STOCK: number;
TOTAL_PENDING: number;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
// 이번 주 월요일 → 오늘
function weekRange() {
const today = new Date();
const day = today.getDay(); // 0=일
const mondayOffset = day === 0 ? -6 : 1 - day;
const monday = new Date(today);
monday.setDate(today.getDate() + mondayOffset);
const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return [iso(monday), iso(today)];
}
export default function WhStockStatusPage() {
const [[dateFrom, dateTo], setRange] = useState(weekRange());
const [warehouses, setWarehouses] = useState<Wh[]>([]);
const [items, setItems] = useState<ItemRow[]>([]);
const [loading, setLoading] = useState(false);
// 보기 모드: by-wh = 헤더가 창고(가로로 김) / by-item = 헤더가 품목(오른쪽으로 길게)
const [viewMode, setViewMode] = useState<"by-wh" | "by-item">("by-item");
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/m/admin/wh-stock-status", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dateFrom, dateTo }),
});
const j = await res.json();
setWarehouses(j.WAREHOUSES ?? []);
setItems(j.ITEMS ?? []);
} finally { setLoading(false); }
}, [dateFrom, dateTo]);
useEffect(() => { load(); }, [load]);
const onExport = () => {
if (items.length === 0) return;
const cols = [
{ header: "품목", key: "ITEM_NAME" },
{ header: "분류", key: "KIND" },
...warehouses.map((w) => ({ header: w.WH_NAME, key: w.WH_CODE })),
];
type Row = Record<string, string | number>;
const data: Row[] = [];
for (const it of items) {
const stockRow: Row = { ITEM_NAME: it.ITEM_NAME, KIND: "발주수량(현재고)" };
const availRow: Row = { ITEM_NAME: it.ITEM_NAME, KIND: "여유분" };
for (const w of warehouses) {
stockRow[w.WH_CODE] = Number(it.STOCK[w.WH_CODE] ?? 0);
availRow[w.WH_CODE] = Number(it.AVAILABLE[w.WH_CODE] ?? 0);
}
data.push(stockRow, availRow);
}
downloadXlsx(`창고별재고_${dateFrom}_${dateTo}`, data, cols);
};
return (
<div className="space-y-4">
<div className="flex items-end justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<Warehouse size={20} className="text-emerald-700" />
</h1>
<p className="text-xs text-slate-500 mt-0.5">
(///) . <b></b> = , <b></b> = ( ).
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* 보기 모드 토글 */}
<div className="inline-flex rounded border border-slate-300 overflow-hidden">
<button onClick={() => setViewMode("by-wh")}
className={`h-9 px-3 text-xs font-semibold inline-flex items-center gap-1 ${viewMode === "by-wh" ? "bg-emerald-700 text-white" : "bg-white text-slate-600 hover:bg-slate-50"}`}>
<Columns3 size={14} />
</button>
<button onClick={() => setViewMode("by-item")}
className={`h-9 px-3 text-xs font-semibold inline-flex items-center gap-1 border-l border-slate-300 ${viewMode === "by-item" ? "bg-emerald-700 text-white" : "bg-white text-slate-600 hover:bg-slate-50"}`}>
<LayoutGrid size={14} />
</button>
</div>
<input type="date" value={dateFrom} onChange={(e) => setRange([e.target.value, dateTo])}
className="h-9 px-3 rounded border border-slate-300 text-sm" />
<span className="text-slate-400">~</span>
<input type="date" value={dateTo} onChange={(e) => setRange([dateFrom, e.target.value])}
className="h-9 px-3 rounded border border-slate-300 text-sm" />
<button onClick={() => setRange(weekRange())}
className="h-9 px-3 rounded border border-slate-300 bg-white text-slate-700 text-xs font-semibold"></button>
<button onClick={load} disabled={loading}
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50">
<RefreshCcw size={14} />
</button>
<button onClick={onExport} disabled={items.length === 0}
className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800 disabled:opacity-40">
<Download size={14} />
</button>
</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
{viewMode === "by-wh" ? (
/* 보기 1: 헤더=창고(가로), 행=품목 */
<table className="w-full text-sm border-collapse">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2 border-b border-slate-200 min-w-[160px]"></th>
<th className="text-center px-3 py-2 border-b border-slate-200 min-w-[100px]"></th>
{warehouses.map((w) => (
<th key={w.WH_CODE} className="text-right px-3 py-2 border-b border-slate-200 min-w-[88px]">
{w.WH_NAME}
</th>
))}
</tr>
</thead>
<tbody className="tabular-nums">
{items.length === 0 ? (
<tr><td colSpan={2 + warehouses.length} className="text-center py-12 text-slate-400">
{loading ? "조회 중..." : "데이터가 없습니다."}
</td></tr>
) : items.flatMap((it) => [
<tr key={`${it.ITEM_OBJID}-stock`} className="border-t border-slate-100 hover:bg-slate-50/60">
<td className="px-3 py-2 align-top font-semibold" rowSpan={2}>
{it.ITEM_NAME}
<div className="text-[10px] text-slate-400 font-mono">{it.ITEM_CODE}</div>
</td>
<td className="px-3 py-1.5 text-center text-[11px] text-slate-700 bg-slate-50/70"></td>
{warehouses.map((w) => {
const v = Number(it.STOCK[w.WH_CODE] ?? 0);
return (
<td key={w.WH_CODE} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-slate-800"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
<tr key={`${it.ITEM_OBJID}-avail`} className="border-b border-slate-100">
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-50/40 font-semibold"></td>
{warehouses.map((w) => {
const v = Number(it.AVAILABLE[w.WH_CODE] ?? 0);
const negative = v < 0;
return (
<td key={w.WH_CODE} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600 font-bold" : v === 0 ? "text-slate-300" : "text-emerald-700 font-semibold"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
])}
</tbody>
</table>
) : (
/* 보기 2: 헤더=품목(가로), 행=창고 7줄 — 전치 */
<table className="text-sm border-collapse">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2 border-b border-slate-200 sticky left-0 bg-slate-50 z-10 min-w-[120px]"></th>
<th className="text-center px-3 py-2 border-b border-slate-200 sticky left-[120px] bg-slate-50 z-10 min-w-[90px]"></th>
{items.map((it) => (
<th key={it.ITEM_OBJID} className="text-right px-3 py-2 border-b border-slate-200 min-w-[100px] whitespace-nowrap">
<div>{it.ITEM_NAME}</div>
<div className="text-[10px] text-slate-400 font-mono font-normal">{it.ITEM_CODE}</div>
</th>
))}
</tr>
</thead>
<tbody className="tabular-nums">
{items.length === 0 || warehouses.length === 0 ? (
<tr><td colSpan={2 + items.length} className="text-center py-12 text-slate-400">
{loading ? "조회 중..." : "데이터가 없습니다."}
</td></tr>
) : [
/* 전체 합계 — 모든 창고의 발주수량/여유분 합 (상단 강조 행) */
<tr key="__total-stock" className="bg-emerald-50/70 border-y-2 border-emerald-300 font-bold">
<td className="px-3 py-2 align-top sticky left-0 bg-emerald-50/70" rowSpan={2}>
<div className="text-emerald-800"> </div>
<div className="text-[10px] text-emerald-600 font-normal"> </div>
</td>
<td className="px-3 py-1.5 text-center text-[11px] text-slate-700 bg-slate-100 sticky left-[120px]"></td>
{items.map((it) => {
const v = warehouses.reduce((acc, w) => acc + Number(it.STOCK[w.WH_CODE] ?? 0), 0);
return (
<td key={it.ITEM_OBJID} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-slate-900"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
<tr key="__total-avail" className="bg-emerald-50/70 border-b-2 border-emerald-300 font-bold">
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-100/60 sticky left-[120px]"></td>
{items.map((it) => {
const v = warehouses.reduce((acc, w) => acc + Number(it.AVAILABLE[w.WH_CODE] ?? 0), 0);
const negative = v < 0;
return (
<td key={it.ITEM_OBJID} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600" : v === 0 ? "text-slate-300" : "text-emerald-700"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
/* 창고별 7줄 */
...warehouses.flatMap((w) => [
<tr key={`${w.WH_CODE}-stock`} className="border-t border-slate-100 hover:bg-slate-50/60">
<td className="px-3 py-2 align-top font-semibold sticky left-0 bg-white" rowSpan={2}>
{w.WH_NAME}
<div className="text-[10px] text-slate-400 font-mono">{w.WH_CODE}</div>
</td>
<td className="px-3 py-1.5 text-center text-[11px] text-slate-700 bg-slate-50/70 sticky left-[120px]"></td>
{items.map((it) => {
const v = Number(it.STOCK[w.WH_CODE] ?? 0);
return (
<td key={it.ITEM_OBJID} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-slate-800"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
<tr key={`${w.WH_CODE}-avail`} className="border-b border-slate-100">
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-50/40 font-semibold sticky left-[120px]"></td>
{items.map((it) => {
const v = Number(it.AVAILABLE[w.WH_CODE] ?? 0);
const negative = v < 0;
return (
<td key={it.ITEM_OBJID} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600 font-bold" : v === 0 ? "text-slate-300" : "text-emerald-700 font-semibold"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
]),
]}
</tbody>
</table>
)}
</div>
</div>
);
}

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