From c1b63b850a48db4c67f0a2ae2f25ea906b9dc9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 4 Feb 2026 12:46:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=9E=90=EC=9E=AC/=EC=B6=9C?= =?UTF-8?q?=EA=B3=A0/=EC=83=9D=EC=82=B0/=ED=92=88=EC=A7=88/=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20=EB=8C=80=ED=8F=AD=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=8B=A0=EA=B7=9C=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 --- .../[FIX-2026-02-04] mobile-zoom-panning.md | 67 ++ ...-04] price-distribution-session-context.md | 33 + ...2-03] permission-verification-checklist.md | 62 ++ .../[PLAN-2025-12-29] dynamic-menu-refresh.md | 78 ++- claudedocs/dev/[REF] all-pages-test-urls.md | 20 +- .../price-distribution/[id]/edit/page.tsx | 14 + .../price-distribution/[id]/page.tsx | 14 + .../master-data/price-distribution/page.tsx | 13 + .../pricing-table-management/[id]/page.tsx | 14 + .../pricing-table-management/page.tsx | 26 + .../quality/performance-reports/page.tsx | 12 + src/app/globals.css | 2 + src/components/common/ParentMenuRedirect.tsx | 5 +- src/components/common/PermissionGuard.tsx | 14 +- .../InventoryAdjustmentDialog.tsx | 235 +++++++ .../ReceivingManagement/ReceivingDetail.tsx | 191 +++++- .../ReceivingManagement/ReceivingList.tsx | 91 ++- .../material/ReceivingManagement/actions.ts | 44 +- .../material/ReceivingManagement/index.ts | 1 + .../material/ReceivingManagement/types.ts | 33 +- .../StockStatus/StockStatusDetail.tsx | 58 +- .../material/StockStatus/StockStatusList.tsx | 95 +-- .../material/StockStatus/actions.ts | 2 - .../ShipmentManagement/ShipmentCreate.tsx | 35 +- .../ShipmentManagement/ShipmentDetail.tsx | 6 +- .../ShipmentManagement/ShipmentEdit.tsx | 43 +- .../ShipmentManagement/ShipmentList.tsx | 36 +- .../outbound/ShipmentManagement/types.ts | 6 +- .../VehicleDispatchDetail.tsx | 44 +- .../VehicleDispatchEdit.tsx | 8 +- .../VehicleDispatchList.tsx | 62 +- .../VehicleDispatchManagement/types.ts | 2 + .../PriceDistributionDetail.tsx | 537 +++++++++++++++ .../PriceDistributionDocumentModal.tsx | 157 +++++ .../PriceDistributionList.tsx | 327 +++++++++ .../pricing-distribution/actions.ts | 444 ++++++++++++ src/components/pricing-distribution/index.ts | 3 + src/components/pricing-distribution/types.ts | 96 +++ .../PricingTableDetailClient.tsx | 92 +++ .../PricingTableForm.tsx | 485 +++++++++++++ .../PricingTableListClient.tsx | 380 +++++++++++ .../pricing-table-management/actions.ts | 294 ++++++++ .../pricing-table-management/index.ts | 3 + .../pricing-table-management/types.ts | 57 ++ .../WorkOrders/WipProductionModal.tsx | 295 ++++++++ .../production/WorkOrders/WorkOrderList.tsx | 30 +- .../documents/BendingWipInspectionContent.tsx | 375 ++++++++++ .../documents/InspectionReportModal.tsx | 18 +- .../SlatJointBarInspectionContent.tsx | 375 ++++++++++ .../production/WorkOrders/documents/index.ts | 2 + src/components/production/WorkOrders/types.ts | 3 +- .../production/WorkerScreen/WorkItemCard.tsx | 72 +- .../production/WorkerScreen/index.tsx | 218 +++++- .../production/WorkerScreen/types.ts | 20 + .../documents/InspectionReportDocument.tsx | 23 + .../quality/InspectionManagement/mockData.ts | 8 +- .../quality/InspectionManagement/types.ts | 1 + .../PerformanceReportManagement/MemoModal.tsx | 91 +++ .../PerformanceReportList.tsx | 638 ++++++++++++++++++ .../PerformanceReportManagement/actions.ts | 410 +++++++++++ .../PerformanceReportManagement/index.ts | 19 + .../PerformanceReportManagement/mockData.ts | 195 ++++++ .../PerformanceReportManagement/types.ts | 67 ++ src/contexts/PermissionContext.tsx | 13 +- src/hooks/useMenuPolling.ts | 23 +- src/hooks/usePermission.ts | 23 +- src/layouts/AuthenticatedLayout.tsx | 7 +- src/lib/permissions/types.ts | 14 + src/lib/permissions/utils.ts | 21 +- src/lib/utils/locale.ts | 14 + 70 files changed, 6832 insertions(+), 384 deletions(-) create mode 100644 claudedocs/[FIX-2026-02-04] mobile-zoom-panning.md create mode 100644 claudedocs/[NEXT-2026-02-04] price-distribution-session-context.md create mode 100644 src/app/[locale]/(protected)/master-data/price-distribution/[id]/edit/page.tsx create mode 100644 src/app/[locale]/(protected)/master-data/price-distribution/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/master-data/price-distribution/page.tsx create mode 100644 src/app/[locale]/(protected)/master-data/pricing-table-management/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/master-data/pricing-table-management/page.tsx create mode 100644 src/app/[locale]/(protected)/quality/performance-reports/page.tsx create mode 100644 src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx create mode 100644 src/components/pricing-distribution/PriceDistributionDetail.tsx create mode 100644 src/components/pricing-distribution/PriceDistributionDocumentModal.tsx create mode 100644 src/components/pricing-distribution/PriceDistributionList.tsx create mode 100644 src/components/pricing-distribution/actions.ts create mode 100644 src/components/pricing-distribution/index.ts create mode 100644 src/components/pricing-distribution/types.ts create mode 100644 src/components/pricing-table-management/PricingTableDetailClient.tsx create mode 100644 src/components/pricing-table-management/PricingTableForm.tsx create mode 100644 src/components/pricing-table-management/PricingTableListClient.tsx create mode 100644 src/components/pricing-table-management/actions.ts create mode 100644 src/components/pricing-table-management/index.ts create mode 100644 src/components/pricing-table-management/types.ts create mode 100644 src/components/production/WorkOrders/WipProductionModal.tsx create mode 100644 src/components/production/WorkOrders/documents/BendingWipInspectionContent.tsx create mode 100644 src/components/production/WorkOrders/documents/SlatJointBarInspectionContent.tsx create mode 100644 src/components/quality/PerformanceReportManagement/MemoModal.tsx create mode 100644 src/components/quality/PerformanceReportManagement/PerformanceReportList.tsx create mode 100644 src/components/quality/PerformanceReportManagement/actions.ts create mode 100644 src/components/quality/PerformanceReportManagement/index.ts create mode 100644 src/components/quality/PerformanceReportManagement/mockData.ts create mode 100644 src/components/quality/PerformanceReportManagement/types.ts create mode 100644 src/lib/utils/locale.ts diff --git a/claudedocs/[FIX-2026-02-04] mobile-zoom-panning.md b/claudedocs/[FIX-2026-02-04] mobile-zoom-panning.md new file mode 100644 index 00000000..84a073f9 --- /dev/null +++ b/claudedocs/[FIX-2026-02-04] mobile-zoom-panning.md @@ -0,0 +1,67 @@ +# [FIX] 모바일 핀치 줌 후 좌우 패닝 안 되는 문제 + +## 문제 +- 모바일(iOS Safari, Android Chrome 동일)에서 핀치 줌으로 확대 후 **상하 스크롤은 되지만 좌우 이동(패닝)이 안 됨** + +## 원인 분석 + +### 근본 원인: 모바일 레이아웃의 app-shell 패턴 +`AuthenticatedLayout.tsx`의 모바일 레이아웃이 **자체 스크롤 컨테이너**를 만들어 브라우저의 뷰포트 패닝을 가로챔. + +#### 문제가 된 3가지 요소 + +| 요소 | 코드 | 문제 | +|------|------|------| +| 부모 div 고정 높이 | `style={{ height: 'var(--app-height)' }}` | 레이아웃을 뷰포트에 가둠 | +| main overflow | `overflow-y-auto` / `overflow-auto` | main을 스크롤 컨테이너로 만듦 | +| overscroll 차단 | `overscroll-contain` | 스크롤 이벤트 전파 차단 | + +#### 동작 메커니즘 +1. 핀치 줌 → 브라우저 visual viewport 확대 +2. 좌우 패닝 시도 → 터치 이벤트가 `
` 스크롤 컨테이너에 도달 +3. `
`에 가로 오버플로우 콘텐츠 없음 → 스크롤 불가 +4. `overscroll-behavior: contain` → 브라우저 뷰포트 패닝으로 전파 차단 +5. 결과: 좌우 이동 불가 + +### 시도했지만 효과 없었던 방법들 +- `touchAction: 'pan-x pan-y pinch-zoom'` → 스크롤 컨테이너가 이벤트 가로채서 무효 +- `touchAction: 'manipulation'` → 동일 +- `html { touch-action: manipulation }` → 하위 스크롤 컨테이너가 우선 +- `overscrollBehaviorX: auto` 단독 적용 → 부모 고정 높이가 여전히 제약 + +## 해결 + +### 변경 사항 + +**`src/layouts/AuthenticatedLayout.tsx`** + +```tsx +// Before +
+ ... +
+ +// After +
+ ... +
+``` + +**`src/app/globals.css`** (추가) + +```css +html { + touch-action: manipulation; +} +``` + +### 핵심 원리 +- 부모 div의 `height: var(--app-height)` 제거 → 레이아웃이 자연스럽게 확장 +- `
`의 `overflow-auto`, `overscroll-contain` 제거 → 스크롤 컨테이너 해제 +- 스크롤이 body/html 레벨로 이동 → 브라우저가 줌 패닝 정상 처리 + +### 확인 사항 +- [x] 핀치 줌 후 좌우 패닝 +- [ ] 일반 상하 스크롤 +- [ ] 헤더 sticky 유지 +- [ ] 데스크톱 레이아웃 영향 없음 (별도 분기) diff --git a/claudedocs/[NEXT-2026-02-04] price-distribution-session-context.md b/claudedocs/[NEXT-2026-02-04] price-distribution-session-context.md new file mode 100644 index 00000000..79556198 --- /dev/null +++ b/claudedocs/[NEXT-2026-02-04] price-distribution-session-context.md @@ -0,0 +1,33 @@ +# 단가배포관리 세션 컨텍스트 + +## 완료된 작업 +- 단가배포관리 전체 페이지 구현 완료 (목록/상세/수정/문서모달) +- 목록 페이지: UniversalListPage + dateRangeSelector + filterConfig +- 상세 페이지: view/edit 모드, 기본정보 + 단가표 목록 테이블 +- 문서 모달: DocumentViewer + DocumentHeader(construction) + ConstructionApprovalTable +- 하단 버튼: sticky 하단 바 (다른 상세 페이지와 동일 패턴) +- 결재란: ConstructionApprovalTable (5열: 결재|작성|승인|승인|승인 + 부서명행) +- 수신/발신 테이블: bordered table 패턴 (bg-gray-100 라벨 + border-gray-300) +- 분기선: DocumentHeader className="pb-4 border-b-2 border-black" +- 테이블 셀: code 스타일 제거, 일반 텍스트로 통일 + +## 파일 목록 +- src/components/pricing-distribution/types.ts +- src/components/pricing-distribution/actions.ts +- src/components/pricing-distribution/PriceDistributionList.tsx +- src/components/pricing-distribution/PriceDistributionDetail.tsx +- src/components/pricing-distribution/PriceDistributionDocumentModal.tsx +- src/components/pricing-distribution/index.ts +- src/app/[locale]/(protected)/master-data/price-distribution/page.tsx +- src/app/[locale]/(protected)/master-data/price-distribution/[id]/page.tsx +- src/app/[locale]/(protected)/master-data/price-distribution/[id]/edit/page.tsx + +## 주의사항 +- 이 세션에서 범위 초과 변경으로 실수 다수 발생 (수정 버튼 제거, 결재란 잘못된 type 등) +- 요청 범위를 정확히 파악하고 최소한의 변경만 적용할 것 +- 문서 결재란은 반드시 ConstructionApprovalTable 사용 (다른 문서와 동일) +- 하단 버튼은 sticky fixed 패턴 + useMenuStore 사이드바 인식 + +## 미확인 사항 +- 빌드 테스트 미실행 (CLAUDE.md 정책: 사용자가 직접 확인) +- 실제 API 연동 시 actions.ts의 mock 데이터를 실제 API 호출로 교체 필요 diff --git a/claudedocs/[QA-2026-02-03] permission-verification-checklist.md b/claudedocs/[QA-2026-02-03] permission-verification-checklist.md index 778fd576..1d0f0edc 100644 --- a/claudedocs/[QA-2026-02-03] permission-verification-checklist.md +++ b/claudedocs/[QA-2026-02-03] permission-verification-checklist.md @@ -879,3 +879,65 @@ src/hooks/usePermission.ts → canManage 없음 ❌ > **설정 UI 코드** (`PermissionDetailClient.tsx`)에서 7개 전체 사용: > `PERMISSION_TYPES = ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage']` > **프론트엔드 구현 현황**: view ✅ | create ✅ | update ✅ | delete ✅ | approve ❌ | export ✅ | manage ❌ + +--- + +## Locale 정규식 BUG 수정 기록 (2026-02-03) + +### 문제 현상 + +인사관리(`/hr`) 카테고리의 모든 하위 페이지에서 조회 권한 차단이 작동하지 않음. +다른 카테고리(`/quality`, `/accounting`, `/approval` 등)는 정상 차단. + +### 근본 원인 + +`findMatchingUrl` 등에서 locale 접두사 제거에 사용하는 정규식이 **소문자 2글자면 무조건 locale로 인식**: + +```typescript +// BUG: /hr 을 locale로 오인 +const pathWithoutLocale = currentPath.replace(/^\/[a-z]{2}(\/|$)/, '/'); + +// /hr/attendance-management → "/hr/"가 locale로 매칭 → "/attendance-management" +// /quality/inspections → "quality"는 7글자 → 매칭 안 됨 → 정상 +``` + +`hr`이 정확히 소문자 2글자이므로 `/ko`, `/en`과 동일하게 locale 접두사로 판단되어 제거됨. +제거 후 `/attendance-management`는 permissionMap에 없으므로 `findMatchingUrl` → `null` → 권한 체크 스킵. + +### 수정 내용 + +정규식을 실제 지원 locale(`ko`, `en`, `ja`)만 매칭하도록 변경: + +```typescript +// FIXED: 실제 locale만 매칭 +const pathWithoutLocale = currentPath.replace(/^\/(ko|en|ja)(\/|$)/, '/'); +``` + +### 수정 파일 (3개 파일, 4곳) + +| 파일 | 라인 | 용도 | +|------|------|------| +| `src/lib/permissions/utils.ts` | 87 | `findMatchingUrl` — 권한 URL 매칭 | +| `src/contexts/PermissionContext.tsx` | 101 | `isGateBypassed` — BYPASS 경로 판별 | +| `src/components/common/ParentMenuRedirect.tsx` | 44, 62 | 부모 메뉴 리다이렉트 경로 비교 | + +### 영향받은 경로 + +현재 라우트 중 소문자 2글자 경로는 `/hr`만 해당. +다만 향후 소문자 2글자 경로(예: `/qa`, `/cs` 등)가 추가될 경우 동일 BUG 발생 가능했음. + +### 검증 결과 + +| 테스트 | 결과 | +|--------|------| +| 인사관리 조회=OFF → `/hr/attendance-management` 접근 | ✅ "접근 권한이 없습니다" 표시 | +| 인사관리 조회=ON 복원 → `/hr/attendance-management` 접근 | ✅ 정상 페이지 표시 | +| 기존 동작(품질관리 등) 영향 없음 | ✅ 변경 없음 | + +### 새 페이지 추가 시 주의사항 + +1. **locale 관련 정규식 수정 시**: 반드시 `src/i18n/config.ts`의 `locales` 배열과 동기화할 것 +2. **새 locale 추가 시**: `config.ts`뿐 아니라 위 3개 파일의 정규식에도 추가 필요 + - 예: 중국어 추가 시 `/^\/(ko|en|ja)(\/|$)/` → `/^\/(ko|en|ja|zh)(\/|$)/` +3. **소문자 2글자 경로 추가 시**: 이 BUG가 이전에 발생했던 이유를 참고하여, 해당 경로가 locale과 충돌하지 않는지 확인 +4. **권한 시스템 전체 아키텍처**: `findMatchingUrl`은 longest-prefix-match를 사용하므로, `/hr` 카테고리 권한이 `/hr/attendance-management` 하위 페이지에도 자동 적용됨 diff --git a/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md b/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md index effee8dc..0df29fba 100644 --- a/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md +++ b/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md @@ -52,6 +52,8 @@ |------|------| | `src/store/menuStore.ts` | Zustand 메뉴 상태 관리 | | `src/lib/utils/menuTransform.ts` | API 메뉴 → UI 메뉴 변환 | +| `src/lib/utils/menuRefresh.ts` | 메뉴 갱신 유틸리티 (해시 비교, localStorage/Zustand 동시 업데이트) | +| `src/hooks/useMenuPolling.ts` | 메뉴 폴링 훅 (30초 간격, 탭 가시성, 세션 만료 처리) | | `src/layouts/AuthenticatedLayout.tsx` | 메뉴 로드 및 스토어 설정 | | `src/components/layout/Sidebar.tsx` | 메뉴 렌더링 | | `src/contexts/AuthContext.tsx` | 사용자 인증 컨텍스트 | @@ -229,28 +231,30 @@ export async function refreshMenus(): Promise { ```typescript // src/hooks/useMenuPolling.ts -import { useEffect, useRef } from 'react'; -import { refreshMenus } from '@/lib/utils/menuRefresh'; +// 주요 기능: 30초 폴링, 탭 가시성 처리, 세션 만료 감지(3회 연속 401), 토큰 갱신 쿠키 감지 -const POLLING_INTERVAL = 30000; // 30초 - -export function useMenuPolling(enabled: boolean = true) { - const intervalRef = useRef(null); +export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPollingReturn { + // ⚠️ 콜백 안정화 패턴 (2026-02-03 버그 수정) + // 부모 컴포넌트에서 인라인 콜백을 전달하면 매 렌더마다 새 참조가 생성되어 + // executeRefresh → useEffect 의존성이 변경 → setInterval이 매 렌더마다 리셋되는 버그 발생. + // 해결: 콜백을 ref로 저장하여 executeRefresh 의존성에서 제거. + const onMenuUpdatedRef = useRef(onMenuUpdated); + const onErrorRef = useRef(onError); + const onSessionExpiredRef = useRef(onSessionExpired); useEffect(() => { - if (!enabled) return; + onMenuUpdatedRef.current = onMenuUpdated; + onErrorRef.current = onError; + onSessionExpiredRef.current = onSessionExpired; + }); - // 초기 실행은 하지 않음 (로그인 시 이미 받아옴) - intervalRef.current = setInterval(() => { - refreshMenus(); - }, POLLING_INTERVAL); - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - }; - }, [enabled]); + // executeRefresh 의존성: [stopPolling] 만 — 안정적 + const executeRefresh = useCallback(async () => { + // ref를 통해 최신 콜백 호출 + onMenuUpdatedRef.current?.(); + onSessionExpiredRef.current?.(); + onErrorRef.current?.(result.error); + }, [stopPolling]); } ``` @@ -303,6 +307,40 @@ menuStore.setMenuItems(newMenus); // UI 즉시 반영 ## 작성 정보 - **작성일**: 2025-12-29 -- **상태**: ✅ 1단계 구현 완료 (테스트 대기) +- **최종 수정**: 2026-02-03 +- **상태**: ✅ 1단계 구현 완료 + 폴링 버그 수정 - **담당**: 프론트엔드 팀 -- **백엔드**: `GET /api/v1/menus` API 이미 존재 ✅ \ No newline at end of file +- **백엔드**: `GET /api/v1/menus` API 이미 존재 ✅ + +--- + +## 변경 이력 + +### 2026-02-03: 폴링 인터벌 리셋 버그 수정 + +**문제**: 메뉴 폴링이 실제로 실행되지 않아 백엔드에서 메뉴를 추가해도 재로그인 전까지 반영되지 않음. + +**원인**: `useMenuPolling` 훅의 `executeRefresh` 콜백이 매 렌더마다 새 참조를 생성하여 `setInterval`이 리셋됨. + +``` +AuthenticatedLayout에서 인라인 콜백 전달: + onMenuUpdated: () => { ... } ← 매 렌더마다 새 함수 + onSessionExpired: () => { ... } ← 매 렌더마다 새 함수 + ↓ + executeRefresh deps: [onMenuUpdated, onError, onSessionExpired, stopPolling] + ↓ 매 렌더마다 변경 + useEffect deps: [executeRefresh] → clearInterval → setInterval 재설정 + ↓ + 알림 폴링이 30초마다 state 업데이트 → 리렌더 → 메뉴 인터벌 리셋 + ↓ + 메뉴 폴링이 30초에 도달하지 못하고 영원히 미실행 +``` + +**수정**: 콜백을 `useRef`로 안정화하여 `executeRefresh` 의존성에서 제거. + +``` +수정 전: executeRefresh deps = [onMenuUpdated, onError, onSessionExpired, stopPolling] +수정 후: executeRefresh deps = [stopPolling] ← 안정적, 인터벌 리셋 없음 +``` + +**수정 파일**: `src/hooks/useMenuPolling.ts` \ No newline at end of file diff --git a/claudedocs/dev/[REF] all-pages-test-urls.md b/claudedocs/dev/[REF] all-pages-test-urls.md index 73dc2b84..885745cc 100644 --- a/claudedocs/dev/[REF] all-pages-test-urls.md +++ b/claudedocs/dev/[REF] all-pages-test-urls.md @@ -88,10 +88,14 @@ http://localhost:3000/ko/sales/quote-management/test/1/edit # 🧪 견적 수 |--------|-----|------| | 품목기준관리 | `/ko/master-data/item-master-data-management` | ✅ | | **공정관리** | `/ko/master-data/process-management` | ✅ | +| **단가표관리** | `/ko/master-data/pricing-table-management` | 🆕 NEW | +| **└ 단가배포관리** | `/ko/master-data/price-distribution` | 🆕 NEW | ``` http://localhost:3000/ko/master-data/item-master-data-management -http://localhost:3000/ko/master-data/process-management # 공정관리 +http://localhost:3000/ko/master-data/process-management # 공정관리 +http://localhost:3000/ko/master-data/pricing-table-management # 🆕 단가표관리 +http://localhost:3000/ko/master-data/price-distribution # 🆕 단가배포관리 ``` --- @@ -129,9 +133,11 @@ http://localhost:3000/ko/material/stock-status # 🆕 재고현황 | 페이지 | URL | 상태 | |--------|-----|------| | **검사관리** | `/ko/quality/inspections` | 🆕 NEW | +| **실적신고관리** | `/ko/quality/performance-reports` | 🆕 NEW | ``` -http://localhost:3000/ko/quality/inspections # 🆕 검사관리 +http://localhost:3000/ko/quality/inspections # 🆕 검사관리 +http://localhost:3000/ko/quality/performance-reports # 🆕 실적신고관리 ``` --- @@ -353,6 +359,8 @@ http://localhost:3000/ko/sales/pricing-management ### Master Data ``` http://localhost:3000/ko/master-data/item-master-data-management +http://localhost:3000/ko/master-data/pricing-table-management # 🆕 단가표관리 +http://localhost:3000/ko/master-data/price-distribution # 🆕 단가배포관리 ``` ### Production @@ -369,7 +377,8 @@ http://localhost:3000/ko/material/stock-status # 🆕 재고현황 ### Quality ``` -http://localhost:3000/ko/quality/inspections # 🆕 검사관리 +http://localhost:3000/ko/quality/inspections # 🆕 검사관리 +http://localhost:3000/ko/quality/performance-reports # 🆕 실적신고관리 ``` ### Outbound @@ -477,6 +486,8 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트 // Master Data '/master-data/item-master-data-management' +'/master-data/pricing-table-management' // 단가표관리 (🆕 NEW) +'/master-data/price-distribution' // 단가배포관리 (🆕 NEW) // Production '/production/screen-production' @@ -488,6 +499,7 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트 // Quality (품질관리) '/quality/inspections' // 검사관리 (🆕 NEW) +'/quality/performance-reports' // 실적신고관리 (🆕 NEW) // Outbound (출고관리) '/outbound/shipments' // 출하관리 (🆕 NEW) @@ -555,4 +567,4 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트 ## 작성일 - 최초 작성: 2025-12-06 -- 최종 업데이트: 2026-02-02 (배차차량관리 추가) +- 최종 업데이트: 2026-02-03 (단가배포관리 추가) diff --git a/src/app/[locale]/(protected)/master-data/price-distribution/[id]/edit/page.tsx b/src/app/[locale]/(protected)/master-data/price-distribution/[id]/edit/page.tsx new file mode 100644 index 00000000..6c915a30 --- /dev/null +++ b/src/app/[locale]/(protected)/master-data/price-distribution/[id]/edit/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { use } from 'react'; +import { PriceDistributionDetail } from '@/components/pricing-distribution'; + +export default function PriceDistributionEditPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + + return ; +} diff --git a/src/app/[locale]/(protected)/master-data/price-distribution/[id]/page.tsx b/src/app/[locale]/(protected)/master-data/price-distribution/[id]/page.tsx new file mode 100644 index 00000000..f1aae77d --- /dev/null +++ b/src/app/[locale]/(protected)/master-data/price-distribution/[id]/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { use } from 'react'; +import { PriceDistributionDetail } from '@/components/pricing-distribution'; + +export default function PriceDistributionDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + + return ; +} diff --git a/src/app/[locale]/(protected)/master-data/price-distribution/page.tsx b/src/app/[locale]/(protected)/master-data/price-distribution/page.tsx new file mode 100644 index 00000000..4fc96e95 --- /dev/null +++ b/src/app/[locale]/(protected)/master-data/price-distribution/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Suspense } from 'react'; +import { PriceDistributionList } from '@/components/pricing-distribution'; +import { ListPageSkeleton } from '@/components/ui/skeleton'; + +export default function PriceDistributionPage() { + return ( + }> + + + ); +} diff --git a/src/app/[locale]/(protected)/master-data/pricing-table-management/[id]/page.tsx b/src/app/[locale]/(protected)/master-data/pricing-table-management/[id]/page.tsx new file mode 100644 index 00000000..10aa9a5e --- /dev/null +++ b/src/app/[locale]/(protected)/master-data/pricing-table-management/[id]/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { use } from 'react'; +import { PricingTableDetailClient } from '@/components/pricing-table-management'; + +export default function PricingTableDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + + return ; +} diff --git a/src/app/[locale]/(protected)/master-data/pricing-table-management/page.tsx b/src/app/[locale]/(protected)/master-data/pricing-table-management/page.tsx new file mode 100644 index 00000000..4536ab88 --- /dev/null +++ b/src/app/[locale]/(protected)/master-data/pricing-table-management/page.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import PricingTableListClient from '@/components/pricing-table-management/PricingTableListClient'; +import { PricingTableDetailClient } from '@/components/pricing-table-management'; +import { ListPageSkeleton } from '@/components/ui/skeleton'; + +function PricingTableManagementContent() { + const searchParams = useSearchParams(); + const mode = searchParams.get('mode'); + + if (mode === 'new') { + return ; + } + + return ; +} + +export default function PricingTableManagementPage() { + return ( + }> + + + ); +} diff --git a/src/app/[locale]/(protected)/quality/performance-reports/page.tsx b/src/app/[locale]/(protected)/quality/performance-reports/page.tsx new file mode 100644 index 00000000..76eba135 --- /dev/null +++ b/src/app/[locale]/(protected)/quality/performance-reports/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +/** + * 실적신고관리 페이지 + * URL: /quality/performance-reports + */ + +import { PerformanceReportList } from '@/components/quality/PerformanceReportManagement'; + +export default function PerformanceReportsPage() { + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index b5f4a7dd..7556a068 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -222,6 +222,8 @@ html { /* 🔧 Always show scrollbar to prevent layout shift */ /*overflow-y: scroll;*/ + /* 📱 모바일 확대 후 좌우 패닝 허용 */ + touch-action: manipulation; } body { diff --git a/src/components/common/ParentMenuRedirect.tsx b/src/components/common/ParentMenuRedirect.tsx index 11fce179..6482c4ce 100644 --- a/src/components/common/ParentMenuRedirect.tsx +++ b/src/components/common/ParentMenuRedirect.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useRouter, usePathname } from 'next/navigation'; +import { stripLocaleSlashPrefix } from '@/lib/utils/locale'; interface ParentMenuRedirectProps { /** 현재 부모 메뉴 경로 (예: '/accounting') */ @@ -41,7 +42,7 @@ export function ParentMenuRedirect({ parentPath, fallbackPath }: ParentMenuRedir const findParentMenu = (items: any[], targetPath: string): any | null => { for (const item of items) { // 경로가 일치하는지 확인 (locale prefix 제거 후 비교) - const itemPath = item.path?.replace(/^\/(ko|en|ja)\//, '/') || ''; + const itemPath = stripLocaleSlashPrefix(item.path || ''); if (itemPath === targetPath || item.path === targetPath) { return item; } @@ -59,7 +60,7 @@ export function ParentMenuRedirect({ parentPath, fallbackPath }: ParentMenuRedir if (parentMenu && parentMenu.children && parentMenu.children.length > 0) { // 첫 번째 자식 메뉴의 경로로 리다이렉트 const firstChild = parentMenu.children[0]; - const firstChildPath = firstChild.path?.replace(/^\/(ko|en|ja)\//, '/') || fallbackPath; + const firstChildPath = stripLocaleSlashPrefix(firstChild.path || '') || fallbackPath; router.replace(firstChildPath); } else { // 자식이 없으면 fallback으로 이동 diff --git a/src/components/common/PermissionGuard.tsx b/src/components/common/PermissionGuard.tsx index c4351e39..aca27a64 100644 --- a/src/components/common/PermissionGuard.tsx +++ b/src/components/common/PermissionGuard.tsx @@ -1,7 +1,7 @@ 'use client'; import { usePermission } from '@/hooks/usePermission'; -import type { PermissionAction } from '@/lib/permissions/types'; +import type { PermissionAction, UsePermissionReturn } from '@/lib/permissions/types'; interface PermissionGuardProps { action: PermissionAction; @@ -34,16 +34,8 @@ export function PermissionGuard({ }: PermissionGuardProps) { const permission = usePermission(url); - const actionMap: Record = { - view: permission.canView, - create: permission.canCreate, - update: permission.canUpdate, - delete: permission.canDelete, - approve: permission.canApprove, - export: permission.canExport, - }; - - if (!actionMap[action]) { + const key = `can${action.charAt(0).toUpperCase()}${action.slice(1)}` as keyof UsePermissionReturn; + if (!permission[key]) { return <>{fallback}; } diff --git a/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx b/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx new file mode 100644 index 00000000..e6e4b9bf --- /dev/null +++ b/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx @@ -0,0 +1,235 @@ +'use client'; + +/** + * 재고 조정 팝업 (기획서 Page 78) + * + * - 품목 선택 (검색) + * - 유형 필터 셀렉트 박스 (전체, 유형 목록) + * - 테이블: 로트번호, 품목코드, 품목유형, 품목명, 규격, 단위, 재고량, 증감 수량 + * - 증감 수량: 양수/음수 입력 가능 + * - 취소/저장 버튼 + */ + +import { useState, useMemo, useCallback } from 'react'; +import { Search, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { toast } from 'sonner'; +import type { InventoryAdjustmentItem } from './types'; + +// 목데이터 - 품목 유형 목록 +const ITEM_TYPE_OPTIONS = [ + { value: 'all', label: '전체' }, + { value: 'raw', label: '원자재' }, + { value: 'sub', label: '부자재' }, + { value: 'part', label: '부품' }, + { value: 'product', label: '완제품' }, +]; + +// 목데이터 - 재고 품목 목록 +const MOCK_STOCK_ITEMS: InventoryAdjustmentItem[] = [ + { id: '1', lotNo: 'LOT-2026-001', itemCode: 'STEEL-001', itemType: '원자재', itemName: 'SUS304 스테인리스 판재', specification: '1000x2000x3T', unit: 'EA', stockQty: 100 }, + { id: '2', lotNo: 'LOT-2026-002', itemCode: 'ELEC-002', itemType: '부품', itemName: 'MCU 컨트롤러 IC', specification: 'STM32F103C8T6', unit: 'EA', stockQty: 500 }, + { id: '3', lotNo: 'LOT-2026-003', itemCode: 'PLAS-003', itemType: '부자재', itemName: 'ABS 사출 케이스', specification: '150x100x50', unit: 'SET', stockQty: 200 }, + { id: '4', lotNo: 'LOT-2026-004', itemCode: 'STEEL-002', itemType: '원자재', itemName: '알루미늄 프로파일', specification: '40x40x2000L', unit: 'EA', stockQty: 50 }, + { id: '5', lotNo: 'LOT-2026-005', itemCode: 'ELEC-005', itemType: '부품', itemName: 'DC 모터 24V', specification: '24V 100RPM', unit: 'EA', stockQty: 80 }, + { id: '6', lotNo: 'LOT-2026-006', itemCode: 'CHEM-001', itemType: '부자재', itemName: '에폭시 접착제', specification: '500ml', unit: 'EA', stockQty: 300 }, + { id: '7', lotNo: 'LOT-2026-007', itemCode: 'ELEC-007', itemType: '부품', itemName: '커패시터 100uF', specification: '100uF 50V', unit: 'EA', stockQty: 1000 }, +]; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave?: (items: InventoryAdjustmentItem[]) => void; +} + +export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props) { + const [search, setSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [adjustments, setAdjustments] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + + // 필터링된 품목 목록 + const filteredItems = useMemo(() => { + return MOCK_STOCK_ITEMS.filter((item) => { + const matchesSearch = !search || + item.itemCode.toLowerCase().includes(search.toLowerCase()) || + item.itemName.toLowerCase().includes(search.toLowerCase()) || + item.lotNo.toLowerCase().includes(search.toLowerCase()); + const matchesType = typeFilter === 'all' || item.itemType === ITEM_TYPE_OPTIONS.find(o => o.value === typeFilter)?.label; + return matchesSearch && matchesType; + }); + }, [search, typeFilter]); + + // 증감 수량 변경 + const handleAdjustmentChange = useCallback((itemId: string, value: string) => { + const numValue = value === '' || value === '-' ? undefined : Number(value); + setAdjustments((prev) => ({ + ...prev, + [itemId]: numValue, + })); + }, []); + + // 저장 + const handleSave = async () => { + const itemsWithAdjustment = MOCK_STOCK_ITEMS + .filter((item) => adjustments[item.id] !== undefined && adjustments[item.id] !== 0) + .map((item) => ({ ...item, adjustmentQty: adjustments[item.id] })); + + if (itemsWithAdjustment.length === 0) { + toast.error('증감 수량을 입력해주세요.'); + return; + } + + setIsSaving(true); + try { + onSave?.(itemsWithAdjustment); + toast.success('재고 조정이 저장되었습니다.'); + handleClose(); + } catch { + toast.error('저장 중 오류가 발생했습니다.'); + } finally { + setIsSaving(false); + } + }; + + // 닫기 (상태 초기화) + const handleClose = () => { + setSearch(''); + setTypeFilter('all'); + setAdjustments({}); + onOpenChange(false); + }; + + return ( + + + + 재고 조정 + + +
+ {/* 품목 선택 - 검색 */} +
품목 선택
+
+ setSearch(e.target.value)} + placeholder="품목코드, 품목명, 로트번호 검색" + className="pr-10" + /> + +
+ + {/* 총 건수 + 유형 필터 */} +
+ + 총 {filteredItems.length}건 + + +
+ + {/* 테이블 */} +
+ + + + 로트번호 + 품목코드 + 품목유형 + 품목명 + 규격 + 단위 + 재고량 + 증감 수량 + + + + {filteredItems.map((item) => ( + + {item.lotNo} + {item.itemCode} + {item.itemType} + {item.itemName} + {item.specification} + {item.unit} + {item.stockQty} + + handleAdjustmentChange(item.id, e.target.value)} + className="h-8 text-sm text-center w-[80px] mx-auto" + placeholder="0" + /> + + + ))} + {filteredItems.length === 0 && ( + + + 검색 결과가 없습니다. + + + )} + +
+
+ + {/* 하단 버튼 */} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/material/ReceivingManagement/ReceivingDetail.tsx b/src/components/material/ReceivingManagement/ReceivingDetail.tsx index 6740dafe..347cf2d4 100644 --- a/src/components/material/ReceivingManagement/ReceivingDetail.tsx +++ b/src/components/material/ReceivingManagement/ReceivingDetail.tsx @@ -16,7 +16,7 @@ import { useState, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { Upload, FileText, Search, X } from 'lucide-react'; +import { Upload, FileText, Search, X, Plus } from 'lucide-react'; import { FileDropzone } from '@/components/ui/file-dropzone'; import { ItemSearchModal } from '@/components/quotes/ItemSearchModal'; import { InspectionModalV2 } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModalV2'; @@ -40,10 +40,19 @@ import { createReceiving, updateReceiving, } from './actions'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; import { RECEIVING_STATUS_OPTIONS, type ReceivingDetail as ReceivingDetailType, type ReceivingStatus, + type InventoryAdjustmentRecord, } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { toast } from 'sonner'; @@ -56,6 +65,7 @@ interface Props { // 초기 폼 데이터 const INITIAL_FORM_DATA: Partial = { + materialNo: '', lotNo: '', itemCode: '', itemName: '', @@ -71,6 +81,7 @@ const INITIAL_FORM_DATA: Partial = { inspectionDate: '', inspectionResult: '', certificateFile: undefined, + inventoryAdjustments: [], }; // 로트번호 생성 (YYMMDD-NN) @@ -121,6 +132,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { const [isItemSearchOpen, setIsItemSearchOpen] = useState(false); const [isSupplierSearchOpen, setIsSupplierSearchOpen] = useState(false); + // 재고 조정 이력 상태 + const [adjustments, setAdjustments] = useState([]); + // Dev 모드 폼 자동 채우기 useDevFill( 'receiving', @@ -159,9 +173,14 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { if (result.success && result.data) { setDetail(result.data); + // 재고 조정 이력 설정 + if (result.data.inventoryAdjustments) { + setAdjustments(result.data.inventoryAdjustments); + } // 수정 모드일 때 폼 데이터 설정 if (isEditMode) { setFormData({ + materialNo: result.data.materialNo || '', lotNo: result.data.lotNo || '', itemCode: result.data.itemCode, itemName: result.data.itemName, @@ -239,6 +258,30 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { setIsInspectionModalOpen(true); }; + // 재고 조정 행 추가 + const handleAddAdjustment = () => { + const newRecord: InventoryAdjustmentRecord = { + id: `adj-${Date.now()}`, + adjustmentDate: new Date().toISOString().split('T')[0], + quantity: 0, + inspector: getLoggedInUserName() || '홍길동', + }; + setAdjustments((prev) => [...prev, newRecord]); + }; + + // 재고 조정 행 삭제 + const handleRemoveAdjustment = (adjId: string) => { + setAdjustments((prev) => prev.filter((a) => a.id !== adjId)); + }; + + // 재고 조정 수량 변경 + const handleAdjustmentQtyChange = (adjId: string, value: string) => { + const numValue = value === '' || value === '-' ? 0 : Number(value); + setAdjustments((prev) => + prev.map((a) => (a.id === adjId ? { ...a, quantity: numValue } : a)) + ); + }; + // 취소 핸들러 - 등록 모드면 목록으로, 수정 모드면 상세로 이동 const handleCancel = () => { if (isNewMode) { @@ -277,6 +320,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
+ {renderReadOnlyField('자재번호', detail.materialNo)} {renderReadOnlyField('로트번호', detail.lotNo)} {renderReadOnlyField('품목코드', detail.itemCode)} {renderReadOnlyField('품목명', detail.itemName)} @@ -293,6 +337,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { detail.status === 'inspection_completed' ? '검사완료' : detail.status )} +
+ {/* 비고 - 전체 너비 */} +
{renderReadOnlyField('비고', detail.remark)}
@@ -317,15 +364,54 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { {detail.certificateFileName}
) : ( - '등록된 파일이 없습니다.' + '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.' )}
+ + {/* 재고 조정 */} + + + 재고 조정 + + +
+ + + + No + 조정일시 + 증감 수량 + 검수자 + + + + {adjustments.length > 0 ? ( + adjustments.map((adj, idx) => ( + + {idx + 1} + {adj.adjustmentDate} + {adj.quantity} + {adj.inspector} + + )) + ) : ( + + + 재고 조정 이력이 없습니다. + + + )} + +
+
+
+
); - }, [detail]); + }, [detail, adjustments]); // ===== 등록/수정 폼 콘텐츠 ===== const renderFormContent = useCallback(() => { @@ -338,6 +424,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
+ {/* 자재번호 - 읽기전용 */} + {renderReadOnlyField('자재번호', formData.materialNo, true)} + {/* 로트번호 - 읽기전용 */} {renderReadOnlyField('로트번호', formData.lotNo, true)} @@ -512,17 +601,107 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
+ + {/* 재고 조정 */} + + + 재고 조정 + + + +
+ + + + No + 조정일시 + 증감 수량 + 검수자 + + + + + {adjustments.length > 0 ? ( + adjustments.map((adj, idx) => ( + + {idx + 1} + + { + setAdjustments((prev) => + prev.map((a) => + a.id === adj.id ? { ...a, adjustmentDate: e.target.value } : a + ) + ); + }} + className="h-8 text-sm" + /> + + + handleAdjustmentQtyChange(adj.id, e.target.value)} + className="h-8 text-sm text-center w-[100px] mx-auto" + placeholder="0" + /> + + {adj.inspector} + + + + + )) + ) : ( + + + 재고 조정 이력이 없습니다. + + + )} + +
+
+
+
); - }, [formData]); + }, [formData, adjustments]); // ===== 커스텀 헤더 액션 (view/edit 모드) ===== const customHeaderActions = (isViewMode || isEditMode) && detail ? ( - <> +
- + {isViewMode && ( + + )} +
) : undefined; // 에러 상태 표시 (view/edit 모드에서만) diff --git a/src/components/material/ReceivingManagement/ReceivingList.tsx b/src/components/material/ReceivingManagement/ReceivingList.tsx index 2d22c127..d082220c 100644 --- a/src/components/material/ReceivingManagement/ReceivingList.tsx +++ b/src/components/material/ReceivingManagement/ReceivingList.tsx @@ -20,6 +20,7 @@ import { ClipboardCheck, Plus, Eye, + Settings2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -35,6 +36,7 @@ import { type FilterFieldConfig, } from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; +import { InventoryAdjustmentDialog } from './InventoryAdjustmentDialog'; import { getReceivings, getReceivingStats } from './actions'; import { RECEIVING_STATUS_LABELS, RECEIVING_STATUS_STYLES } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; @@ -50,6 +52,9 @@ export function ReceivingList() { const [stats, setStats] = useState(null); const [totalItems, setTotalItems] = useState(0); + // ===== 재고 조정 팝업 상태 ===== + const [isAdjustmentOpen, setIsAdjustmentOpen] = useState(false); + // ===== 날짜 범위 상태 (최근 30일) ===== const today = new Date(); const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000); @@ -139,7 +144,7 @@ export function ReceivingList() { const tableFooter = useMemo( () => ( - + 총 {totalItems}건 @@ -200,20 +205,23 @@ export function ReceivingList() { }, }, - // 테이블 컬럼 (기획서 순서) + // 테이블 컬럼 (기획서 2026-02-03 순서) columns: [ - { key: 'no', label: 'No.', className: 'w-[60px] text-center' }, + { key: 'no', label: 'No.', className: 'w-[50px] text-center' }, + { key: 'materialNo', label: '자재번호', className: 'w-[100px]' }, { key: 'lotNo', label: '로트번호', className: 'w-[120px]' }, - { key: 'inspectionStatus', label: '수입검사', className: 'w-[80px] text-center' }, - { key: 'inspectionDate', label: '검사일', className: 'w-[100px] text-center' }, + { key: 'inspectionStatus', label: '수입검사', className: 'w-[70px] text-center' }, + { key: 'inspectionDate', label: '검사일', className: 'w-[90px] text-center' }, { key: 'supplier', label: '발주처', className: 'min-w-[100px]' }, - { key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' }, - { key: 'itemName', label: '품목명', className: 'min-w-[150px]' }, - { key: 'specification', label: '규격', className: 'w-[100px]' }, - { key: 'unit', label: '단위', className: 'w-[60px] text-center' }, - { key: 'receivingQty', label: '입고수량', className: 'w-[80px] text-center' }, - { key: 'receivingDate', label: '입고일', className: 'w-[100px] text-center' }, - { key: 'createdBy', label: '작성자', className: 'w-[80px] text-center' }, + { key: 'manufacturer', label: '제조사', className: 'min-w-[100px]' }, + { key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' }, + { key: 'itemType', label: '품목유형', className: 'w-[80px] text-center' }, + { key: 'itemName', label: '품목명', className: 'min-w-[130px]' }, + { key: 'specification', label: '규격', className: 'w-[90px]' }, + { key: 'unit', label: '단위', className: 'w-[50px] text-center' }, + { key: 'receivingQty', label: '수량', className: 'w-[60px] text-center' }, + { key: 'receivingDate', label: '입고변경일', className: 'w-[100px] text-center' }, + { key: 'createdBy', label: '작성자', className: 'w-[70px] text-center' }, { key: 'status', label: '상태', className: 'w-[80px] text-center' }, ], @@ -250,17 +258,27 @@ export function ReceivingList() { // 통계 카드 stats: statCards, - // 헤더 액션 (입고 등록 버튼) + // 헤더 액션 (재고 조정 + 입고 등록 버튼) headerActions: () => ( - +
+ + +
), // 테이블 푸터 @@ -286,12 +304,15 @@ export function ReceivingList() { />
{globalIndex} + {item.materialNo || '-'} {item.lotNo || '-'} {item.inspectionStatus || '-'} {item.inspectionDate || '-'} {item.supplier} + {item.manufacturer || '-'} {item.itemCode} - {item.itemName} + {item.itemType || '-'} + {item.itemName} {item.specification || '-'} {item.unit} @@ -342,12 +363,14 @@ export function ReceivingList() { } infoGrid={
+ + + - - - + +
} actions={ @@ -376,9 +399,17 @@ export function ReceivingList() { ); return ( - setFilterValues(newFilters)} - /> + <> + setFilterValues(newFilters)} + /> + + {/* 재고 조정 팝업 */} + + ); } diff --git a/src/components/material/ReceivingManagement/actions.ts b/src/components/material/ReceivingManagement/actions.ts index 00808ccc..e142cfec 100644 --- a/src/components/material/ReceivingManagement/actions.ts +++ b/src/components/material/ReceivingManagement/actions.ts @@ -31,11 +31,14 @@ import type { const MOCK_RECEIVING_LIST: ReceivingItem[] = [ { id: '1', + materialNo: 'MAT-001', lotNo: 'LOT-2026-001', inspectionStatus: '적', inspectionDate: '2026-01-25', supplier: '(주)대한철강', + manufacturer: '포스코', itemCode: 'STEEL-001', + itemType: '원자재', itemName: 'SUS304 스테인리스 판재', specification: '1000x2000x3T', unit: 'EA', @@ -46,11 +49,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [ }, { id: '2', + materialNo: 'MAT-002', lotNo: 'LOT-2026-002', inspectionStatus: '적', inspectionDate: '2026-01-26', supplier: '삼성전자부품', + manufacturer: '삼성전자', itemCode: 'ELEC-002', + itemType: '부품', itemName: 'MCU 컨트롤러 IC', specification: 'STM32F103C8T6', unit: 'EA', @@ -61,11 +67,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [ }, { id: '3', + materialNo: 'MAT-003', lotNo: 'LOT-2026-003', inspectionStatus: '-', inspectionDate: undefined, supplier: '한국플라스틱', + manufacturer: '한국플라스틱', itemCode: 'PLAS-003', + itemType: '부자재', itemName: 'ABS 사출 케이스', specification: '150x100x50', unit: 'SET', @@ -76,11 +85,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [ }, { id: '4', + materialNo: 'MAT-004', lotNo: 'LOT-2026-004', inspectionStatus: '부적', inspectionDate: '2026-01-27', supplier: '(주)대한철강', + manufacturer: '포스코', itemCode: 'STEEL-002', + itemType: '원자재', itemName: '알루미늄 프로파일', specification: '40x40x2000L', unit: 'EA', @@ -91,11 +103,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [ }, { id: '5', + materialNo: 'MAT-005', lotNo: 'LOT-2026-005', inspectionStatus: '-', inspectionDate: undefined, supplier: '글로벌전자', + manufacturer: '글로벌전자', itemCode: 'ELEC-005', + itemType: '부품', itemName: 'DC 모터 24V', specification: '24V 100RPM', unit: 'EA', @@ -106,11 +121,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [ }, { id: '6', + materialNo: 'MAT-006', lotNo: 'LOT-2026-006', inspectionStatus: '적', inspectionDate: '2026-01-24', supplier: '동양화학', + manufacturer: '동양화학', itemCode: 'CHEM-001', + itemType: '부자재', itemName: '에폭시 접착제', specification: '500ml', unit: 'EA', @@ -121,11 +139,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [ }, { id: '7', + materialNo: 'MAT-007', lotNo: 'LOT-2026-007', inspectionStatus: '적', inspectionDate: '2026-01-28', supplier: '삼성전자부품', + manufacturer: '삼성전자', itemCode: 'ELEC-007', + itemType: '부품', itemName: '커패시터 100uF', specification: '100uF 50V', unit: 'EA', @@ -136,11 +157,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [ }, { id: '8', + materialNo: 'MAT-008', lotNo: 'LOT-2026-008', inspectionStatus: '-', inspectionDate: undefined, supplier: '한국볼트', + manufacturer: '한국볼트', itemCode: 'BOLT-001', + itemType: '부품', itemName: 'SUS 볼트 M8x30', specification: 'M8x30 SUS304', unit: 'EA', @@ -158,11 +182,11 @@ const MOCK_RECEIVING_STATS: ReceivingStats = { inspectionCompletedCount: 5, }; -// 기획서 2026-01-28 기준 상세 목데이터 +// 기획서 2026-02-03 기준 상세 목데이터 const MOCK_RECEIVING_DETAIL: Record = { '1': { id: '1', - // 기본 정보 + materialNo: 'MAT-001', lotNo: 'LOT-2026-001', itemCode: 'STEEL-001', itemName: 'SUS304 스테인리스 판재', @@ -175,16 +199,21 @@ const MOCK_RECEIVING_DETAIL: Record = { createdBy: '김철수', status: 'completed', remark: '', - // 수입검사 정보 inspectionDate: '2026-01-25', inspectionResult: '합격', certificateFile: undefined, - // 하위 호환 + inventoryAdjustments: [ + { id: 'adj-1', adjustmentDate: '2026-01-05', quantity: 10, inspector: '홍길동' }, + { id: 'adj-2', adjustmentDate: '2026-01-05', quantity: 5, inspector: '홍길동' }, + { id: 'adj-3', adjustmentDate: '2026-01-05', quantity: -15, inspector: '홍길동' }, + { id: 'adj-4', adjustmentDate: '2026-01-05', quantity: 5, inspector: '홍길동' }, + ], orderNo: 'PO-2026-001', orderUnit: 'EA', }, '2': { id: '2', + materialNo: 'MAT-002', lotNo: 'LOT-2026-002', itemCode: 'ELEC-002', itemName: 'MCU 컨트롤러 IC', @@ -199,11 +228,13 @@ const MOCK_RECEIVING_DETAIL: Record = { remark: '긴급 입고', inspectionDate: '2026-01-26', inspectionResult: '합격', + inventoryAdjustments: [], orderNo: 'PO-2026-002', orderUnit: 'EA', }, '3': { id: '3', + materialNo: 'MAT-003', lotNo: 'LOT-2026-003', itemCode: 'PLAS-003', itemName: 'ABS 사출 케이스', @@ -218,11 +249,13 @@ const MOCK_RECEIVING_DETAIL: Record = { remark: '', inspectionDate: undefined, inspectionResult: undefined, + inventoryAdjustments: [], orderNo: 'PO-2026-003', orderUnit: 'SET', }, '4': { id: '4', + materialNo: 'MAT-004', lotNo: 'LOT-2026-004', itemCode: 'STEEL-002', itemName: '알루미늄 프로파일', @@ -237,11 +270,13 @@ const MOCK_RECEIVING_DETAIL: Record = { remark: '검사 진행 중', inspectionDate: '2026-01-27', inspectionResult: '불합격', + inventoryAdjustments: [], orderNo: 'PO-2026-004', orderUnit: 'EA', }, '5': { id: '5', + materialNo: 'MAT-005', lotNo: 'LOT-2026-005', itemCode: 'ELEC-005', itemName: 'DC 모터 24V', @@ -256,6 +291,7 @@ const MOCK_RECEIVING_DETAIL: Record = { remark: '', inspectionDate: undefined, inspectionResult: undefined, + inventoryAdjustments: [], orderNo: 'PO-2026-005', orderUnit: 'EA', }, diff --git a/src/components/material/ReceivingManagement/index.ts b/src/components/material/ReceivingManagement/index.ts index 69af03b2..1193b4df 100644 --- a/src/components/material/ReceivingManagement/index.ts +++ b/src/components/material/ReceivingManagement/index.ts @@ -7,5 +7,6 @@ export { ReceivingDetail } from './ReceivingDetail'; export { InspectionCreate } from './InspectionCreate'; export { ReceivingProcessDialog } from './ReceivingProcessDialog'; export { ReceivingReceiptDialog } from './ReceivingReceiptDialog'; +export { InventoryAdjustmentDialog } from './InventoryAdjustmentDialog'; export { SuccessDialog } from './SuccessDialog'; export * from './types'; \ No newline at end of file diff --git a/src/components/material/ReceivingManagement/types.ts b/src/components/material/ReceivingManagement/types.ts index e6a59c94..3f2ec604 100644 --- a/src/components/material/ReceivingManagement/types.ts +++ b/src/components/material/ReceivingManagement/types.ts @@ -41,16 +41,19 @@ export const RECEIVING_STATUS_OPTIONS = [ // 입고 목록 아이템 export interface ReceivingItem { id: string; + materialNo?: string; // 자재번호 lotNo?: string; // 로트번호 inspectionStatus?: string; // 수입검사 (적/부적/-) inspectionDate?: string; // 검사일 supplier: string; // 발주처 + manufacturer?: string; // 제조사 itemCode: string; // 품목코드 + itemType?: string; // 품목유형 itemName: string; // 품목명 specification?: string; // 규격 unit: string; // 단위 - receivingQty?: number; // 입고수량 - receivingDate?: string; // 입고일 + receivingQty?: number; // 수량 + receivingDate?: string; // 입고변경일 createdBy?: string; // 작성자 status: ReceivingStatus; // 상태 // 기존 필드 (하위 호환) @@ -59,10 +62,11 @@ export interface ReceivingItem { orderUnit?: string; // 발주단위 } -// 입고 상세 정보 (기획서 2026-01-28 기준) +// 입고 상세 정보 (기획서 2026-02-03 기준) export interface ReceivingDetail { id: string; // 기본 정보 + materialNo?: string; // 자재번호 (읽기전용) lotNo?: string; // 로트번호 (읽기전용) itemCode: string; // 품목코드 (수정가능) itemName: string; // 품목명 (읽기전용 - 품목코드 선택 시 자동) @@ -80,6 +84,8 @@ export interface ReceivingDetail { inspectionResult?: string; // 검사결과 (읽기전용) - 합격/불합격 certificateFile?: string; // 업체 제공 성적서 자료 (수정가능) certificateFileName?: string; // 파일명 + // 재고 조정 이력 + inventoryAdjustments?: InventoryAdjustmentRecord[]; // 기존 필드 (하위 호환) orderNo?: string; // 발주번호 orderDate?: string; // 발주일자 @@ -92,6 +98,27 @@ export interface ReceivingDetail { receivingManager?: string; // 입고담당 } +// 재고 조정 이력 레코드 (입고 상세 내) +export interface InventoryAdjustmentRecord { + id: string; + adjustmentDate: string; // 조정일시 + quantity: number; // 증감 수량 (양수/음수) + inspector: string; // 검수자 +} + +// 재고 조정 팝업용 품목 아이템 +export interface InventoryAdjustmentItem { + id: string; + lotNo: string; // 로트번호 + itemCode: string; // 품목코드 + itemType: string; // 품목유형 + itemName: string; // 품목명 + specification: string; // 규격 + unit: string; // 단위 + stockQty: number; // 재고량 + adjustmentQty?: number; // 증감 수량 +} + // 검사 대상 아이템 export interface InspectionTarget { id: string; diff --git a/src/components/material/StockStatus/StockStatusDetail.tsx b/src/components/material/StockStatus/StockStatusDetail.tsx index 681cf8ed..a600c76e 100644 --- a/src/components/material/StockStatus/StockStatusDetail.tsx +++ b/src/components/material/StockStatus/StockStatusDetail.tsx @@ -4,13 +4,12 @@ * 재고현황 상세/수정 페이지 * * 기획서 기준: - * - 기본 정보: 재고번호, 품목코드, 품목명, 규격, 단위, 계산 재고량 (읽기 전용) - * - 수정 가능: 실제 재고량, 안전재고, 상태 (사용/미사용) + * - 기본 정보: 자재번호, 품목코드, 품목유형, 품목명, 규격, 단위, 재고량 (읽기 전용) + * - 수정 가능: 안전재고, 상태 (사용/미사용) */ import { useState, useCallback, useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Loader2 } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -25,7 +24,8 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai import { stockStatusConfig } from './stockStatusConfig'; import { ServerErrorPage } from '@/components/common/ServerErrorPage'; import { getStockById, updateStock } from './actions'; -import { USE_STATUS_LABELS } from './types'; +import { USE_STATUS_LABELS, ITEM_TYPE_LABELS } from './types'; +import type { ItemType } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { toast } from 'sonner'; @@ -38,11 +38,11 @@ interface StockDetailData { id: string; stockNumber: string; itemCode: string; + itemType: ItemType; itemName: string; specification: string; unit: string; calculatedQty: number; - actualQty: number; safetyStock: number; useStatus: 'active' | 'inactive'; } @@ -59,11 +59,9 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) { // 폼 데이터 (수정 모드용) const [formData, setFormData] = useState<{ - actualQty: number; safetyStock: number; useStatus: 'active' | 'inactive'; }>({ - actualQty: 0, safetyStock: 0, useStatus: 'active', }); @@ -86,17 +84,16 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) { id: data.id, stockNumber: data.id, // stockNumber가 없으면 id 사용 itemCode: data.itemCode, + itemType: (data.itemType || 'RM') as ItemType, itemName: data.itemName, specification: data.specification || '-', unit: data.unit, - calculatedQty: data.currentStock, // 계산 재고량 - actualQty: data.currentStock, // 실제 재고량 (별도 필드 없으면 currentStock 사용) + calculatedQty: data.currentStock, // 재고량 safetyStock: data.safetyStock, useStatus: data.status === null ? 'active' : 'active', // 기본값 }; setDetail(detailData); setFormData({ - actualQty: detailData.actualQty, safetyStock: detailData.safetyStock, useStatus: detailData.useStatus, }); @@ -140,7 +137,6 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) { prev ? { ...prev, - actualQty: formData.actualQty, safetyStock: formData.safetyStock, useStatus: formData.useStatus, } @@ -189,19 +185,19 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
- {/* Row 1: 재고번호, 품목코드, 품목명, 규격 */} + {/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 */}
- {renderReadOnlyField('재고번호', detail.stockNumber)} + {renderReadOnlyField('자재번호', detail.stockNumber)} {renderReadOnlyField('품목코드', detail.itemCode)} + {renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')} {renderReadOnlyField('품목명', detail.itemName)} - {renderReadOnlyField('규격', detail.specification)}
- {/* Row 2: 단위, 계산 재고량, 실제 재고량, 안전재고 */} + {/* Row 2: 규격, 단위, 재고량, 안전재고 */}
+ {renderReadOnlyField('규격', detail.specification)} {renderReadOnlyField('단위', detail.unit)} - {renderReadOnlyField('계산 재고량', detail.calculatedQty)} - {renderReadOnlyField('실제 재고량', detail.actualQty)} + {renderReadOnlyField('재고량', detail.calculatedQty)} {renderReadOnlyField('안전재고', detail.safetyStock)}
@@ -226,33 +222,19 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
- {/* Row 1: 재고번호, 품목코드, 품목명, 규격 (읽기 전용) */} + {/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */}
- {renderReadOnlyField('재고번호', detail.stockNumber, true)} + {renderReadOnlyField('자재번호', detail.stockNumber, true)} {renderReadOnlyField('품목코드', detail.itemCode, true)} + {renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-', true)} {renderReadOnlyField('품목명', detail.itemName, true)} - {renderReadOnlyField('규격', detail.specification, true)}
- {/* Row 2: 단위, 계산 재고량 (읽기 전용) + 실제 재고량, 안전재고 (수정 가능) */} + {/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */}
+ {renderReadOnlyField('규격', detail.specification, true)} {renderReadOnlyField('단위', detail.unit, true)} - {renderReadOnlyField('계산 재고량', detail.calculatedQty, true)} - - {/* 실제 재고량 (수정 가능) */} -
- - handleInputChange('actualQty', e.target.value)} - className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500" - min={0} - /> -
+ {renderReadOnlyField('재고량', detail.calculatedQty, true)} {/* 안전재고 (수정 가능) */}
@@ -322,4 +304,4 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) { onSubmit={async () => { await handleSave(); return { success: true }; }} /> ); -} +} \ No newline at end of file diff --git a/src/components/material/StockStatus/StockStatusList.tsx b/src/components/material/StockStatus/StockStatusList.tsx index 7c5e37a0..909812a9 100644 --- a/src/components/material/StockStatus/StockStatusList.tsx +++ b/src/components/material/StockStatus/StockStatusList.tsx @@ -17,7 +17,7 @@ import { CheckCircle2, AlertCircle, Eye, - Loader2, + AlertTriangle, } from 'lucide-react'; import type { ExcelColumn } from '@/lib/utils/excel-download'; import { Button } from '@/components/ui/button'; @@ -33,11 +33,9 @@ import { } from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; import { getStocks, getStockStats } from './actions'; -import { USE_STATUS_LABELS } from './types'; +import { USE_STATUS_LABELS, ITEM_TYPE_LABELS } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import type { StockItem, StockStats, ItemType, StockStatusType } from './types'; -import { ClipboardList } from 'lucide-react'; -import { StockAuditModal } from './StockAuditModal'; // 페이지당 항목 수 const ITEMS_PER_PAGE = 20; @@ -65,10 +63,6 @@ export function StockStatusList() { useStatus: 'all', }); - // ===== 재고 실사 모달 상태 ===== - const [isAuditModalOpen, setIsAuditModalOpen] = useState(false); - const [isAuditLoading, setIsAuditLoading] = useState(false); - // 데이터 로드 함수 const loadData = useCallback(async () => { try { @@ -130,30 +124,15 @@ export function StockStatusList() { router.push(`/ko/material/stock-status/${item.id}?mode=view`); }; - // ===== 재고 실사 버튼 핸들러 ===== - const handleStockAudit = () => { - setIsAuditLoading(true); - // 약간의 딜레이 후 모달 오픈 (로딩 UI 표시를 위해) - setTimeout(() => { - setIsAuditModalOpen(true); - setIsAuditLoading(false); - }, 100); - }; - - // ===== 재고 실사 완료 핸들러 ===== - const handleAuditComplete = () => { - loadData(); // 데이터 새로고침 - }; - // ===== 엑셀 컬럼 정의 ===== const excelColumns: ExcelColumn[] = [ - { header: '재고번호', key: 'stockNumber' }, + { header: '자재번호', key: 'stockNumber' }, { header: '품목코드', key: 'itemCode' }, + { header: '품목유형', key: 'itemType', transform: (value) => ITEM_TYPE_LABELS[value as ItemType] || '-' }, { header: '품목명', key: 'itemName' }, { header: '규격', key: 'specification' }, { header: '단위', key: 'unit' }, - { header: '계산 재고량', key: 'calculatedQty' }, - { header: '실제 재고량', key: 'actualQty' }, + { header: '재고량', key: 'calculatedQty' }, { header: '안전재고', key: 'safetyStock' }, { header: '상태', key: 'useStatus', transform: (value) => USE_STATUS_LABELS[value as 'active' | 'inactive'] || '-' }, ]; @@ -202,11 +181,17 @@ export function StockStatusList() { iconColor: 'text-green-600', }, { - label: '재고부족', + label: '재고 부족', value: `${stockStats?.lowCount || 0}`, icon: AlertCircle, iconColor: 'text-red-600', }, + { + label: '안전재고 미달', + value: `${stockStats?.outCount || 0}`, + icon: AlertTriangle, + iconColor: 'text-orange-600', + }, ]; // ===== 필터 설정 (전체/사용/미사용) ===== @@ -226,13 +211,13 @@ export function StockStatusList() { // ===== 테이블 컬럼 ===== const tableColumns = [ { key: 'no', label: 'No.', className: 'w-[60px] text-center' }, - { key: 'stockNumber', label: '재고번호', className: 'w-[100px]' }, + { key: 'stockNumber', label: '자재번호', className: 'w-[100px]' }, { key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' }, + { key: 'itemType', label: '품목유형', className: 'w-[80px]' }, { key: 'itemName', label: '품목명', className: 'min-w-[150px]' }, { key: 'specification', label: '규격', className: 'w-[100px]' }, { key: 'unit', label: '단위', className: 'w-[60px] text-center' }, - { key: 'calculatedQty', label: '계산 재고량', className: 'w-[100px] text-center' }, - { key: 'actualQty', label: '실제 재고량', className: 'w-[100px] text-center' }, + { key: 'calculatedQty', label: '재고량', className: 'w-[80px] text-center' }, { key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' }, { key: 'useStatus', label: '상태', className: 'w-[80px] text-center' }, ]; @@ -259,11 +244,11 @@ export function StockStatusList() { {globalIndex} {item.stockNumber} {item.itemCode} + {ITEM_TYPE_LABELS[item.itemType] || '-'} {item.itemName} {item.specification || '-'} {item.unit} {item.calculatedQty} - {item.actualQty} {item.safetyStock} @@ -306,10 +291,10 @@ export function StockStatusList() { infoGrid={
+ - - +
} @@ -401,24 +386,6 @@ export function StockStatusList() { // 통계 computeStats: () => stats, - // 헤더 액션 버튼 - headerActions: () => ( - - ), - // 테이블 푸터 tableFooter: ( @@ -468,22 +435,12 @@ export function StockStatusList() { } return ( - <> - - config={config} - initialData={filteredStocks} - initialTotalCount={filteredStocks.length} - onFilterChange={(newFilters) => setFilterValues(newFilters)} - onSearchChange={setSearchTerm} - /> - - {/* 재고 실사 모달 */} - - + + config={config} + initialData={filteredStocks} + initialTotalCount={filteredStocks.length} + onFilterChange={(newFilters) => setFilterValues(newFilters)} + onSearchChange={setSearchTerm} + /> ); -} +} \ No newline at end of file diff --git a/src/components/material/StockStatus/actions.ts b/src/components/material/StockStatus/actions.ts index ac337d3f..ca66d166 100644 --- a/src/components/material/StockStatus/actions.ts +++ b/src/components/material/StockStatus/actions.ts @@ -439,7 +439,6 @@ export async function getStockById(id: string): Promise<{ export async function updateStock( id: string, data: { - actualQty: number; safetyStock: number; useStatus: 'active' | 'inactive'; } @@ -457,7 +456,6 @@ export async function updateStock( 'Content-Type': 'application/json', }, body: JSON.stringify({ - actual_qty: data.actualQty, safety_stock: data.safetyStock, is_active: data.useStatus === 'active', }), diff --git a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx index 8d1fa265..fd4cd3d0 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx @@ -50,10 +50,6 @@ import { getLogisticsOptions, getVehicleTonnageOptions, } from './actions'; -import { - FREIGHT_COST_LABELS, - DELIVERY_METHOD_LABELS, -} from './types'; import type { ShipmentCreateFormData, DeliveryMethod, @@ -83,10 +79,12 @@ const deliveryMethodOptions: { value: DeliveryMethod; label: string }[] = [ { value: 'self_pickup', label: '직접수령' }, ]; -// 운임비용 옵션 -const freightCostOptions: { value: FreightCostType; label: string }[] = Object.entries( - FREIGHT_COST_LABELS -).map(([value, label]) => ({ value: value as FreightCostType, label })); +// 운임비용 옵션 (선불, 착불, 없음) +const freightCostOptions: { value: FreightCostType; label: string }[] = [ + { value: 'prepaid', label: '선불' }, + { value: 'collect', label: '착불' }, + { value: 'none', label: '없음' }, +]; // 빈 배차 행 생성 function createEmptyDispatch(): VehicleDispatch { @@ -111,7 +109,7 @@ export function ShipmentCreate() { priority: 'normal', deliveryMethod: 'direct_dispatch', shipmentDate: '', - freightCost: undefined, + freightCost: 'none', receiver: '', receiverContact: '', zipCode: '', @@ -229,9 +227,22 @@ export function ShipmentCreate() { if (validationErrors.length > 0) setValidationErrors([]); }, [validationErrors]); + // 배송방식에 따라 운임비용 '없음' 고정 여부 판단 + const isFreightCostLocked = (method: DeliveryMethod) => + method === 'direct_dispatch' || method === 'self_pickup'; + // 폼 입력 핸들러 const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => { - setFormData(prev => ({ ...prev, [field]: value })); + if (field === 'deliveryMethod') { + const method = value as DeliveryMethod; + if (isFreightCostLocked(method)) { + setFormData(prev => ({ ...prev, deliveryMethod: method, freightCost: 'none' as FreightCostType })); + } else { + setFormData(prev => ({ ...prev, deliveryMethod: method })); + } + } else { + setFormData(prev => ({ ...prev, [field]: value })); + } if (validationErrors.length > 0) setValidationErrors([]); }; @@ -455,7 +466,7 @@ export function ShipmentCreate() { handleInputChange('tonnage', e.target.value)} - placeholder="예: 3.5톤" + placeholder="예: 3.5 톤" disabled={isSubmitting} />
diff --git a/src/components/outbound/VehicleDispatchManagement/VehicleDispatchList.tsx b/src/components/outbound/VehicleDispatchManagement/VehicleDispatchList.tsx index 7ea7591b..bb587fc2 100644 --- a/src/components/outbound/VehicleDispatchManagement/VehicleDispatchList.tsx +++ b/src/components/outbound/VehicleDispatchManagement/VehicleDispatchList.tsx @@ -163,23 +163,19 @@ export function VehicleDispatchList() { onEndDateChange: setEndDate, }, - // 테이블 컬럼 + // 테이블 컬럼 (13개) columns: [ - { key: 'no', label: '번호', className: 'w-[50px] text-center' }, + { key: 'no', label: 'No.', className: 'w-[50px] text-center' }, { key: 'dispatchNo', label: '배차번호', className: 'min-w-[130px]' }, - { key: 'shipmentNo', label: '출고번호', className: 'min-w-[130px]' }, + { key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' }, { key: 'siteName', label: '현장명', className: 'min-w-[100px]' }, { key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]' }, { key: 'logisticsCompany', label: '물류업체', className: 'min-w-[90px]' }, - { key: 'tonnage', label: '톤수', className: 'w-[70px] text-center' }, { key: 'supplyAmount', label: '공급가액', className: 'w-[100px] text-right' }, { key: 'vat', label: '부가세', className: 'w-[90px] text-right' }, { key: 'totalAmount', label: '합계', className: 'w-[100px] text-right' }, { key: 'freightCostType', label: '선/착불', className: 'w-[70px] text-center' }, - { key: 'vehicleNo', label: '차량번호', className: 'min-w-[100px]' }, - { key: 'driverContact', label: '기사연락처', className: 'min-w-[110px]' }, { key: 'writer', label: '작성자', className: 'w-[80px] text-center' }, - { key: 'arrivalDateTime', label: '입차일시', className: 'w-[130px] text-center' }, { key: 'status', label: '상태', className: 'w-[80px] text-center' }, { key: 'remarks', label: '비고', className: 'min-w-[100px]' }, ], @@ -201,15 +197,14 @@ export function VehicleDispatchList() { itemsPerPage: ITEMS_PER_PAGE, // 검색 - searchPlaceholder: '배차번호, 출고번호, 현장명, 수주처, 차량번호 검색...', + searchPlaceholder: '배차번호, 로트번호, 현장명, 수주처 검색...', searchFilter: (item: VehicleDispatchItem, search: string) => { const s = search.toLowerCase(); return ( item.dispatchNo.toLowerCase().includes(s) || - item.shipmentNo.toLowerCase().includes(s) || + (item.lotNo || item.shipmentNo).toLowerCase().includes(s) || item.siteName.toLowerCase().includes(s) || - item.orderCustomer.toLowerCase().includes(s) || - item.vehicleNo.toLowerCase().includes(s) + item.orderCustomer.toLowerCase().includes(s) ); }, @@ -235,31 +230,29 @@ export function VehicleDispatchList() { onCheckedChange={handlers.onToggle} /> - {globalIndex} - {item.dispatchNo} - {item.shipmentNo} - {item.siteName} - {item.orderCustomer} - {item.logisticsCompany} - {item.tonnage} - {formatAmount(item.supplyAmount)} - {formatAmount(item.vat)} - {formatAmount(item.totalAmount)} - - - {FREIGHT_COST_LABELS[item.freightCostType]} - + {globalIndex} + {item.dispatchNo} + {item.lotNo || item.shipmentNo} + {item.siteName} + {item.orderCustomer} + {item.logisticsCompany} + {formatAmount(item.supplyAmount || 0)} + {formatAmount(item.vat || 0)} + {formatAmount(item.totalAmount || 0)} + + {item.freightCostType ? ( + + {FREIGHT_COST_LABELS[item.freightCostType]} + + ) : '-'} - {item.vehicleNo} - {item.driverContact} - {item.writer} - {item.arrivalDateTime} - + {item.writer || '-'} + {VEHICLE_DISPATCH_STATUS_LABELS[item.status]} - {item.remarks || '-'} + {item.remarks || '-'} ); }, @@ -296,17 +289,16 @@ export function VehicleDispatchList() { } infoGrid={
- + - + - - +
} actions={ diff --git a/src/components/outbound/VehicleDispatchManagement/types.ts b/src/components/outbound/VehicleDispatchManagement/types.ts index 262a43fb..3725ba72 100644 --- a/src/components/outbound/VehicleDispatchManagement/types.ts +++ b/src/components/outbound/VehicleDispatchManagement/types.ts @@ -35,6 +35,7 @@ export interface VehicleDispatchItem { id: string; dispatchNo: string; // 배차번호 shipmentNo: string; // 출고번호 + lotNo?: string; // 로트번호 siteName: string; // 현장명 orderCustomer: string; // 수주처 logisticsCompany: string; // 물류업체 @@ -57,6 +58,7 @@ export interface VehicleDispatchDetail { // 기본 정보 dispatchNo: string; // 배차번호 shipmentNo: string; // 출고번호 + lotNo?: string; // 로트번호 siteName: string; // 현장명 orderCustomer: string; // 수주처 freightCostType: FreightCostType; // 운임비용 diff --git a/src/components/pricing-distribution/PriceDistributionDetail.tsx b/src/components/pricing-distribution/PriceDistributionDetail.tsx new file mode 100644 index 00000000..37594d5d --- /dev/null +++ b/src/components/pricing-distribution/PriceDistributionDetail.tsx @@ -0,0 +1,537 @@ +/** + * 단가배포 상세/수정 페이지 + * + * mode 패턴: + * - view: 상세 조회 (읽기 전용) → 하단: 단가표 보기, 최종확정, 수정 + * - edit: 수정 모드 → 하단: 취소, 저장 + */ + +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { ArrowLeft, FileText, CheckCircle2, Edit3, Save, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useMenuStore } from '@/store/menuStore'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { PageLayout } from '@/components/organisms/PageLayout'; +import { PageHeader } from '@/components/organisms/PageHeader'; +import { toast } from 'sonner'; +import type { + PriceDistributionDetail as DetailType, + PriceDistributionFormData, + DistributionStatus, +} from './types'; +import { + DISTRIBUTION_STATUS_LABELS, + DISTRIBUTION_STATUS_STYLES, + TRADE_GRADE_OPTIONS, +} from './types'; +import { + getPriceDistributionById, + updatePriceDistribution, + finalizePriceDistribution, +} from './actions'; +import { PriceDistributionDocumentModal } from './PriceDistributionDocumentModal'; +import { usePermission } from '@/hooks/usePermission'; + +interface Props { + id: string; + mode?: 'view' | 'edit'; +} + +export function PriceDistributionDetail({ id, mode: propMode }: Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + const { canUpdate, canApprove } = usePermission(); + const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); + const mode = propMode || (searchParams.get('mode') as 'view' | 'edit') || 'view'; + const isEditMode = mode === 'edit'; + const isViewMode = mode === 'view'; + const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [showFinalizeDialog, setShowFinalizeDialog] = useState(false); + const [showDocumentModal, setShowDocumentModal] = useState(false); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [gradeFilter, setGradeFilter] = useState('all'); + + // 수정 가능 폼 데이터 + const [formData, setFormData] = useState({ + distributionName: '', + documentNo: '', + effectiveDate: '', + officePhone: '', + orderPhone: '', + }); + + // 데이터 로드 + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const result = await getPriceDistributionById(id); + if (result.success && result.data) { + setDetail(result.data); + setFormData({ + distributionName: result.data.distributionName, + documentNo: result.data.documentNo, + effectiveDate: result.data.effectiveDate, + officePhone: result.data.officePhone, + orderPhone: result.data.orderPhone, + }); + } else { + toast.error(result.error || '데이터를 불러올 수 없습니다.'); + } + } finally { + setIsLoading(false); + } + }, [id]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // 폼 값 변경 + const handleChange = (field: keyof PriceDistributionFormData, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + // 저장 + const handleSave = async () => { + setIsSaving(true); + try { + const result = await updatePriceDistribution(id, formData); + if (result.success) { + toast.success('저장되었습니다.'); + router.push(`/master-data/price-distribution/${id}`); + } else { + toast.error(result.error || '저장에 실패했습니다.'); + } + } catch { + toast.error('저장 중 오류가 발생했습니다.'); + } finally { + setIsSaving(false); + } + }; + + // 최종확정 + const handleFinalize = async () => { + try { + const result = await finalizePriceDistribution(id); + if (result.success) { + toast.success('최종확정 되었습니다.'); + setShowFinalizeDialog(false); + loadData(); + } else { + toast.error(result.error || '최종확정에 실패했습니다.'); + } + } catch { + toast.error('최종확정 중 오류가 발생했습니다.'); + } + }; + + // 수정 모드 전환 + const handleEditMode = () => { + router.push(`/master-data/price-distribution/${id}/edit`); + }; + + // 취소 + const handleCancel = () => { + router.push(`/master-data/price-distribution/${id}`); + }; + + // 목록으로 + const handleBack = () => { + router.push('/master-data/price-distribution'); + }; + + // 체크박스 전체 선택/해제 + const handleSelectAll = (checked: boolean) => { + if (!detail) return; + if (checked) { + setSelectedItems(new Set(detail.items.map((item) => item.id))); + } else { + setSelectedItems(new Set()); + } + }; + + // 체크박스 개별 선택 + const handleSelectItem = (itemId: string, checked: boolean) => { + setSelectedItems((prev) => { + const next = new Set(prev); + if (checked) { + next.add(itemId); + } else { + next.delete(itemId); + } + return next; + }); + }; + + // 상태 뱃지 + const renderStatusBadge = (status: DistributionStatus) => { + const style = DISTRIBUTION_STATUS_STYLES[status]; + const label = DISTRIBUTION_STATUS_LABELS[status]; + return ( + + {label} + + ); + }; + + // 금액 포맷 + const formatPrice = (price?: number) => { + if (price === undefined || price === null) return '-'; + return price.toLocaleString(); + }; + + if (isLoading) { + return ( + +
+

로딩 중...

+
+
+ ); + } + + if (!detail) { + return ( + +
+

데이터를 찾을 수 없습니다.

+ +
+
+ ); + } + + const isAllSelected = detail.items.length > 0 && selectedItems.size === detail.items.length; + + return ( + + + + {/* 기본 정보 */} + + + 기본 정보 + + +
+ {/* 단가배포번호 */} +
+ +

{detail.distributionNo}

+
+ + {/* 단가배포명 */} +
+ + {isEditMode ? ( + handleChange('distributionName', e.target.value)} + className="h-8 text-sm" + /> + ) : ( +

{detail.distributionName}

+ )} +
+ + {/* 상태 */} +
+ + +
+ + {/* 작성자 */} +
+ +

{detail.author}

+
+ + {/* 등록일 */} +
+ +

{detail.createdAt}

+
+ + {/* 적용시점 */} +
+ + {isEditMode ? ( + handleChange('effectiveDate', e.target.value)} + className="h-8 text-sm" + /> + ) : ( +

+ {detail.effectiveDate ? new Date(detail.effectiveDate).toLocaleDateString('ko-KR') : '-'} +

+ )} +
+ + {/* 사무실 연락처 */} +
+ + {isEditMode ? ( + handleChange('officePhone', e.target.value)} + className="h-8 text-sm" + placeholder="02-0000-0000" + /> + ) : ( +

{detail.officePhone || '-'}

+ )} +
+ + {/* 발주전용 연락처 */} +
+ + {isEditMode ? ( + handleChange('orderPhone', e.target.value)} + className="h-8 text-sm" + placeholder="02-0000-0000" + /> + ) : ( +

{detail.orderPhone || '-'}

+ )} +
+
+
+
+ + {/* 단가 목록 테이블 */} + + + 단가표 목록 +
+ + + 총 {detail.items.length}건 + +
+
+ +
+ + + + + handleSelectAll(!!checked)} + /> + + 번호 + 단가번호 + 품목코드 + 품목유형 + 품목명 + 규격 + 단위 + 매입단가 + 가공비 + 마진율 + 판매단가 + 상태 + 작성자 + 변경일 + + + + {detail.items.length === 0 ? ( + + + 단가 데이터가 없습니다. + + + ) : ( + detail.items.map((item, index) => ( + + + handleSelectItem(item.id, !!checked)} + /> + + + {index + 1} + + {item.pricingCode} + {item.itemCode} + {item.itemType} + {item.itemName} + {item.specification} + {item.unit} + {formatPrice(item.purchasePrice)} + {formatPrice(item.processingCost)} + {item.marginRate}% + {formatPrice(item.salesPrice)} + + + {item.status} + + + {item.author} + {item.changedDate} + + )) + )} + +
+
+
+
+ + {/* 하단 버튼 (sticky 하단 바) */} +
+ {/* 왼쪽: 목록으로 / 취소 */} + {isViewMode ? ( + + ) : ( + + )} + + {/* 오른쪽: 액션 버튼들 */} +
+ {isViewMode && ( + <> + + {canApprove && ( + + )} + {canUpdate && ( + + )} + + )} + {isEditMode && canUpdate && ( + + )} +
+
+ + {/* 최종확정 다이얼로그 */} + + + + 최종확정 + + 단가배포를 최종 확정하시겠습니까? 확정 후에는 수정이 불가합니다. + + + + 취소 + + 확정 + + + + + + {/* 단가표 보기 모달 */} + +
+ ); +} + +export default PriceDistributionDetail; diff --git a/src/components/pricing-distribution/PriceDistributionDocumentModal.tsx b/src/components/pricing-distribution/PriceDistributionDocumentModal.tsx new file mode 100644 index 00000000..bd2d0c2f --- /dev/null +++ b/src/components/pricing-distribution/PriceDistributionDocumentModal.tsx @@ -0,0 +1,157 @@ +/** + * 단가표 보기 모달 (문서 스타일) + * + * DocumentViewer 래퍼 사용 (인쇄/공유/닫기) + * DocumentHeader + ApprovalLine 활용 + * 경동기업 자재단가 조정 문서 + */ + +'use client'; + +import { DocumentViewer } from '@/components/document-system/viewer/DocumentViewer'; +import { DocumentHeader } from '@/components/document-system/components/DocumentHeader'; +import { ConstructionApprovalTable } from '@/components/document-system/components/ConstructionApprovalTable'; +import type { PriceDistributionDetail } from './types'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + detail: PriceDistributionDetail; +} + +export function PriceDistributionDocumentModal({ open, onOpenChange, detail }: Props) { + const effectiveDate = detail.effectiveDate + ? new Date(detail.effectiveDate) + : new Date(); + const year = effectiveDate.getFullYear(); + const month = effectiveDate.getMonth() + 1; + const day = effectiveDate.getDate(); + + return ( + +
+ {/* 문서 헤더 + 결재란 */} + + } + /> + + {/* 수신/발신 정보 */} +
+ + + + + + + + + + + + + + + + + + + + + +
수신자경동기업 고객사발신자경동기업
발신일자 + {year}-{String(month).padStart(2, '0')}-{String(day).padStart(2, '0')} + 사무실 연락처{detail.officePhone || '-'}
발주전용 연락처{detail.orderPhone || '-'}
+
+ + {/* 안내 문구 */} +
+

+ 1. 귀사의 무궁한 발전을 기원합니다. +

+

+ 2. 자사에서 귀사에 가격인 변동으로 인해 단가(단가표)에 아래와 같이 조정하여 단가표를 보내드리오니, + 우순거래처 귀 물록표를 보내드리오며 이 단가는 거래(단가표)로 알고서시고 첫 기간 다른 거래처에 단가가 + 표를 보내 데이터가 안되도록 합니다. +

+

+ 3. 귀사에 공급하는 자재가 조정되었으니, +
+ + 가. {year}년 {month}월 {day}일 발주요분부터~ + +
+ + 나. 단가표 + +

+
+ + {/* 단가 테이블 */} +
+ + + + + + + + + + + + + + {detail.items.map((item) => ( + + + + + + + + + + ))} + +
구분물록규격두께/T입가/M단위 + 조정금액 +
+ (VAT별도) +
{item.itemType}{item.itemName}{item.specification}--{item.unit} + {item.salesPrice.toLocaleString()} +
+
+ + {/* 하단 날짜 및 회사 정보 */} +
+

+ {year}년 {month}월 {day}일 +

+

+ ㈜ 경동기업 +

+
+
+
+ ); +} + +export default PriceDistributionDocumentModal; diff --git a/src/components/pricing-distribution/PriceDistributionList.tsx b/src/components/pricing-distribution/PriceDistributionList.tsx new file mode 100644 index 00000000..715a5d64 --- /dev/null +++ b/src/components/pricing-distribution/PriceDistributionList.tsx @@ -0,0 +1,327 @@ +/** + * 단가배포 목록 클라이언트 컴포넌트 + * + * UniversalListPage 공통 템플릿 활용 + * - 탭 없음, 통계 카드 없음 + * - 상태 필터: filterConfig (SELECT 드롭다운) + */ + +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { FileText, FilePlus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { TableRow, TableCell } from '@/components/ui/table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + UniversalListPage, + type UniversalListConfig, + type TableColumn, + type FilterFieldConfig, + type SelectionHandlers, + type RowClickHandlers, +} from '@/components/templates/UniversalListPage'; +import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; +import { toast } from 'sonner'; +import type { PriceDistributionListItem, DistributionStatus } from './types'; +import { + DISTRIBUTION_STATUS_LABELS, + DISTRIBUTION_STATUS_STYLES, +} from './types'; +import { + getPriceDistributionList, + createPriceDistribution, + deletePriceDistribution, +} from './actions'; + +export function PriceDistributionList() { + const router = useRouter(); + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showRegisterDialog, setShowRegisterDialog] = useState(false); + const [isRegistering, setIsRegistering] = useState(false); + const pageSize = 20; + + // 날짜 범위 상태 (최근 30일) + const today = new Date(); + const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000); + const [startDate, setStartDate] = useState(thirtyDaysAgo.toISOString().split('T')[0]); + const [endDate, setEndDate] = useState(today.toISOString().split('T')[0]); + + // 데이터 로드 + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const listResult = await getPriceDistributionList(); + if (listResult.success && listResult.data) { + setData(listResult.data.items); + } + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + // 검색 필터 + const searchFilter = (item: PriceDistributionListItem, search: string) => { + const s = search.toLowerCase(); + return ( + item.distributionNo.toLowerCase().includes(s) || + item.distributionName.toLowerCase().includes(s) || + item.author.toLowerCase().includes(s) + ); + }; + + // 상태 Badge 렌더링 + const renderStatusBadge = (status: DistributionStatus) => { + const style = DISTRIBUTION_STATUS_STYLES[status]; + const label = DISTRIBUTION_STATUS_LABELS[status]; + return ( + + {label} + + ); + }; + + // 등록 핸들러 + const handleRegister = async () => { + setIsRegistering(true); + try { + const result = await createPriceDistribution(); + if (result.success && result.data) { + toast.success('단가배포가 등록되었습니다.'); + setShowRegisterDialog(false); + router.push(`/master-data/price-distribution/${result.data.id}`); + } else { + toast.error(result.error || '등록에 실패했습니다.'); + } + } catch { + toast.error('등록 중 오류가 발생했습니다.'); + } finally { + setIsRegistering(false); + } + }; + + // 행 클릭 → 상세 + const handleRowClick = (item: PriceDistributionListItem) => { + router.push(`/master-data/price-distribution/${item.id}`); + }; + + // 상태 필터 설정 + const filterConfig: FilterFieldConfig[] = [ + { + key: 'status', + label: '상태', + type: 'single', + options: [ + { value: 'initial', label: '최초작성' }, + { value: 'revision', label: '보이수정' }, + { value: 'finalized', label: '최종확정' }, + ], + }, + ]; + + // 커스텀 필터 함수 + const customFilterFn = (items: PriceDistributionListItem[], filterValues: Record) => { + const status = filterValues.status as string; + if (!status || status === '') return items; + return items.filter((item) => item.status === status); + }; + + // 테이블 컬럼 + const tableColumns: TableColumn[] = [ + { key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' }, + { key: 'distributionNo', label: '단가배포번호', className: 'min-w-[120px]' }, + { key: 'distributionName', label: '단가배포명', className: 'min-w-[150px]' }, + { key: 'status', label: '상태', className: 'min-w-[100px]' }, + { key: 'author', label: '작성자', className: 'min-w-[100px]' }, + { key: 'createdAt', label: '등록일', className: 'min-w-[120px]' }, + ]; + + // 테이블 행 렌더링 + const renderTableRow = ( + item: PriceDistributionListItem, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const { isSelected, onToggle } = handlers; + + return ( + handleRowClick(item)} + > + e.stopPropagation()}> + + + + {globalIndex} + + + + {item.distributionNo} + + + + {item.distributionName} + + {renderStatusBadge(item.status)} + {item.author} + + {new Date(item.createdAt).toLocaleDateString('ko-KR')} + + + ); + }; + + // 모바일 카드 렌더링 + const renderMobileCard = ( + item: PriceDistributionListItem, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const { isSelected, onToggle } = handlers; + return ( + + {item.distributionNo} + + } + statusBadge={renderStatusBadge(item.status)} + isSelected={isSelected} + onToggleSelection={onToggle} + onCardClick={() => handleRowClick(item)} + infoGrid={ +
+ + +
+ } + /> + ); + }; + + // 헤더 액션 + const headerActions = () => ( + + ); + + // UniversalListPage 설정 + const listConfig: UniversalListConfig = { + title: '단가배포 목록', + description: '단가표 기준 거래처별 단가 배포를 관리합니다', + icon: FileText, + basePath: '/master-data/price-distribution', + idField: 'id', + + actions: { + getList: async () => ({ + success: true, + data: data, + totalCount: data.length, + }), + deleteBulk: async (ids: string[]) => { + const result = await deletePriceDistribution(ids); + return { success: result.success, error: result.error }; + }, + }, + + columns: tableColumns, + headerActions, + filterConfig, + + // 날짜 범위 필터 + 프리셋 버튼 + dateRangeSelector: { + enabled: true, + showPresets: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + dateField: 'createdAt', + }, + + searchPlaceholder: '단가배포번호, 단가배포명, 작성자 검색...', + itemsPerPage: pageSize, + clientSideFiltering: true, + searchFilter, + customFilterFn, + + renderTableRow, + renderMobileCard, + }; + + return ( + <> + + config={listConfig} + initialData={data} + initialTotalCount={data.length} + /> + + {/* 단가배포 등록 확인 다이얼로그 */} + + + + 알림 + + + 새로운 단가배포 버전을 등록하시겠습니까? + + + 현재 단가표 기준으로 자동 생성됩니다. + + + + + 취소 + + {isRegistering ? '등록 중...' : '등록'} + + + + + + ); +} + +export default PriceDistributionList; diff --git a/src/components/pricing-distribution/actions.ts b/src/components/pricing-distribution/actions.ts new file mode 100644 index 00000000..2a5c4a88 --- /dev/null +++ b/src/components/pricing-distribution/actions.ts @@ -0,0 +1,444 @@ +'use server'; + +import type { + PriceDistributionListItem, + PriceDistributionDetail, + PriceDistributionFormData, + PriceDistributionStats, + DistributionStatus, + PriceDistributionItem, +} from './types'; + +// ============================================================================ +// 목데이터 +// ============================================================================ + +const MOCK_ITEMS: PriceDistributionItem[] = [ + { + id: 'item-1', + pricingCode: '121212', + itemCode: '123123', + itemType: '반제품', + itemName: '품목명A', + specification: 'ST', + unit: 'EA', + purchasePrice: 10000, + processingCost: 5000, + marginRate: 50.0, + salesPrice: 20000, + status: '사용', + author: '홍길동', + changedDate: '2026-01-15', + }, + { + id: 'item-2', + pricingCode: '121213', + itemCode: '123124', + itemType: '완제품', + itemName: '품목명B', + specification: '규격B', + unit: 'SET', + purchasePrice: 8000, + processingCost: 3000, + marginRate: 40.0, + salesPrice: 14000, + status: '사용', + author: '김철수', + changedDate: '2026-01-16', + }, + { + id: 'item-3', + pricingCode: '121214', + itemCode: '123125', + itemType: '반제품', + itemName: '품목명C', + specification: 'ST', + unit: 'EA', + purchasePrice: 15000, + processingCost: 5000, + marginRate: 50.0, + salesPrice: 27000, + status: '사용', + author: '이영희', + changedDate: '2026-01-17', + }, + { + id: 'item-4', + pricingCode: '121215', + itemCode: '123126', + itemType: '원자재', + itemName: '품목명D', + specification: 'AL', + unit: 'KG', + purchasePrice: 5000, + processingCost: 2000, + marginRate: 60.0, + salesPrice: 10000, + status: '사용', + author: '박민수', + changedDate: '2026-01-18', + }, + { + id: 'item-5', + pricingCode: '121216', + itemCode: '123127', + itemType: '완제품', + itemName: '품목명E', + specification: '규격E', + unit: 'SET', + purchasePrice: 20000, + processingCost: 8000, + marginRate: 50.0, + salesPrice: 38000, + status: '사용', + author: '홍길동', + changedDate: '2026-01-19', + }, + { + id: 'item-6', + pricingCode: '121217', + itemCode: '123128', + itemType: '반제품', + itemName: '품목명F', + specification: 'ST', + unit: 'EA', + purchasePrice: 12000, + processingCost: 4000, + marginRate: 50.0, + salesPrice: 22000, + status: '사용', + author: '김철수', + changedDate: '2026-01-20', + }, + { + id: 'item-7', + pricingCode: '121218', + itemCode: '123129', + itemType: '원자재', + itemName: '품목명G', + specification: 'SUS', + unit: 'KG', + purchasePrice: 7000, + processingCost: 3000, + marginRate: 45.0, + salesPrice: 13000, + status: '사용', + author: '이영희', + changedDate: '2026-01-21', + }, +]; + +const MOCK_LIST: PriceDistributionListItem[] = [ + { + id: '1', + distributionNo: '121212', + distributionName: '2025년 1월', + status: 'finalized', + author: '김대표', + createdAt: '2026-01-15', + revisionCount: 3, + }, + { + id: '2', + distributionNo: '121213', + distributionName: '2025년 1월', + status: 'revision', + author: '김대표', + createdAt: '2026-01-20', + revisionCount: 1, + }, + { + id: '3', + distributionNo: '121214', + distributionName: '2025년 1월', + status: 'initial', + author: '김대표', + createdAt: '2026-01-25', + revisionCount: 0, + }, + { + id: '4', + distributionNo: '121215', + distributionName: '2025년 1월', + status: 'revision', + author: '김대표', + createdAt: '2026-01-28', + revisionCount: 2, + }, + { + id: '5', + distributionNo: '121216', + distributionName: '2025년 1월', + status: 'finalized', + author: '김대표', + createdAt: '2026-02-01', + revisionCount: 4, + }, + { + id: '6', + distributionNo: '121217', + distributionName: '2025년 1월', + status: 'initial', + author: '김대표', + createdAt: '2026-02-03', + revisionCount: 0, + }, + { + id: '7', + distributionNo: '121218', + distributionName: '2025년 1월', + status: 'revision', + author: '김대표', + createdAt: '2026-02-03', + revisionCount: 1, + }, +]; + +const MOCK_DETAILS: Record = { + '1': { + id: '1', + distributionNo: '121212', + distributionName: '2025년 1월', + status: 'finalized', + createdAt: '2026-01-15', + documentNo: '', + effectiveDate: '2026-01-01', + officePhone: '02-1234-1234', + orderPhone: '02-1234-1234', + author: '김대표', + items: MOCK_ITEMS, + }, + '2': { + id: '2', + distributionNo: '121213', + distributionName: '2025년 1월', + status: 'revision', + createdAt: '2026-01-20', + documentNo: '', + effectiveDate: '2026-01-01', + officePhone: '02-1234-1234', + orderPhone: '02-1234-1234', + author: '김대표', + items: MOCK_ITEMS.slice(0, 5), + }, + '3': { + id: '3', + distributionNo: '121214', + distributionName: '2025년 1월', + status: 'initial', + createdAt: '2026-01-25', + documentNo: '', + effectiveDate: '2026-02-01', + officePhone: '02-1234-1234', + orderPhone: '02-1234-1234', + author: '김대표', + items: MOCK_ITEMS.slice(0, 3), + }, +}; + +// ============================================================================ +// API 함수 (목데이터 기반) +// ============================================================================ + +/** + * 단가배포 목록 조회 + */ +export async function getPriceDistributionList(params?: { + page?: number; + size?: number; + q?: string; + status?: DistributionStatus; + dateFrom?: string; + dateTo?: string; +}): Promise<{ + success: boolean; + data?: { items: PriceDistributionListItem[]; total: number }; + error?: string; +}> { + let items = [...MOCK_LIST]; + + if (params?.status) { + items = items.filter((item) => item.status === params.status); + } + + if (params?.q) { + const q = params.q.toLowerCase(); + items = items.filter( + (item) => + item.distributionNo.toLowerCase().includes(q) || + item.distributionName.toLowerCase().includes(q) || + item.author.toLowerCase().includes(q) + ); + } + + if (params?.dateFrom) { + items = items.filter((item) => item.createdAt >= params.dateFrom!); + } + + if (params?.dateTo) { + items = items.filter((item) => item.createdAt <= params.dateTo!); + } + + return { + success: true, + data: { items, total: items.length }, + }; +} + +/** + * 단가배포 통계 + */ +export async function getPriceDistributionStats(): Promise<{ + success: boolean; + data?: PriceDistributionStats; + error?: string; +}> { + const total = MOCK_LIST.length; + const initial = MOCK_LIST.filter((p) => p.status === 'initial').length; + const revision = MOCK_LIST.filter((p) => p.status === 'revision').length; + const finalized = MOCK_LIST.filter((p) => p.status === 'finalized').length; + return { success: true, data: { total, initial, revision, finalized } }; +} + +/** + * 단가배포 상세 조회 + */ +export async function getPriceDistributionById(id: string): Promise<{ + success: boolean; + data?: PriceDistributionDetail; + error?: string; +}> { + const detail = MOCK_DETAILS[id]; + if (!detail) { + // 목록에 있는 항목은 기본 상세 데이터 생성 + const listItem = MOCK_LIST.find((item) => item.id === id); + if (listItem) { + return { + success: true, + data: { + id: listItem.id, + distributionNo: listItem.distributionNo, + distributionName: listItem.distributionName, + status: listItem.status, + createdAt: listItem.createdAt, + documentNo: '', + effectiveDate: '2026-01-01', + officePhone: '02-1234-1234', + orderPhone: '02-1234-1234', + author: listItem.author, + items: MOCK_ITEMS, + }, + }; + } + return { success: false, error: '단가배포를 찾을 수 없습니다.' }; + } + return { success: true, data: detail }; +} + +/** + * 단가배포 등록 (현재 단가표 기준 자동 생성) + */ +export async function createPriceDistribution(): Promise<{ + success: boolean; + data?: PriceDistributionDetail; + error?: string; +}> { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + let name = `${year}년 ${month}월`; + + // 중복 체크 → (N) 추가 + const existing = MOCK_LIST.filter((item) => + item.distributionName.startsWith(name) + ); + if (existing.length > 0) { + name = `${name}(${existing.length})`; + } + + const newId = String(Date.now()); + const newItem: PriceDistributionDetail = { + id: newId, + distributionNo: String(Math.floor(100000 + Math.random() * 900000)), + distributionName: name, + status: 'initial', + createdAt: now.toISOString().split('T')[0], + documentNo: '', + effectiveDate: now.toISOString().split('T')[0], + officePhone: '', + orderPhone: '', + author: '현재사용자', + items: MOCK_ITEMS, + }; + + return { success: true, data: newItem }; +} + +/** + * 단가배포 수정 + */ +export async function updatePriceDistribution( + id: string, + data: PriceDistributionFormData +): Promise<{ + success: boolean; + data?: PriceDistributionDetail; + error?: string; +}> { + const detailData = MOCK_DETAILS[id]; + const listItem = MOCK_LIST.find((item) => item.id === id); + if (!detailData && !listItem) { + return { success: false, error: '단가배포를 찾을 수 없습니다.' }; + } + + const detail: PriceDistributionDetail = detailData || { + id, + distributionNo: listItem!.distributionNo, + distributionName: data.distributionName, + status: 'revision' as DistributionStatus, + createdAt: listItem!.createdAt, + documentNo: data.documentNo, + effectiveDate: data.effectiveDate, + officePhone: data.officePhone, + orderPhone: data.orderPhone, + author: listItem!.author, + items: MOCK_ITEMS, + }; + + const updated: PriceDistributionDetail = { + ...detail, + distributionName: data.distributionName, + documentNo: data.documentNo, + effectiveDate: data.effectiveDate, + officePhone: data.officePhone, + orderPhone: data.orderPhone, + status: detail.status === 'initial' ? 'revision' : detail.status, + }; + + return { success: true, data: updated }; +} + +/** + * 단가배포 최종확정 + */ +export async function finalizePriceDistribution(id: string): Promise<{ + success: boolean; + error?: string; +}> { + const exists = MOCK_LIST.find((item) => item.id === id) || MOCK_DETAILS[id]; + if (!exists) { + return { success: false, error: '단가배포를 찾을 수 없습니다.' }; + } + return { success: true }; +} + +/** + * 단가배포 삭제 + */ +export async function deletePriceDistribution(ids: string[]): Promise<{ + success: boolean; + deletedCount?: number; + error?: string; +}> { + return { success: true, deletedCount: ids.length }; +} diff --git a/src/components/pricing-distribution/index.ts b/src/components/pricing-distribution/index.ts new file mode 100644 index 00000000..590b7fcd --- /dev/null +++ b/src/components/pricing-distribution/index.ts @@ -0,0 +1,3 @@ +export { PriceDistributionList } from './PriceDistributionList'; +export { PriceDistributionDetail } from './PriceDistributionDetail'; +export { PriceDistributionDocumentModal } from './PriceDistributionDocumentModal'; diff --git a/src/components/pricing-distribution/types.ts b/src/components/pricing-distribution/types.ts new file mode 100644 index 00000000..65b3ed6e --- /dev/null +++ b/src/components/pricing-distribution/types.ts @@ -0,0 +1,96 @@ +/** + * 단가배포관리 타입 정의 + */ + +// ===== 단가배포 상태 ===== + +export type DistributionStatus = 'initial' | 'revision' | 'finalized'; + +export const DISTRIBUTION_STATUS_LABELS: Record = { + initial: '최초작성', + revision: '보이수정', + finalized: '최종확정', +}; + +export const DISTRIBUTION_STATUS_STYLES: Record = { + initial: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200' }, + revision: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' }, + finalized: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' }, +}; + +// ===== 단가배포 목록 아이템 ===== + +export interface PriceDistributionListItem { + id: string; + distributionNo: string; // 단가배포번호 + distributionName: string; // 단가배포명 (YYYY년 MM월) + status: DistributionStatus; // 상태 + author: string; // 작성자 + createdAt: string; // 등록일 + revisionCount: number; // 보이수정 횟수 +} + +// ===== 단가배포 상세 ===== + +export interface PriceDistributionDetail { + id: string; + distributionNo: string; // 단가배포번호 + distributionName: string; // 단가배포명 + status: DistributionStatus; // 상태 + createdAt: string; // 작성일 + documentNo: string; // 용지번 (발송번) + effectiveDate: string; // 적용시점 + officePhone: string; // 사무실 연락처 + orderPhone: string; // 발주전용 연락처 + author: string; // 작성자 + items: PriceDistributionItem[]; // 단가 목록 +} + +// ===== 단가배포 품목 항목 ===== + +export interface PriceDistributionItem { + id: string; + pricingCode: string; // 단가번호 + itemCode: string; // 품목코드 + itemType: string; // 품목유형 + itemName: string; // 품목명 + specification: string; // 규격 + unit: string; // 단위 + purchasePrice: number; // 매입단가 + processingCost: number; // 가공비 + marginRate: number; // 마진율 + salesPrice: number; // 판매단가 + status: string; // 상태 + author: string; // 작성자 + changedDate: string; // 변경일 +} + +// ===== 등급 필터 ===== + +export type TradeGrade = 'A등급' | 'B등급' | 'C등급' | 'D등급'; + +export const TRADE_GRADE_OPTIONS: { value: TradeGrade; label: string }[] = [ + { value: 'A등급', label: 'A등급' }, + { value: 'B등급', label: 'B등급' }, + { value: 'C등급', label: 'C등급' }, + { value: 'D등급', label: 'D등급' }, +]; + +// ===== 단가배포 폼 데이터 (수정용) ===== + +export interface PriceDistributionFormData { + distributionName: string; + documentNo: string; + effectiveDate: string; + officePhone: string; + orderPhone: string; +} + +// ===== 통계 ===== + +export interface PriceDistributionStats { + total: number; + initial: number; + revision: number; + finalized: number; +} diff --git a/src/components/pricing-table-management/PricingTableDetailClient.tsx b/src/components/pricing-table-management/PricingTableDetailClient.tsx new file mode 100644 index 00000000..ece073be --- /dev/null +++ b/src/components/pricing-table-management/PricingTableDetailClient.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { PricingTableForm } from './PricingTableForm'; +import { getPricingTableById } from './actions'; +import type { PricingTable } from './types'; +import { DetailPageSkeleton } from '@/components/ui/skeleton'; +import { ErrorCard } from '@/components/ui/error-card'; +import { toast } from 'sonner'; + +interface PricingTableDetailClientProps { + pricingTableId?: string; +} + +const BASE_PATH = '/ko/master-data/pricing-table-management'; + +export function PricingTableDetailClient({ pricingTableId }: PricingTableDetailClientProps) { + const isNewMode = !pricingTableId || pricingTableId === 'new'; + + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(!isNewMode); + const [error, setError] = useState(null); + + useEffect(() => { + const loadData = async () => { + if (isNewMode) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + try { + const result = await getPricingTableById(pricingTableId!); + if (result.success && result.data) { + setData(result.data); + } else { + setError(result.error || '단가표를 찾을 수 없습니다.'); + toast.error('단가표를 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('단가표 조회 실패:', err); + setError('단가표 정보를 불러오는 중 오류가 발생했습니다.'); + toast.error('단가표를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [pricingTableId, isNewMode]); + + if (isLoading) { + return ; + } + + if (error && !isNewMode) { + return ( + + ); + } + + if (isNewMode) { + return ; + } + + if (data) { + return ; + } + + return ( + + ); +} diff --git a/src/components/pricing-table-management/PricingTableForm.tsx b/src/components/pricing-table-management/PricingTableForm.tsx new file mode 100644 index 00000000..850f920b --- /dev/null +++ b/src/components/pricing-table-management/PricingTableForm.tsx @@ -0,0 +1,485 @@ +'use client'; + +/** + * 단가표 등록/상세(수정) 통합 폼 + * + * 기획서 기준: + * - edit 모드(=상세): 기본정보 readonly, 상태/단가정보 editable, 하단 삭제+수정 + * - create 모드(=등록): 기본정보 전부 editable, 하단 등록 + */ + +import { useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft, Save, Trash2, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { PageLayout } from '@/components/organisms/PageLayout'; +import { PageHeader } from '@/components/organisms/PageHeader'; +import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; +import { useMenuStore } from '@/store/menuStore'; +import { usePermission } from '@/hooks/usePermission'; +import { toast } from 'sonner'; +import { createPricingTable, updatePricingTable, deletePricingTable } from './actions'; +import { calculateSellingPrice } from './types'; +import type { PricingTable, PricingTableFormData, GradePricing, TradeGrade, PricingTableStatus } from './types'; + +interface PricingTableFormProps { + mode: 'create' | 'edit'; + initialData?: PricingTable; +} + +const GRADE_OPTIONS: TradeGrade[] = ['A등급', 'B등급', 'C등급', 'D등급']; + +export function PricingTableForm({ mode, initialData }: PricingTableFormProps) { + const router = useRouter(); + const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); + const { canCreate, canUpdate, canDelete } = usePermission(); + const [isSaving, setIsSaving] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const isEdit = mode === 'edit'; + + // ===== 폼 상태 ===== + const [itemCode, setItemCode] = useState(initialData?.itemCode ?? ''); + const [itemType, setItemType] = useState(initialData?.itemType ?? ''); + const [itemName, setItemName] = useState(initialData?.itemName ?? ''); + const [specification, setSpecification] = useState(initialData?.specification ?? ''); + const [unit, setUnit] = useState(initialData?.unit ?? ''); + const [status, setStatus] = useState(initialData?.status ?? '사용'); + const [purchasePrice, setPurchasePrice] = useState(initialData?.purchasePrice ?? 0); + const [processingCost, setProcessingCost] = useState(initialData?.processingCost ?? 0); + const [gradePricings, setGradePricings] = useState( + initialData?.gradePricings ?? [ + { id: `gp-new-1`, grade: 'A등급', marginRate: 50.0, sellingPrice: 0, note: '' }, + ] + ); + + // ===== 판매단가 계산 ===== + const recalcSellingPrices = useCallback( + (newPurchasePrice: number, newProcessingCost: number, pricings: GradePricing[]) => { + return pricings.map((gp) => ({ + ...gp, + sellingPrice: calculateSellingPrice(newPurchasePrice, gp.marginRate, newProcessingCost), + })); + }, + [] + ); + + const handlePurchasePriceChange = (value: string) => { + const num = parseInt(value, 10) || 0; + setPurchasePrice(num); + setGradePricings((prev) => recalcSellingPrices(num, processingCost, prev)); + }; + + const handleProcessingCostChange = (value: string) => { + const num = parseInt(value, 10) || 0; + setProcessingCost(num); + setGradePricings((prev) => recalcSellingPrices(purchasePrice, num, prev)); + }; + + const handleGradeChange = (index: number, grade: TradeGrade) => { + setGradePricings((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], grade }; + return updated; + }); + }; + + const handleMarginRateChange = (index: number, value: string) => { + const rate = parseFloat(value) || 0; + setGradePricings((prev) => { + const updated = [...prev]; + updated[index] = { + ...updated[index], + marginRate: rate, + sellingPrice: calculateSellingPrice(purchasePrice, rate, processingCost), + }; + return updated; + }); + }; + + const handleNoteChange = (index: number, note: string) => { + setGradePricings((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], note }; + return updated; + }); + }; + + const handleAddRow = () => { + const usedGrades = gradePricings.map((gp) => gp.grade); + const nextGrade = GRADE_OPTIONS.find((g) => !usedGrades.includes(g)) ?? 'A등급'; + setGradePricings((prev) => [ + ...prev, + { + id: `gp-new-${Date.now()}`, + grade: nextGrade, + marginRate: 50.0, + sellingPrice: calculateSellingPrice(purchasePrice, 50.0, processingCost), + note: '', + }, + ]); + }; + + const handleRemoveRow = (index: number) => { + setGradePricings((prev) => prev.filter((_, i) => i !== index)); + }; + + // ===== 저장 ===== + const handleSave = async () => { + if (!isEdit && !itemCode.trim()) { + toast.error('품목코드를 입력해주세요.'); + return; + } + if (!isEdit && !itemName.trim()) { + toast.error('품목명을 입력해주세요.'); + return; + } + if (gradePricings.length === 0) { + toast.error('거래등급별 판매단가를 최소 1개 이상 등록해주세요.'); + return; + } + + setIsSaving(true); + try { + const formData: PricingTableFormData = { + itemCode: isEdit ? initialData!.itemCode : itemCode, + itemType: isEdit ? initialData!.itemType : itemType, + itemName: isEdit ? initialData!.itemName : itemName, + specification: isEdit ? initialData!.specification : specification, + unit: isEdit ? initialData!.unit : unit, + purchasePrice, + processingCost, + status, + gradePricings, + }; + + const result = isEdit + ? await updatePricingTable(initialData!.id, formData) + : await createPricingTable(formData); + + if (result.success) { + toast.success(isEdit ? '단가표가 수정되었습니다.' : '단가표가 등록되었습니다.'); + router.push('/ko/master-data/pricing-table-management'); + } else { + toast.error(result.error || '저장에 실패했습니다.'); + } + } catch { + toast.error('저장 중 오류가 발생했습니다.'); + } finally { + setIsSaving(false); + } + }; + + // ===== 삭제 ===== + const handleDeleteConfirm = useCallback(async () => { + if (!initialData) return; + setIsDeleting(true); + try { + const result = await deletePricingTable(initialData.id); + if (result.success) { + toast.success('단가표가 삭제되었습니다.'); + router.push('/ko/master-data/pricing-table-management'); + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + } catch { + toast.error('삭제 중 오류가 발생했습니다.'); + } finally { + setIsDeleting(false); + setDeleteDialogOpen(false); + } + }, [initialData, router]); + + const handleList = () => { + router.push('/ko/master-data/pricing-table-management'); + }; + + const formatNumber = (num: number) => num.toLocaleString('ko-KR'); + + return ( + + + +
+ {/* 기본 정보 */} + + + 기본 정보 + + + {/* Row 1: 단가번호, 품목코드, 품목유형, 품목명 */} +
+
+ + +
+
+ + setItemCode(e.target.value)} + disabled={isEdit} + placeholder={isEdit ? undefined : '품목코드 입력'} + /> +
+
+ + setItemType(e.target.value)} + disabled={isEdit} + placeholder={isEdit ? undefined : '품목유형 입력'} + /> +
+
+ + setItemName(e.target.value)} + disabled={isEdit} + placeholder={isEdit ? undefined : '품목명 입력'} + /> +
+
+ + {/* Row 2: 규격, 단위, 상태, 작성자 */} +
+
+ + setSpecification(e.target.value)} + disabled={isEdit} + placeholder={isEdit ? undefined : '규격 입력'} + /> +
+
+ + setUnit(e.target.value)} + disabled={isEdit} + placeholder={isEdit ? undefined : '단위 입력'} + /> +
+
+ + +
+
+ + +
+
+ + {/* Row 3: 변경일 */} +
+
+ + +
+
+
+
+ + {/* 단가 정보 */} + + + 단가 정보 + + + {/* 매입단가 / 가공비 */} +
+
+ + handlePurchasePriceChange(e.target.value)} + placeholder="숫자 입력" + /> +
+
+ + handleProcessingCostChange(e.target.value)} + placeholder="숫자 입력" + /> +
+
+ + {/* 거래등급별 판매단가 */} +
+ + + + + + + + + + + + {gradePricings.map((gp, index) => ( + + + + + + + + ))} + +
+ 거래등급 + + 마진율 + + 판매단가 + + 비고 + + +
+ + +
+ handleMarginRateChange(index, e.target.value)} + className="h-9 text-right" + /> + % +
+
+ {formatNumber(gp.sellingPrice)} + + handleNoteChange(index, e.target.value)} + placeholder="비고" + className="h-9" + /> + + +
+
+

+ 판매단가 = 매입단가 x (1 + 마진율) + 가공비 (1천원 이하 절사) +

+
+
+
+ + {/* 하단 액션 버튼 (sticky) */} +
+ +
+ {isEdit ? ( + <> + {canDelete && ( + + )} + {canUpdate && ( + + )} + + ) : ( + canCreate && ( + + ) + )} +
+
+ + {/* 삭제 확인 다이얼로그 */} + {isEdit && ( + + )} +
+ ); +} diff --git a/src/components/pricing-table-management/PricingTableListClient.tsx b/src/components/pricing-table-management/PricingTableListClient.tsx new file mode 100644 index 00000000..7a8e8638 --- /dev/null +++ b/src/components/pricing-table-management/PricingTableListClient.tsx @@ -0,0 +1,380 @@ +'use client'; + +import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { DollarSign, Plus } from 'lucide-react'; +import { TableCell, TableRow } from '@/components/ui/table'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, +} from '@/components/templates/UniversalListPage'; +import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; +import { toast } from 'sonner'; +import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; +import type { PricingTable, TradeGrade } from './types'; +import { + getPricingTableList, + getPricingTableStats, + deletePricingTable, + deletePricingTables, +} from './actions'; + +export default function PricingTableListClient() { + const router = useRouter(); + + // ===== 상태 ===== + const [allItems, setAllItems] = useState([]); + const [stats, setStats] = useState({ total: 0, active: 0, inactive: 0 }); + const [isLoading, setIsLoading] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); + + // 날짜 범위 + const [startDate, setStartDate] = useState('2025-01-01'); + const [endDate, setEndDate] = useState('2025-12-31'); + + // 검색어 + const [searchQuery, setSearchQuery] = useState(''); + + // 거래등급 필터 + const [selectedGrade, setSelectedGrade] = useState('A등급'); + + // ===== 데이터 로드 ===== + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const [listResult, statsResult] = await Promise.all([ + getPricingTableList({ size: 1000 }), + getPricingTableStats(), + ]); + + if (listResult.success && listResult.data) { + setAllItems(listResult.data.items); + } + if (statsResult.success && statsResult.data) { + setStats(statsResult.data); + } + } catch { + toast.error('데이터 로드에 실패했습니다.'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + // ===== 핸들러 ===== + const handleRowClick = useCallback( + (item: PricingTable) => { + router.push(`/ko/master-data/pricing-table-management/${item.id}?mode=view`); + }, + [router] + ); + + const handleCreate = useCallback(() => { + router.push('/ko/master-data/pricing-table-management?mode=new'); + }, [router]); + + const handleDeleteConfirm = useCallback(async () => { + if (!deleteTargetId) return; + + setIsLoading(true); + try { + const result = await deletePricingTable(deleteTargetId); + if (result.success) { + toast.success('단가표가 삭제되었습니다.'); + setAllItems((prev) => prev.filter((p) => p.id !== deleteTargetId)); + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + } catch { + toast.error('삭제 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + setDeleteDialogOpen(false); + setDeleteTargetId(null); + } + }, [deleteTargetId]); + + const handleBulkDelete = useCallback( + async (selectedIds: string[]) => { + if (selectedIds.length === 0) { + toast.warning('삭제할 항목을 선택해주세요.'); + return; + } + + setIsLoading(true); + try { + const result = await deletePricingTables(selectedIds); + if (result.success) { + toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`); + await loadData(); + } else { + toast.error(result.error || '일괄 삭제에 실패했습니다.'); + } + } catch { + toast.error('일괄 삭제 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }, + [loadData] + ); + + // 해당 거래등급의 마진율/판매단가 가져오기 + const getGradePricing = useCallback( + (item: PricingTable) => { + return item.gradePricings.find((gp) => gp.grade === selectedGrade); + }, + [selectedGrade] + ); + + // 숫자 포맷 + const formatNumber = (num: number) => num.toLocaleString('ko-KR'); + + // ===== Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + title: '단가표 목록', + icon: DollarSign, + basePath: '/master-data/pricing-table-management', + + idField: 'id', + + actions: { + getList: async () => { + try { + const [listResult, statsResult] = await Promise.all([ + getPricingTableList({ size: 1000 }), + getPricingTableStats(), + ]); + + if (listResult.success && listResult.data) { + setAllItems(listResult.data.items); + if (statsResult.success && statsResult.data) { + setStats(statsResult.data); + } + return { + success: true, + data: listResult.data.items, + totalCount: listResult.data.items.length, + totalPages: 1, + }; + } + return { success: false, error: '데이터 로드에 실패했습니다.' }; + } catch { + return { success: false, error: '서버 오류가 발생했습니다.' }; + } + }, + deleteItem: async (id: string) => { + const result = await deletePricingTable(id); + return { success: result.success, error: result.error }; + }, + deleteBulk: async (ids: string[]) => { + const result = await deletePricingTables(ids); + return { success: result.success, error: result.error }; + }, + }, + + columns: [ + { key: 'no', label: '번호', className: 'w-[50px] text-center' }, + { key: 'pricingCode', label: '단가번호', className: 'w-[100px]' }, + { key: 'itemCode', label: '품목코드', className: 'w-[100px]' }, + { key: 'itemType', label: '품목유형', className: 'w-[80px]' }, + { key: 'itemName', label: '품목명', className: 'min-w-[120px]' }, + { key: 'specification', label: '규격', className: 'w-[70px]' }, + { key: 'unit', label: '단위', className: 'w-[50px] text-center' }, + { key: 'purchasePrice', label: '매입단가', className: 'w-[90px] text-right' }, + { key: 'processingCost', label: '가공비', className: 'w-[80px] text-right' }, + { key: 'marginRate', label: '마진율', className: 'w-[70px] text-right' }, + { key: 'sellingPrice', label: '판매단가', className: 'w-[90px] text-right' }, + { key: 'status', label: '상태', className: 'w-[70px] text-center' }, + { key: 'author', label: '작성자', className: 'w-[80px] text-center' }, + { key: 'changedDate', label: '변경일', className: 'w-[100px] text-center' }, + ], + + clientSideFiltering: true, + itemsPerPage: 20, + + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + + dateRangeSelector: { + enabled: true, + showPresets: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + + filterConfig: [ + { + key: 'status', + label: '상태', + type: 'single' as const, + options: [ + { value: '사용', label: '사용' }, + { value: '미사용', label: '미사용' }, + ], + allOptionLabel: '전체', + }, + ], + initialFilters: { status: '' }, + + customFilterFn: (items: PricingTable[], filterValues: Record) => { + const statusFilter = filterValues.status as string; + if (!statusFilter) return items; + return items.filter((item) => item.status === statusFilter); + }, + + searchFilter: (item, searchValue) => { + if (!searchValue || !searchValue.trim()) return true; + const search = searchValue.toLowerCase().trim(); + return ( + item.pricingCode.toLowerCase().includes(search) || + item.itemCode.toLowerCase().includes(search) || + item.itemName.toLowerCase().includes(search) || + item.itemType.toLowerCase().includes(search) + ); + }, + + createButton: { + label: '단가표 등록', + onClick: handleCreate, + icon: Plus, + }, + + onBulkDelete: handleBulkDelete, + + // 테이블 위 커스텀 영역 (거래등급 배지 필터) + renderCustomHeader: () => ( +
+ {(['A등급', 'B등급', 'C등급', 'D등급'] as TradeGrade[]).map((grade) => ( + setSelectedGrade(grade)} + > + {grade} + + ))} +
+ ), + + renderTableRow: ( + item: PricingTable, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const gp = getGradePricing(item); + return ( + handleRowClick(item)} + > + e.stopPropagation()}> + + + {globalIndex} + {item.pricingCode} + {item.itemCode} + {item.itemType} + {item.itemName} + {item.specification} + {item.unit} + {formatNumber(item.purchasePrice)} + {formatNumber(item.processingCost)} + {gp ? `${gp.marginRate}%` : '-'} + {gp ? formatNumber(gp.sellingPrice) : '-'} + + + {item.status} + + + {item.author} + {item.changedDate} + + ); + }, + + renderMobileCard: ( + item: PricingTable, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const gp = getGradePricing(item); + return ( + handleRowClick(item)} + headerBadges={ + <> + + #{globalIndex} + + + {item.pricingCode} + + + } + title={item.itemName} + statusBadge={ + + {item.status} + + } + infoGrid={ +
+ + + + + + +
+ } + /> + ); + }, + }), + [ + handleCreate, + handleRowClick, + handleBulkDelete, + startDate, + endDate, + searchQuery, + selectedGrade, + getGradePricing, + ] + ); + + return ( + <> + + + + + ); +} diff --git a/src/components/pricing-table-management/actions.ts b/src/components/pricing-table-management/actions.ts new file mode 100644 index 00000000..6362404a --- /dev/null +++ b/src/components/pricing-table-management/actions.ts @@ -0,0 +1,294 @@ +'use server'; + +import type { PricingTable, PricingTableFormData, TradeGrade } from './types'; + +// ============================================================================ +// 목데이터 +// ============================================================================ + +const MOCK_PRICING_TABLES: PricingTable[] = [ + { + id: '1', + pricingCode: '123123', + itemCode: '123123', + itemType: '반제품', + itemName: '품목명A', + specification: 'ST', + unit: 'EA', + purchasePrice: 10000, + processingCost: 5000, + status: '사용', + author: '홍길동', + changedDate: '2026-01-15', + gradePricings: [ + { id: 'gp-1-1', grade: 'A등급', marginRate: 50.0, sellingPrice: 20000, note: '' }, + { id: 'gp-1-2', grade: 'B등급', marginRate: 50.0, sellingPrice: 20000, note: '' }, + { id: 'gp-1-3', grade: 'C등급', marginRate: 50.0, sellingPrice: 20000, note: '' }, + { id: 'gp-1-4', grade: 'D등급', marginRate: 50.0, sellingPrice: 20000, note: '' }, + ], + }, + { + id: '2', + pricingCode: '123124', + itemCode: '123124', + itemType: '완제품', + itemName: '품목명B', + specification: '규격B', + unit: 'SET', + purchasePrice: 8000, + processingCost: 3000, + status: '사용', + author: '김철수', + changedDate: '2026-01-20', + gradePricings: [ + { id: 'gp-2-1', grade: 'A등급', marginRate: 40.0, sellingPrice: 14000, note: '' }, + { id: 'gp-2-2', grade: 'B등급', marginRate: 35.0, sellingPrice: 13000, note: '' }, + ], + }, + { + id: '3', + pricingCode: '123125', + itemCode: '123125', + itemType: '반제품', + itemName: '품목명C', + specification: 'ST', + unit: 'EA', + purchasePrice: 15000, + processingCost: 5000, + status: '미사용', + author: '이영희', + changedDate: '2026-01-10', + gradePricings: [ + { id: 'gp-3-1', grade: 'A등급', marginRate: 50.0, sellingPrice: 27000, note: '' }, + { id: 'gp-3-2', grade: 'B등급', marginRate: 45.0, sellingPrice: 26000, note: '' }, + { id: 'gp-3-3', grade: 'C등급', marginRate: 40.0, sellingPrice: 26000, note: '' }, + ], + }, + { + id: '4', + pricingCode: '123126', + itemCode: '123126', + itemType: '원자재', + itemName: '품목명D', + specification: 'AL', + unit: 'KG', + purchasePrice: 5000, + processingCost: 2000, + status: '사용', + author: '박민수', + changedDate: '2026-02-01', + gradePricings: [ + { id: 'gp-4-1', grade: 'A등급', marginRate: 60.0, sellingPrice: 10000, note: '' }, + { id: 'gp-4-2', grade: 'B등급', marginRate: 55.0, sellingPrice: 9000, note: '' }, + ], + }, + { + id: '5', + pricingCode: '123127', + itemCode: '123127', + itemType: '완제품', + itemName: '품목명E', + specification: '규격E', + unit: 'SET', + purchasePrice: 20000, + processingCost: 8000, + status: '사용', + author: '홍길동', + changedDate: '2026-01-25', + gradePricings: [ + { id: 'gp-5-1', grade: 'A등급', marginRate: 50.0, sellingPrice: 38000, note: '' }, + { id: 'gp-5-2', grade: 'B등급', marginRate: 45.0, sellingPrice: 37000, note: '' }, + { id: 'gp-5-3', grade: 'C등급', marginRate: 40.0, sellingPrice: 36000, note: '' }, + { id: 'gp-5-4', grade: 'D등급', marginRate: 35.0, sellingPrice: 35000, note: '' }, + ], + }, + { + id: '6', + pricingCode: '123128', + itemCode: '123128', + itemType: '반제품', + itemName: '품목명F', + specification: 'ST', + unit: 'EA', + purchasePrice: 12000, + processingCost: 4000, + status: '사용', + author: '김철수', + changedDate: '2026-01-18', + gradePricings: [ + { id: 'gp-6-1', grade: 'A등급', marginRate: 50.0, sellingPrice: 22000, note: '' }, + ], + }, + { + id: '7', + pricingCode: '123129', + itemCode: '123129', + itemType: '원자재', + itemName: '품목명G', + specification: 'SUS', + unit: 'KG', + purchasePrice: 7000, + processingCost: 3000, + status: '사용', + author: '이영희', + changedDate: '2026-01-22', + gradePricings: [ + { id: 'gp-7-1', grade: 'A등급', marginRate: 45.0, sellingPrice: 13000, note: '' }, + { id: 'gp-7-2', grade: 'B등급', marginRate: 40.0, sellingPrice: 12000, note: '' }, + ], + }, +]; + +// ============================================================================ +// API 함수 (목데이터 기반) +// ============================================================================ + +/** + * 단가표 목록 조회 + */ +export async function getPricingTableList(params?: { + page?: number; + size?: number; + q?: string; + status?: string; + grade?: TradeGrade; +}): Promise<{ + success: boolean; + data?: { items: PricingTable[]; total: number }; + error?: string; +}> { + let items = [...MOCK_PRICING_TABLES]; + + // 상태 필터 + if (params?.status) { + items = items.filter((item) => item.status === params.status); + } + + // 거래등급 필터 (해당 등급의 gradePricing이 있는 항목만) + if (params?.grade) { + items = items.filter((item) => + item.gradePricings.some((gp) => gp.grade === params.grade) + ); + } + + // 검색 + if (params?.q) { + const q = params.q.toLowerCase(); + items = items.filter( + (item) => + item.pricingCode.toLowerCase().includes(q) || + item.itemCode.toLowerCase().includes(q) || + item.itemName.toLowerCase().includes(q) || + item.itemType.toLowerCase().includes(q) + ); + } + + return { + success: true, + data: { items, total: items.length }, + }; +} + +/** + * 단가표 통계 + */ +export async function getPricingTableStats(): Promise<{ + success: boolean; + data?: { total: number; active: number; inactive: number }; + error?: string; +}> { + const total = MOCK_PRICING_TABLES.length; + const active = MOCK_PRICING_TABLES.filter((p) => p.status === '사용').length; + const inactive = total - active; + return { success: true, data: { total, active, inactive } }; +} + +/** + * 단가표 상세 조회 + */ +export async function getPricingTableById(id: string): Promise<{ + success: boolean; + data?: PricingTable; + error?: string; +}> { + const item = MOCK_PRICING_TABLES.find((p) => p.id === id); + if (!item) { + return { success: false, error: '단가표를 찾을 수 없습니다.' }; + } + return { success: true, data: item }; +} + +/** + * 단가표 생성 + */ +export async function createPricingTable(data: PricingTableFormData): Promise<{ + success: boolean; + data?: PricingTable; + error?: string; +}> { + const newItem: PricingTable = { + id: String(Date.now()), + pricingCode: `PT-${Date.now()}`, + itemCode: data.itemCode, + itemType: data.itemType, + itemName: data.itemName, + specification: data.specification, + unit: data.unit, + purchasePrice: data.purchasePrice, + processingCost: data.processingCost, + status: data.status, + author: '현재사용자', + changedDate: new Date().toISOString().split('T')[0], + gradePricings: data.gradePricings, + }; + return { success: true, data: newItem }; +} + +/** + * 단가표 수정 + */ +export async function updatePricingTable( + id: string, + data: PricingTableFormData +): Promise<{ + success: boolean; + data?: PricingTable; + error?: string; +}> { + const existing = MOCK_PRICING_TABLES.find((p) => p.id === id); + if (!existing) { + return { success: false, error: '단가표를 찾을 수 없습니다.' }; + } + const updated: PricingTable = { + ...existing, + ...data, + changedDate: new Date().toISOString().split('T')[0], + }; + return { success: true, data: updated }; +} + +/** + * 단가표 삭제 + */ +export async function deletePricingTable(id: string): Promise<{ + success: boolean; + error?: string; +}> { + const exists = MOCK_PRICING_TABLES.find((p) => p.id === id); + if (!exists) { + return { success: false, error: '단가표를 찾을 수 없습니다.' }; + } + return { success: true }; +} + +/** + * 단가표 일괄 삭제 + */ +export async function deletePricingTables(ids: string[]): Promise<{ + success: boolean; + deletedCount?: number; + error?: string; +}> { + return { success: true, deletedCount: ids.length }; +} + diff --git a/src/components/pricing-table-management/index.ts b/src/components/pricing-table-management/index.ts new file mode 100644 index 00000000..890e8e8d --- /dev/null +++ b/src/components/pricing-table-management/index.ts @@ -0,0 +1,3 @@ +export { default as PricingTableListClient } from './PricingTableListClient'; +export { PricingTableForm } from './PricingTableForm'; +export { PricingTableDetailClient } from './PricingTableDetailClient'; diff --git a/src/components/pricing-table-management/types.ts b/src/components/pricing-table-management/types.ts new file mode 100644 index 00000000..6159c9f9 --- /dev/null +++ b/src/components/pricing-table-management/types.ts @@ -0,0 +1,57 @@ +// 거래등급 +export type TradeGrade = 'A등급' | 'B등급' | 'C등급' | 'D등급'; + +// 단가표 상태 +export type PricingTableStatus = '사용' | '미사용'; + +// 거래등급별 판매단가 행 +export interface GradePricing { + id: string; + grade: TradeGrade; + marginRate: number; // 마진율 (%, 소수점 첫째자리) + sellingPrice: number; // 판매단가 (자동계산) + note: string; // 비고 +} + +// 단가표 엔티티 +export interface PricingTable { + id: string; + pricingCode: string; // 단가번호 + itemCode: string; // 품목코드 + itemType: string; // 품목유형 + itemName: string; // 품목명 + specification: string; // 규격 + unit: string; // 단위 + purchasePrice: number; // 매입단가 + processingCost: number; // 가공비 + status: PricingTableStatus; // 상태 + author: string; // 작성자 + changedDate: string; // 변경일 + gradePricings: GradePricing[]; // 거래등급별 판매단가 +} + +// 폼 데이터 (등록/수정) +export interface PricingTableFormData { + itemCode: string; + itemType: string; + itemName: string; + specification: string; + unit: string; + purchasePrice: number; + processingCost: number; + status: PricingTableStatus; + gradePricings: GradePricing[]; +} + +/** + * 판매단가 계산 유틸리티 + * 매입단가 × (1 + 마진율/100) + 가공비 → 1천원 이하 절사 + */ +export function calculateSellingPrice( + purchasePrice: number, + marginRate: number, + processingCost: number +): number { + const raw = purchasePrice * (1 + marginRate / 100) + processingCost; + return Math.floor(raw / 1000) * 1000; +} diff --git a/src/components/production/WorkOrders/WipProductionModal.tsx b/src/components/production/WorkOrders/WipProductionModal.tsx new file mode 100644 index 00000000..ea27380d --- /dev/null +++ b/src/components/production/WorkOrders/WipProductionModal.tsx @@ -0,0 +1,295 @@ +'use client'; + +/** + * 재공품 생산 모달 + * + * 기획서 기준: + * - 품목 선택 (검색) → 재공품 목록 테이블 추가 + * - 테이블: 품목코드, 품목명, 규격, 단위, 재고량, 안전재고, 수량(입력) + * - 행 삭제 (X 버튼) + * - 우선순위: 긴급/우선/일반 토글 (디폴트: 일반) + * - 부서 Select (디폴트: 생산부서) + * - 비고 Textarea + * - 하단: 취소 / 생산지시 확정 + */ + +import { useState, useCallback } from 'react'; +import { Search, X } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { toast } from 'sonner'; + +// 재공품 아이템 타입 +interface WipItem { + id: string; + itemCode: string; + itemName: string; + specification: string; + unit: string; + stockQuantity: number; + safetyStock: number; + quantity: number; // 사용자 입력 +} + +// 우선순위 +type Priority = '긴급' | '우선' | '일반'; + +// Mock 재공품 데이터 (품목관리 > 재공품/사용 상태 '사용' 품목) +const MOCK_WIP_ITEMS: Omit[] = [ + { id: 'wip-1', itemCode: 'WIP-GR-001', itemName: '가이드레일(벽면형)', specification: '120X70 EGI 1.6T', unit: 'EA', stockQuantity: 150, safetyStock: 50 }, + { id: 'wip-2', itemCode: 'WIP-CS-001', itemName: '케이스(500X380)', specification: '500X380 EGI 1.6T', unit: 'EA', stockQuantity: 80, safetyStock: 30 }, + { id: 'wip-3', itemCode: 'WIP-BF-001', itemName: '하단마감재(60X40)', specification: '60X40 EGI 1.6T', unit: 'EA', stockQuantity: 200, safetyStock: 100 }, + { id: 'wip-4', itemCode: 'WIP-LB-001', itemName: '하단L-BAR(17X60)', specification: '17X60 EGI 1.6T', unit: 'EA', stockQuantity: 120, safetyStock: 40 }, +]; + +interface WipProductionModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function WipProductionModal({ open, onOpenChange }: WipProductionModalProps) { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedItems, setSelectedItems] = useState([]); + const [priority, setPriority] = useState('일반'); + const [department, setDepartment] = useState('생산부서'); + const [note, setNote] = useState(''); + + // 검색 결과 필터링 + const searchResults = searchTerm.trim() + ? MOCK_WIP_ITEMS.filter( + (item) => + !selectedItems.some((s) => s.id === item.id) && + (item.itemCode.toLowerCase().includes(searchTerm.toLowerCase()) || + item.itemName.toLowerCase().includes(searchTerm.toLowerCase())) + ) + : []; + + // 품목 추가 + const handleAddItem = useCallback((item: Omit) => { + setSelectedItems((prev) => [...prev, { ...item, quantity: 0 }]); + setSearchTerm(''); + }, []); + + // 품목 삭제 + const handleRemoveItem = useCallback((id: string) => { + setSelectedItems((prev) => prev.filter((item) => item.id !== id)); + }, []); + + // 수량 변경 + const handleQuantityChange = useCallback((id: string, value: string) => { + const qty = parseInt(value) || 0; + setSelectedItems((prev) => + prev.map((item) => (item.id === id ? { ...item, quantity: qty } : item)) + ); + }, []); + + // 생산지시 확정 + const handleConfirm = useCallback(() => { + if (selectedItems.length === 0) { + toast.error('품목을 추가해주세요.'); + return; + } + const invalidItems = selectedItems.filter((item) => item.quantity <= 0); + if (invalidItems.length > 0) { + toast.error('수량을 입력해주세요.'); + return; + } + + // TODO: API 연동 + toast.success(`재공품 생산지시가 확정되었습니다. (${selectedItems.length}건)`); + handleReset(); + onOpenChange(false); + }, [selectedItems, onOpenChange]); + + // 초기화 + const handleReset = useCallback(() => { + setSearchTerm(''); + setSelectedItems([]); + setPriority('일반'); + setDepartment('생산부서'); + setNote(''); + }, []); + + const handleCancel = useCallback(() => { + handleReset(); + onOpenChange(false); + }, [handleReset, onOpenChange]); + + const priorityOptions: Priority[] = ['긴급', '우선', '일반']; + const priorityColors: Record = { + '긴급': 'bg-red-500 text-white hover:bg-red-600', + '우선': 'bg-orange-500 text-white hover:bg-orange-600', + '일반': 'bg-gray-500 text-white hover:bg-gray-600', + }; + const priorityInactiveColors: Record = { + '긴급': 'bg-white text-red-500 border-red-300 hover:bg-red-50', + '우선': 'bg-white text-orange-500 border-orange-300 hover:bg-orange-50', + '일반': 'bg-white text-gray-500 border-gray-300 hover:bg-gray-50', + }; + + return ( + + + + 재공품 생산 + + +
+ {/* 품목 검색 */} +
+ +
+ + setSearchTerm(e.target.value)} + className="pl-9" + /> +
+ {/* 검색 결과 드롭다운 */} + {searchResults.length > 0 && ( +
+ {searchResults.map((item) => ( + + ))} +
+ )} +
+ + {/* 재공품 목록 테이블 */} + {selectedItems.length > 0 && ( +
+ + + + + + + + + + + + + + + {selectedItems.map((item) => ( + + + + + + + + + + + ))} + +
품목코드품목명규격단위재고량안전재고수량
{item.itemCode}{item.itemName}{item.specification}{item.unit}{item.stockQuantity}{item.safetyStock} + handleQuantityChange(item.id, e.target.value)} + className="h-8 text-center text-sm" + placeholder="0" + /> + + +
+
+ )} + + {/* 우선순위 */} +
+ +
+ {priorityOptions.map((opt) => ( + + ))} +
+
+ + {/* 부서 */} +
+ + +
+ + {/* 비고 */} +
+ +