feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가
자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
67
claudedocs/[FIX-2026-02-04] mobile-zoom-panning.md
Normal file
67
claudedocs/[FIX-2026-02-04] mobile-zoom-panning.md
Normal file
@@ -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. 좌우 패닝 시도 → 터치 이벤트가 `<main>` 스크롤 컨테이너에 도달
|
||||
3. `<main>`에 가로 오버플로우 콘텐츠 없음 → 스크롤 불가
|
||||
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
|
||||
<div className="flex flex-col bg-background min-h-screen" style={{ height: 'var(--app-height)' }}>
|
||||
...
|
||||
<main className="flex-1 overflow-y-auto px-3 overscroll-contain" style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y pinch-zoom' }}>
|
||||
|
||||
// After
|
||||
<div className="flex flex-col bg-background min-h-screen">
|
||||
...
|
||||
<main className="flex-1 px-3">
|
||||
```
|
||||
|
||||
**`src/app/globals.css`** (추가)
|
||||
|
||||
```css
|
||||
html {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
```
|
||||
|
||||
### 핵심 원리
|
||||
- 부모 div의 `height: var(--app-height)` 제거 → 레이아웃이 자연스럽게 확장
|
||||
- `<main>`의 `overflow-auto`, `overscroll-contain` 제거 → 스크롤 컨테이너 해제
|
||||
- 스크롤이 body/html 레벨로 이동 → 브라우저가 줌 패닝 정상 처리
|
||||
|
||||
### 확인 사항
|
||||
- [x] 핀치 줌 후 좌우 패닝
|
||||
- [ ] 일반 상하 스크롤
|
||||
- [ ] 헤더 sticky 유지
|
||||
- [ ] 데스크톱 레이아웃 영향 없음 (별도 분기)
|
||||
@@ -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 호출로 교체 필요
|
||||
@@ -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` 하위 페이지에도 자동 적용됨
|
||||
|
||||
@@ -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<boolean> {
|
||||
|
||||
```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<NodeJS.Timeout | null>(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 이미 존재 ✅
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
### 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`
|
||||
@@ -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 (단가배포관리 추가)
|
||||
|
||||
@@ -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 <PriceDistributionDetail id={id} mode="edit" />;
|
||||
}
|
||||
@@ -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 <PriceDistributionDetail id={id} mode="view" />;
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense fallback={<ListPageSkeleton showHeader={false} />}>
|
||||
<PriceDistributionList />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 <PricingTableDetailClient pricingTableId={id} />;
|
||||
}
|
||||
@@ -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 <PricingTableDetailClient pricingTableId="new" />;
|
||||
}
|
||||
|
||||
return <PricingTableListClient />;
|
||||
}
|
||||
|
||||
export default function PricingTableManagementPage() {
|
||||
return (
|
||||
<Suspense fallback={<ListPageSkeleton showHeader={false} />}>
|
||||
<PricingTableManagementContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 실적신고관리 페이지
|
||||
* URL: /quality/performance-reports
|
||||
*/
|
||||
|
||||
import { PerformanceReportList } from '@/components/quality/PerformanceReportManagement';
|
||||
|
||||
export default function PerformanceReportsPage() {
|
||||
return <PerformanceReportList />;
|
||||
}
|
||||
@@ -222,6 +222,8 @@
|
||||
html {
|
||||
/* 🔧 Always show scrollbar to prevent layout shift */
|
||||
/*overflow-y: scroll;*/
|
||||
/* 📱 모바일 확대 후 좌우 패닝 허용 */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -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으로 이동
|
||||
|
||||
@@ -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<PermissionAction, boolean> = {
|
||||
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}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Record<string, number | undefined>>({});
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[700px] max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">재고 조정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
{/* 품목 선택 - 검색 */}
|
||||
<div className="font-medium text-sm">품목 선택</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="품목코드, 품목명, 로트번호 검색"
|
||||
className="pr-10"
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* 총 건수 + 유형 필터 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 <strong>{filteredItems.length}</strong>건
|
||||
</span>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[120px] h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ITEM_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[100px]">로트번호</TableHead>
|
||||
<TableHead className="text-center w-[90px]">품목코드</TableHead>
|
||||
<TableHead className="text-center w-[70px]">품목유형</TableHead>
|
||||
<TableHead className="min-w-[120px]">품목명</TableHead>
|
||||
<TableHead className="w-[90px]">규격</TableHead>
|
||||
<TableHead className="text-center w-[50px]">단위</TableHead>
|
||||
<TableHead className="text-center w-[70px]">재고량</TableHead>
|
||||
<TableHead className="text-center w-[90px]">증감 수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center text-sm">{item.lotNo}</TableCell>
|
||||
<TableCell className="text-center text-sm">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-center text-sm">{item.itemType}</TableCell>
|
||||
<TableCell className="text-sm">{item.itemName}</TableCell>
|
||||
<TableCell className="text-sm">{item.specification}</TableCell>
|
||||
<TableCell className="text-center text-sm">{item.unit}</TableCell>
|
||||
<TableCell className="text-center text-sm">{item.stockQty}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
value={adjustments[item.id] ?? ''}
|
||||
onChange={(e) => handleAdjustmentChange(item.id, e.target.value)}
|
||||
className="h-8 text-sm text-center w-[80px] mx-auto"
|
||||
placeholder="0"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="min-w-[120px]"
|
||||
disabled={isSaving}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleSave}
|
||||
className="min-w-[120px] bg-gray-900 text-white hover:bg-gray-800"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<ReceivingDetailType> = {
|
||||
materialNo: '',
|
||||
lotNo: '',
|
||||
itemCode: '',
|
||||
itemName: '',
|
||||
@@ -71,6 +81,7 @@ const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
|
||||
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<InventoryAdjustmentRecord[]>([]);
|
||||
|
||||
// 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) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{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
|
||||
)}
|
||||
</div>
|
||||
{/* 비고 - 전체 너비 */}
|
||||
<div className="mt-4">
|
||||
{renderReadOnlyField('비고', detail.remark)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -317,15 +364,54 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
<span>{detail.certificateFileName}</span>
|
||||
</div>
|
||||
) : (
|
||||
'등록된 파일이 없습니다.'
|
||||
'클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 재고 조정 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[50px]">No</TableHead>
|
||||
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">증감 수량</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">검수자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.length > 0 ? (
|
||||
adjustments.map((adj, idx) => (
|
||||
<TableRow key={adj.id}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-center">{adj.adjustmentDate}</TableCell>
|
||||
<TableCell className="text-center">{adj.quantity}</TableCell>
|
||||
<TableCell className="text-center">{adj.inspector}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-6 text-muted-foreground">
|
||||
재고 조정 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [detail]);
|
||||
}, [detail, adjustments]);
|
||||
|
||||
// ===== 등록/수정 폼 콘텐츠 =====
|
||||
const renderFormContent = useCallback(() => {
|
||||
@@ -338,6 +424,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* 자재번호 - 읽기전용 */}
|
||||
{renderReadOnlyField('자재번호', formData.materialNo, true)}
|
||||
|
||||
{/* 로트번호 - 읽기전용 */}
|
||||
{renderReadOnlyField('로트번호', formData.lotNo, true)}
|
||||
|
||||
@@ -512,17 +601,107 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 재고 조정 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddAdjustment}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[50px]">No</TableHead>
|
||||
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">증감 수량</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">검수자</TableHead>
|
||||
<TableHead className="text-center w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.length > 0 ? (
|
||||
adjustments.map((adj, idx) => (
|
||||
<TableRow key={adj.id}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="date"
|
||||
value={adj.adjustmentDate}
|
||||
onChange={(e) => {
|
||||
setAdjustments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === adj.id ? { ...a, adjustmentDate: e.target.value } : a
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
value={adj.quantity || ''}
|
||||
onChange={(e) => handleAdjustmentQtyChange(adj.id, e.target.value)}
|
||||
className="h-8 text-sm text-center w-[100px] mx-auto"
|
||||
placeholder="0"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{adj.inspector}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleRemoveAdjustment(adj.id)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">
|
||||
재고 조정 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [formData]);
|
||||
}, [formData, adjustments]);
|
||||
|
||||
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
|
||||
const customHeaderActions = (isViewMode || isEditMode) && detail ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleInspection}>
|
||||
수입검사하기
|
||||
</Button>
|
||||
</>
|
||||
{isViewMode && (
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
onClick={() => router.push(`/ko/material/receiving-management/${id}?mode=edit`)}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
// 에러 상태 표시 (view/edit 모드에서만)
|
||||
|
||||
@@ -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<ReceivingStats | null>(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(
|
||||
() => (
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableCell colSpan={15} className="py-3">
|
||||
<TableCell colSpan={18} className="py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 {totalItems}건
|
||||
</span>
|
||||
@@ -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: () => (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
onClick={handleRegister}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
입고 등록
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAdjustmentOpen(true)}
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-1" />
|
||||
재고 조정
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
onClick={handleRegister}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
입고 등록
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 푸터
|
||||
@@ -286,12 +304,15 @@ export function ReceivingList() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{item.materialNo || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{item.lotNo || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.inspectionStatus || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.inspectionDate || '-'}</TableCell>
|
||||
<TableCell>{item.supplier}</TableCell>
|
||||
<TableCell>{item.manufacturer || '-'}</TableCell>
|
||||
<TableCell>{item.itemCode}</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell className="text-center">{item.itemType || '-'}</TableCell>
|
||||
<TableCell className="max-w-[130px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>{item.specification || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
@@ -342,12 +363,14 @@ export function ReceivingList() {
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="자재번호" value={item.materialNo || '-'} />
|
||||
<InfoField label="품목코드" value={item.itemCode} />
|
||||
<InfoField label="품목유형" value={item.itemType || '-'} />
|
||||
<InfoField label="발주처" value={item.supplier} />
|
||||
<InfoField label="제조사" value={item.manufacturer || '-'} />
|
||||
<InfoField label="수입검사" value={item.inspectionStatus || '-'} />
|
||||
<InfoField label="검사일" value={item.inspectionDate || '-'} />
|
||||
<InfoField label="입고수량" value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'} />
|
||||
<InfoField label="입고일" value={item.receivingDate || '-'} />
|
||||
<InfoField label="수량" value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'} />
|
||||
<InfoField label="입고변경일" value={item.receivingDate || '-'} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
@@ -376,9 +399,17 @@ export function ReceivingList() {
|
||||
);
|
||||
|
||||
return (
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
/>
|
||||
<>
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
/>
|
||||
|
||||
{/* 재고 조정 팝업 */}
|
||||
<InventoryAdjustmentDialog
|
||||
open={isAdjustmentOpen}
|
||||
onOpenChange={setIsAdjustmentOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, ReceivingDetail> = {
|
||||
'1': {
|
||||
id: '1',
|
||||
// 기본 정보
|
||||
materialNo: 'MAT-001',
|
||||
lotNo: 'LOT-2026-001',
|
||||
itemCode: 'STEEL-001',
|
||||
itemName: 'SUS304 스테인리스 판재',
|
||||
@@ -175,16 +199,21 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
|
||||
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<string, ReceivingDetail> = {
|
||||
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<string, ReceivingDetail> = {
|
||||
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<string, ReceivingDetail> = {
|
||||
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<string, ReceivingDetail> = {
|
||||
remark: '',
|
||||
inspectionDate: undefined,
|
||||
inspectionResult: undefined,
|
||||
inventoryAdjustments: [],
|
||||
orderNo: 'PO-2026-005',
|
||||
orderUnit: 'EA',
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 재고번호, 품목코드, 품목명, 규격 */}
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('재고번호', detail.stockNumber)}
|
||||
{renderReadOnlyField('자재번호', detail.stockNumber)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode)}
|
||||
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')}
|
||||
{renderReadOnlyField('품목명', detail.itemName)}
|
||||
{renderReadOnlyField('규격', detail.specification)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: 단위, 계산 재고량, 실제 재고량, 안전재고 */}
|
||||
{/* Row 2: 규격, 단위, 재고량, 안전재고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification)}
|
||||
{renderReadOnlyField('단위', detail.unit)}
|
||||
{renderReadOnlyField('계산 재고량', detail.calculatedQty)}
|
||||
{renderReadOnlyField('실제 재고량', detail.actualQty)}
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty)}
|
||||
{renderReadOnlyField('안전재고', detail.safetyStock)}
|
||||
</div>
|
||||
|
||||
@@ -226,33 +222,19 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 재고번호, 품목코드, 품목명, 규격 (읽기 전용) */}
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{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)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: 단위, 계산 재고량 (읽기 전용) + 실제 재고량, 안전재고 (수정 가능) */}
|
||||
{/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification, true)}
|
||||
{renderReadOnlyField('단위', detail.unit, true)}
|
||||
{renderReadOnlyField('계산 재고량', detail.calculatedQty, true)}
|
||||
|
||||
{/* 실제 재고량 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="actualQty" className="text-sm text-muted-foreground">
|
||||
실제 재고량
|
||||
</Label>
|
||||
<Input
|
||||
id="actualQty"
|
||||
type="number"
|
||||
value={formData.actualQty}
|
||||
onChange={(e) => handleInputChange('actualQty', e.target.value)}
|
||||
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty, true)}
|
||||
|
||||
{/* 안전재고 (수정 가능) */}
|
||||
<div>
|
||||
|
||||
@@ -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<StockItem>[] = [
|
||||
{ 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() {
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.stockNumber}</TableCell>
|
||||
<TableCell>{item.itemCode}</TableCell>
|
||||
<TableCell>{ITEM_TYPE_LABELS[item.itemType] || '-'}</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>{item.specification || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center">{item.calculatedQty}</TableCell>
|
||||
<TableCell className="text-center">{item.actualQty}</TableCell>
|
||||
<TableCell className="text-center">{item.safetyStock}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={item.useStatus === 'inactive' ? 'text-gray-400' : ''}>
|
||||
@@ -306,10 +291,10 @@ export function StockStatusList() {
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="품목코드" value={item.itemCode} />
|
||||
<InfoField label="품목유형" value={ITEM_TYPE_LABELS[item.itemType] || '-'} />
|
||||
<InfoField label="규격" value={item.specification || '-'} />
|
||||
<InfoField label="단위" value={item.unit} />
|
||||
<InfoField label="계산 재고량" value={`${item.calculatedQty}`} />
|
||||
<InfoField label="실제 재고량" value={`${item.actualQty}`} />
|
||||
<InfoField label="재고량" value={`${item.calculatedQty}`} />
|
||||
<InfoField label="안전재고" value={`${item.safetyStock}`} />
|
||||
</div>
|
||||
}
|
||||
@@ -401,24 +386,6 @@ export function StockStatusList() {
|
||||
// 통계
|
||||
computeStats: () => stats,
|
||||
|
||||
// 헤더 액션 버튼
|
||||
headerActions: () => (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
onClick={handleStockAudit}
|
||||
disabled={isAuditLoading}
|
||||
>
|
||||
{isAuditLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<ClipboardList className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{isAuditLoading ? '로딩 중...' : '재고 실사'}
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 테이블 푸터
|
||||
tableFooter: (
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
@@ -468,22 +435,12 @@ export function StockStatusList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage<StockItem>
|
||||
config={config}
|
||||
initialData={filteredStocks}
|
||||
initialTotalCount={filteredStocks.length}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
|
||||
{/* 재고 실사 모달 */}
|
||||
<StockAuditModal
|
||||
open={isAuditModalOpen}
|
||||
onOpenChange={setIsAuditModalOpen}
|
||||
stocks={stocks}
|
||||
onComplete={handleAuditComplete}
|
||||
/>
|
||||
</>
|
||||
<UniversalListPage<StockItem>
|
||||
config={config}
|
||||
initialData={filteredStocks}
|
||||
initialTotalCount={filteredStocks.length}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
|
||||
@@ -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() {
|
||||
<Select
|
||||
value={formData.freightCost || ''}
|
||||
onValueChange={(value) => handleInputChange('freightCost', value)}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isFreightCostLocked(formData.deliveryMethod)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
@@ -549,7 +560,7 @@ export function ShipmentCreate() {
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>톤수</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
|
||||
@@ -338,18 +338,16 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('출고번호', detail.shipmentNo)}
|
||||
{renderInfoField('로트번호', detail.lotNo)}
|
||||
{renderInfoField('현장명', detail.siteName)}
|
||||
{renderInfoField('수주처', detail.customerName)}
|
||||
{renderInfoField('거래등급', detail.customerGrade)}
|
||||
{renderInfoField('작성자', detail.registrant)}
|
||||
{renderInfoField(
|
||||
'상태',
|
||||
<Badge className={SHIPMENT_STATUS_STYLES[detail.status]}>
|
||||
{SHIPMENT_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
)}
|
||||
{renderInfoField('작성자', detail.registrant)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -408,7 +406,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>톤수</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
import {
|
||||
SHIPMENT_STATUS_LABELS,
|
||||
SHIPMENT_STATUS_STYLES,
|
||||
FREIGHT_COST_LABELS,
|
||||
} from './types';
|
||||
import type {
|
||||
ShipmentDetail,
|
||||
@@ -79,10 +78,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 {
|
||||
@@ -174,12 +175,13 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
setDetail(shipmentDetail);
|
||||
|
||||
// 폼 초기값 설정
|
||||
const lockedFreight = shipmentDetail.deliveryMethod === 'direct_dispatch' || shipmentDetail.deliveryMethod === 'self_pickup';
|
||||
setFormData({
|
||||
scheduledDate: shipmentDetail.scheduledDate,
|
||||
shipmentDate: shipmentDetail.shipmentDate || '',
|
||||
priority: shipmentDetail.priority,
|
||||
deliveryMethod: shipmentDetail.deliveryMethod,
|
||||
freightCost: shipmentDetail.freightCost,
|
||||
freightCost: lockedFreight ? 'none' : shipmentDetail.freightCost,
|
||||
receiver: shipmentDetail.receiver || '',
|
||||
receiverContact: shipmentDetail.receiverContact || '',
|
||||
zipCode: shipmentDetail.zipCode || '',
|
||||
@@ -223,9 +225,22 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 배송방식에 따라 운임비용 '없음' 고정 여부 판단
|
||||
const isFreightCostLocked = (method: DeliveryMethod) =>
|
||||
method === 'direct_dispatch' || method === 'self_pickup';
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof ShipmentEditFormData, value: string | number | undefined) => {
|
||||
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([]);
|
||||
};
|
||||
|
||||
@@ -375,10 +390,6 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">출고번호</Label>
|
||||
<div className="font-medium">{detail.shipmentNo}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">로트번호</Label>
|
||||
<div className="font-medium">{detail.lotNo}</div>
|
||||
@@ -388,12 +399,12 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
<div className="font-medium">{detail.siteName}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">수주처</Label>
|
||||
<Label className="text-muted-foreground">회사명</Label>
|
||||
<div className="font-medium">{detail.customerName}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">거래등급</Label>
|
||||
<div className="font-medium">{detail.customerGrade || '-'}</div>
|
||||
<Label className="text-muted-foreground">수주자</Label>
|
||||
<div className="font-medium">{detail.orderer || '-'}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">작성자</Label>
|
||||
@@ -454,7 +465,7 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
key={`freight-${formData.freightCost}`}
|
||||
value={formData.freightCost || ''}
|
||||
onValueChange={(value) => handleInputChange('freightCost', value)}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isFreightCostLocked(formData.deliveryMethod)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
@@ -548,7 +559,7 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>톤수</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
|
||||
@@ -231,23 +231,17 @@ export function ShipmentList() {
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 테이블 컬럼 (18개 - 출고번호/로트번호 통합)
|
||||
// 테이블 컬럼 (11개)
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'shipmentNo', label: '출고번호/로트번호', className: 'min-w-[160px]' },
|
||||
{ key: 'scheduledDate', label: '출고예정일', className: 'w-[100px] text-center' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
|
||||
{ key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]' },
|
||||
{ key: 'customerGrade', label: '거래등급', className: 'w-[80px] text-center' },
|
||||
{ key: 'receiver', label: '수신자', className: 'w-[80px] text-center' },
|
||||
{ key: 'receiverAddress', label: '수신주소', className: 'min-w-[140px]' },
|
||||
{ key: 'receiverCompany', label: '수신처', className: 'min-w-[100px]' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'dispatch', label: '배차', className: 'w-[80px] text-center' },
|
||||
{ key: 'arrivalDateTime', label: '입차일시', className: 'w-[130px] text-center' },
|
||||
{ key: 'tonnage', label: '톤수', className: 'w-[70px] text-center' },
|
||||
{ key: 'unloadingNo', label: '하차번호', className: 'w-[90px] text-center' },
|
||||
{ key: 'driverContact', label: '기사연락처', className: 'min-w-[110px] text-center' },
|
||||
{ key: 'writer', label: '작성자', className: 'w-[80px] text-center' },
|
||||
{ key: 'shipmentDate', label: '출고일', className: 'w-[100px] text-center' },
|
||||
],
|
||||
@@ -292,7 +286,7 @@ export function ShipmentList() {
|
||||
// 통계 카드
|
||||
stats,
|
||||
|
||||
// 테이블 행 렌더링 (19개 컬럼)
|
||||
// 테이블 행 렌더링 (11개 컬럼)
|
||||
renderTableRow: (
|
||||
item: ShipmentItem,
|
||||
index: number,
|
||||
@@ -312,16 +306,9 @@ export function ShipmentList() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div>{item.shipmentNo}</div>
|
||||
{item.lotNo && item.lotNo !== item.shipmentNo && (
|
||||
<div className="text-xs text-muted-foreground">{item.lotNo}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.scheduledDate}</TableCell>
|
||||
<TableCell className="font-medium">{item.lotNo || item.shipmentNo || '-'}</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate">{item.siteName}</TableCell>
|
||||
<TableCell>{item.orderCustomer || item.customerName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.customerGrade || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.receiver || '-'}</TableCell>
|
||||
<TableCell className="max-w-[140px] truncate">{item.receiverAddress || '-'}</TableCell>
|
||||
<TableCell>{item.receiverCompany || '-'}</TableCell>
|
||||
@@ -331,10 +318,6 @@ export function ShipmentList() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.dispatch || item.deliveryMethodLabel || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.arrivalDateTime || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.tonnage || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.unloadingNo || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.driverContact || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.writer || item.manager || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.shipmentDate || '-'}</TableCell>
|
||||
</TableRow>
|
||||
@@ -373,11 +356,14 @@ export function ShipmentList() {
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="출고번호/로트번호" value={item.shipmentNo || item.lotNo} />
|
||||
<InfoField label="수주처" value={item.orderCustomer || item.customerName} />
|
||||
<InfoField label="출고예정일" value={item.scheduledDate} />
|
||||
<InfoField label="배송방식" value={item.deliveryMethodLabel || DELIVERY_METHOD_LABELS[item.deliveryMethod]} />
|
||||
<InfoField label="로트번호" value={item.lotNo || item.shipmentNo} />
|
||||
<InfoField label="현장명" value={item.siteName} />
|
||||
<InfoField label="수주처" value={item.orderCustomer || item.customerName || '-'} />
|
||||
<InfoField label="수신자" value={item.receiver || '-'} />
|
||||
<InfoField label="수신주소" value={item.receiverAddress || '-'} />
|
||||
<InfoField label="수신처" value={item.receiverCompany || '-'} />
|
||||
<InfoField label="배차" value={item.dispatch || item.deliveryMethodLabel || '-'} />
|
||||
<InfoField label="작성자" value={item.writer || item.manager || '-'} />
|
||||
<InfoField label="출고일" value={item.shipmentDate || '-'} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -41,11 +41,12 @@ export const PRIORITY_STYLES: Record<ShipmentPriority, string> = {
|
||||
};
|
||||
|
||||
// 운임비용 타입
|
||||
export type FreightCostType = 'prepaid' | 'collect' | 'free' | 'negotiable';
|
||||
export type FreightCostType = 'prepaid' | 'collect' | 'free' | 'negotiable' | 'none';
|
||||
|
||||
export const FREIGHT_COST_LABELS: Record<FreightCostType, string> = {
|
||||
prepaid: '선불',
|
||||
collect: '착불',
|
||||
none: '없음',
|
||||
free: '무료',
|
||||
negotiable: '협의',
|
||||
};
|
||||
@@ -137,6 +138,8 @@ export interface ShipmentItem {
|
||||
writer?: string; // 작성자
|
||||
shipmentDate?: string; // 출고일
|
||||
shipmentTime?: string; // 출고시간 (캘린더용)
|
||||
orderer?: string; // 수주자
|
||||
createdAt?: string; // 작성일
|
||||
}
|
||||
|
||||
// 출고 품목
|
||||
@@ -162,6 +165,7 @@ export interface ShipmentDetail {
|
||||
customerGrade: string; // 거래등급
|
||||
status: ShipmentStatus; // 상태
|
||||
registrant?: string; // 작성자
|
||||
orderer?: string; // 수주자
|
||||
|
||||
// 수주/배송 정보
|
||||
scheduledDate: string; // 출고 예정일
|
||||
|
||||
@@ -9,6 +9,14 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { vehicleDispatchConfig } from './vehicleDispatchConfig';
|
||||
import { getVehicleDispatchById } from './actions';
|
||||
@@ -87,7 +95,7 @@ export function VehicleDispatchDetail({ id }: VehicleDispatchDetailProps) {
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('배차번호', detail.dispatchNo)}
|
||||
{renderInfoField('출고번호', detail.shipmentNo)}
|
||||
{renderInfoField('로트번호', detail.lotNo || detail.shipmentNo)}
|
||||
{renderInfoField('현장명', detail.siteName)}
|
||||
{renderInfoField('수주처', detail.orderCustomer)}
|
||||
{renderInfoField(
|
||||
@@ -107,20 +115,34 @@ export function VehicleDispatchDetail({ id }: VehicleDispatchDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 2: 배차 정보 */}
|
||||
{/* 카드 2: 배차 정보 (테이블 형태) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{renderInfoField('물류업체', detail.logisticsCompany)}
|
||||
{renderInfoField('입차일시', detail.arrivalDateTime)}
|
||||
{renderInfoField('톤수', detail.tonnage)}
|
||||
{renderInfoField('차량번호', detail.vehicleNo)}
|
||||
{renderInfoField('기사연락처', detail.driverContact)}
|
||||
{renderInfoField('비고', detail.remarks || '-')}
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>{detail.logisticsCompany || '-'}</TableCell>
|
||||
<TableCell>{detail.arrivalDateTime || '-'}</TableCell>
|
||||
<TableCell>{detail.tonnage || '-'}</TableCell>
|
||||
<TableCell>{detail.vehicleNo || '-'}</TableCell>
|
||||
<TableCell>{detail.driverContact || '-'}</TableCell>
|
||||
<TableCell>{detail.remarks || '-'}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -202,8 +202,8 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
<div className="font-medium">{detail.dispatchNo}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">출고번호</Label>
|
||||
<div className="font-medium">{detail.shipmentNo}</div>
|
||||
<Label className="text-muted-foreground">로트번호</Label>
|
||||
<div className="font-medium">{detail.lotNo || detail.shipmentNo}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">현장명</Label>
|
||||
@@ -275,11 +275,11 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>톤수</Label>
|
||||
<Label>구분</Label>
|
||||
<Input
|
||||
value={formData.tonnage}
|
||||
onChange={(e) => handleInputChange('tonnage', e.target.value)}
|
||||
placeholder="예: 3.5톤"
|
||||
placeholder="예: 3.5 톤"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.dispatchNo}</TableCell>
|
||||
<TableCell>{item.shipmentNo}</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate">{item.siteName}</TableCell>
|
||||
<TableCell>{item.orderCustomer}</TableCell>
|
||||
<TableCell>{item.logisticsCompany}</TableCell>
|
||||
<TableCell className="text-center">{item.tonnage}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.supplyAmount)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.vat)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatAmount(item.totalAmount)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`text-xs ${FREIGHT_COST_STYLES[item.freightCostType]}`}>
|
||||
{FREIGHT_COST_LABELS[item.freightCostType]}
|
||||
</Badge>
|
||||
<TableCell className="w-[50px] text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="min-w-[130px] font-medium">{item.dispatchNo}</TableCell>
|
||||
<TableCell className="min-w-[120px]">{item.lotNo || item.shipmentNo}</TableCell>
|
||||
<TableCell className="min-w-[100px] truncate">{item.siteName}</TableCell>
|
||||
<TableCell className="min-w-[100px]">{item.orderCustomer}</TableCell>
|
||||
<TableCell className="min-w-[90px]">{item.logisticsCompany}</TableCell>
|
||||
<TableCell className="w-[100px] text-right">{formatAmount(item.supplyAmount || 0)}</TableCell>
|
||||
<TableCell className="w-[90px] text-right">{formatAmount(item.vat || 0)}</TableCell>
|
||||
<TableCell className="w-[100px] text-right font-medium">{formatAmount(item.totalAmount || 0)}</TableCell>
|
||||
<TableCell className="w-[70px] text-center">
|
||||
{item.freightCostType ? (
|
||||
<Badge className={`text-xs ${FREIGHT_COST_STYLES[item.freightCostType]}`}>
|
||||
{FREIGHT_COST_LABELS[item.freightCostType]}
|
||||
</Badge>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>{item.vehicleNo}</TableCell>
|
||||
<TableCell>{item.driverContact}</TableCell>
|
||||
<TableCell className="text-center">{item.writer}</TableCell>
|
||||
<TableCell className="text-center">{item.arrivalDateTime}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="w-[80px] text-center">{item.writer || '-'}</TableCell>
|
||||
<TableCell className="w-[80px] text-center">
|
||||
<Badge className={`text-xs ${VEHICLE_DISPATCH_STATUS_STYLES[item.status]}`}>
|
||||
{VEHICLE_DISPATCH_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate">{item.remarks || '-'}</TableCell>
|
||||
<TableCell className="min-w-[100px] truncate">{item.remarks || '-'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
@@ -296,17 +289,16 @@ export function VehicleDispatchList() {
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="출고번호" value={item.shipmentNo} />
|
||||
<InfoField label="로트번호" value={item.lotNo || item.shipmentNo} />
|
||||
<InfoField label="수주처" value={item.orderCustomer} />
|
||||
<InfoField label="물류업체" value={item.logisticsCompany} />
|
||||
<InfoField label="톤수" value={item.tonnage} />
|
||||
<InfoField label="공급가액" value={`${formatAmount(item.supplyAmount)}원`} />
|
||||
<InfoField label="합계" value={`${formatAmount(item.totalAmount)}원`} />
|
||||
<InfoField
|
||||
label="선/착불"
|
||||
value={FREIGHT_COST_LABELS[item.freightCostType]}
|
||||
/>
|
||||
<InfoField label="차량번호" value={item.vehicleNo} />
|
||||
<InfoField label="입차일시" value={item.arrivalDateTime} />
|
||||
<InfoField label="작성자" value={item.writer} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
|
||||
@@ -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; // 운임비용
|
||||
|
||||
537
src/components/pricing-distribution/PriceDistributionDetail.tsx
Normal file
537
src/components/pricing-distribution/PriceDistributionDetail.tsx
Normal file
@@ -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<DetailType | null>(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<Set<string>>(new Set());
|
||||
const [gradeFilter, setGradeFilter] = useState<string>('all');
|
||||
|
||||
// 수정 가능 폼 데이터
|
||||
const [formData, setFormData] = useState<PriceDistributionFormData>({
|
||||
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 (
|
||||
<Badge variant="outline" className={`${style.bg} ${style.text} ${style.border}`}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// 금액 포맷
|
||||
const formatPrice = (price?: number) => {
|
||||
if (price === undefined || price === null) return '-';
|
||||
return price.toLocaleString();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<p className="text-muted-foreground">데이터를 찾을 수 없습니다.</p>
|
||||
<Button variant="outline" onClick={handleBack}>목록으로</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const isAllSelected = detail.items.length > 0 && selectedItems.size === detail.items.length;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={`단가배포 ${isEditMode ? '수정' : '상세'}`}
|
||||
description={`${detail.distributionName} (${detail.distributionNo})`}
|
||||
icon={FileText}
|
||||
/>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 단가배포번호 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">단가배포번호</Label>
|
||||
<p className="text-sm font-medium">{detail.distributionNo}</p>
|
||||
</div>
|
||||
|
||||
{/* 단가배포명 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">단가배포명</Label>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
value={formData.distributionName}
|
||||
onChange={(e) => handleChange('distributionName', e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm font-medium">{detail.distributionName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">상태</Label>
|
||||
<Select value={detail.status} disabled>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="initial">{DISTRIBUTION_STATUS_LABELS.initial}</SelectItem>
|
||||
<SelectItem value="revision">{DISTRIBUTION_STATUS_LABELS.revision}</SelectItem>
|
||||
<SelectItem value="finalized">{DISTRIBUTION_STATUS_LABELS.finalized}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 작성자 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">작성자</Label>
|
||||
<p className="text-sm font-medium">{detail.author}</p>
|
||||
</div>
|
||||
|
||||
{/* 등록일 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">등록일</Label>
|
||||
<p className="text-sm font-medium">{detail.createdAt}</p>
|
||||
</div>
|
||||
|
||||
{/* 적용시점 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">적용시점</Label>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.effectiveDate}
|
||||
onChange={(e) => handleChange('effectiveDate', e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm font-medium">
|
||||
{detail.effectiveDate ? new Date(detail.effectiveDate).toLocaleDateString('ko-KR') : '-'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사무실 연락처 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">사무실 연락처</Label>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
value={formData.officePhone}
|
||||
onChange={(e) => handleChange('officePhone', e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
placeholder="02-0000-0000"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm font-medium">{detail.officePhone || '-'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 발주전용 연락처 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">발주전용 연락처</Label>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
value={formData.orderPhone}
|
||||
onChange={(e) => handleChange('orderPhone', e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
placeholder="02-0000-0000"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm font-medium">{detail.orderPhone || '-'}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 단가 목록 테이블 */}
|
||||
<Card className="mt-4">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">단가표 목록</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={gradeFilter} onValueChange={setGradeFilter}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-sm">
|
||||
<SelectValue placeholder="등급" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{TRADE_GRADE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 {detail.items.length}건
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={(checked) => handleSelectAll(!!checked)}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px] text-center">번호</TableHead>
|
||||
<TableHead className="min-w-[100px]">단가번호</TableHead>
|
||||
<TableHead className="min-w-[100px]">품목코드</TableHead>
|
||||
<TableHead className="min-w-[80px]">품목유형</TableHead>
|
||||
<TableHead className="min-w-[120px]">품목명</TableHead>
|
||||
<TableHead className="min-w-[80px]">규격</TableHead>
|
||||
<TableHead className="min-w-[60px]">단위</TableHead>
|
||||
<TableHead className="min-w-[100px] text-right">매입단가</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">가공비</TableHead>
|
||||
<TableHead className="min-w-[70px] text-right">마진율</TableHead>
|
||||
<TableHead className="min-w-[100px] text-right">판매단가</TableHead>
|
||||
<TableHead className="min-w-[80px]">상태</TableHead>
|
||||
<TableHead className="min-w-[80px]">작성자</TableHead>
|
||||
<TableHead className="min-w-[100px]">변경일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detail.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={15} className="text-center text-muted-foreground py-8">
|
||||
단가 데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
detail.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.id)}
|
||||
onCheckedChange={(checked) => handleSelectItem(item.id, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>{item.pricingCode}</TableCell>
|
||||
<TableCell>{item.itemCode}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{item.itemType}</TableCell>
|
||||
<TableCell className="font-medium">{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{item.specification}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{item.unit}</TableCell>
|
||||
<TableCell className="text-right font-mono">{formatPrice(item.purchasePrice)}</TableCell>
|
||||
<TableCell className="text-right font-mono">{formatPrice(item.processingCost)}</TableCell>
|
||||
<TableCell className="text-right font-mono">{item.marginRate}%</TableCell>
|
||||
<TableCell className="text-right font-mono font-semibold">{formatPrice(item.salesPrice)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{item.author}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{item.changedDate}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 하단 버튼 (sticky 하단 바) */}
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
|
||||
{/* 왼쪽: 목록으로 / 취소 */}
|
||||
{isViewMode ? (
|
||||
<Button variant="outline" onClick={handleBack} size="sm" className="md:size-default">
|
||||
<ArrowLeft className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">목록으로</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isSaving} size="sm" className="md:size-default">
|
||||
<X className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">취소</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 오른쪽: 액션 버튼들 */}
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{isViewMode && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:size-default"
|
||||
onClick={() => setShowDocumentModal(true)}
|
||||
>
|
||||
<FileText className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">단가표 보기</span>
|
||||
</Button>
|
||||
{canApprove && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:size-default"
|
||||
onClick={() => setShowFinalizeDialog(true)}
|
||||
disabled={detail.status === 'finalized'}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">최종확정</span>
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button
|
||||
onClick={handleEditMode}
|
||||
size="sm"
|
||||
className="md:size-default"
|
||||
disabled={detail.status === 'finalized'}
|
||||
>
|
||||
<Edit3 className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수정</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isEditMode && canUpdate && (
|
||||
<Button onClick={handleSave} disabled={isSaving} size="sm" className="md:size-default">
|
||||
<Save className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">{isSaving ? '저장 중...' : '저장'}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최종확정 다이얼로그 */}
|
||||
<AlertDialog open={showFinalizeDialog} onOpenChange={setShowFinalizeDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>최종확정</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
단가배포를 최종 확정하시겠습니까? 확정 후에는 수정이 불가합니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleFinalize}>
|
||||
확정
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 단가표 보기 모달 */}
|
||||
<PriceDistributionDocumentModal
|
||||
open={showDocumentModal}
|
||||
onOpenChange={setShowDocumentModal}
|
||||
detail={detail}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default PriceDistributionDetail;
|
||||
@@ -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 (
|
||||
<DocumentViewer
|
||||
title="문서 상세_단가표_팝업"
|
||||
subtitle="단가표 보기"
|
||||
preset="readonly"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<div className="bg-white mx-auto" style={{ width: '210mm', minHeight: '297mm', padding: '15mm 20mm' }}>
|
||||
{/* 문서 헤더 + 결재란 */}
|
||||
<DocumentHeader
|
||||
title="경동기업 자재단가 조정"
|
||||
documentCode={detail.distributionNo}
|
||||
subtitle={`적용기간: ${year}년 ${month}월 ${day}일 ~`}
|
||||
layout="construction"
|
||||
className="pb-4 border-b-2 border-black"
|
||||
approval={null}
|
||||
customApproval={
|
||||
<ConstructionApprovalTable
|
||||
approvers={{
|
||||
writer: { name: detail.author },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 수신/발신 정보 */}
|
||||
<div className="border border-gray-300 mb-6 mt-6">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 w-28 font-medium">수신자</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">경동기업 고객사</td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 w-28 font-medium">발신자</td>
|
||||
<td className="px-2 py-1">경동기업</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium">발신일자</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">
|
||||
{year}-{String(month).padStart(2, '0')}-{String(day).padStart(2, '0')}
|
||||
</td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium">사무실 연락처</td>
|
||||
<td className="px-2 py-1">{detail.officePhone || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium"></td>
|
||||
<td className="border-r border-gray-300 px-2 py-1"></td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium">발주전용 연락처</td>
|
||||
<td className="px-2 py-1">{detail.orderPhone || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="text-sm leading-relaxed space-y-4 mb-8">
|
||||
<p>
|
||||
1. 귀사의 무궁한 발전을 기원합니다.
|
||||
</p>
|
||||
<p>
|
||||
2. 자사에서 귀사에 가격인 변동으로 인해 단가(단가표)에 아래와 같이 조정하여 단가표를 보내드리오니,
|
||||
우순거래처 귀 물록표를 보내드리오며 이 단가는 거래(단가표)로 알고서시고 첫 기간 다른 거래처에 단가가
|
||||
표를 보내 데이터가 안되도록 합니다.
|
||||
</p>
|
||||
<p>
|
||||
3. 귀사에 공급하는 자재가 조정되었으니,
|
||||
<br />
|
||||
<span className="ml-4">
|
||||
가. {year}년 {month}월 {day}일 발주요분부터~
|
||||
</span>
|
||||
<br />
|
||||
<span className="ml-4">
|
||||
나. 단가표
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 단가 테이블 */}
|
||||
<div className="overflow-x-auto mb-8">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">구분</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">물록</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">규격</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">두께/T</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">입가/M</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">단위</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">
|
||||
조정금액
|
||||
<br />
|
||||
<span className="text-xs font-normal">(VAT별도)</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">{item.itemType}</td>
|
||||
<td className="border border-gray-300 px-3 py-2">{item.itemName}</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">{item.specification}</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">-</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">-</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">{item.unit}</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-right font-mono">
|
||||
{item.salesPrice.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 하단 날짜 및 회사 정보 */}
|
||||
<div className="text-center mt-12 space-y-2">
|
||||
<p className="text-sm">
|
||||
{year}년 {month}월 {day}일
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
㈜ 경동기업
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
|
||||
export default PriceDistributionDocumentModal;
|
||||
327
src/components/pricing-distribution/PriceDistributionList.tsx
Normal file
327
src/components/pricing-distribution/PriceDistributionList.tsx
Normal file
@@ -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<PriceDistributionListItem[]>([]);
|
||||
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<string>(thirtyDaysAgo.toISOString().split('T')[0]);
|
||||
const [endDate, setEndDate] = useState<string>(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 (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${style.bg} ${style.text} ${style.border}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// 등록 핸들러
|
||||
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<string, string | string[]>) => {
|
||||
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<PriceDistributionListItem>
|
||||
) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-center">
|
||||
{globalIndex}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{item.distributionNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{item.distributionName}</span>
|
||||
</TableCell>
|
||||
<TableCell>{renderStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{item.author}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(item.createdAt).toLocaleDateString('ko-KR')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
item: PriceDistributionListItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<PriceDistributionListItem>
|
||||
) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
title={item.distributionName}
|
||||
headerBadges={
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{item.distributionNo}
|
||||
</code>
|
||||
}
|
||||
statusBadge={renderStatusBadge(item.status)}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => handleRowClick(item)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="작성자" value={item.author} />
|
||||
<InfoField
|
||||
label="등록일"
|
||||
value={new Date(item.createdAt).toLocaleDateString('ko-KR')}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = () => (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setShowRegisterDialog(true)}
|
||||
className="ml-auto gap-2 bg-gray-900 text-white hover:bg-gray-800"
|
||||
>
|
||||
<FilePlus className="h-4 w-4" />
|
||||
단가배포 등록
|
||||
</Button>
|
||||
);
|
||||
|
||||
// UniversalListPage 설정
|
||||
const listConfig: UniversalListConfig<PriceDistributionListItem> = {
|
||||
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 (
|
||||
<>
|
||||
<UniversalListPage<PriceDistributionListItem>
|
||||
config={listConfig}
|
||||
initialData={data}
|
||||
initialTotalCount={data.length}
|
||||
/>
|
||||
|
||||
{/* 단가배포 등록 확인 다이얼로그 */}
|
||||
<AlertDialog open={showRegisterDialog} onOpenChange={setShowRegisterDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>알림</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<span className="block font-semibold text-foreground">
|
||||
새로운 단가배포 버전을 등록하시겠습니까?
|
||||
</span>
|
||||
<span className="block text-muted-foreground">
|
||||
현재 단가표 기준으로 자동 생성됩니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isRegistering}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRegister}
|
||||
disabled={isRegistering}
|
||||
>
|
||||
{isRegistering ? '등록 중...' : '등록'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PriceDistributionList;
|
||||
444
src/components/pricing-distribution/actions.ts
Normal file
444
src/components/pricing-distribution/actions.ts
Normal file
@@ -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<string, PriceDistributionDetail> = {
|
||||
'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 };
|
||||
}
|
||||
3
src/components/pricing-distribution/index.ts
Normal file
3
src/components/pricing-distribution/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PriceDistributionList } from './PriceDistributionList';
|
||||
export { PriceDistributionDetail } from './PriceDistributionDetail';
|
||||
export { PriceDistributionDocumentModal } from './PriceDistributionDocumentModal';
|
||||
96
src/components/pricing-distribution/types.ts
Normal file
96
src/components/pricing-distribution/types.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 단가배포관리 타입 정의
|
||||
*/
|
||||
|
||||
// ===== 단가배포 상태 =====
|
||||
|
||||
export type DistributionStatus = 'initial' | 'revision' | 'finalized';
|
||||
|
||||
export const DISTRIBUTION_STATUS_LABELS: Record<DistributionStatus, string> = {
|
||||
initial: '최초작성',
|
||||
revision: '보이수정',
|
||||
finalized: '최종확정',
|
||||
};
|
||||
|
||||
export const DISTRIBUTION_STATUS_STYLES: Record<DistributionStatus, { bg: string; text: string; border: string }> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<PricingTable | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(!isNewMode);
|
||||
const [error, setError] = useState<string | null>(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 <DetailPageSkeleton sections={2} fieldsPerSection={4} />;
|
||||
}
|
||||
|
||||
if (error && !isNewMode) {
|
||||
return (
|
||||
<ErrorCard
|
||||
type="network"
|
||||
title="단가표를 불러올 수 없습니다"
|
||||
description={error}
|
||||
tips={[
|
||||
'해당 단가표가 존재하는지 확인해주세요',
|
||||
'인터넷 연결 상태를 확인해주세요',
|
||||
'잠시 후 다시 시도해주세요',
|
||||
]}
|
||||
homeButtonLabel="목록으로 이동"
|
||||
homeButtonHref={BASE_PATH}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNewMode) {
|
||||
return <PricingTableForm mode="create" />;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return <PricingTableForm mode="edit" initialData={data} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorCard
|
||||
type="not-found"
|
||||
title="단가표를 찾을 수 없습니다"
|
||||
description="요청하신 단가표 정보가 존재하지 않습니다."
|
||||
homeButtonLabel="목록으로 이동"
|
||||
homeButtonHref={BASE_PATH}
|
||||
/>
|
||||
);
|
||||
}
|
||||
485
src/components/pricing-table-management/PricingTableForm.tsx
Normal file
485
src/components/pricing-table-management/PricingTableForm.tsx
Normal file
@@ -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<PricingTableStatus>(initialData?.status ?? '사용');
|
||||
const [purchasePrice, setPurchasePrice] = useState(initialData?.purchasePrice ?? 0);
|
||||
const [processingCost, setProcessingCost] = useState(initialData?.processingCost ?? 0);
|
||||
const [gradePricings, setGradePricings] = useState<GradePricing[]>(
|
||||
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 (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={isEdit ? '단가표 상세' : '단가표 등록'}
|
||||
description={isEdit ? '단가표 상세를 관리합니다' : '새 단가표를 등록합니다'}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 pb-24">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{/* Row 1: 단가번호, 품목코드, 품목유형, 품목명 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>단가번호</Label>
|
||||
<Input
|
||||
value={isEdit ? initialData?.pricingCode ?? '' : '자동생성'}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>품목코드</Label>
|
||||
<Input
|
||||
value={isEdit ? initialData?.itemCode ?? '' : itemCode}
|
||||
onChange={isEdit ? undefined : (e) => setItemCode(e.target.value)}
|
||||
disabled={isEdit}
|
||||
placeholder={isEdit ? undefined : '품목코드 입력'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>품목유형</Label>
|
||||
<Input
|
||||
value={isEdit ? initialData?.itemType ?? '' : itemType}
|
||||
onChange={isEdit ? undefined : (e) => setItemType(e.target.value)}
|
||||
disabled={isEdit}
|
||||
placeholder={isEdit ? undefined : '품목유형 입력'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>품목명</Label>
|
||||
<Input
|
||||
value={isEdit ? initialData?.itemName ?? '' : itemName}
|
||||
onChange={isEdit ? undefined : (e) => setItemName(e.target.value)}
|
||||
disabled={isEdit}
|
||||
placeholder={isEdit ? undefined : '품목명 입력'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 규격, 단위, 상태, 작성자 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 mt-6">
|
||||
<div className="space-y-2">
|
||||
<Label>규격</Label>
|
||||
<Input
|
||||
value={isEdit ? initialData?.specification ?? '' : specification}
|
||||
onChange={isEdit ? undefined : (e) => setSpecification(e.target.value)}
|
||||
disabled={isEdit}
|
||||
placeholder={isEdit ? undefined : '규격 입력'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>단위</Label>
|
||||
<Input
|
||||
value={isEdit ? initialData?.unit ?? '' : unit}
|
||||
onChange={isEdit ? undefined : (e) => setUnit(e.target.value)}
|
||||
disabled={isEdit}
|
||||
placeholder={isEdit ? undefined : '단위 입력'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
<Select
|
||||
key={`status-${status}`}
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v as PricingTableStatus)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사용">사용</SelectItem>
|
||||
<SelectItem value="미사용">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={isEdit ? initialData?.author ?? '' : '현재사용자'}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 변경일 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 mt-6">
|
||||
<div className="space-y-2">
|
||||
<Label>변경일</Label>
|
||||
<Input
|
||||
value={isEdit ? initialData?.changedDate ?? '' : new Date().toISOString().split('T')[0]}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 단가 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="text-base">단가 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{/* 매입단가 / 가공비 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||
<div className="space-y-2">
|
||||
<Label>매입단가</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={purchasePrice || ''}
|
||||
onChange={(e) => handlePurchasePriceChange(e.target.value)}
|
||||
placeholder="숫자 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>가공비</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={processingCost || ''}
|
||||
onChange={(e) => handleProcessingCostChange(e.target.value)}
|
||||
placeholder="숫자 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 거래등급별 판매단가 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground w-[140px]">
|
||||
거래등급
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground w-[120px]">
|
||||
마진율
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground w-[120px]">
|
||||
판매단가
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">
|
||||
비고
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-muted-foreground w-[80px]">
|
||||
<Button size="sm" variant="outline" onClick={handleAddRow} className="h-7 text-xs">
|
||||
추가
|
||||
</Button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{gradePricings.map((gp, index) => (
|
||||
<tr key={gp.id} className="border-b last:border-b-0">
|
||||
<td className="px-4 py-2">
|
||||
<Select
|
||||
key={`grade-${gp.id}-${gp.grade}`}
|
||||
value={gp.grade}
|
||||
onValueChange={(v) => handleGradeChange(index, v as TradeGrade)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GRADE_OPTIONS.map((g) => (
|
||||
<SelectItem key={g} value={g}>
|
||||
{g}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={gp.marginRate || ''}
|
||||
onChange={(e) => handleMarginRateChange(index, e.target.value)}
|
||||
className="h-9 text-right"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-medium text-sm">
|
||||
{formatNumber(gp.sellingPrice)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Input
|
||||
value={gp.note}
|
||||
onChange={(e) => handleNoteChange(index, e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-9"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveRow(index)}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
판매단가 = 매입단가 x (1 + 마진율) + 가공비 (1천원 이하 절사)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 (sticky) */}
|
||||
<div
|
||||
className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}
|
||||
>
|
||||
<Button variant="outline" onClick={handleList}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEdit ? (
|
||||
<>
|
||||
{canDelete && (
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(true)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '저장 중...' : '수정'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
canCreate && (
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '저장 중...' : '등록'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
{isEdit && (
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
description="이 단가표를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -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<PricingTable[]>([]);
|
||||
const [stats, setStats] = useState({ total: 0, active: 0, inactive: 0 });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState('2025-01-01');
|
||||
const [endDate, setEndDate] = useState('2025-12-31');
|
||||
|
||||
// 검색어
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 거래등급 필터
|
||||
const [selectedGrade, setSelectedGrade] = useState<TradeGrade>('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<PricingTable> = 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<string, string | string[]>) => {
|
||||
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: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{(['A등급', 'B등급', 'C등급', 'D등급'] as TradeGrade[]).map((grade) => (
|
||||
<Badge
|
||||
key={grade}
|
||||
variant={selectedGrade === grade ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedGrade(grade)}
|
||||
>
|
||||
{grade}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
|
||||
renderTableRow: (
|
||||
item: PricingTable,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<PricingTable>
|
||||
) => {
|
||||
const gp = getGradePricing(item);
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{item.pricingCode}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{item.itemCode}</TableCell>
|
||||
<TableCell>{item.itemType}</TableCell>
|
||||
<TableCell className="font-medium">{item.itemName}</TableCell>
|
||||
<TableCell>{item.specification}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.purchasePrice)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.processingCost)}</TableCell>
|
||||
<TableCell className="text-right">{gp ? `${gp.marginRate}%` : '-'}</TableCell>
|
||||
<TableCell className="text-right">{gp ? formatNumber(gp.sellingPrice) : '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={item.status === '사용' ? 'default' : 'secondary'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.author}</TableCell>
|
||||
<TableCell className="text-center">{item.changedDate}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
renderMobileCard: (
|
||||
item: PricingTable,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<PricingTable>
|
||||
) => {
|
||||
const gp = getGradePricing(item);
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{item.pricingCode}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.itemName}
|
||||
statusBadge={
|
||||
<Badge variant={item.status === '사용' ? 'default' : 'secondary'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="품목코드" value={item.itemCode} />
|
||||
<InfoField label="품목유형" value={item.itemType} />
|
||||
<InfoField label="매입단가" value={formatNumber(item.purchasePrice)} />
|
||||
<InfoField label="가공비" value={formatNumber(item.processingCost)} />
|
||||
<InfoField label="마진율" value={gp ? `${gp.marginRate}%` : '-'} />
|
||||
<InfoField label="판매단가" value={gp ? formatNumber(gp.sellingPrice) : '-'} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[
|
||||
handleCreate,
|
||||
handleRowClick,
|
||||
handleBulkDelete,
|
||||
startDate,
|
||||
endDate,
|
||||
searchQuery,
|
||||
selectedGrade,
|
||||
getGradePricing,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={allItems} onSearchChange={setSearchQuery} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
description="선택한 단가표를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
loading={isLoading}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
294
src/components/pricing-table-management/actions.ts
Normal file
294
src/components/pricing-table-management/actions.ts
Normal file
@@ -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 };
|
||||
}
|
||||
|
||||
3
src/components/pricing-table-management/index.ts
Normal file
3
src/components/pricing-table-management/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as PricingTableListClient } from './PricingTableListClient';
|
||||
export { PricingTableForm } from './PricingTableForm';
|
||||
export { PricingTableDetailClient } from './PricingTableDetailClient';
|
||||
57
src/components/pricing-table-management/types.ts
Normal file
57
src/components/pricing-table-management/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
295
src/components/production/WorkOrders/WipProductionModal.tsx
Normal file
295
src/components/production/WorkOrders/WipProductionModal.tsx
Normal file
@@ -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<WipItem, 'quantity'>[] = [
|
||||
{ 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<WipItem[]>([]);
|
||||
const [priority, setPriority] = useState<Priority>('일반');
|
||||
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<WipItem, 'quantity'>) => {
|
||||
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<Priority, string> = {
|
||||
'긴급': '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<Priority, string> = {
|
||||
'긴급': '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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>재공품 생산</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* 품목 검색 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">품목 선택</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="품목코드 또는 품목명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{/* 검색 결과 드롭다운 */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="border rounded-md bg-white shadow-md max-h-40 overflow-y-auto">
|
||||
{searchResults.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-gray-50 text-sm"
|
||||
onClick={() => handleAddItem(item)}
|
||||
>
|
||||
<span className="text-muted-foreground font-mono text-xs">{item.itemCode}</span>
|
||||
<span className="font-medium">{item.itemName}</span>
|
||||
<span className="text-muted-foreground text-xs">{item.specification}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 재공품 목록 테이블 */}
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="px-3 py-2 text-left font-medium text-xs">품목코드</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-xs">품목명</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-xs">규격</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">단위</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">재고량</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">안전재고</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs w-24">수량</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedItems.map((item) => (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-3 py-2 font-mono text-xs">{item.itemCode}</td>
|
||||
<td className="px-3 py-2">{item.itemName}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{item.specification}</td>
|
||||
<td className="px-3 py-2 text-center">{item.unit}</td>
|
||||
<td className="px-3 py-2 text-center">{item.stockQuantity}</td>
|
||||
<td className="px-3 py-2 text-center">{item.safetyStock}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={item.quantity || ''}
|
||||
onChange={(e) => handleQuantityChange(item.id, e.target.value)}
|
||||
className="h-8 text-center text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우선순위 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">우선순위</Label>
|
||||
<div className="flex gap-2">
|
||||
{priorityOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${
|
||||
priority === opt ? priorityColors[opt] : priorityInactiveColors[opt]
|
||||
}`}
|
||||
onClick={() => setPriority(opt)}
|
||||
>
|
||||
{opt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부서 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">부서</Label>
|
||||
<Select value={department} onValueChange={setDepartment}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="생산부서">생산부서</SelectItem>
|
||||
<SelectItem value="품질관리부">품질관리부</SelectItem>
|
||||
<SelectItem value="자재부">자재부</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">비고</Label>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="비고 사항을 입력하세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
생산지시 확정
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Clock, Loader, CheckCircle2, AlertTriangle, TimerOff } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
type WorkOrderStatus,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { WipProductionModal } from './WipProductionModal';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
@@ -93,6 +95,10 @@ const filterConfig: FilterFieldConfig[] = [
|
||||
export function WorkOrderList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 활성 탭 및 재공품 모달 =====
|
||||
const [activeTab, setActiveTab] = useState('screen');
|
||||
const [isWipModalOpen, setIsWipModalOpen] = useState(false);
|
||||
|
||||
// ===== 공정 ID 매핑 (getProcessOptions) =====
|
||||
const [processMap, setProcessMap] = useState<Record<string, number>>({});
|
||||
const [processMapLoaded, setProcessMapLoaded] = useState(false);
|
||||
@@ -240,6 +246,7 @@ export function WorkOrderList() {
|
||||
try {
|
||||
// 탭 → processId 매핑
|
||||
const tabValue = params?.tab || 'screen';
|
||||
setActiveTab(tabValue);
|
||||
const processId = processMap[tabValue];
|
||||
|
||||
// 해당 공정이 DB에 없으면 빈 목록 반환
|
||||
@@ -342,6 +349,17 @@ export function WorkOrderList() {
|
||||
defaultTab: 'screen',
|
||||
tabsPosition: 'above-stats',
|
||||
|
||||
// 테이블 헤더 액션 (절곡 탭일 때만 재공품 생산 버튼)
|
||||
tableHeaderActions: activeTab === 'bending' ? (
|
||||
<Button
|
||||
onClick={() => setIsWipModalOpen(true)}
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white"
|
||||
size="sm"
|
||||
>
|
||||
재공품 생산
|
||||
</Button>
|
||||
) : undefined,
|
||||
|
||||
// 통계 카드 (6개)
|
||||
stats,
|
||||
|
||||
@@ -446,7 +464,7 @@ export function WorkOrderList() {
|
||||
);
|
||||
},
|
||||
}),
|
||||
[tabs, stats, processMap, handleRowClick]
|
||||
[tabs, stats, processMap, handleRowClick, activeTab]
|
||||
);
|
||||
|
||||
// processMap 로딩 완료 전에는 UniversalListPage를 마운트하지 않음
|
||||
@@ -459,5 +477,13 @@ export function WorkOrderList() {
|
||||
);
|
||||
}
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} />
|
||||
<WipProductionModal
|
||||
open={isWipModalOpen}
|
||||
onOpenChange={setIsWipModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 절곡 재공품 통합 문서 (작업일지 & 중간검사성적서)
|
||||
*
|
||||
* 기획서 기준:
|
||||
* - 제목: "절곡품 재고생산 작업일지 중간검사성적서"
|
||||
* - 결재란: 작성/승인/승인/승인
|
||||
* - 기본 정보: 제품명, 규격, 길이, 판고 LOT NO / 생산 LOT NO, 수량, 검사일자, 검사자
|
||||
* - ■ 중간검사 기준서 KDPS-20: 도해 IMG + 검사항목(겉모양/절곡상태, 치수/길이/폭/간격)
|
||||
* - ■ 중간검사 DATA: No, 제품명, 절곡상태(양호/불량), 길이(mm), 너비(mm)+포인트, 간격(mm), 판정
|
||||
* - 부적합 내용 / 종합판정 (자동)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import type { WorkOrder } from '../types';
|
||||
|
||||
export interface InspectionContentRef {
|
||||
getInspectionData: () => unknown;
|
||||
}
|
||||
|
||||
interface BendingWipInspectionContentProps {
|
||||
data: WorkOrder;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
type CheckStatus = '양호' | '불량' | null;
|
||||
|
||||
interface InspectionRow {
|
||||
id: number;
|
||||
productName: string; // 제품명
|
||||
processStatus: CheckStatus; // 절곡상태
|
||||
lengthDesign: string; // 길이 도면치수
|
||||
lengthMeasured: string; // 길이 측정값
|
||||
widthDesign: string; // 너비 도면치수
|
||||
widthMeasured: string; // 너비 측정값
|
||||
spacingPoint: string; // 너비 포인트
|
||||
spacingDesign: string; // 간격 도면치수
|
||||
spacingMeasured: string; // 간격 측정값
|
||||
}
|
||||
|
||||
const DEFAULT_ROW_COUNT = 6;
|
||||
|
||||
export const BendingWipInspectionContent = forwardRef<InspectionContentRef, BendingWipInspectionContentProps>(function BendingWipInspectionContent({ data: order, readOnly = false }, ref) {
|
||||
const fullDate = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const today = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
const documentNo = order.workOrderNo || 'ABC123';
|
||||
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
|
||||
|
||||
// 아이템 기반 초기 행 생성
|
||||
const [rows, setRows] = useState<InspectionRow[]>(() => {
|
||||
const items = order.items || [];
|
||||
const count = Math.max(items.length, DEFAULT_ROW_COUNT);
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
productName: items[i]?.productName || '',
|
||||
processStatus: null,
|
||||
lengthDesign: '4000',
|
||||
lengthMeasured: '',
|
||||
widthDesign: 'N/A',
|
||||
widthMeasured: 'N/A',
|
||||
spacingPoint: '',
|
||||
spacingDesign: '380',
|
||||
spacingMeasured: '',
|
||||
}));
|
||||
});
|
||||
|
||||
const [inadequateContent, setInadequateContent] = useState('');
|
||||
|
||||
const handleStatusChange = useCallback((rowId: number, value: CheckStatus) => {
|
||||
if (readOnly) return;
|
||||
setRows(prev => prev.map(row =>
|
||||
row.id === rowId ? { ...row, processStatus: value } : row
|
||||
));
|
||||
}, [readOnly]);
|
||||
|
||||
const handleInputChange = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
|
||||
if (readOnly) return;
|
||||
setRows(prev => prev.map(row =>
|
||||
row.id === rowId ? { ...row, [field]: value } : row
|
||||
));
|
||||
}, [readOnly]);
|
||||
|
||||
const handleNumericInput = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
|
||||
if (readOnly) return;
|
||||
const filtered = value.replace(/[^\d.]/g, '');
|
||||
setRows(prev => prev.map(row =>
|
||||
row.id === rowId ? { ...row, [field]: filtered } : row
|
||||
));
|
||||
}, [readOnly]);
|
||||
|
||||
// 행별 판정 자동 계산
|
||||
const getRowJudgment = useCallback((row: InspectionRow): '적' | '부' | null => {
|
||||
if (row.processStatus === '불량') return '부';
|
||||
if (row.processStatus === '양호') return '적';
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// 종합판정 자동 계산
|
||||
const overallResult = useMemo(() => {
|
||||
const judgments = rows.map(getRowJudgment);
|
||||
if (judgments.some(j => j === '부')) return '불합격';
|
||||
if (judgments.every(j => j === '적')) return '합격';
|
||||
return null;
|
||||
}, [rows, getRowJudgment]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getInspectionData: () => ({
|
||||
rows: rows.map(row => ({
|
||||
id: row.id,
|
||||
productName: row.productName,
|
||||
processStatus: row.processStatus,
|
||||
lengthMeasured: row.lengthMeasured,
|
||||
widthMeasured: row.widthMeasured,
|
||||
spacingPoint: row.spacingPoint,
|
||||
spacingMeasured: row.spacingMeasured,
|
||||
})),
|
||||
inadequateContent,
|
||||
overallResult,
|
||||
}),
|
||||
}), [rows, inadequateContent, overallResult]);
|
||||
|
||||
// PDF 호환 체크박스 렌더
|
||||
const renderCheckbox = (checked: boolean, onClick: () => void) => (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
|
||||
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
|
||||
}`}
|
||||
onClick={() => !readOnly && onClick()}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
>
|
||||
{checked ? '✓' : ''}
|
||||
</span>
|
||||
);
|
||||
|
||||
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white">
|
||||
{/* ===== 헤더 영역 ===== */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">절곡품 재고생산 작업일지 중간검사성적서</h1>
|
||||
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
|
||||
문서번호: {documentNo} | 작성일자: {fullDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 결재란 */}
|
||||
<table className="border-collapse text-sm flex-shrink-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}>결<br/>재</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">작성</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ===== 기본 정보 ===== */}
|
||||
<table className="w-full border-collapse text-xs mb-6">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24">제품명</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.items?.[0]?.productName || '가이드레일'}</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-28">판고 LOT NO</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.lotNo || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">규격</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.items?.[0]?.specification || 'EGI 1.6T'}</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">생산 LOT NO</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.workOrderNo || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">길이</td>
|
||||
<td className="border border-gray-400 px-3 py-2">3,000 mm</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">수량</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.items?.reduce((sum, item) => sum + item.quantity, 0) || 0} EA</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">검사일자</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{today}</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">검사자</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 중간검사 기준서 KDPS-20 ===== */}
|
||||
<div className="mb-1 font-bold text-sm">■ 중간검사 기준서 KDPS-20</div>
|
||||
<table className="w-full table-fixed border-collapse text-xs mb-6">
|
||||
<colgroup>
|
||||
<col style={{width: '200px'}} />
|
||||
<col style={{width: '52px'}} />
|
||||
<col style={{width: '58px'}} />
|
||||
<col />
|
||||
<col style={{width: '68px'}} />
|
||||
<col style={{width: '78px'}} />
|
||||
<col style={{width: '120px'}} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
{/* 도해 영역 - 넓게 */}
|
||||
<td className="border border-gray-400 p-3 text-center align-middle" rowSpan={4}>
|
||||
<div className="text-xs font-medium text-gray-500 mb-2 text-left">도해</div>
|
||||
<div className="h-32 border border-gray-300 rounded flex items-center justify-center text-gray-300 text-sm">IMG</div>
|
||||
</td>
|
||||
{/* 헤더 행 */}
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}>검사항목</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사기준</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사방법</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사주기</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">관련규정</th>
|
||||
</tr>
|
||||
{/* 겉모양 > 절곡상태 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">겉모양</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">절곡상태</td>
|
||||
<td className="border border-gray-400 px-2 py-1">사용상 재료로 결함이 없을것</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">육안검사</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}>n = 1, c = 0</td>
|
||||
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1항</td>
|
||||
</tr>
|
||||
{/* 치수 > 길이 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50" rowSpan={2}>치수<br/>(mm)</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">길이</td>
|
||||
<td className="border border-gray-400 px-2 py-1">도면치수 ± 4</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={2}>체크검사</td>
|
||||
<td className="border border-gray-400 px-2 py-1">KS F 4510 7항<br/>표9</td>
|
||||
</tr>
|
||||
{/* 치수 > 간격 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">간격</td>
|
||||
<td className="border border-gray-400 px-2 py-1">도면치수 ± 2</td>
|
||||
<td className="border border-gray-400 px-2 py-1">KS F 4510 7항<br/>표9 / 자체규정</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 중간검사 DATA ===== */}
|
||||
<div className="mb-1 font-bold text-sm">■ 중간검사 DATA</div>
|
||||
<table className="w-full border-collapse text-xs mb-4">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-400 p-1 w-8" rowSpan={2}>No.</th>
|
||||
<th className="border border-gray-400 p-1 w-20" rowSpan={2}>제품명</th>
|
||||
<th className="border border-gray-400 p-1 w-16" rowSpan={2}>절곡상태<br/>겉모양</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>길이 (mm)</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>너비 (mm)</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={3}>간격 (mm)</th>
|
||||
<th className="border border-gray-400 p-1 w-14" rowSpan={2}>판정<br/>(적/부)</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-400 p-1 w-14">도면치수</th>
|
||||
<th className="border border-gray-400 p-1 w-14">측정값</th>
|
||||
<th className="border border-gray-400 p-1 w-14">도면치수</th>
|
||||
<th className="border border-gray-400 p-1 w-14">측정값</th>
|
||||
<th className="border border-gray-400 p-1 w-12">포인트</th>
|
||||
<th className="border border-gray-400 p-1 w-14">도면치수</th>
|
||||
<th className="border border-gray-400 p-1 w-14">측정값</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const judgment = getRowJudgment(row);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td className="border border-gray-400 p-1 text-center">{row.id}</td>
|
||||
{/* 제품명 */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input
|
||||
type="text"
|
||||
value={row.productName}
|
||||
onChange={(e) => handleInputChange(row.id, 'productName', e.target.value)}
|
||||
disabled={readOnly}
|
||||
className={inputClass}
|
||||
placeholder="-"
|
||||
/>
|
||||
</td>
|
||||
{/* 절곡상태 - 단일 셀, 세로 체크박스 (절곡 버전 동일) */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||
{renderCheckbox(row.processStatus === '양호', () => handleStatusChange(row.id, row.processStatus === '양호' ? null : '양호'))}
|
||||
양호
|
||||
</label>
|
||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||
{renderCheckbox(row.processStatus === '불량', () => handleStatusChange(row.id, row.processStatus === '불량' ? null : '불량'))}
|
||||
불량
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
{/* 길이 - 도면치수 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign}</td>
|
||||
{/* 길이 - 측정값 */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.lengthMeasured} onChange={(e) => handleNumericInput(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 너비 - 도면치수 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.widthDesign}</td>
|
||||
{/* 너비 - 측정값 */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.widthMeasured} onChange={(e) => handleInputChange(row.id, 'widthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 간격 - 포인트 */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.spacingPoint} onChange={(e) => handleInputChange(row.id, 'spacingPoint', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 간격 - 도면치수 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.spacingDesign}</td>
|
||||
{/* 간격 - 측정값 */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.spacingMeasured} onChange={(e) => handleNumericInput(row.id, 'spacingMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 판정 - 자동 계산 */}
|
||||
<td className={`border border-gray-400 p-1 text-center font-bold ${
|
||||
judgment === '적' ? 'text-blue-600' : judgment === '부' ? 'text-red-600' : 'text-gray-300'
|
||||
}`}>
|
||||
{judgment || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 부적합 내용 + 종합판정 ===== */}
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center">부적합 내용</td>
|
||||
<td className="border border-gray-400 px-3 py-2">
|
||||
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
|
||||
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
|
||||
</td>
|
||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24">종합판정</td>
|
||||
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
|
||||
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
|
||||
}`}>
|
||||
{overallResult || '합격'}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -19,12 +19,15 @@ import type { WorkOrder, ProcessType } from '../types';
|
||||
import { ScreenInspectionContent } from './ScreenInspectionContent';
|
||||
import { SlatInspectionContent } from './SlatInspectionContent';
|
||||
import { BendingInspectionContent } from './BendingInspectionContent';
|
||||
import { BendingWipInspectionContent } from './BendingWipInspectionContent';
|
||||
import { SlatJointBarInspectionContent } from './SlatJointBarInspectionContent';
|
||||
import type { InspectionContentRef } from './ScreenInspectionContent';
|
||||
|
||||
const PROCESS_LABELS: Record<ProcessType, string> = {
|
||||
screen: '스크린',
|
||||
slat: '슬랫',
|
||||
bending: '절곡',
|
||||
bending_wip: '절곡 재공품',
|
||||
};
|
||||
|
||||
interface InspectionReportModalProps {
|
||||
@@ -33,6 +36,7 @@ interface InspectionReportModalProps {
|
||||
workOrderId: string | null;
|
||||
processType?: ProcessType;
|
||||
readOnly?: boolean;
|
||||
isJointBar?: boolean;
|
||||
}
|
||||
|
||||
export function InspectionReportModal({
|
||||
@@ -41,6 +45,7 @@ export function InspectionReportModal({
|
||||
workOrderId,
|
||||
processType = 'screen',
|
||||
readOnly = true,
|
||||
isJointBar = false,
|
||||
}: InspectionReportModalProps) {
|
||||
const [order, setOrder] = useState<WorkOrder | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -138,6 +143,11 @@ export function InspectionReportModal({
|
||||
|
||||
const processLabel = PROCESS_LABELS[processType] || '스크린';
|
||||
const subtitle = order ? `${processLabel} 생산부서` : undefined;
|
||||
const modalTitle = processType === 'bending_wip'
|
||||
? '절곡품 재고생산 작업일지 중간검사성적서'
|
||||
: (isJointBar || (order?.items?.some(item => item.productName?.includes('조인트바'))))
|
||||
? '중간검사성적서 (조인트바)'
|
||||
: '중간검사 성적서';
|
||||
|
||||
const renderContent = () => {
|
||||
if (!order) return null;
|
||||
@@ -146,9 +156,15 @@ export function InspectionReportModal({
|
||||
case 'screen':
|
||||
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
case 'slat':
|
||||
// 조인트바 여부 체크: isJointBar prop 또는 items에서 자동 감지
|
||||
if (isJointBar || order.items?.some(item => item.productName?.includes('조인트바'))) {
|
||||
return <SlatJointBarInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
}
|
||||
return <SlatInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
case 'bending':
|
||||
return <BendingInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
case 'bending_wip':
|
||||
return <BendingWipInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
default:
|
||||
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
}
|
||||
@@ -167,7 +183,7 @@ export function InspectionReportModal({
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title="중간검사 성적서"
|
||||
title={modalTitle}
|
||||
subtitle={subtitle}
|
||||
preset="inspection"
|
||||
open={open}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 슬랫 조인트바 중간검사성적서
|
||||
*
|
||||
* 기획서 기준:
|
||||
* - 제목: "중간검사성적서 (조인트바)"
|
||||
* - 결재란: 작성/승인/승인/승인
|
||||
* - 기본 정보: 제품명/슬랫, 규격/슬랫, 수주처, 현장명 / 제품 LOT NO, 부서, 검사일자, 검사자
|
||||
* - ■ 중간검사 기준서 KOPS-20: 도해 IMG + 치수 기준 (43.1 ± 0.5 등)
|
||||
* - ■ 중간검사 DATA: No, 가공상태, 조립상태, ①높이(기준치/측정값),
|
||||
* ②높이(기준치/측정값), ③길이(기준치/측정값), ④간격(기준치/측정값), 판정
|
||||
* - 부적합 내용 / 종합판정 (자동)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import type { WorkOrder } from '../types';
|
||||
|
||||
export interface InspectionContentRef {
|
||||
getInspectionData: () => unknown;
|
||||
}
|
||||
|
||||
interface SlatJointBarInspectionContentProps {
|
||||
data: WorkOrder;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
type CheckStatus = '양호' | '불량' | null;
|
||||
|
||||
interface InspectionRow {
|
||||
id: number;
|
||||
processStatus: CheckStatus; // 가공상태
|
||||
assemblyStatus: CheckStatus; // 조립상태
|
||||
height1Standard: string; // ①높이 기준치
|
||||
height1Measured: string; // ①높이 측정값
|
||||
height2Standard: string; // ②높이 기준치
|
||||
height2Measured: string; // ②높이 측정값
|
||||
lengthDesign: string; // 길이 도면치수
|
||||
lengthMeasured: string; // 길이 측정값
|
||||
intervalStandard: string; // 간격 기준치
|
||||
intervalMeasured: string; // 간격 측정값
|
||||
}
|
||||
|
||||
const DEFAULT_ROW_COUNT = 6;
|
||||
|
||||
export const SlatJointBarInspectionContent = forwardRef<InspectionContentRef, SlatJointBarInspectionContentProps>(function SlatJointBarInspectionContent({ data: order, readOnly = false }, ref) {
|
||||
const fullDate = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const today = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
const documentNo = order.workOrderNo || 'ABC123';
|
||||
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
|
||||
|
||||
const [rows, setRows] = useState<InspectionRow[]>(() =>
|
||||
Array.from({ length: DEFAULT_ROW_COUNT }, (_, i) => ({
|
||||
id: i + 1,
|
||||
processStatus: null,
|
||||
assemblyStatus: null,
|
||||
height1Standard: '43.1 ± 0.5',
|
||||
height1Measured: '',
|
||||
height2Standard: '14.5 ± 1',
|
||||
height2Measured: '',
|
||||
lengthDesign: '',
|
||||
lengthMeasured: '',
|
||||
intervalStandard: '150 ± 4',
|
||||
intervalMeasured: '',
|
||||
}))
|
||||
);
|
||||
|
||||
const [inadequateContent, setInadequateContent] = useState('');
|
||||
|
||||
const handleStatusChange = useCallback((rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => {
|
||||
if (readOnly) return;
|
||||
setRows(prev => prev.map(row =>
|
||||
row.id === rowId ? { ...row, [field]: value } : row
|
||||
));
|
||||
}, [readOnly]);
|
||||
|
||||
const handleInputChange = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
|
||||
if (readOnly) return;
|
||||
const filtered = value.replace(/[^\d.]/g, '');
|
||||
setRows(prev => prev.map(row =>
|
||||
row.id === rowId ? { ...row, [field]: filtered } : row
|
||||
));
|
||||
}, [readOnly]);
|
||||
|
||||
// 행별 판정 자동 계산
|
||||
const getRowJudgment = useCallback((row: InspectionRow): '적' | '부' | null => {
|
||||
const { processStatus, assemblyStatus } = row;
|
||||
if (processStatus === '불량' || assemblyStatus === '불량') return '부';
|
||||
if (processStatus === '양호' && assemblyStatus === '양호') return '적';
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// 종합판정 자동 계산
|
||||
const overallResult = useMemo(() => {
|
||||
const judgments = rows.map(getRowJudgment);
|
||||
if (judgments.some(j => j === '부')) return '불합격';
|
||||
if (judgments.every(j => j === '적')) return '합격';
|
||||
return null;
|
||||
}, [rows, getRowJudgment]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getInspectionData: () => ({
|
||||
rows: rows.map(row => ({
|
||||
id: row.id,
|
||||
processStatus: row.processStatus,
|
||||
assemblyStatus: row.assemblyStatus,
|
||||
height1Measured: row.height1Measured,
|
||||
height2Measured: row.height2Measured,
|
||||
lengthMeasured: row.lengthMeasured,
|
||||
intervalMeasured: row.intervalMeasured,
|
||||
})),
|
||||
inadequateContent,
|
||||
overallResult,
|
||||
}),
|
||||
}), [rows, inadequateContent, overallResult]);
|
||||
|
||||
// PDF 호환 체크박스 렌더
|
||||
const renderCheckbox = (checked: boolean, onClick: () => void) => (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
|
||||
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
|
||||
}`}
|
||||
onClick={() => !readOnly && onClick()}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
>
|
||||
{checked ? '✓' : ''}
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => (
|
||||
<td className="border border-gray-400 p-1">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||
{renderCheckbox(value === '양호', () => handleStatusChange(rowId, field, value === '양호' ? null : '양호'))}
|
||||
양호
|
||||
</label>
|
||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||
{renderCheckbox(value === '불량', () => handleStatusChange(rowId, field, value === '불량' ? null : '불량'))}
|
||||
불량
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
|
||||
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white">
|
||||
{/* ===== 헤더 영역 ===== */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">중간검사성적서 (조인트바)</h1>
|
||||
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
|
||||
문서번호: {documentNo} | 작성일자: {fullDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 결재란 */}
|
||||
<table className="border-collapse text-sm flex-shrink-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}>결<br/>재</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">작성</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ===== 기본 정보 ===== */}
|
||||
<table className="w-full border-collapse text-xs mb-6">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24">제품명</td>
|
||||
<td className="border border-gray-400 px-3 py-2">슬랫</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-28">제품 LOT NO</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.lotNo || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">규격</td>
|
||||
<td className="border border-gray-400 px-3 py-2">슬랫</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">부서</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.department || '생산부'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">수주처</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.client || '-'}</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">검사일자</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{today}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">현장명</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.projectName || '-'}</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">검사자</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 중간검사 기준서 KOPS-20 ===== */}
|
||||
<div className="mb-1 font-bold text-sm">■ 중간검사 기준서 KOPS-20</div>
|
||||
<table className="w-full border-collapse text-xs mb-6">
|
||||
<tbody>
|
||||
<tr>
|
||||
{/* 도해 영역 */}
|
||||
<td className="border border-gray-400 p-4 text-center text-gray-300 align-middle w-1/5" rowSpan={8}>
|
||||
<div className="h-40 flex items-center justify-center">도해 이미지 영역</div>
|
||||
</td>
|
||||
{/* 헤더 행 */}
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}>검사항목</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사기준</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사방법</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사주기</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">관련규정</th>
|
||||
</tr>
|
||||
{/* 결모양 > 가공상태 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium text-center" rowSpan={3}>결모양</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">가공상태</td>
|
||||
<td className="border border-gray-400 px-2 py-1">사용상 해로운 결함이 없을것</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}>육안검사</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={7}>n = 1, c = 0</td>
|
||||
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1항</td>
|
||||
</tr>
|
||||
{/* 결모양 > 조립상태 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium" rowSpan={2}>조립상태</td>
|
||||
<td className="border border-gray-400 px-2 py-1">엔드락이 용접에 의해<br/>견고하게 조립되어야 함</td>
|
||||
<td className="border border-gray-400 px-2 py-1">KS F 4510 9항</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1">용접부위에 락카도색이<br/>되어야 함</td>
|
||||
<td className="border border-gray-400 px-2 py-1">자체규정</td>
|
||||
</tr>
|
||||
{/* 치수 > ① 높이 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50 text-center" rowSpan={4}>치수<br/>(mm)</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">① 높이</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">43.1 ± 0.5</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={4}>체크검사</td>
|
||||
<td className="border border-gray-400 px-2 py-1" rowSpan={3}>KS F 4510 7항<br/>표9</td>
|
||||
</tr>
|
||||
{/* 치수 > ② 높이 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">② 높이</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">14.5 ± 1</td>
|
||||
</tr>
|
||||
{/* 치수 > 길이 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">길이</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">도면치수 ± 4</td>
|
||||
</tr>
|
||||
{/* 치수 > 간격 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">간격</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">150 ± 4</td>
|
||||
<td className="border border-gray-400 px-2 py-1">자체규정</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 중간검사 DATA ===== */}
|
||||
<div className="mb-1 font-bold text-sm">■ 중간검사 DATA</div>
|
||||
<table className="w-full border-collapse text-xs mb-4">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-400 p-1 w-8" rowSpan={2}>No.</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>겉모양</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>① 높이 (mm)</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>② 높이 (mm)</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>③ 길이 (mm)</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>④ 간격</th>
|
||||
<th className="border border-gray-400 p-1 w-14" rowSpan={2}>판정<br/>(적/부)</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-400 p-1 w-16">가공상태</th>
|
||||
<th className="border border-gray-400 p-1 w-16">조립상태</th>
|
||||
<th className="border border-gray-400 p-1 w-16">기준치</th>
|
||||
<th className="border border-gray-400 p-1 w-16">측정값</th>
|
||||
<th className="border border-gray-400 p-1 w-16">기준치</th>
|
||||
<th className="border border-gray-400 p-1 w-16">측정값</th>
|
||||
<th className="border border-gray-400 p-1 w-16">기준치</th>
|
||||
<th className="border border-gray-400 p-1 w-16">측정값</th>
|
||||
<th className="border border-gray-400 p-1 w-16">기준치</th>
|
||||
<th className="border border-gray-400 p-1 w-16">측정값</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const judgment = getRowJudgment(row);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td className="border border-gray-400 p-1 text-center">{row.id}</td>
|
||||
{/* 가공상태 */}
|
||||
{renderCheckStatus(row.id, 'processStatus', row.processStatus)}
|
||||
{/* 조립상태 */}
|
||||
{renderCheckStatus(row.id, 'assemblyStatus', row.assemblyStatus)}
|
||||
{/* ① 높이 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.height1Standard}</td>
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.height1Measured} onChange={(e) => handleInputChange(row.id, 'height1Measured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* ② 높이 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.height2Standard}</td>
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.height2Measured} onChange={(e) => handleInputChange(row.id, 'height2Measured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 길이 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign || '-'}</td>
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.lengthMeasured} onChange={(e) => handleInputChange(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* ④ 간격 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.intervalStandard}</td>
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.intervalMeasured} onChange={(e) => handleInputChange(row.id, 'intervalMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 판정 - 자동 계산 */}
|
||||
<td className={`border border-gray-400 p-1 text-center font-bold ${
|
||||
judgment === '적' ? 'text-blue-600' : judgment === '부' ? 'text-red-600' : 'text-gray-300'
|
||||
}`}>
|
||||
{judgment || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 부적합 내용 + 종합판정 ===== */}
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center">부적합 내용</td>
|
||||
<td className="border border-gray-400 px-3 py-2">
|
||||
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
|
||||
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
|
||||
</td>
|
||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24">종합판정</td>
|
||||
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
|
||||
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
|
||||
}`}>
|
||||
{overallResult || '합격'}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,8 @@ export { BendingWorkLogContent } from './BendingWorkLogContent';
|
||||
export { ScreenInspectionContent } from './ScreenInspectionContent';
|
||||
export { SlatInspectionContent } from './SlatInspectionContent';
|
||||
export { BendingInspectionContent } from './BendingInspectionContent';
|
||||
export { BendingWipInspectionContent } from './BendingWipInspectionContent';
|
||||
export { SlatJointBarInspectionContent } from './SlatJointBarInspectionContent';
|
||||
export type { InspectionContentRef } from './ScreenInspectionContent';
|
||||
|
||||
// 모달
|
||||
|
||||
@@ -11,12 +11,13 @@ export interface ProcessInfo {
|
||||
|
||||
// @deprecated process_type은 process_id FK로 변경됨
|
||||
// 하위 호환성을 위해 유지
|
||||
export type ProcessType = 'screen' | 'slat' | 'bending';
|
||||
export type ProcessType = 'screen' | 'slat' | 'bending' | 'bending_wip';
|
||||
|
||||
export const PROCESS_TYPE_LABELS: Record<ProcessType, string> = {
|
||||
screen: '스크린',
|
||||
slat: '슬랫',
|
||||
bending: '절곡',
|
||||
bending_wip: '절곡 재공품',
|
||||
};
|
||||
|
||||
// 작업 상태
|
||||
|
||||
@@ -70,19 +70,23 @@ export function WorkItemCard({
|
||||
{item.itemCode} ({item.itemName})
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{item.floor} / {item.code}
|
||||
</span>
|
||||
{!item.isWip && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{item.floor} / {item.code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 제작 사이즈 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<span className="text-gray-500">제작 사이즈</span>
|
||||
<span className="font-medium">
|
||||
{item.width.toLocaleString()} X {item.height.toLocaleString()} mm
|
||||
</span>
|
||||
<span className="font-medium">{item.quantity}개</span>
|
||||
</div>
|
||||
{/* 제작 사이즈 (재공품은 숨김) */}
|
||||
{!item.isWip && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<span className="text-gray-500">제작 사이즈</span>
|
||||
<span className="font-medium">
|
||||
{item.width.toLocaleString()} X {item.height.toLocaleString()} mm
|
||||
</span>
|
||||
<span className="font-medium">{item.quantity}개</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공정별 추가 정보 */}
|
||||
{item.processType === 'screen' && item.cuttingInfo && (
|
||||
@@ -100,10 +104,14 @@ export function WorkItemCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.processType === 'bending' && item.bendingInfo && (
|
||||
{item.processType === 'bending' && !item.isWip && item.bendingInfo && (
|
||||
<BendingExtraInfo info={item.bendingInfo} />
|
||||
)}
|
||||
|
||||
{item.isWip && item.wipInfo && (
|
||||
<WipExtraInfo info={item.wipInfo} />
|
||||
)}
|
||||
|
||||
{/* 진척률 프로그래스 바 */}
|
||||
<div className="space-y-1">
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
@@ -240,7 +248,7 @@ function SlatExtraInfo({
|
||||
}
|
||||
|
||||
// ===== 절곡 전용: 도면 + 공통사항 + 세부부품 =====
|
||||
import type { BendingInfo } from './types';
|
||||
import type { BendingInfo, WipInfo } from './types';
|
||||
|
||||
function BendingExtraInfo({ info }: { info: BendingInfo }) {
|
||||
return (
|
||||
@@ -311,3 +319,41 @@ function BendingExtraInfo({ info }: { info: BendingInfo }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 재공품 전용: 도면 + 공통사항 (규격, 길이별 수량) =====
|
||||
function WipExtraInfo({ info }: { info: WipInfo }) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{/* 도면 이미지 (큰 영역) */}
|
||||
<div className="flex-1 min-h-[160px] border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{info.drawingUrl ? (
|
||||
<img
|
||||
src={info.drawingUrl}
|
||||
alt="도면"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<span className="text-xs">IMG</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 공통사항 */}
|
||||
<div className="flex-1 space-y-0">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">공통사항</p>
|
||||
<div className="border rounded-lg divide-y">
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">규격</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.specification}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">길이별 수량</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.lengthQuantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,8 +166,71 @@ const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
// 절곡 재공품 전용 목업 데이터 (토글로 전환)
|
||||
const MOCK_ITEMS_BENDING_WIP: WorkItemData[] = [
|
||||
{
|
||||
id: 'mock-bw1', itemNo: 1, itemCode: 'KWWS03', itemName: '케이스 - 전면부', floor: '-', code: '-',
|
||||
width: 0, height: 0, quantity: 6, processType: 'bending',
|
||||
isWip: true,
|
||||
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '4,000mm X 6개' },
|
||||
steps: [
|
||||
{ id: 'bw1-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw1-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-bw2', itemNo: 2, itemCode: 'KWWS04', itemName: '케이스 - 후면부', floor: '-', code: '-',
|
||||
width: 0, height: 0, quantity: 4, processType: 'bending',
|
||||
isWip: true,
|
||||
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '3,500mm X 4개' },
|
||||
steps: [
|
||||
{ id: 'bw2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw2-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-bw3', itemNo: 3, itemCode: 'KWWS05', itemName: '하단마감재', floor: '-', code: '-',
|
||||
width: 0, height: 0, quantity: 10, processType: 'bending',
|
||||
isWip: true,
|
||||
wipInfo: { specification: 'EGI 1.2T (W400)', lengthQuantity: '2,800mm X 10개' },
|
||||
steps: [
|
||||
{ id: 'bw3-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw3-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 슬랫 조인트바 전용 목업 데이터 (토글로 전환)
|
||||
const MOCK_ITEMS_SLAT_JOINTBAR: WorkItemData[] = [
|
||||
{
|
||||
id: 'mock-jb1', itemNo: 1, itemCode: 'KQJB01', itemName: '조인트바 A', floor: '1층', code: 'FSS-01',
|
||||
width: 0, height: 0, quantity: 8, processType: 'slat',
|
||||
isJointBar: true,
|
||||
slatJointBarInfo: { specification: 'EGI 1.6T', length: 3910, quantity: 8 },
|
||||
steps: [
|
||||
{ id: 'jb1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'jb1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'jb1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'mjb1', lotNo: 'LOT-2026-020', itemName: '조인트바 코일', quantity: 100, unit: 'kg' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-jb2', itemNo: 2, itemCode: 'KQJB02', itemName: '조인트바 B', floor: '2층', code: 'FSS-02',
|
||||
width: 0, height: 0, quantity: 12, processType: 'slat',
|
||||
isJointBar: true,
|
||||
slatJointBarInfo: { specification: 'EGI 1.6T', length: 5200, quantity: 12 },
|
||||
steps: [
|
||||
{ id: 'jb2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'jb2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'jb2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 하드코딩된 공정별 단계 폴백
|
||||
const PROCESS_STEPS: Record<ProcessTab, { name: string; isMaterialInput: boolean }[]> = {
|
||||
const PROCESS_STEPS: Record<string, { name: string; isMaterialInput: boolean }[]> = {
|
||||
screen: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
@@ -185,6 +248,10 @@ const PROCESS_STEPS: Record<ProcessTab, { name: string; isMaterialInput: boolean
|
||||
{ name: '절곡', isMaterialInput: false },
|
||||
{ name: '포장완료', isMaterialInput: false },
|
||||
],
|
||||
bending_wip: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
],
|
||||
};
|
||||
|
||||
export default function WorkerScreen() {
|
||||
@@ -193,6 +260,8 @@ export default function WorkerScreen() {
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
|
||||
const [bendingSubMode, setBendingSubMode] = useState<'normal' | 'wip'>('normal');
|
||||
const [slatSubMode, setSlatSubMode] = useState<'normal' | 'jointbar'>('normal');
|
||||
|
||||
// 작업 정보
|
||||
const [productionManagerId, setProductionManagerId] = useState('');
|
||||
@@ -305,13 +374,27 @@ export default function WorkerScreen() {
|
||||
});
|
||||
|
||||
// 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서)
|
||||
const mockItems = MOCK_ITEMS[activeTab].map((item, i) => ({
|
||||
// 절곡 탭에서 재공품 서브모드면 WIP 전용 목업 사용
|
||||
// 슬랫 탭에서 조인트바 서브모드면 조인트바 전용 목업 사용
|
||||
const baseMockItems = (activeTab === 'bending' && bendingSubMode === 'wip')
|
||||
? MOCK_ITEMS_BENDING_WIP
|
||||
: (activeTab === 'slat' && slatSubMode === 'jointbar')
|
||||
? MOCK_ITEMS_SLAT_JOINTBAR
|
||||
: MOCK_ITEMS[activeTab];
|
||||
const mockItems = baseMockItems.map((item, i) => ({
|
||||
...item,
|
||||
itemNo: apiItems.length + i + 1,
|
||||
steps: item.steps.map((step) => {
|
||||
const stepKey = `${item.id}-${step.name}`;
|
||||
return {
|
||||
...step,
|
||||
isCompleted: stepCompletionMap[stepKey] ?? step.isCompleted,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return [...apiItems, ...mockItems];
|
||||
}, [filteredWorkOrders, activeTab, stepCompletionMap]);
|
||||
}, [filteredWorkOrders, activeTab, stepCompletionMap, bendingSubMode, slatSubMode]);
|
||||
|
||||
// ===== 수주 정보 (첫 번째 작업 기반 표시) =====
|
||||
const orderInfo = useMemo(() => {
|
||||
@@ -509,6 +592,27 @@ export default function WorkerScreen() {
|
||||
}
|
||||
}, [getTargetOrder]);
|
||||
|
||||
// ===== 재공품 감지 =====
|
||||
const hasWipItems = useMemo(() => {
|
||||
return activeTab === 'bending' && workItems.some(item => item.isWip);
|
||||
}, [activeTab, workItems]);
|
||||
|
||||
// ===== 조인트바 감지 =====
|
||||
const hasJointBarItems = useMemo(() => {
|
||||
return activeTab === 'slat' && slatSubMode === 'jointbar';
|
||||
}, [activeTab, slatSubMode]);
|
||||
|
||||
// 재공품 통합 문서 (작업일지 + 중간검사) 핸들러
|
||||
const handleWipInspection = useCallback(() => {
|
||||
const target = getTargetOrder();
|
||||
if (target) {
|
||||
setSelectedOrder(target);
|
||||
setIsInspectionModalOpen(true);
|
||||
} else {
|
||||
toast.error('표시할 작업이 없습니다.');
|
||||
}
|
||||
}, [getTargetOrder]);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6 pb-20">
|
||||
@@ -521,7 +625,9 @@ export default function WorkerScreen() {
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">작업자 화면</h1>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{hasJointBarItems ? '슬랫 조인트바 공정' : hasWipItems ? '절곡 재공품 공정' : '작업자 화면'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">작업을 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -571,6 +677,68 @@ export default function WorkerScreen() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 슬랫 탭: 슬랫/조인트바 전환 토글 */}
|
||||
{tab === 'slat' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSlatSubMode('normal')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
slatSubMode === 'normal'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
슬랫
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSlatSubMode('jointbar')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
slatSubMode === 'jointbar'
|
||||
? 'bg-blue-500 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
조인트바
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">* 샘플 데이터 전환용</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 절곡 탭: 절곡/재공품 전환 토글 */}
|
||||
{tab === 'bending' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('normal')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'normal'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
절곡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('wip')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'wip'
|
||||
? 'bg-orange-500 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
재공품
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">* 샘플 데이터 전환용</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수주 정보 섹션 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
@@ -661,19 +829,32 @@ export default function WorkerScreen() {
|
||||
{/* 하단 고정 버튼 - DetailActions 패턴 적용 */}
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'}`}>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleWorkLog}
|
||||
className="flex-1 py-6 text-base font-medium"
|
||||
>
|
||||
작업일지 보기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInspection}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
중간검사하기
|
||||
</Button>
|
||||
{hasWipItems ? (
|
||||
// 재공품: 통합 버튼 1개
|
||||
<Button
|
||||
onClick={handleWipInspection}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
작업일지 및 중간검사하기
|
||||
</Button>
|
||||
) : (
|
||||
// 일반/조인트바: 버튼 2개
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleWorkLog}
|
||||
className="flex-1 py-6 text-base font-medium"
|
||||
>
|
||||
작업일지 보기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInspection}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
중간검사하기
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -706,8 +887,9 @@ export default function WorkerScreen() {
|
||||
open={isInspectionModalOpen}
|
||||
onOpenChange={setIsInspectionModalOpen}
|
||||
workOrderId={selectedOrder?.id || null}
|
||||
processType={activeTab}
|
||||
processType={hasWipItems ? 'bending_wip' : activeTab}
|
||||
readOnly={false}
|
||||
isJointBar={hasJointBarItems}
|
||||
/>
|
||||
|
||||
<IssueReportModal
|
||||
|
||||
@@ -41,16 +41,29 @@ export interface WorkItemData {
|
||||
quantity: number; // 수량
|
||||
processType: ProcessTab; // 공정 타입
|
||||
steps: WorkStepData[]; // 공정 단계들
|
||||
isWip?: boolean; // 재공품 여부
|
||||
isJointBar?: boolean; // 조인트바 여부
|
||||
// 스크린 전용
|
||||
cuttingInfo?: CuttingInfo;
|
||||
// 슬랫 전용
|
||||
slatInfo?: SlatInfo;
|
||||
// 슬랫 조인트바 전용
|
||||
slatJointBarInfo?: SlatJointBarInfo;
|
||||
// 절곡 전용
|
||||
bendingInfo?: BendingInfo;
|
||||
// 재공품 전용
|
||||
wipInfo?: WipInfo;
|
||||
// 자재 투입 목록
|
||||
materialInputs?: MaterialListItem[];
|
||||
}
|
||||
|
||||
// ===== 재공품 전용 정보 =====
|
||||
export interface WipInfo {
|
||||
drawingUrl?: string; // 도면 이미지 URL
|
||||
specification: string; // 규격 (EGI 1.55T (W576))
|
||||
lengthQuantity: string; // 길이별 수량 (4,000mm X 6개)
|
||||
}
|
||||
|
||||
// ===== 절단 정보 (스크린 전용) =====
|
||||
export interface CuttingInfo {
|
||||
width: number; // 절단 폭 (mm)
|
||||
@@ -64,6 +77,13 @@ export interface SlatInfo {
|
||||
jointBar: number; // 조인트바 개수
|
||||
}
|
||||
|
||||
// ===== 슬랫 조인트바 전용 정보 =====
|
||||
export interface SlatJointBarInfo {
|
||||
specification: string; // 규격 (예: EGI 1.6T)
|
||||
length: number; // 길이 (mm)
|
||||
quantity: number; // 수량
|
||||
}
|
||||
|
||||
// ===== 절곡 전용 정보 =====
|
||||
export interface BendingInfo {
|
||||
drawingUrl?: string; // 도면 이미지 URL
|
||||
|
||||
@@ -104,6 +104,15 @@ export function InspectionReportDocument({ data }: InspectionReportDocumentProps
|
||||
return '합격';
|
||||
}, [items, judgmentCoverage.covered]);
|
||||
|
||||
// 측정값 변경 핸들러
|
||||
const handleMeasuredValueChange = useCallback((flatIdx: number, value: string) => {
|
||||
setItems(prev => {
|
||||
const next = [...prev];
|
||||
next[flatIdx] = { ...next[flatIdx], measuredValue: value };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 판정 클릭 핸들러
|
||||
const handleJudgmentClick = useCallback((flatIdx: number, value: '적합' | '부적합') => {
|
||||
setItems(prev => {
|
||||
@@ -349,6 +358,20 @@ export function InspectionReportDocument({ data }: InspectionReportDocumentProps
|
||||
if (measuredValueCoverage.covered.has(flatIdx)) {
|
||||
return null;
|
||||
}
|
||||
// 편집 가능한 측정값 셀 → input 렌더
|
||||
if (row.editable) {
|
||||
return (
|
||||
<td className="border border-gray-400 px-0.5 py-0.5 text-center">
|
||||
<input
|
||||
type="text"
|
||||
value={row.measuredValue || ''}
|
||||
onChange={(e) => handleMeasuredValueChange(flatIdx, e.target.value)}
|
||||
className="w-full h-full px-1 py-0.5 text-center text-[11px] border border-gray-300 rounded-sm focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-200"
|
||||
placeholder=""
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">
|
||||
{row.measuredValue || ''}
|
||||
|
||||
@@ -430,10 +430,10 @@ export const mockReportInspectionItems: ReportInspectionItem[] = [
|
||||
// 3. 재질
|
||||
{ no: 3, category: '재질', criteria: 'WY-SC780 인쇄상태 확인', method: '', frequency: '' },
|
||||
// 4. 치수(오픈사이즈) (4개 세부항목) — 항목4만 병합: 체크검사/전수검사 (4행)
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '길이', criteria: '수주 치수 ± 30mm', method: '체크검사', frequency: '전수검사', methodSpan: 4, freqSpan: 4 },
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '높이', criteria: '수주 치수 ± 30mm', method: '', frequency: '' },
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '가이드레일 간격', criteria: '10 ± 5mm (측정부위 @ 높이 100 이하)', method: '', frequency: '' },
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '하단막대 간격', criteria: '간격 (③+④)\n가이드레일과 하단마감재 측 사이 25mm 이내', method: '', frequency: '' },
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '길이', criteria: '수주 치수 ± 30mm', method: '체크검사', frequency: '전수검사', methodSpan: 4, freqSpan: 4, editable: true },
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '높이', criteria: '수주 치수 ± 30mm', method: '', frequency: '', editable: true },
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '가이드레일 간격', criteria: '10 ± 5mm (측정부위 @ 높이 100 이하)', method: '', frequency: '', editable: true },
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '하단막대 간격', criteria: '간격 (③+④)\n가이드레일과 하단마감재 측 사이 25mm 이내', method: '', frequency: '', editable: true },
|
||||
// 5. 작동테스트 — 판정 없음
|
||||
{ no: 5, category: '작동테스트', subCategory: '개폐성능', criteria: '작동 유무 확인\n(일부 및 완전폐쇄)', method: '', frequency: '', hideJudgment: true },
|
||||
// 6. 내화시험 (3개 세부항목) — "비차열\n차열성" 3행 병합, 항목 6+7+8+9 검사방법/주기/판정 모두 병합 (10행)
|
||||
|
||||
@@ -211,6 +211,7 @@ export interface ReportInspectionItem {
|
||||
freqSpan?: number; // 검사주기 셀 rowSpan (크로스그룹 병합)
|
||||
judgmentSpan?: number; // 판정 셀 rowSpan (크로스그룹 병합, 예: 항목6+7+8+9)
|
||||
hideJudgment?: boolean; // 판정 표시 안함 (빈 셀 렌더)
|
||||
editable?: boolean; // 측정값 입력 가능 여부
|
||||
}
|
||||
|
||||
// 제품검사성적서
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 메모 모달
|
||||
* - 선택한 항목 정보 표시 (N건, 총 N개소)
|
||||
* - 메모 textarea
|
||||
* - 취소/작성 버튼 (일괄 적용)
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface MemoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedCount: number;
|
||||
totalLocations: number;
|
||||
onSubmit: (memo: string) => void;
|
||||
}
|
||||
|
||||
export function MemoModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedCount,
|
||||
totalLocations,
|
||||
onSubmit,
|
||||
}: MemoModalProps) {
|
||||
const [memo, setMemo] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit(memo);
|
||||
setMemo('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setMemo('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>메모 작성</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 선택 항목 정보 */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground bg-muted/50 rounded-lg px-4 py-3">
|
||||
<span>선택: <strong className="text-foreground">{selectedCount}건</strong></span>
|
||||
<span className="text-border">|</span>
|
||||
<span>총 개소: <strong className="text-foreground">{totalLocations}개소</strong></span>
|
||||
</div>
|
||||
|
||||
{/* 메모 입력 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">메모</label>
|
||||
<Textarea
|
||||
placeholder="메모를 입력하세요"
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
rows={5}
|
||||
className="resize-none"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
선택한 {selectedCount}건에 동일한 메모가 일괄 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
작성
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 실적신고 목록 페이지
|
||||
*
|
||||
* 탭 2개 (above-stats 위치):
|
||||
* - 분기별 실적신고: 연도+분기 버튼 필터, 검색, 통계 카드 4개, 액션 버튼, 테이블
|
||||
* - 누락체크: 설명 박스 + 누락 목록 테이블
|
||||
*
|
||||
* UniversalListPage 공통 템플릿 사용
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
MapPin,
|
||||
Check,
|
||||
Undo2,
|
||||
Send,
|
||||
Pencil,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
type ListParams,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { MemoModal } from './MemoModal';
|
||||
import {
|
||||
getPerformanceReports,
|
||||
getPerformanceReportStats,
|
||||
getMissedReports,
|
||||
confirmReports,
|
||||
unconfirmReports,
|
||||
distributeReports,
|
||||
updateMemo,
|
||||
} from './actions';
|
||||
import { confirmStatusColorMap } from './mockData';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type {
|
||||
PerformanceReportStats,
|
||||
ReportListItem,
|
||||
Quarter,
|
||||
} from './types';
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function PerformanceReportList() {
|
||||
// ===== 통계 =====
|
||||
const [statsData, setStatsData] = useState<PerformanceReportStats>({
|
||||
totalCount: 0,
|
||||
confirmedCount: 0,
|
||||
unconfirmedCount: 0,
|
||||
totalLocations: 0,
|
||||
});
|
||||
|
||||
// ===== 연도/분기 필터 =====
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [year, setYear] = useState(currentYear);
|
||||
const [quarter, setQuarter] = useState<Quarter | '전체'>('전체');
|
||||
|
||||
// ===== 메모 모달 =====
|
||||
const [isMemoModalOpen, setIsMemoModalOpen] = useState(false);
|
||||
const [memoSelectedIds, setMemoSelectedIds] = useState<string[]>([]);
|
||||
const [memoTotalLocations, setMemoTotalLocations] = useState(0);
|
||||
|
||||
// ===== 리프레시 트리거 =====
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// ===== 현재 탭 추적 (headerActions에서 사용) =====
|
||||
const [currentTab, setCurrentTab] = useState('quarterly');
|
||||
|
||||
// ===== 현재 데이터 추적 (메모 모달 개소 계산용) =====
|
||||
const [currentData, setCurrentData] = useState<ReportListItem[]>([]);
|
||||
|
||||
// ===== 연도 옵션 =====
|
||||
const yearOptions = useMemo(() => {
|
||||
const years = [];
|
||||
for (let y = currentYear; y >= currentYear - 5; y--) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
}, [currentYear]);
|
||||
|
||||
// ===== 통계 로드 =====
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await getPerformanceReportStats({ year, quarter });
|
||||
if (result.success && result.data) {
|
||||
setStatsData(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[PerformanceReportList] loadStats error:', error);
|
||||
}
|
||||
};
|
||||
loadStats();
|
||||
}, [year, quarter, refreshKey]);
|
||||
|
||||
// ===== 분기 버튼 클릭 =====
|
||||
const handleQuarterChange = useCallback((q: Quarter | '전체') => {
|
||||
setQuarter(q);
|
||||
}, []);
|
||||
|
||||
// ===== 액션 핸들러 =====
|
||||
const handleConfirm = useCallback(async (
|
||||
selectedItems: Set<string>,
|
||||
onClearSelection: () => void,
|
||||
onRefresh: () => void
|
||||
) => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.error('확정할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await confirmReports(Array.from(selectedItems));
|
||||
if (result.success) {
|
||||
toast.success(`${selectedItems.size}건이 확정되었습니다.`);
|
||||
onClearSelection();
|
||||
onRefresh();
|
||||
setRefreshKey((k) => k + 1);
|
||||
} else {
|
||||
toast.error(result.error || '확정 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUnconfirm = useCallback(async (
|
||||
selectedItems: Set<string>,
|
||||
onClearSelection: () => void,
|
||||
onRefresh: () => void
|
||||
) => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.error('확정 해제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await unconfirmReports(Array.from(selectedItems));
|
||||
if (result.success) {
|
||||
toast.success(`${selectedItems.size}건의 확정이 해제되었습니다.`);
|
||||
onClearSelection();
|
||||
onRefresh();
|
||||
setRefreshKey((k) => k + 1);
|
||||
} else {
|
||||
toast.error(result.error || '확정 해제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDistribute = useCallback(async (
|
||||
selectedItems: Set<string>,
|
||||
onClearSelection: () => void,
|
||||
onRefresh: () => void
|
||||
) => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.error('배포할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await distributeReports(Array.from(selectedItems));
|
||||
if (result.success) {
|
||||
toast.success(`${selectedItems.size}건이 배포되었습니다.`);
|
||||
onClearSelection();
|
||||
onRefresh();
|
||||
setRefreshKey((k) => k + 1);
|
||||
} else {
|
||||
toast.error(result.error || '배포에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenMemoModal = useCallback((selectedItems: Set<string>) => {
|
||||
const ids = Array.from(selectedItems);
|
||||
if (ids.length === 0) {
|
||||
toast.error('메모를 작성할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setMemoSelectedIds(ids);
|
||||
const totalLoc = currentData
|
||||
.filter((r) => selectedItems.has(r.id))
|
||||
.reduce((sum, r) => sum + r.locationCount, 0);
|
||||
setMemoTotalLocations(totalLoc);
|
||||
setIsMemoModalOpen(true);
|
||||
}, [currentData]);
|
||||
|
||||
const handleMemoSubmit = useCallback(async (memo: string) => {
|
||||
if (memoSelectedIds.length === 0) return;
|
||||
try {
|
||||
const result = await updateMemo(memoSelectedIds, memo);
|
||||
if (result.success) {
|
||||
toast.success(`${memoSelectedIds.length}건에 메모가 적용되었습니다.`);
|
||||
setRefreshKey((k) => k + 1);
|
||||
} else {
|
||||
toast.error(result.error || '메모 저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
}
|
||||
}, [memoSelectedIds]);
|
||||
|
||||
const handleExcelDownload = useCallback(() => {
|
||||
toast.info('확정건 엑셀 다운로드 기능은 API 연동 후 활성화됩니다.');
|
||||
}, []);
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
const stats: StatCard[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: '전체',
|
||||
value: statsData.totalCount,
|
||||
icon: ClipboardList,
|
||||
iconColor: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
label: '확정',
|
||||
value: statsData.confirmedCount,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '미확정',
|
||||
value: statsData.unconfirmedCount,
|
||||
icon: XCircle,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '총 개소',
|
||||
value: statsData.totalLocations,
|
||||
icon: MapPin,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
],
|
||||
[statsData]
|
||||
);
|
||||
|
||||
// ===== 연도/분기 필터 슬롯 (dateRangeSelector.extraActions) =====
|
||||
const quarterFilterSlot = useMemo(
|
||||
() => (
|
||||
<div className="flex items-center gap-2 flex-wrap order-first">
|
||||
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
|
||||
<SelectTrigger className="w-[100px] h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((y) => (
|
||||
<SelectItem key={y} value={String(y)}>
|
||||
{y}년
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-1">
|
||||
{(['전체', 'Q1', 'Q2', 'Q3', 'Q4'] as const).map((q) => (
|
||||
<Button
|
||||
key={q}
|
||||
size="sm"
|
||||
variant={quarter === q ? 'default' : 'outline'}
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => handleQuarterChange(q)}
|
||||
>
|
||||
{q === '전체' ? '전체' : `${q.replace('Q', '')}분기`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[year, quarter, yearOptions, handleQuarterChange]
|
||||
);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<ReportListItem> = useMemo(
|
||||
() => ({
|
||||
title: '실적신고 목록',
|
||||
description: '분기별 실적신고 및 누락체크를 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/quality/performance-reports',
|
||||
idField: 'id',
|
||||
detailMode: 'none' as const,
|
||||
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
const tab = params?.tab || 'quarterly';
|
||||
|
||||
if (tab === 'missed') {
|
||||
const result = await getMissedReports({
|
||||
page: params?.page || 1,
|
||||
size: params?.pageSize || ITEMS_PER_PAGE,
|
||||
q: params?.search || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
const mapped: ReportListItem[] = result.data.map((r) => ({
|
||||
id: r.id,
|
||||
qualityDocNumber: r.qualityDocNumber,
|
||||
siteName: r.siteName,
|
||||
client: r.client,
|
||||
locationCount: r.locationCount,
|
||||
memo: r.memo,
|
||||
inspectionCompleteDate: r.inspectionCompleteDate,
|
||||
}));
|
||||
return {
|
||||
success: true,
|
||||
data: mapped,
|
||||
totalCount: result.pagination.total,
|
||||
totalPages: result.pagination.lastPage,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
// quarterly tab
|
||||
const result = await getPerformanceReports({
|
||||
page: params?.page || 1,
|
||||
size: params?.pageSize || ITEMS_PER_PAGE,
|
||||
q: params?.search || undefined,
|
||||
year,
|
||||
quarter,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 통계 갱신
|
||||
const statsResult = await getPerformanceReportStats({ year, quarter });
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatsData(statsResult.data);
|
||||
}
|
||||
|
||||
const mapped: ReportListItem[] = result.data.map((r) => ({
|
||||
id: r.id,
|
||||
qualityDocNumber: r.qualityDocNumber,
|
||||
siteName: r.siteName,
|
||||
client: r.client,
|
||||
locationCount: r.locationCount,
|
||||
memo: r.memo,
|
||||
createdDate: r.createdDate,
|
||||
requiredInfo: r.requiredInfo,
|
||||
confirmStatus: r.confirmStatus,
|
||||
confirmDate: r.confirmDate,
|
||||
year: r.year,
|
||||
quarter: r.quarter,
|
||||
}));
|
||||
return {
|
||||
success: true,
|
||||
data: mapped,
|
||||
totalCount: result.pagination.total,
|
||||
totalPages: result.pagination.lastPage,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
return { success: false, error: '데이터 로드 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// 탭
|
||||
tabs: [
|
||||
{ value: 'quarterly', label: '분기별 실적신고', count: 0 },
|
||||
{ value: 'missed', label: '누락체크', count: 0 },
|
||||
],
|
||||
defaultTab: 'quarterly',
|
||||
tabsPosition: 'above-stats',
|
||||
|
||||
// 탭별 컬럼
|
||||
columnsPerTab: {
|
||||
quarterly: [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'qualityDocNumber', label: '품질관리서 번호', className: 'min-w-[130px]' },
|
||||
{ key: 'createdDate', label: '작성일', className: 'w-[100px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
|
||||
{ key: 'client', label: '수주처', className: 'min-w-[80px]' },
|
||||
{ key: 'locationCount', label: '개소', className: 'w-[60px] text-center' },
|
||||
{ key: 'requiredInfo', label: '필수정보', className: 'w-[90px] text-center' },
|
||||
{ key: 'confirmStatus', label: '확정상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'confirmDate', label: '확정일', className: 'w-[100px]' },
|
||||
{ key: 'memo', label: '메모', className: 'min-w-[120px]' },
|
||||
],
|
||||
missed: [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'qualityDocNumber', label: '품질관리서 번호', className: 'min-w-[130px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
|
||||
{ key: 'client', label: '수주처', className: 'min-w-[80px]' },
|
||||
{ key: 'locationCount', label: '개소', className: 'w-[60px] text-center' },
|
||||
{ key: 'inspectionCompleteDate', label: '제품검사완료일', className: 'w-[120px]' },
|
||||
{ key: 'memo', label: '메모', className: 'min-w-[120px]' },
|
||||
],
|
||||
},
|
||||
columns: [], // columnsPerTab 사용
|
||||
|
||||
// 날짜 범위 선택기 → 연도/분기 필터
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
hideDateInputs: true,
|
||||
showPresets: false,
|
||||
extraActions: quarterFilterSlot,
|
||||
},
|
||||
|
||||
// 통계 (quarterly 탭만)
|
||||
stats: currentTab === 'quarterly' ? stats : undefined,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '품질관리서 번호, 현장명, 수주처 검색...',
|
||||
clientSideFiltering: false,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
|
||||
// 체크박스 항상 표시
|
||||
showCheckbox: true,
|
||||
|
||||
// 누락체크 탭: 경고 배너
|
||||
beforeTableContent: currentTab === 'missed' ? (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg border border-amber-200 bg-amber-50">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div className="text-sm text-amber-800 space-y-1">
|
||||
<p className="font-medium">누락체크 안내</p>
|
||||
<p>
|
||||
제품검사가 완료되었으나 실적신고에 포함되지 않은 건을 표시합니다.
|
||||
해당 건들을 확인하여 실적신고에 반영해주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined,
|
||||
|
||||
// 헤더 액션 (선택 기반 버튼)
|
||||
headerActions: ({ selectedItems, onClearSelection, onRefresh }) => {
|
||||
if (currentTab !== 'quarterly') return null;
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedItems.size > 0 && (
|
||||
<>
|
||||
<Button size="sm" onClick={() => handleConfirm(selectedItems, onClearSelection, onRefresh)}>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
선택 확정
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleUnconfirm(selectedItems, onClearSelection, onRefresh)}>
|
||||
<Undo2 className="h-4 w-4 mr-1" />
|
||||
확정 해제
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleDistribute(selectedItems, onClearSelection, onRefresh)}>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
배포
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleOpenMemoModal(selectedItems)}>
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
메모
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={handleExcelDownload}>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
확정건 엑셀다운로드
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
// 데이터 변경 콜백 (메모 모달용)
|
||||
onDataChange: (data) => {
|
||||
setCurrentData(data);
|
||||
},
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: ReportListItem,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<ReportListItem>
|
||||
) => {
|
||||
// quarterly 탭 렌더링
|
||||
if (item.createdDate !== undefined) {
|
||||
const isRequiredMissing = item.requiredInfo && item.requiredInfo !== '입력완료';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={handlers.isSelected ? 'bg-blue-50' : ''}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={handlers.onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.qualityDocNumber}</TableCell>
|
||||
<TableCell>{item.createdDate}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell>{item.client}</TableCell>
|
||||
<TableCell className="text-center">{item.locationCount}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.requiredInfo && (
|
||||
<span className={isRequiredMissing ? 'text-red-600 font-medium' : 'text-green-600'}>
|
||||
{item.requiredInfo}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.confirmStatus && (
|
||||
<Badge className={`text-xs ${confirmStatusColorMap[item.confirmStatus]} border-0`}>
|
||||
{item.confirmStatus}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.confirmDate || '-'}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm truncate max-w-[200px]">
|
||||
{item.memo || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// missed 탭 렌더링
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={handlers.isSelected ? 'bg-blue-50' : ''}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={handlers.onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.qualityDocNumber}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell>{item.client}</TableCell>
|
||||
<TableCell className="text-center">{item.locationCount}</TableCell>
|
||||
<TableCell>{item.inspectionCompleteDate}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm truncate max-w-[200px]">
|
||||
{item.memo || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: ReportListItem,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<ReportListItem>
|
||||
) => {
|
||||
const isQuarterly = item.createdDate !== undefined;
|
||||
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{item.qualityDocNumber}</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.siteName}
|
||||
statusBadge={
|
||||
isQuarterly && item.confirmStatus ? (
|
||||
<Badge className={`text-xs ${confirmStatusColorMap[item.confirmStatus]} border-0`}>
|
||||
{item.confirmStatus}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="수주처" value={item.client} />
|
||||
<InfoField label="개소" value={String(item.locationCount)} />
|
||||
{isQuarterly ? (
|
||||
<>
|
||||
<InfoField label="작성일" value={item.createdDate || '-'} />
|
||||
<InfoField label="필수정보" value={item.requiredInfo || '-'} />
|
||||
<InfoField label="확정상태" value={item.confirmStatus || '-'} />
|
||||
<InfoField label="확정일" value={item.confirmDate || '-'} />
|
||||
</>
|
||||
) : (
|
||||
<InfoField label="제품검사완료일" value={item.inspectionCompleteDate || '-'} />
|
||||
)}
|
||||
<InfoField label="메모" value={item.memo || '-'} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[stats, quarterFilterSlot, currentTab, year, quarter, refreshKey, handleConfirm, handleUnconfirm, handleDistribute, handleOpenMemoModal, handleExcelDownload]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
onTabChange={(tab) => setCurrentTab(tab)}
|
||||
/>
|
||||
|
||||
{/* 메모 모달 */}
|
||||
<MemoModal
|
||||
isOpen={isMemoModalOpen}
|
||||
onClose={() => setIsMemoModalOpen(false)}
|
||||
selectedCount={memoSelectedIds.length}
|
||||
totalLocations={memoTotalLocations}
|
||||
onSubmit={handleMemoSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
410
src/components/quality/PerformanceReportManagement/actions.ts
Normal file
410
src/components/quality/PerformanceReportManagement/actions.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* 실적신고관리 Server Actions
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/performance-reports - 분기별 실적신고 목록
|
||||
* - GET /api/v1/performance-reports/stats - 통계
|
||||
* - GET /api/v1/performance-reports/missed - 누락체크 목록
|
||||
* - PATCH /api/v1/performance-reports/confirm - 선택 확정
|
||||
* - PATCH /api/v1/performance-reports/unconfirm - 확정 해제
|
||||
* - POST /api/v1/performance-reports/distribute - 배포
|
||||
* - PATCH /api/v1/performance-reports/memo - 메모 일괄 적용
|
||||
*/
|
||||
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type {
|
||||
PerformanceReport,
|
||||
PerformanceReportStats,
|
||||
MissedReport,
|
||||
Quarter,
|
||||
} from './types';
|
||||
import {
|
||||
mockPerformanceReports,
|
||||
mockPerformanceReportStats,
|
||||
mockMissedReports,
|
||||
} from './mockData';
|
||||
|
||||
// 개발환경 Mock 데이터 fallback 플래그
|
||||
const USE_MOCK_FALLBACK = true;
|
||||
|
||||
// ===== 페이지네이션 =====
|
||||
|
||||
interface PaginationMeta {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const API_BASE = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/performance-reports`;
|
||||
|
||||
// ===== 분기별 실적신고 목록 조회 =====
|
||||
|
||||
export async function getPerformanceReports(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
q?: string;
|
||||
year?: number;
|
||||
quarter?: Quarter | '전체';
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: PerformanceReport[];
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const defaultPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
if (params?.size) searchParams.set('per_page', String(params.size));
|
||||
if (params?.q) searchParams.set('q', params.q);
|
||||
if (params?.year) searchParams.set('year', String(params.year));
|
||||
if (params?.quarter && params.quarter !== '전체') {
|
||||
searchParams.set('quarter', params.quarter);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${API_BASE}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error || !response || !response.ok) {
|
||||
if (USE_MOCK_FALLBACK) {
|
||||
console.warn('[PerformanceReportActions] API 실패, Mock 데이터 사용');
|
||||
let filtered = [...mockPerformanceReports];
|
||||
if (params?.year) {
|
||||
filtered = filtered.filter(i => i.year === params.year);
|
||||
}
|
||||
if (params?.quarter && params.quarter !== '전체') {
|
||||
filtered = filtered.filter(i => i.quarter === params.quarter);
|
||||
}
|
||||
if (params?.q) {
|
||||
const q = params.q.toLowerCase();
|
||||
filtered = filtered.filter(i =>
|
||||
i.siteName.toLowerCase().includes(q) ||
|
||||
i.client.toLowerCase().includes(q) ||
|
||||
i.qualityDocNumber.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
const page = params?.page || 1;
|
||||
const size = params?.size || 20;
|
||||
const start = (page - 1) * size;
|
||||
const paged = filtered.slice(start, start + size);
|
||||
return {
|
||||
success: true,
|
||||
data: paged,
|
||||
pagination: { currentPage: page, lastPage: Math.ceil(filtered.length / size), perPage: size, total: filtered.length },
|
||||
};
|
||||
}
|
||||
const errMsg = error ? error.message : `API 오류: ${response?.status || 'no response'}`;
|
||||
return { success: false, data: [], pagination: defaultPagination, error: errMsg, __authError: error?.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
return { success: false, data: [], pagination: defaultPagination, error: result.message || '목록 조회 실패' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data?.items || [],
|
||||
pagination: {
|
||||
currentPage: result.data?.current_page || 1,
|
||||
lastPage: result.data?.last_page || 1,
|
||||
perPage: result.data?.per_page || 20,
|
||||
total: result.data?.total || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[PerformanceReportActions] getPerformanceReports error:', error);
|
||||
if (USE_MOCK_FALLBACK) {
|
||||
return {
|
||||
success: true,
|
||||
data: mockPerformanceReports,
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockPerformanceReports.length },
|
||||
};
|
||||
}
|
||||
return { success: false, data: [], pagination: defaultPagination, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 통계 조회 =====
|
||||
|
||||
export async function getPerformanceReportStats(params?: {
|
||||
year?: number;
|
||||
quarter?: Quarter | '전체';
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: PerformanceReportStats;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.year) searchParams.set('year', String(params.year));
|
||||
if (params?.quarter && params.quarter !== '전체') {
|
||||
searchParams.set('quarter', params.quarter);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${API_BASE}/stats${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error || !response || !response.ok) {
|
||||
if (USE_MOCK_FALLBACK) {
|
||||
console.warn('[PerformanceReportActions] Stats API 실패, Mock 데이터 사용');
|
||||
return { success: true, data: mockPerformanceReportStats };
|
||||
}
|
||||
const errMsg = error ? error.message : `API 오류: ${response?.status || 'no response'}`;
|
||||
return { success: false, error: errMsg, __authError: error?.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.message || '통계 조회 실패' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[PerformanceReportActions] getPerformanceReportStats error:', error);
|
||||
if (USE_MOCK_FALLBACK) return { success: true, data: mockPerformanceReportStats };
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 누락체크 목록 조회 =====
|
||||
|
||||
export async function getMissedReports(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
q?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: MissedReport[];
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const defaultPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
if (params?.size) searchParams.set('per_page', String(params.size));
|
||||
if (params?.q) searchParams.set('q', params.q);
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${API_BASE}/missed${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error || !response || !response.ok) {
|
||||
if (USE_MOCK_FALLBACK) {
|
||||
console.warn('[PerformanceReportActions] Missed API 실패, Mock 데이터 사용');
|
||||
let filtered = [...mockMissedReports];
|
||||
if (params?.q) {
|
||||
const q = params.q.toLowerCase();
|
||||
filtered = filtered.filter(i =>
|
||||
i.siteName.toLowerCase().includes(q) ||
|
||||
i.client.toLowerCase().includes(q) ||
|
||||
i.qualityDocNumber.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
const page = params?.page || 1;
|
||||
const size = params?.size || 20;
|
||||
const start = (page - 1) * size;
|
||||
const paged = filtered.slice(start, start + size);
|
||||
return {
|
||||
success: true,
|
||||
data: paged,
|
||||
pagination: { currentPage: page, lastPage: Math.ceil(filtered.length / size), perPage: size, total: filtered.length },
|
||||
};
|
||||
}
|
||||
const errMsg = error ? error.message : `API 오류: ${response?.status || 'no response'}`;
|
||||
return { success: false, data: [], pagination: defaultPagination, error: errMsg, __authError: error?.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
return { success: false, data: [], pagination: defaultPagination, error: result.message || '누락체크 조회 실패' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data?.items || [],
|
||||
pagination: {
|
||||
currentPage: result.data?.current_page || 1,
|
||||
lastPage: result.data?.last_page || 1,
|
||||
perPage: result.data?.per_page || 20,
|
||||
total: result.data?.total || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[PerformanceReportActions] getMissedReports error:', error);
|
||||
if (USE_MOCK_FALLBACK) {
|
||||
return {
|
||||
success: true,
|
||||
data: mockMissedReports,
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockMissedReports.length },
|
||||
};
|
||||
}
|
||||
return { success: false, data: [], pagination: defaultPagination, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 선택 확정 =====
|
||||
|
||||
export async function confirmReports(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(`${API_BASE}/confirm`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
if (!response) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: '확정 처리에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: result.message || '확정 처리에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[PerformanceReportActions] confirmReports error:', error);
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 확정 해제 =====
|
||||
|
||||
export async function unconfirmReports(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(`${API_BASE}/unconfirm`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
if (!response) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: '확정 해제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: result.message || '확정 해제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[PerformanceReportActions] unconfirmReports error:', error);
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 배포 =====
|
||||
|
||||
export async function distributeReports(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(`${API_BASE}/distribute`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
if (!response) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: '배포에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: result.message || '배포에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[PerformanceReportActions] distributeReports error:', error);
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 메모 일괄 적용 =====
|
||||
|
||||
export async function updateMemo(ids: string[], memo: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(`${API_BASE}/memo`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, memo }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
if (!response) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: '메모 저장에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: result.message || '메모 저장에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[PerformanceReportActions] updateMemo error:', error);
|
||||
if (USE_MOCK_FALLBACK) return { success: true };
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
19
src/components/quality/PerformanceReportManagement/index.ts
Normal file
19
src/components/quality/PerformanceReportManagement/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 실적신고관리 컴포넌트 및 타입 export
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './mockData';
|
||||
export { PerformanceReportList } from './PerformanceReportList';
|
||||
export { MemoModal } from './MemoModal';
|
||||
|
||||
// Server Actions (API 연동)
|
||||
export {
|
||||
getPerformanceReports,
|
||||
getPerformanceReportStats,
|
||||
getMissedReports,
|
||||
confirmReports,
|
||||
unconfirmReports,
|
||||
distributeReports,
|
||||
updateMemo,
|
||||
} from './actions';
|
||||
195
src/components/quality/PerformanceReportManagement/mockData.ts
Normal file
195
src/components/quality/PerformanceReportManagement/mockData.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// 실적신고관리 Mock 데이터
|
||||
|
||||
import type {
|
||||
PerformanceReport,
|
||||
MissedReport,
|
||||
PerformanceReportStats,
|
||||
ConfirmStatus,
|
||||
} from './types';
|
||||
|
||||
// ===== 상태별 색상 매핑 =====
|
||||
|
||||
export const confirmStatusColorMap: Record<ConfirmStatus, string> = {
|
||||
확정: 'bg-green-100 text-green-800',
|
||||
미확정: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
// ===== Mock 데이터 - 분기별 실적신고 =====
|
||||
|
||||
export const mockPerformanceReports: PerformanceReport[] = [
|
||||
{
|
||||
id: '1',
|
||||
qualityDocNumber: 'QD-2026-001',
|
||||
createdDate: '2026-01-05',
|
||||
siteName: '강남 센트럴 파크',
|
||||
client: '삼성물산',
|
||||
locationCount: 45,
|
||||
requiredInfo: '입력완료',
|
||||
confirmStatus: '확정',
|
||||
confirmDate: '2026-01-10',
|
||||
memo: '3차 검사 완료',
|
||||
year: 2026,
|
||||
quarter: 'Q1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
qualityDocNumber: 'QD-2026-002',
|
||||
createdDate: '2026-01-08',
|
||||
siteName: '서초 리버사이드',
|
||||
client: '현대건설',
|
||||
locationCount: 32,
|
||||
requiredInfo: '입력완료',
|
||||
confirmStatus: '확정',
|
||||
confirmDate: '2026-01-12',
|
||||
memo: '',
|
||||
year: 2026,
|
||||
quarter: 'Q1',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
qualityDocNumber: 'QD-2026-003',
|
||||
createdDate: '2026-01-12',
|
||||
siteName: '판교 테크노밸리 2단지',
|
||||
client: '대우건설',
|
||||
locationCount: 78,
|
||||
requiredInfo: '2건 누락',
|
||||
confirmStatus: '미확정',
|
||||
confirmDate: '',
|
||||
memo: '추가 검사 필요',
|
||||
year: 2026,
|
||||
quarter: 'Q1',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
qualityDocNumber: 'QD-2026-004',
|
||||
createdDate: '2026-01-15',
|
||||
siteName: '용산 파크타워',
|
||||
client: 'GS건설',
|
||||
locationCount: 56,
|
||||
requiredInfo: '1건 누락',
|
||||
confirmStatus: '미확정',
|
||||
confirmDate: '',
|
||||
memo: '',
|
||||
year: 2026,
|
||||
quarter: 'Q1',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
qualityDocNumber: 'QD-2026-005',
|
||||
createdDate: '2026-01-20',
|
||||
siteName: '마포 리버뷰',
|
||||
client: '포스코건설',
|
||||
locationCount: 23,
|
||||
requiredInfo: '입력완료',
|
||||
confirmStatus: '확정',
|
||||
confirmDate: '2026-01-25',
|
||||
memo: '최종 검사 완료',
|
||||
year: 2026,
|
||||
quarter: 'Q1',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
qualityDocNumber: 'QD-2026-006',
|
||||
createdDate: '2026-01-25',
|
||||
siteName: '송파 헬리오시티',
|
||||
client: '롯데건설',
|
||||
locationCount: 91,
|
||||
requiredInfo: '미입력',
|
||||
confirmStatus: '미확정',
|
||||
confirmDate: '',
|
||||
memo: '',
|
||||
year: 2026,
|
||||
quarter: 'Q1',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
qualityDocNumber: 'QD-2026-007',
|
||||
createdDate: '2026-01-28',
|
||||
siteName: '잠실 엘스',
|
||||
client: '삼성물산',
|
||||
locationCount: 67,
|
||||
requiredInfo: '입력완료',
|
||||
confirmStatus: '미확정',
|
||||
confirmDate: '',
|
||||
memo: '공사 일시 중단',
|
||||
year: 2026,
|
||||
quarter: 'Q1',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== Mock 데이터 - 누락체크 =====
|
||||
|
||||
export const mockMissedReports: MissedReport[] = [
|
||||
{
|
||||
id: 'm1',
|
||||
qualityDocNumber: 'QD-2025-089',
|
||||
siteName: '강동 그린파크',
|
||||
client: '현대건설',
|
||||
locationCount: 34,
|
||||
inspectionCompleteDate: '2025-12-15',
|
||||
memo: '',
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
qualityDocNumber: 'QD-2025-092',
|
||||
siteName: '성북 힐스테이트',
|
||||
client: 'GS건설',
|
||||
locationCount: 28,
|
||||
inspectionCompleteDate: '2025-12-20',
|
||||
memo: '확인 필요',
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
qualityDocNumber: 'QD-2025-095',
|
||||
siteName: '노원 래미안',
|
||||
client: '삼성물산',
|
||||
locationCount: 52,
|
||||
inspectionCompleteDate: '2025-12-28',
|
||||
memo: '',
|
||||
},
|
||||
{
|
||||
id: 'm4',
|
||||
qualityDocNumber: 'QD-2026-008',
|
||||
siteName: '동작 자이',
|
||||
client: 'GS건설',
|
||||
locationCount: 19,
|
||||
inspectionCompleteDate: '2026-01-05',
|
||||
memo: '',
|
||||
},
|
||||
{
|
||||
id: 'm5',
|
||||
qualityDocNumber: 'QD-2026-009',
|
||||
siteName: '관악 e편한세상',
|
||||
client: '대림건설',
|
||||
locationCount: 41,
|
||||
inspectionCompleteDate: '2026-01-10',
|
||||
memo: '서류 미비',
|
||||
},
|
||||
{
|
||||
id: 'm6',
|
||||
qualityDocNumber: 'QD-2026-010',
|
||||
siteName: '은평 뉴타운',
|
||||
client: '대우건설',
|
||||
locationCount: 63,
|
||||
inspectionCompleteDate: '2026-01-18',
|
||||
memo: '',
|
||||
},
|
||||
{
|
||||
id: 'm7',
|
||||
qualityDocNumber: 'QD-2026-011',
|
||||
siteName: '광진 현대프리미엄',
|
||||
client: '현대건설',
|
||||
locationCount: 37,
|
||||
inspectionCompleteDate: '2026-01-22',
|
||||
memo: '',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== Mock 통계 =====
|
||||
|
||||
export const mockPerformanceReportStats: PerformanceReportStats = {
|
||||
totalCount: 7,
|
||||
confirmedCount: 3,
|
||||
unconfirmedCount: 4,
|
||||
totalLocations: 392,
|
||||
};
|
||||
67
src/components/quality/PerformanceReportManagement/types.ts
Normal file
67
src/components/quality/PerformanceReportManagement/types.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// 실적신고관리 타입 정의
|
||||
|
||||
// ===== 기본 열거 타입 =====
|
||||
|
||||
// 분기
|
||||
export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4';
|
||||
|
||||
// 확정 상태
|
||||
export type ConfirmStatus = '확정' | '미확정';
|
||||
|
||||
// ===== 메인 데이터 =====
|
||||
|
||||
// 분기별 실적신고 항목
|
||||
export interface PerformanceReport {
|
||||
id: string;
|
||||
qualityDocNumber: string; // 품질관리서 번호
|
||||
createdDate: string; // 작성일
|
||||
siteName: string; // 현장명
|
||||
client: string; // 수주처
|
||||
locationCount: number; // 개소
|
||||
requiredInfo: string; // 필수정보
|
||||
confirmStatus: ConfirmStatus; // 확정 상태
|
||||
confirmDate: string; // 확정일
|
||||
memo: string; // 메모
|
||||
year: number; // 연도
|
||||
quarter: Quarter; // 분기
|
||||
}
|
||||
|
||||
// 누락체크 항목
|
||||
export interface MissedReport {
|
||||
id: string;
|
||||
qualityDocNumber: string; // 품질관리서 번호
|
||||
siteName: string; // 현장명
|
||||
client: string; // 수주처
|
||||
locationCount: number; // 개소
|
||||
inspectionCompleteDate: string; // 제품검사완료일
|
||||
memo: string; // 메모
|
||||
}
|
||||
|
||||
// ===== 통계 =====
|
||||
|
||||
export interface PerformanceReportStats {
|
||||
totalCount: number; // 전체
|
||||
confirmedCount: number; // 확정
|
||||
unconfirmedCount: number; // 미확정
|
||||
totalLocations: number; // 총 개소
|
||||
}
|
||||
|
||||
// ===== 리스트 통합 아이템 (UniversalListPage 용) =====
|
||||
|
||||
export interface ReportListItem {
|
||||
id: string;
|
||||
qualityDocNumber: string;
|
||||
siteName: string;
|
||||
client: string;
|
||||
locationCount: number;
|
||||
memo: string;
|
||||
// 분기별 실적신고 전용
|
||||
createdDate?: string;
|
||||
requiredInfo?: string;
|
||||
confirmStatus?: ConfirmStatus;
|
||||
confirmDate?: string;
|
||||
year?: number;
|
||||
quarter?: Quarter;
|
||||
// 누락체크 전용
|
||||
inspectionCompleteDate?: string;
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { createContext, useContext, useEffect, useState, useCallback } from 'rea
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { getRolePermissionMatrix, getPermissionMenuUrlMap } from '@/lib/permissions/actions';
|
||||
import { buildMenuIdToUrlMap, convertMatrixToPermissionMap, findMatchingUrl, mergePermissionMaps } from '@/lib/permissions/utils';
|
||||
import { ALL_DENIED_PERMS } from '@/lib/permissions/types';
|
||||
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
|
||||
import { AccessDenied } from '@/components/common/AccessDenied';
|
||||
import { stripLocalePrefix } from '@/lib/utils/locale';
|
||||
|
||||
interface PermissionContextType {
|
||||
permissionMap: PermissionMap | null;
|
||||
@@ -58,7 +60,7 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
|
||||
// (모든 권한 OFF → API가 해당 menuId를 생략 → "all denied"로 보완)
|
||||
for (const [, url] of Object.entries(permMenuUrlMap)) {
|
||||
if (url && !merged[url]) {
|
||||
merged[url] = { view: false, create: false, update: false, delete: false, approve: false, export: false };
|
||||
merged[url] = { ...ALL_DENIED_PERMS };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +82,10 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
|
||||
|
||||
const can = useCallback((url: string, action: PermissionAction): boolean => {
|
||||
if (!permissionMap) return true;
|
||||
const perms = permissionMap[url];
|
||||
if (!perms) return true;
|
||||
return perms[action] ?? true;
|
||||
const matchedUrl = findMatchingUrl(url, permissionMap);
|
||||
if (!matchedUrl) return true;
|
||||
const perms = permissionMap[matchedUrl];
|
||||
return perms?.[action] ?? true;
|
||||
}, [permissionMap]);
|
||||
|
||||
return (
|
||||
@@ -98,7 +101,7 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
|
||||
const BYPASS_PATHS = ['/settings/permissions'];
|
||||
|
||||
function isGateBypassed(pathname: string): boolean {
|
||||
const pathWithoutLocale = pathname.replace(/^\/(ko|en|ja)(\/|$)/, '/');
|
||||
const pathWithoutLocale = stripLocalePrefix(pathname);
|
||||
return BYPASS_PATHS.some(bp => pathWithoutLocale.startsWith(bp));
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,19 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
||||
const sessionExpiredCountRef = useRef(0); // 연속 401 카운트
|
||||
const isSessionExpiredRef = useRef(false); // 세션 만료로 중지된 상태
|
||||
|
||||
// 콜백을 ref로 저장하여 인터벌 리셋 방지
|
||||
// (인라인 콜백이 매 렌더마다 새 참조를 생성해도 인터벌에 영향 없음)
|
||||
const onMenuUpdatedRef = useRef(onMenuUpdated);
|
||||
const onErrorRef = useRef(onError);
|
||||
const onSessionExpiredRef = useRef(onSessionExpired);
|
||||
|
||||
// 콜백 ref를 최신 값으로 동기화
|
||||
useEffect(() => {
|
||||
onMenuUpdatedRef.current = onMenuUpdated;
|
||||
onErrorRef.current = onError;
|
||||
onSessionExpiredRef.current = onSessionExpired;
|
||||
});
|
||||
|
||||
// 폴링 중지 (내부용)
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
@@ -114,7 +127,7 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 메뉴 갱신 실행
|
||||
// 메뉴 갱신 실행 (의존성: stopPolling만 — 안정적)
|
||||
const executeRefresh = useCallback(async () => {
|
||||
if (isPausedRef.current || isSessionExpiredRef.current) return;
|
||||
|
||||
@@ -125,7 +138,7 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
||||
sessionExpiredCountRef.current = 0;
|
||||
|
||||
if (result.updated) {
|
||||
onMenuUpdated?.();
|
||||
onMenuUpdatedRef.current?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -140,16 +153,16 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
||||
console.log('[Menu] 세션 만료로 폴링 중지');
|
||||
isSessionExpiredRef.current = true;
|
||||
stopPolling();
|
||||
onSessionExpired?.();
|
||||
onSessionExpiredRef.current?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 기타 에러 (네트워크 등) → 401 카운트 리셋하지 않음
|
||||
if (result.error) {
|
||||
onError?.(result.error);
|
||||
onErrorRef.current?.(result.error);
|
||||
}
|
||||
}, [onMenuUpdated, onError, onSessionExpired, stopPolling]);
|
||||
}, [stopPolling]);
|
||||
|
||||
// 수동 갱신 함수
|
||||
const refresh = useCallback(async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { usePermissionContext } from '@/contexts/PermissionContext';
|
||||
import { findMatchingUrl } from '@/lib/permissions/utils';
|
||||
import { ALL_ALLOWED } from '@/lib/permissions/types';
|
||||
import type { UsePermissionReturn } from '@/lib/permissions/types';
|
||||
|
||||
/**
|
||||
@@ -25,31 +26,13 @@ export function usePermission(overrideUrl?: string): UsePermissionReturn {
|
||||
const targetPath = overrideUrl || pathname;
|
||||
|
||||
if (isLoading || !permissionMap) {
|
||||
return {
|
||||
canView: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
canApprove: true,
|
||||
canExport: true,
|
||||
isLoading,
|
||||
matchedUrl: null,
|
||||
};
|
||||
return { ...ALL_ALLOWED, isLoading };
|
||||
}
|
||||
|
||||
const matchedUrl = findMatchingUrl(targetPath, permissionMap);
|
||||
|
||||
if (!matchedUrl) {
|
||||
return {
|
||||
canView: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
canApprove: true,
|
||||
canExport: true,
|
||||
isLoading: false,
|
||||
matchedUrl: null,
|
||||
};
|
||||
return ALL_ALLOWED;
|
||||
}
|
||||
|
||||
const perms = permissionMap[matchedUrl] || {};
|
||||
|
||||
@@ -46,6 +46,7 @@ import CommandMenuSearch, { type CommandMenuSearchRef } from '@/components/layou
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
|
||||
import { stripLocalePrefix } from '@/lib/utils/locale';
|
||||
import { useMenuPolling } from '@/hooks/useMenuPolling';
|
||||
import {
|
||||
Select,
|
||||
@@ -415,7 +416,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
if (!pathname || menuItems.length === 0) return;
|
||||
|
||||
// 경로 정규화 (로케일 제거)
|
||||
const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, '');
|
||||
const normalizedPath = stripLocalePrefix(pathname);
|
||||
|
||||
// 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색
|
||||
// 경로 매칭: 정확히 일치하거나 하위 경로인 경우만 매칭
|
||||
@@ -668,7 +669,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
// 모바일 레이아웃
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex flex-col bg-background min-h-screen" style={{ height: 'var(--app-height)' }}>
|
||||
<div className="flex flex-col bg-background min-h-screen">
|
||||
{/* 모바일 헤더 - sam-design 스타일 */}
|
||||
<header className="clean-glass sticky top-0 z-40 px-1.5 py-1.5 m-1.5 min-[320px]:px-2 min-[320px]:py-2 min-[320px]:m-2 sm:px-4 sm:py-4 sm:m-3 rounded-2xl clean-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -942,7 +943,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
</header>
|
||||
|
||||
{/* 모바일 콘텐츠 */}
|
||||
<main className="flex-1 overflow-y-auto px-3 overscroll-contain" style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y pinch-zoom' }}>
|
||||
<main className="flex-1 px-3">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export type PermissionAction = 'view' | 'create' | 'update' | 'delete' | 'approve' | 'export';
|
||||
|
||||
export const PERMISSION_ACTIONS: PermissionAction[] =
|
||||
['view', 'create', 'update', 'delete', 'approve', 'export'];
|
||||
|
||||
/** flat 변환된 권한 맵 (프론트엔드 사용) */
|
||||
export interface PermissionMap {
|
||||
[url: string]: {
|
||||
@@ -18,3 +21,14 @@ export interface UsePermissionReturn {
|
||||
isLoading: boolean;
|
||||
matchedUrl: string | null;
|
||||
}
|
||||
|
||||
export const ALL_ALLOWED: UsePermissionReturn = {
|
||||
canView: true, canCreate: true, canUpdate: true,
|
||||
canDelete: true, canApprove: true, canExport: true,
|
||||
isLoading: false, matchedUrl: null,
|
||||
};
|
||||
|
||||
export const ALL_DENIED_PERMS: Record<PermissionAction, false> = {
|
||||
view: false, create: false, update: false,
|
||||
delete: false, approve: false, export: false,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { PermissionMap, PermissionAction } from './types';
|
||||
import { PERMISSION_ACTIONS } from './types';
|
||||
import type { PermissionMap } from './types';
|
||||
import { stripLocalePrefix } from '@/lib/utils/locale';
|
||||
|
||||
interface SerializableMenuItem {
|
||||
id: string;
|
||||
@@ -38,14 +40,13 @@ export function convertMatrixToPermissionMap(
|
||||
menuIdToUrl: Record<string, string>
|
||||
): PermissionMap {
|
||||
const map: PermissionMap = {};
|
||||
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve', 'export'];
|
||||
|
||||
for (const [menuId, perms] of Object.entries(permissions)) {
|
||||
const url = menuIdToUrl[menuId];
|
||||
if (!url) continue; // URL 매핑 없는 메뉴 스킵
|
||||
|
||||
map[url] = {};
|
||||
for (const action of actions) {
|
||||
for (const action of PERMISSION_ACTIONS) {
|
||||
// API는 허용된 권한만 포함, 누락된 action = 비허용(false)
|
||||
map[url][action] = perms[action] === true;
|
||||
}
|
||||
@@ -66,8 +67,7 @@ export function mergePermissionMaps(maps: PermissionMap[]): PermissionMap {
|
||||
|
||||
for (const url of allUrls) {
|
||||
merged[url] = {};
|
||||
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve', 'export'];
|
||||
for (const action of actions) {
|
||||
for (const action of PERMISSION_ACTIONS) {
|
||||
const values = maps
|
||||
.map(m => m[url]?.[action])
|
||||
.filter((v): v is boolean => v !== undefined);
|
||||
@@ -84,7 +84,7 @@ export function mergePermissionMaps(maps: PermissionMap[]): PermissionMap {
|
||||
* Longest prefix match: 현재 경로에서 가장 길게 매칭되는 권한 URL 찾기
|
||||
*/
|
||||
export function findMatchingUrl(currentPath: string, permissionMap: PermissionMap): string | null {
|
||||
const pathWithoutLocale = currentPath.replace(/^\/(ko|en|ja)(\/|$)/, '/');
|
||||
const pathWithoutLocale = stripLocalePrefix(currentPath);
|
||||
|
||||
if (permissionMap[pathWithoutLocale]) {
|
||||
return pathWithoutLocale;
|
||||
@@ -100,12 +100,3 @@ export function findMatchingUrl(currentPath: string, permissionMap: PermissionMa
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CRUD 라우트에서 현재 액션 추론
|
||||
*/
|
||||
export function inferActionFromPath(path: string): PermissionAction {
|
||||
if (path.endsWith('/new') || path.endsWith('/create')) return 'create';
|
||||
if (path.endsWith('/edit')) return 'update';
|
||||
return 'view';
|
||||
}
|
||||
|
||||
14
src/lib/utils/locale.ts
Normal file
14
src/lib/utils/locale.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { locales } from '@/i18n/config';
|
||||
|
||||
const LOCALE_PREFIX_RE = new RegExp(`^\\/(${locales.join('|')})(\/|$)`);
|
||||
const LOCALE_PREFIX_SLASH_RE = new RegExp(`^\\/(${locales.join('|')})\\/`);
|
||||
|
||||
/** URL에서 locale prefix 제거 (/ko/hr/... → /hr/...) */
|
||||
export function stripLocalePrefix(path: string): string {
|
||||
return path.replace(LOCALE_PREFIX_RE, '/');
|
||||
}
|
||||
|
||||
/** path 내부의 locale prefix만 제거 (슬래시 필수) */
|
||||
export function stripLocaleSlashPrefix(path: string): string {
|
||||
return path.replace(LOCALE_PREFIX_SLASH_RE, '/');
|
||||
}
|
||||
Reference in New Issue
Block a user