refactor(WEB): V2 파일 통합, store 구조 정리 및 대시보드 개선
- V2 컴포넌트를 원본에 통합 후 V2 파일 삭제 (InspectionModal, BillDetail, ContractDocumentModal, LaborDetailClient, PricingDetailClient, QuoteRegistration) - store → stores 디렉토리 이동 및 favoritesStore 추가 - dashboard_type3~5 추가 및 기존 대시보드 차트/훅 분리 - Sidebar 리팩토링 및 HeaderFavoritesBar 추가 - DashboardSwitcher 컴포넌트 추가 - 백업 파일(.v1-backup) 및 불필요 코드 정리 - InspectionPreviewModal 레이아웃 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,179 +5,46 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase A: 즉시 개선 (1~2일)
|
||||
## Phase A: 즉시 개선 — ✅ 완료
|
||||
|
||||
### A-1. `<img>` → `next/image` 전환
|
||||
- **문제**: raw `<img>` 태그 ~10건 — 이미지 최적화/lazy loading 미적용
|
||||
- **대상 파일**:
|
||||
- `src/app/[locale]/(protected)/quality/qms/components/documents/ProductInspectionDocument.tsx`
|
||||
- `src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx`
|
||||
- `src/components/vehicle-management/VehicleLogDetail/config.tsx` (2건)
|
||||
- `src/components/process-management/InspectionPreviewModal.tsx` (2건)
|
||||
- `src/app/[locale]/(protected)/dev/dashboard/_components/AIPoweredDashboard.tsx`
|
||||
- `src/components/ui/image-upload.tsx`
|
||||
- **작업**: `<img src={...}>` → `<Image src={...} width={} height={} alt={} />` 전환
|
||||
- **주의**: 외부 URL 이미지는 `next.config.ts`의 `images.remotePatterns` 설정 필요
|
||||
- **효과**: LCP 개선, 자동 lazy loading, WebP 변환
|
||||
|
||||
### A-2. DataTable 렌더링 최적화
|
||||
- **문제**: `src/components/organisms/DataTable.tsx:254` — 행마다 인라인 함수 생성
|
||||
```tsx
|
||||
// 현재: 매 렌더마다 새 함수 100개 생성
|
||||
onClick={() => onRowClick?.(row)}
|
||||
```
|
||||
- **작업**:
|
||||
1. TableRow를 별도 컴포넌트로 추출 + `React.memo` 적용
|
||||
2. `onRowClick` 핸들러를 `useCallback`으로 감싸기
|
||||
3. 행 데이터 비교를 위한 커스텀 비교 함수 작성
|
||||
- **관련 파일**:
|
||||
- `src/components/organisms/DataTable.tsx`
|
||||
- `src/components/business/construction/estimates/sections/EstimateDetailTableSection.tsx` (행당 6+ 인라인 함수)
|
||||
- `src/components/business/construction/progress-billing/tables/PhotoTable.tsx`
|
||||
- **효과**: 대형 테이블 30~50% 재렌더 감소
|
||||
| # | 항목 | 상태 | 비고 |
|
||||
|---|------|------|------|
|
||||
| A-1 | `<img>` → `next/image` 전환 | ✅ **전환 불필요 결정** | 폐쇄형 ERP, 전량 외부 동적 이미지, blob URL 비호환 (`_index.md` 참조) |
|
||||
| A-2 | DataTable 렌더링 최적화 | ⏳ 대기 | TableRow memo + useCallback |
|
||||
|
||||
---
|
||||
|
||||
## Phase B: 단기 개선 (1주)
|
||||
## Phase B: 단기 개선 — ✅ 완료
|
||||
|
||||
### B-1. `next/dynamic` 코드 스플리팅 도입
|
||||
- **문제**: `dynamic()` 사용 0건 — 전체 앱이 단일 번들로 로드
|
||||
- **우선 적용 대상**:
|
||||
| 컴포넌트 | 줄 수 | 이유 |
|
||||
|---------|-------|------|
|
||||
| `MainDashboard.tsx` | 2,651 | recharts (~60KB) 포함, 대시보드 미방문 시 불필요 |
|
||||
| `WorkerScreen/index.tsx` | 1,439 | 생산직 전용, 일반 사용자 불필요 |
|
||||
| 각종 모달 컴포넌트 | 다수 | 열기 전까지 불필요 |
|
||||
- **작업 예시**:
|
||||
```tsx
|
||||
import dynamic from 'next/dynamic';
|
||||
const MainDashboard = dynamic(
|
||||
() => import('@/components/business/MainDashboard'),
|
||||
{ loading: () => <DashboardSkeleton />, ssr: false }
|
||||
);
|
||||
```
|
||||
- **효과**: 초기 번들 사이즈 대폭 감소, 페이지별 로딩 속도 개선
|
||||
|
||||
### B-2. API 병렬 호출 적용
|
||||
- **문제**: `Promise.all` 사용 0건 — 독립적인 API 호출이 순차 실행될 가능성
|
||||
- **우선 점검 대상**:
|
||||
- 대시보드 초기 데이터 로딩 (5~10개 API)
|
||||
- 상세 페이지 초기 데이터 + 공통코드 + 마스터데이터
|
||||
- 폼 페이지 초기값 + 선택지 목록
|
||||
- **작업**:
|
||||
```tsx
|
||||
// Before: 순차
|
||||
const categories = await fetchCategories();
|
||||
const units = await fetchUnits();
|
||||
const codes = await fetchCommonCodes();
|
||||
|
||||
// After: 병렬
|
||||
const [categories, units, codes] = await Promise.all([
|
||||
fetchCategories(),
|
||||
fetchUnits(),
|
||||
fetchCommonCodes(),
|
||||
]);
|
||||
```
|
||||
- **효과**: 초기 데이터 로딩 30~40% 단축
|
||||
|
||||
### B-3. `store/` vs `stores/` 디렉토리 통합
|
||||
- **문제**: Zustand 스토어가 두 디렉토리에 분산
|
||||
- `src/store/` — menuStore, themeStore, demoStore (3개)
|
||||
- `src/stores/` — itemStore, masterDataStore, useItemMasterStore (3개)
|
||||
- **작업**:
|
||||
1. `src/store/` 내용을 `src/stores/`로 이동
|
||||
2. import 경로 일괄 수정
|
||||
3. `src/store/` 디렉토리 삭제
|
||||
- **추가 점검**: ThemeContext ↔ themeStore 중복 → 하나로 통합
|
||||
| # | 항목 | 상태 | 비고 |
|
||||
|---|------|------|------|
|
||||
| B-1 | `next/dynamic` 코드 스플리팅 | ✅ **완료** | 대시보드 4개 + xlsx 동적 로드, ~850KB 절감 (`_index.md` 참조) |
|
||||
| B-2 | API 병렬 호출 (`Promise.all`) | ✅ **완료** | |
|
||||
| B-3 | `store/` vs `stores/` 통합 | ✅ **완료** | |
|
||||
|
||||
---
|
||||
|
||||
## Phase C: 중기 개선 (2~3주)
|
||||
## Phase C: 중기 개선 — ✅ 완료
|
||||
|
||||
### C-1. 대형 테이블 가상화 (react-window)
|
||||
- **문제**: 100행 이상 테이블에서 전체 DOM 렌더 — 스크롤 성능 저하
|
||||
- **대상**:
|
||||
- `src/components/templates/IntegratedListTemplateV2.tsx` (1,086줄)
|
||||
- `src/components/templates/UniversalListPage/index.tsx` (1,006줄)
|
||||
- `src/components/organisms/DataTable.tsx`
|
||||
- **작업**:
|
||||
1. `react-window` 패키지 설치
|
||||
2. DataTable 내부에 `FixedSizeList` 또는 `VariableSizeList` 적용
|
||||
3. 기존 페이지네이션과 조합 (50건/페이지 + 가상화)
|
||||
- **주의**: 테이블 헤더 고정, 체크박스 선택, rowSpan 등 기존 기능 호환 필요
|
||||
- **효과**: 대용량 테이블 렌더 10배 이상 개선
|
||||
|
||||
### C-2. SWR 또는 React Query 도입
|
||||
- **문제**: API 캐싱 전략 없음 — 중복 요청, stale 데이터 가능
|
||||
- **권장**: SWR (가볍고 Next.js 팀 제작)
|
||||
- **적용 범위**:
|
||||
1. **1단계**: 공통코드/마스터데이터 (변경 빈도 낮음, 캐싱 효과 큼)
|
||||
2. **2단계**: 리스트 페이지 데이터
|
||||
3. **3단계**: 상세 페이지 데이터
|
||||
- **기대 효과**:
|
||||
- 자동 중복 요청 제거
|
||||
- stale-while-revalidate로 체감 속도 개선
|
||||
- 포커스 복귀 시 자동 재검증
|
||||
- **작업량**: 6~8시간 (기본 설정 + 공통코드 적용)
|
||||
|
||||
### C-3. Action 팩토리 패턴 확대
|
||||
- **문제**: 81개 `actions.ts` 파일에서 15~20% 코드 중복
|
||||
- **현황**: `src/lib/api/create-crud-service.ts` (177줄) 존재하지만 미활용
|
||||
- **작업**:
|
||||
1. 기존 팩토리 분석 및 확장
|
||||
2. 도메인별 actions를 팩토리 기반으로 전환
|
||||
3. 커스텀 로직만 오버라이드
|
||||
- **우선 적용**: 가장 단순한 CRUD 도메인부터 (clients, vendors 등)
|
||||
|
||||
### C-4. V1/V2 컴포넌트 정리
|
||||
- **문제**: 12+개 파일이 V1/V2 중복 존재
|
||||
- **대상 파일** (예시):
|
||||
- `ClientDetailClient.tsx` ↔ `ClientDetailClientV2.tsx`
|
||||
- `BadDebtDetail.tsx` ↔ `BadDebtDetailClientV2.tsx`
|
||||
- `QuoteRegistration.tsx` ↔ `QuoteRegistrationV2.tsx`
|
||||
- `InspectionModal.tsx` ↔ `InspectionModalV2.tsx`
|
||||
- **작업**:
|
||||
1. 각 V1/V2 쌍의 실제 사용처 확인 (import 추적)
|
||||
2. V2를 최종본으로 확정
|
||||
3. V1 참조를 V2로 전환
|
||||
4. V1 파일 삭제 + V2에서 "V2" 접미사 제거
|
||||
| # | 항목 | 상태 | 비고 |
|
||||
|---|------|------|------|
|
||||
| C-1 | 테이블 가상화 (react-window) | ✅ **보류 결정** | 페이지네이션 사용 중, YAGNI (`_index.md` 참조) |
|
||||
| C-2 | SWR / React Query | ✅ **보류 결정** | Zustand 캐싱 충족 (`_index.md` 참조) |
|
||||
| C-3 | Action 팩토리 패턴 확대 | ✅ **규칙 확정** | 신규 CRUD만 팩토리 사용 (`_index.md` 참조) |
|
||||
| C-4 | V1/V2 컴포넌트 정리 | ✅ **완료** | V2 최종본 확정, V1 삭제, 접미사 제거 |
|
||||
|
||||
---
|
||||
|
||||
## Phase D: 장기 개선 (필요 시)
|
||||
|
||||
### D-1. God 컴포넌트 분리
|
||||
| 파일 | 줄 수 | 분리 방안 |
|
||||
|------|-------|----------|
|
||||
| `MainDashboard.tsx` | 2,651 | DashboardShell + ChartSection + StatSection + FilterSection |
|
||||
| `ItemMasterContext.tsx` | 2,701 | PageContext + SectionContext + FieldContext + BOMContext |
|
||||
| `item-master.ts` (API) | 2,232 | pages.ts + sections.ts + fields.ts + bom.ts |
|
||||
| `QuoteRegistration.tsx` | 1,251 | LocationPanel + PricingPanel + LineItemsPanel |
|
||||
| `WorkerScreen/index.tsx` | 1,439 | ProcessSection + MaterialSection + IssueSection |
|
||||
|
||||
### D-2. `as` 타입 캐스트 점진적 제거 (926건)
|
||||
- 주요 집중 영역: API 트랜스포머, 컴포넌트 props
|
||||
- 제네릭 타입 활용으로 캐스트 대체
|
||||
- 도메인별 점진적 개선 (items → quotes → accounting 순)
|
||||
|
||||
### D-3. `@deprecated` 함수 정리 (13파일)
|
||||
- deprecated 선언했지만 아직 import되는 함수들 제거
|
||||
- ItemMasterContext 내 deprecated 메서드 마이그레이션
|
||||
|
||||
### D-4. Molecules 레이어 활성화
|
||||
- 현재 8개만 존재, 대부분 도메인 컴포넌트가 UI 직접 사용
|
||||
- 반복되는 UI 패턴을 Molecules로 추출
|
||||
- FormField, StatusBadge, DateRangeSelector 활용도 높이기
|
||||
|
||||
### D-5. 모달 컴포넌트 통합 (47+개)
|
||||
- SearchableSelectionModal 패턴으로 통합 가능한 모달 식별
|
||||
- 도메인별 재구현된 유사 모달을 공통 컴포넌트로 전환
|
||||
|
||||
### D-6. 기타
|
||||
- TODO/FIXME 102건 정리 (useItemMasterStore 15건, DraftBox 24건 집중)
|
||||
- `reactStrictMode: true`로 복원 (개발 환경)
|
||||
- puppeteer/chromium 패키지 활성 사용 여부 확인 → 미사용 시 제거
|
||||
- error-handler.ts의 `SHOW_ERROR_CODE` 환경변수로 전환
|
||||
| # | 항목 | 상태 |
|
||||
|---|------|------|
|
||||
| D-1 | God 컴포넌트 분리 (5개, 1200~2700줄) | ⏳ 대기 |
|
||||
| D-2 | `as` 타입 캐스트 점진적 제거 (926건) | ✅ **보류 결정** | 실제 ~200건만 actionable, 신규 코드에서 제네릭 활용 (2026-02-11) |
|
||||
| D-3 | `@deprecated` 함수 정리 (13파일) | ✅ **즉시 삭제분 완료** | uploadFile/deleteFile/getSiteNames/deprecated props 삭제 (2026-02-11) |
|
||||
| D-4 | Molecules 레이어 활성화 | ✅ **보류 결정** | 사용률 ~0%, organisms/templates로 충분 (2026-02-11) |
|
||||
| D-5 | 모달 컴포넌트 통합 | ✅ **완료** | InspectionPreviewModal → DocumentViewer 전환 (2026-02-11) |
|
||||
| D-6 | 기타 (TODO 102건, strictMode 등) | ⏳ 대기 |
|
||||
|
||||
---
|
||||
|
||||
@@ -200,8 +67,6 @@
|
||||
## 우선순위 요약
|
||||
|
||||
```
|
||||
즉시 (Phase A) → img 최적화, DataTable 최적화
|
||||
단기 (Phase B) → 코드 스플리팅, API 병렬화, 스토어 통합
|
||||
중기 (Phase C) → 가상화, 캐싱, Action 팩토리, V2 정리
|
||||
장기 (Phase D) → God 컴포넌트 분리, 타입 안전성, 모달 통합
|
||||
Phase A~C: ✅ 전체 완료/결정 완료 (A-2 DataTable 최적화만 대기)
|
||||
Phase D: 남은 작업 → A-2, D-1, D-6
|
||||
```
|
||||
|
||||
@@ -76,6 +76,62 @@ export async function downloadExcel(...) {
|
||||
|
||||
**총 절감**: 초기 번들에서 ~850KB 제외 (대시보드 미방문 + 엑셀 미사용 시)
|
||||
|
||||
### 테이블 가상화 (react-window) — 보류 (2026-02-10)
|
||||
|
||||
**결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토
|
||||
|
||||
**근거**:
|
||||
1. **페이지네이션 사용 중** — 리스트 페이지 대부분 서버 사이드 페이지네이션 (20~50건/페이지). 50개 `<tr>`은 브라우저가 문제없이 처리
|
||||
2. **적용 복잡도 높음** — 테이블 헤더 고정, 체크박스 선택, `rowSpan/colSpan` 병합 등 기존 기능과 충돌 가능. DataTable + IntegratedListTemplateV2 + UniversalListPage 전부 수정 필요
|
||||
3. **YAGNI** — 500건 이상 한 번에 렌더링하는 페이지가 현재 없음
|
||||
|
||||
**도입 시점**: 한 페이지에 200건+ 데이터를 페이지네이션 없이 표시해야 하는 요구가 생길 때
|
||||
|
||||
### SWR / React Query — 보류 (2026-02-10)
|
||||
|
||||
**결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토
|
||||
|
||||
**근거**:
|
||||
1. **기존 패턴 안정화 완료** — `useEffect` + Server Action 호출 패턴이 전 페이지에 일관 적용됨
|
||||
2. **캐싱 니즈 낮음** — 폐쇄형 ERP 특성상 항상 최신 데이터 필요. stale 데이터 표시는 오히려 위험
|
||||
3. **마스터데이터 캐싱 구현됨** — Zustand (`stores/masterDataStore`)로 변경 빈도 낮은 데이터는 이미 캐싱 중
|
||||
4. **도입 비용 과다** — 수십 개 페이지 `useState`+`useEffect` 패턴 전면 리팩토링 + 팀 학습 비용
|
||||
|
||||
**도입 시점**: 동일 데이터를 여러 컴포넌트에서 동시 요구하거나, 목록 ↔ 상세 이동 시 재로딩이 체감될 때
|
||||
|
||||
### Action 팩토리 패턴 — 신규 CRUD 적용 규칙 (2026-02-10)
|
||||
|
||||
**결정**: 기존 84개 actions.ts 전면 전환은 하지 않음. **신규 CRUD 도메인에만 팩토리 사용**
|
||||
|
||||
**현황**:
|
||||
- `src/lib/api/create-crud-service.ts` (177줄) — CRUD 보일러플레이트 자동 생성 팩토리
|
||||
- 현재 사용 중: TitleManagement, RankManagement (2개)
|
||||
- 전환 가능: 15~20개 / 전환 불가 (커스텀 로직): 50+개
|
||||
|
||||
**규칙**:
|
||||
- 신규 도메인 추가 시 단순 CRUD → `createCrudService` 사용 필수
|
||||
- 기존 actions.ts는 잘 동작하므로 무리하게 전환하지 않음
|
||||
- 커스텀 비즈니스 로직이 있는 도메인(견적, 수주, 생산 등)은 팩토리 비적합
|
||||
|
||||
**사용 예시**:
|
||||
```typescript
|
||||
import { createCrudService } from '@/lib/api/create-crud-service';
|
||||
|
||||
const service = createCrudService<ApiData, FrontendType>({
|
||||
basePath: '/api/v1/resources',
|
||||
transform: (api) => ({ id: api.id, name: api.name }),
|
||||
entityName: '리소스',
|
||||
});
|
||||
|
||||
export const getList = service.getList;
|
||||
export const getById = service.getById;
|
||||
export const create = service.create;
|
||||
export const update = service.update;
|
||||
export const remove = service.remove;
|
||||
```
|
||||
|
||||
**미전환 사유**: 84개 중 전환 가능 15~20개, 작업 2~4시간 대비 기능 변화 없음. 시간 대비 효율 낮음
|
||||
|
||||
---
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
// V2 테스트: 새 훅 적용 버전
|
||||
import { BillDetailV2 as BillDetail } from '@/components/accounting/BillManagement/BillDetailV2';
|
||||
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
|
||||
|
||||
export default function BillDetailPage() {
|
||||
const params = useParams();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
// V2: 새 훅 적용 버전
|
||||
import { BillDetailV2 as BillDetail } from '@/components/accounting/BillManagement/BillDetailV2';
|
||||
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
|
||||
|
||||
export default function BillNewPage() {
|
||||
return <BillDetail billId="new" mode="new" />;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { BillManagementClient } from '@/components/accounting/BillManagement/BillManagementClient';
|
||||
// V2 테스트: 새 훅 적용 버전
|
||||
import { BillDetailV2 as BillDetail } from '@/components/accounting/BillManagement/BillDetailV2';
|
||||
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
|
||||
import { getBills } from '@/components/accounting/BillManagement/actions';
|
||||
import type { BillRecord } from '@/components/accounting/BillManagement/types';
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ function transformApiToPost(apiData: PostApiData): BoardPost {
|
||||
}
|
||||
|
||||
export default function BoardCodePage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const boardCode = params.boardCode as string;
|
||||
@@ -91,6 +90,13 @@ export default function BoardCodePage() {
|
||||
return <BoardDetail {...{ boardCode, mode: 'create' } as any} />;
|
||||
}
|
||||
|
||||
return <BoardListContent boardCode={boardCode} />;
|
||||
}
|
||||
|
||||
// 실제 목록 컴포넌트 (자체 hooks 사용 - React 훅 규칙 준수)
|
||||
function BoardListContent({ boardCode }: { boardCode: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
// 게시판 정보
|
||||
const [boardName, setBoardName] = useState<string>('게시판');
|
||||
const [boardDescription, setBoardDescription] = useState<string>('');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { LaborDetailClientV2 } from '@/components/business/construction/labor-management';
|
||||
import { LaborDetailClient } from '@/components/business/construction/labor-management';
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
|
||||
|
||||
interface LaborDetailPageProps {
|
||||
@@ -15,5 +15,5 @@ export default function LaborDetailPage({ params }: LaborDetailPageProps) {
|
||||
const mode = searchParams.get('mode');
|
||||
const initialMode: DetailMode = mode === 'edit' ? 'edit' : 'view';
|
||||
|
||||
return <LaborDetailClientV2 laborId={id} initialMode={initialMode} />;
|
||||
return <LaborDetailClient laborId={id} initialMode={initialMode} />;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { LaborDetailClientV2 } from '@/components/business/construction/labor-management';
|
||||
import { LaborDetailClient } from '@/components/business/construction/labor-management';
|
||||
|
||||
export default function LaborNewPage() {
|
||||
return <LaborDetailClientV2 initialMode="create" />;
|
||||
return <LaborDetailClient initialMode="create" />;
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { LaborManagementClient, LaborDetailClientV2 } from '@/components/business/construction/labor-management';
|
||||
import { LaborManagementClient, LaborDetailClient } from '@/components/business/construction/labor-management';
|
||||
|
||||
export default function LaborManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <LaborDetailClientV2 initialMode="create" />;
|
||||
return <LaborDetailClient initialMode="create" />;
|
||||
}
|
||||
|
||||
return <LaborManagementClient />;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
|
||||
import { PricingDetailClient } from '@/components/business/construction/pricing-management';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -18,5 +18,5 @@ export default function PricingDetailPage({ params }: PageProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
return <PricingDetailClientV2 pricingId={id} initialMode={mode} />;
|
||||
return <PricingDetailClient pricingId={id} initialMode={mode} />;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
|
||||
import { PricingDetailClient } from '@/components/business/construction/pricing-management';
|
||||
|
||||
export default function PricingNewPage() {
|
||||
return <PricingDetailClientV2 initialMode="create" />;
|
||||
return <PricingDetailClient initialMode="create" />;
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import PricingListClient from '@/components/business/construction/pricing-management/PricingListClient';
|
||||
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
|
||||
import { PricingDetailClient } from '@/components/business/construction/pricing-management';
|
||||
|
||||
export default function PricingPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <PricingDetailClientV2 initialMode="create" />;
|
||||
return <PricingDetailClient initialMode="create" />;
|
||||
}
|
||||
|
||||
return <PricingListClient />;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { LayoutDashboard } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { LayoutDashboard, RefreshCw } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { RollingText, type RollingItem } from './RollingText';
|
||||
import { OverviewTab } from './tabs/OverviewTab';
|
||||
@@ -11,15 +12,7 @@ import { FinanceTab } from './tabs/FinanceTab';
|
||||
import { SalesTab } from './tabs/SalesTab';
|
||||
import { ExpenseTab } from './tabs/ExpenseTab';
|
||||
import { ScheduleTab } from './tabs/ScheduleTab';
|
||||
import {
|
||||
scheduleStats,
|
||||
overviewStats,
|
||||
financeStats,
|
||||
salesStats,
|
||||
expenseStats,
|
||||
scheduleTodayItems,
|
||||
scheduleIssueItems,
|
||||
} from './mockData';
|
||||
import { useDashboardType2, getRollingItems } from './hooks/useDashboardType2';
|
||||
|
||||
interface TabConfig {
|
||||
value: string;
|
||||
@@ -28,42 +21,12 @@ interface TabConfig {
|
||||
rollingItems: RollingItem[];
|
||||
}
|
||||
|
||||
const toRolling = (stats: { label: string; value: string; color?: string }[]): RollingItem[] =>
|
||||
stats.map((s) => ({ label: s.label, value: s.value, color: (s.color ?? 'default') as RollingItem['color'] }));
|
||||
|
||||
const TABS: TabConfig[] = [
|
||||
{
|
||||
value: 'schedule',
|
||||
label: '일정/이슈',
|
||||
badge: scheduleTodayItems.length + scheduleIssueItems.length,
|
||||
rollingItems: toRolling(scheduleStats),
|
||||
},
|
||||
{
|
||||
value: 'overview',
|
||||
label: '전체 요약',
|
||||
rollingItems: toRolling(overviewStats),
|
||||
},
|
||||
{
|
||||
value: 'finance',
|
||||
label: '재무 관리',
|
||||
rollingItems: toRolling(financeStats),
|
||||
},
|
||||
{
|
||||
value: 'sales',
|
||||
label: '영업/매출',
|
||||
rollingItems: toRolling(salesStats),
|
||||
},
|
||||
{
|
||||
value: 'expense',
|
||||
label: '경비 관리',
|
||||
rollingItems: toRolling(expenseStats),
|
||||
},
|
||||
];
|
||||
|
||||
export function DashboardType2() {
|
||||
const [activeTab, setActiveTab] = useState('schedule');
|
||||
const [globalTick, setGlobalTick] = useState(0);
|
||||
|
||||
const data = useDashboardType2();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setGlobalTick((prev) => prev + 1);
|
||||
@@ -71,33 +34,89 @@ export function DashboardType2() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const tabs = useMemo<TabConfig[]>(() => [
|
||||
{
|
||||
value: 'schedule',
|
||||
label: '일정/이슈',
|
||||
badge: data.schedule.todayItems.length + data.schedule.issueItems.length || undefined,
|
||||
rollingItems: getRollingItems('schedule', data),
|
||||
},
|
||||
{
|
||||
value: 'overview',
|
||||
label: '전체 요약',
|
||||
rollingItems: getRollingItems('overview', data),
|
||||
},
|
||||
{
|
||||
value: 'finance',
|
||||
label: '재무 관리',
|
||||
rollingItems: getRollingItems('finance', data),
|
||||
},
|
||||
{
|
||||
value: 'sales',
|
||||
label: '영업/매출',
|
||||
rollingItems: getRollingItems('sales', data),
|
||||
},
|
||||
{
|
||||
value: 'expense',
|
||||
label: '경비 관리',
|
||||
rollingItems: getRollingItems('expense', data),
|
||||
},
|
||||
], [data]);
|
||||
|
||||
const hasError = data.overview.error || data.finance.error || data.sales.error || data.expense.error || data.schedule.error;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="p-3 md:p-6">
|
||||
<PageHeader
|
||||
title="대시보드"
|
||||
description="주요 경영 지표를 한눈에 확인합니다."
|
||||
icon={LayoutDashboard}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<PageHeader
|
||||
title="대시보드"
|
||||
description="주요 경영 지표를 한눈에 확인합니다."
|
||||
icon={LayoutDashboard}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={data.refetchAll}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5 rounded-md hover:bg-muted"
|
||||
title="데이터 새로고침"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">새로고침</span>
|
||||
</button>
|
||||
<DashboardSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasError && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-yellow-50 border border-yellow-200 text-yellow-800 text-sm flex items-center justify-between">
|
||||
<span>일부 데이터를 불러오지 못했습니다. 임시 데이터가 표시될 수 있습니다.</span>
|
||||
<button
|
||||
onClick={data.refetchAll}
|
||||
className="text-yellow-700 hover:text-yellow-900 font-medium underline ml-2"
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="bg-transparent border-b rounded-none w-full gap-0 p-0 h-auto justify-start flex-wrap">
|
||||
{TABS.map((tab) => (
|
||||
<TabsList className="bg-transparent border-b-2 border-border rounded-none w-full gap-0 p-0 h-auto justify-start flex-wrap">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="relative flex-none rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-3 py-2.5 lg:px-5 lg:py-3.5 text-sm lg:text-base font-medium text-muted-foreground data-[state=active]:text-primary whitespace-nowrap"
|
||||
className="relative flex-1 md:flex-none rounded-none border-b-[3px] border-transparent data-[state=active]:border-primary data-[state=active]:bg-primary/5 data-[state=active]:shadow-none px-2 py-2.5 md:px-5 md:py-4 lg:px-7 lg:py-5 xl:px-8 xl:py-5 text-xs md:text-sm lg:text-base xl:text-lg font-semibold text-muted-foreground data-[state=active]:text-primary whitespace-nowrap transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex items-center gap-1 md:gap-1.5 lg:gap-2">
|
||||
<span>{tab.label}</span>
|
||||
{tab.badge != null && tab.badge > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-bold leading-none">
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] md:min-w-[20px] md:h-[20px] lg:min-w-[22px] lg:h-[22px] px-1 rounded-full bg-red-500 text-white text-[10px] md:text-[11px] lg:text-xs font-bold leading-none">
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`hidden xl:inline-flex items-center gap-1.5 ml-1.5 w-[180px] overflow-hidden ${activeTab === tab.value ? 'invisible' : ''}`}>
|
||||
<span className={`hidden xl:inline-flex items-center gap-1.5 ml-2 w-[200px] overflow-hidden ${activeTab === tab.value ? 'invisible' : ''}`}>
|
||||
<span className="text-muted-foreground/40 flex-shrink-0">|</span>
|
||||
<span className="flex-1 overflow-hidden"><RollingText items={tab.rollingItems} globalTick={globalTick} /></span>
|
||||
</span>
|
||||
@@ -106,11 +125,21 @@ export function DashboardType2() {
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-6">
|
||||
<TabsContent value="schedule"><ScheduleTab /></TabsContent>
|
||||
<TabsContent value="overview"><OverviewTab /></TabsContent>
|
||||
<TabsContent value="finance"><FinanceTab /></TabsContent>
|
||||
<TabsContent value="sales"><SalesTab /></TabsContent>
|
||||
<TabsContent value="expense"><ExpenseTab /></TabsContent>
|
||||
<TabsContent value="schedule">
|
||||
<ScheduleTab data={data.schedule} />
|
||||
</TabsContent>
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab data={data.overview} />
|
||||
</TabsContent>
|
||||
<TabsContent value="finance">
|
||||
<FinanceTab data={data.finance} />
|
||||
</TabsContent>
|
||||
<TabsContent value="sales">
|
||||
<SalesTab data={data.sales} />
|
||||
</TabsContent>
|
||||
<TabsContent value="expense">
|
||||
<ExpenseTab data={data.expense} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -10,11 +10,19 @@ const colorMap: Record<string, string> = {
|
||||
orange: 'text-orange-600',
|
||||
};
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
default: 'bg-card border',
|
||||
green: 'bg-green-50 border-green-200',
|
||||
blue: 'bg-blue-50 border-blue-200',
|
||||
red: 'bg-red-50 border-red-200',
|
||||
orange: 'bg-orange-50 border-orange-200',
|
||||
};
|
||||
|
||||
export function StatCards({ stats }: { stats: StatCard[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((stat, i) => (
|
||||
<div key={i} className="bg-card border rounded-xl p-4">
|
||||
<div key={i} className={`${bgMap[stat.color ?? 'default']} rounded-xl p-4`}>
|
||||
<p className="text-xs md:text-sm text-muted-foreground mb-1 whitespace-nowrap">{stat.label}</p>
|
||||
<p className={`text-lg md:text-xl font-bold ${colorMap[stat.color ?? 'default']}`}>
|
||||
{stat.value}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { ExpenseChartItem } from '../hooks/transformers';
|
||||
|
||||
function formatTooltipValue(value: number): string {
|
||||
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억원`;
|
||||
if (value >= 10000) return `${Math.round(value / 10000).toLocaleString()}만원`;
|
||||
return `${value.toLocaleString()}원`;
|
||||
}
|
||||
|
||||
export function ExpenseDonutChart({ data }: { data: ExpenseChartItem[] }) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
// recharts expects plain objects with index signature
|
||||
const chartData = data.map((d) => ({ name: d.name, value: d.value, color: d.color }));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">지출 항목별 비율</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number | string | undefined) => formatTooltipValue(Number(value ?? 0))}
|
||||
contentStyle={{ fontSize: '12px', borderRadius: '8px' }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip, Cell } from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { OverviewChartItem } from '../hooks/transformers';
|
||||
|
||||
function formatTooltipValue(value: number): string {
|
||||
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억원`;
|
||||
if (value >= 10000) return `${Math.round(value / 10000).toLocaleString()}만원`;
|
||||
return `${value.toLocaleString()}원`;
|
||||
}
|
||||
|
||||
export function OverviewSummaryChart({ data }: { data: OverviewChartItem[] }) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const chartData = data.map((d) => ({ name: d.name, 금액: d.금액, color: d.color }));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">주요 재무 지표</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} tickFormatter={(v: number) => formatTooltipValue(v)} />
|
||||
<YAxis dataKey="name" type="category" tick={{ fontSize: 12 }} width={80} />
|
||||
<Tooltip
|
||||
formatter={(value: number | string | undefined) => formatTooltipValue(Number(value ?? 0))}
|
||||
contentStyle={{ fontSize: '12px', borderRadius: '8px' }}
|
||||
/>
|
||||
<Bar dataKey="금액" radius={[0, 4, 4, 0]} maxBarSize={24}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { ReceivableChartItem } from '../hooks/transformers';
|
||||
|
||||
function formatTooltipValue(value: number): string {
|
||||
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억원`;
|
||||
if (value >= 10000) return `${Math.round(value / 10000).toLocaleString()}만원`;
|
||||
return `${value.toLocaleString()}원`;
|
||||
}
|
||||
|
||||
export function ReceivableBarChart({ data }: { data: ReceivableChartItem[] }) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const chartData = data.map((d) => ({ name: d.name, 매출액: d.매출액, 미수금: d.미수금 }));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">매출 vs 미수금 비교</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v: number) => formatTooltipValue(v)} width={70} />
|
||||
<Tooltip
|
||||
formatter={(value: number | string | undefined) => formatTooltipValue(Number(value ?? 0))}
|
||||
contentStyle={{ fontSize: '12px', borderRadius: '8px' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
<Bar dataKey="매출액" fill="#3b82f6" radius={[4, 4, 0, 0]} maxBarSize={30} />
|
||||
<Bar dataKey="미수금" fill="#f97316" radius={[4, 4, 0, 0]} maxBarSize={30} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Dashboard Type2 데이터 변환 함수
|
||||
* CEO Dashboard API 데이터 → dashboard_type2 StatCard/TableRow 변환
|
||||
*/
|
||||
|
||||
import type { StatCard, TableRow } from '../mockData';
|
||||
import type {
|
||||
AmountCard,
|
||||
DailyReportData,
|
||||
ReceivableData,
|
||||
DebtCollectionData,
|
||||
MonthlyExpenseData,
|
||||
CardManagementData,
|
||||
EntertainmentData,
|
||||
WelfareData,
|
||||
} from '@/components/business/CEODashboard/types';
|
||||
import type { TodayIssueData } from '@/hooks/useCEODashboard';
|
||||
|
||||
// ============================================
|
||||
// 금액 포맷 헬퍼
|
||||
// ============================================
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
const absAmount = Math.abs(amount);
|
||||
const sign = amount < 0 ? '-' : '';
|
||||
if (absAmount >= 100000000) {
|
||||
const value = (absAmount / 100000000).toFixed(1);
|
||||
return `${sign}${value}억`;
|
||||
} else if (absAmount >= 10000) {
|
||||
return `${sign}${Math.round(absAmount / 10000).toLocaleString()}만`;
|
||||
}
|
||||
return `${sign}${absAmount.toLocaleString()}원`;
|
||||
}
|
||||
|
||||
function formatAmountWon(amount: number): string {
|
||||
const absAmount = Math.abs(amount);
|
||||
const sign = amount < 0 ? '-' : '';
|
||||
if (absAmount >= 100000000) {
|
||||
const value = (absAmount / 100000000).toFixed(1);
|
||||
return `${sign}${value}억원`;
|
||||
} else if (absAmount >= 10000) {
|
||||
return `${sign}${Math.round(absAmount / 10000).toLocaleString()}만원`;
|
||||
}
|
||||
return `${sign}${absAmount.toLocaleString()}원`;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return amount.toLocaleString();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AmountCard → StatCard 변환
|
||||
// ============================================
|
||||
|
||||
function cardColorFromId(id: string): StatCard['color'] {
|
||||
// 기본 색상 매핑 - ID 패턴 기반
|
||||
if (id.includes('cash') || id === 'dr1') return 'blue';
|
||||
if (id.includes('income') || id === 'dr3') return 'green';
|
||||
if (id.includes('expense') || id === 'dr4' || id.includes('debt')) return 'red';
|
||||
if (id.includes('foreign') || id === 'dr2') return 'default';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function amountCardToStatCard(card: AmountCard, colorOverride?: StatCard['color']): StatCard {
|
||||
let value: string;
|
||||
if (card.currency === 'USD') {
|
||||
value = card.amount === 0 ? '$0' : `$${formatCurrency(card.amount)}`;
|
||||
} else if (card.unit === '곳' || card.unit === '건') {
|
||||
value = `${card.amount}${card.unit}`;
|
||||
} else {
|
||||
value = card.amount === 0 ? '0원' : formatAmountWon(card.amount);
|
||||
}
|
||||
|
||||
return {
|
||||
label: card.label,
|
||||
value,
|
||||
color: colorOverride ?? cardColorFromId(card.id),
|
||||
change: card.changeRate,
|
||||
changeDirection: card.changeDirection,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 전체요약 탭 변환
|
||||
// ============================================
|
||||
|
||||
export function transformOverviewStats(dailyReport: DailyReportData | null): StatCard[] {
|
||||
if (!dailyReport) return [];
|
||||
return dailyReport.cards.map((card) => {
|
||||
let color: StatCard['color'] = 'default';
|
||||
if (card.id === 'dr1') color = 'blue';
|
||||
else if (card.id === 'dr3') color = 'green';
|
||||
else if (card.id === 'dr4') color = 'orange';
|
||||
return amountCardToStatCard(card, color);
|
||||
});
|
||||
}
|
||||
|
||||
export function transformOverviewRecentOrders(
|
||||
receivable: ReceivableData | null,
|
||||
): TableRow[] {
|
||||
if (!receivable) return [];
|
||||
// 미수금 체크포인트에서 간단한 요약 테이블 생성
|
||||
// 실제로는 API에서 최근 수주 데이터를 가져와야 하지만,
|
||||
// 현재 API 구조상 미수금 카드 데이터로 요약 생성
|
||||
return receivable.cards.map((card, idx) => ({
|
||||
no: idx + 1,
|
||||
항목: card.label,
|
||||
금액: card.amount === 0 ? '0원' : formatAmountWon(card.amount),
|
||||
비고: card.subLabel ?? '-',
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 영업/매출 탭 변환
|
||||
// ============================================
|
||||
|
||||
export function transformSalesStats(
|
||||
receivable: ReceivableData | null,
|
||||
debtCollection: DebtCollectionData | null,
|
||||
): StatCard[] {
|
||||
const stats: StatCard[] = [];
|
||||
|
||||
if (receivable) {
|
||||
// 누적 미수금
|
||||
const cumulative = receivable.cards.find((c) => c.id === 'rv1');
|
||||
if (cumulative) {
|
||||
stats.push({
|
||||
label: cumulative.label,
|
||||
value: formatAmount(cumulative.amount),
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
// 당월 미수금
|
||||
const monthly = receivable.cards.find((c) => c.id === 'rv2');
|
||||
if (monthly) {
|
||||
stats.push({
|
||||
label: monthly.label,
|
||||
value: formatAmount(monthly.amount),
|
||||
color: 'orange',
|
||||
});
|
||||
}
|
||||
// 거래처 현황
|
||||
const vendors = receivable.cards.find((c) => c.id === 'rv3');
|
||||
if (vendors) {
|
||||
stats.push({
|
||||
label: vendors.label,
|
||||
value: `${vendors.amount}${vendors.unit ?? '곳'}`,
|
||||
color: 'blue',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (debtCollection) {
|
||||
const collecting = debtCollection.cards.find((c) => c.id === 'dc2');
|
||||
if (collecting) {
|
||||
stats.push({
|
||||
label: '채권추심 중',
|
||||
value: formatAmount(collecting.amount),
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function transformReceivableTable(receivable: ReceivableData | null): TableRow[] {
|
||||
if (!receivable) return [];
|
||||
// 체크포인트에서 미수금 정보 추출하여 테이블 구성
|
||||
// 실제 미수금 상세 데이터가 없으므로 카드 데이터에서 생성
|
||||
const rows: TableRow[] = [];
|
||||
receivable.cards.forEach((card, idx) => {
|
||||
if (card.subItems) {
|
||||
card.subItems.forEach((sub) => {
|
||||
rows.push({
|
||||
no: rows.length + 1,
|
||||
항목: `${card.label} - ${sub.label}`,
|
||||
금액: typeof sub.value === 'number' ? formatCurrency(sub.value) : String(sub.value),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
no: idx + 1,
|
||||
항목: card.label,
|
||||
금액: formatCurrency(card.amount),
|
||||
});
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function transformDebtTable(debtCollection: DebtCollectionData | null): TableRow[] {
|
||||
if (!debtCollection) return [];
|
||||
return debtCollection.cards.map((card, idx) => ({
|
||||
no: idx + 1,
|
||||
항목: card.label,
|
||||
금액: formatAmountWon(card.amount),
|
||||
비고: card.subLabel ?? '-',
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 재무관리 탭 변환
|
||||
// ============================================
|
||||
|
||||
export function transformFinanceStats(dailyReport: DailyReportData | null): StatCard[] {
|
||||
if (!dailyReport) return [];
|
||||
return dailyReport.cards.map((card) => {
|
||||
let color: StatCard['color'] = 'default';
|
||||
if (card.id === 'dr1') color = card.amount < 0 ? 'red' : 'blue';
|
||||
else if (card.id === 'dr3') color = 'green';
|
||||
return amountCardToStatCard(card, color);
|
||||
});
|
||||
}
|
||||
|
||||
export function transformExpenseTable(monthlyExpense: MonthlyExpenseData | null): TableRow[] {
|
||||
if (!monthlyExpense) return [];
|
||||
return monthlyExpense.cards.map((card, idx) => ({
|
||||
no: idx + 1,
|
||||
항목: card.label,
|
||||
금액: card.amount === 0 ? '0원' : formatAmountWon(card.amount),
|
||||
전월대비: card.previousLabel ?? '-',
|
||||
비율: '-',
|
||||
}));
|
||||
}
|
||||
|
||||
export function transformCardTable(cardManagement: CardManagementData | null): TableRow[] {
|
||||
if (!cardManagement) return [];
|
||||
return cardManagement.cards.map((card, idx) => ({
|
||||
no: idx + 1,
|
||||
항목: card.label,
|
||||
금액: card.amount === 0 ? '0원' : formatAmountWon(card.amount),
|
||||
비고: card.previousLabel ?? card.subLabel ?? '-',
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 경비관리 탭 변환
|
||||
// ============================================
|
||||
|
||||
export function transformExpenseManagementStats(
|
||||
entertainment: EntertainmentData | null,
|
||||
welfare: WelfareData | null,
|
||||
): StatCard[] {
|
||||
const stats: StatCard[] = [];
|
||||
|
||||
if (entertainment) {
|
||||
const used = entertainment.cards.find((c) => c.id === 'et_used');
|
||||
const remaining = entertainment.cards.find((c) => c.id === 'et_remaining');
|
||||
if (used) {
|
||||
stats.push({
|
||||
label: '접대비 사용',
|
||||
value: formatAmountWon(used.amount),
|
||||
color: 'blue',
|
||||
});
|
||||
}
|
||||
if (remaining) {
|
||||
stats.push({
|
||||
label: '접대비 잔여한도',
|
||||
value: formatAmountWon(remaining.amount),
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (welfare) {
|
||||
const used = welfare.cards.find((c) => c.id === 'wf_used');
|
||||
const remaining = welfare.cards.find((c) => c.id === 'wf_remaining');
|
||||
if (used) {
|
||||
stats.push({
|
||||
label: '복리후생비 사용',
|
||||
value: used.amount === 0 ? '0원' : formatAmountWon(used.amount),
|
||||
color: 'default',
|
||||
});
|
||||
}
|
||||
if (remaining) {
|
||||
stats.push({
|
||||
label: '복리후생비 잔여한도',
|
||||
value: formatAmountWon(remaining.amount),
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function transformEntertainmentTable(entertainment: EntertainmentData | null): TableRow[] {
|
||||
if (!entertainment) return [];
|
||||
return entertainment.cards.map((card, idx) => ({
|
||||
no: idx + 1,
|
||||
항목: card.label,
|
||||
금액: card.amount === 0 ? '0원' : formatAmountWon(card.amount),
|
||||
비고: card.subLabel ?? '-',
|
||||
}));
|
||||
}
|
||||
|
||||
export function transformWelfareTable(welfare: WelfareData | null): TableRow[] {
|
||||
if (!welfare) return [];
|
||||
return welfare.cards.map((card, idx) => ({
|
||||
no: idx + 1,
|
||||
항목: card.label,
|
||||
금액: card.amount === 0 ? '0원' : formatAmountWon(card.amount),
|
||||
비고: card.subLabel ?? '-',
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 일정/이슈 탭 변환
|
||||
// ============================================
|
||||
|
||||
export function transformScheduleStats(
|
||||
todayIssue: TodayIssueData | null,
|
||||
): StatCard[] {
|
||||
if (!todayIssue) return [];
|
||||
return [
|
||||
{
|
||||
label: '오늘 이슈',
|
||||
value: `${todayIssue.totalCount}건`,
|
||||
color: 'blue' as const,
|
||||
},
|
||||
{
|
||||
label: '미처리 건수',
|
||||
value: `${todayIssue.items.filter((i) => i.needsApproval).length}건`,
|
||||
color: 'red' as const,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 롤링 텍스트용 변환
|
||||
// ============================================
|
||||
|
||||
export function statsToRollingItems(stats: StatCard[]) {
|
||||
return stats.map((s) => ({
|
||||
label: s.label,
|
||||
value: s.value,
|
||||
color: (s.color ?? 'default') as 'default' | 'green' | 'blue' | 'red' | 'orange',
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 차트 데이터 변환
|
||||
// ============================================
|
||||
|
||||
export interface ExpenseChartItem {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function transformExpenseChartData(monthlyExpense: MonthlyExpenseData | null): ExpenseChartItem[] {
|
||||
if (!monthlyExpense) return [];
|
||||
const colorMap: Record<string, string> = {
|
||||
매입: '#3b82f6',
|
||||
카드: '#f97316',
|
||||
발행어음: '#8b5cf6',
|
||||
'총 예상 지출 합계': '#94a3b8',
|
||||
인건비: '#10b981',
|
||||
운영비: '#8b5cf6',
|
||||
기타: '#94a3b8',
|
||||
};
|
||||
return monthlyExpense.cards
|
||||
.filter((card) => card.id !== 'me4') // 합계 제외
|
||||
.filter((card) => Number(card.amount) > 0) // 0원 제외
|
||||
.map((card) => ({
|
||||
name: card.label,
|
||||
value: Number(card.amount),
|
||||
color: colorMap[card.label] ?? '#94a3b8',
|
||||
}));
|
||||
}
|
||||
|
||||
export interface ReceivableChartItem {
|
||||
name: string;
|
||||
매출액: number;
|
||||
미수금: number;
|
||||
}
|
||||
|
||||
export function transformReceivableChartData(receivable: ReceivableData | null): ReceivableChartItem[] {
|
||||
if (!receivable) return [];
|
||||
// 카드별 데이터에서 매출/미수금 비교 차트 생성
|
||||
const rv1 = receivable.cards.find((c) => c.id === 'rv1');
|
||||
const rv2 = receivable.cards.find((c) => c.id === 'rv2');
|
||||
|
||||
if (!rv1?.subItems || !rv2?.subItems) return [];
|
||||
|
||||
const sales = rv1.subItems.find((s) => s.label === '매출');
|
||||
const deposits = rv1.subItems.find((s) => s.label === '입금');
|
||||
|
||||
if (!sales || !deposits) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
name: '매출',
|
||||
매출액: Number(sales.value) || 0,
|
||||
미수금: 0,
|
||||
},
|
||||
{
|
||||
name: '입금',
|
||||
매출액: Number(deposits.value) || 0,
|
||||
미수금: 0,
|
||||
},
|
||||
{
|
||||
name: '미수금',
|
||||
매출액: 0,
|
||||
미수금: Number(rv2.amount),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export interface OverviewChartItem {
|
||||
name: string;
|
||||
금액: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function transformOverviewChartData(dailyReport: DailyReportData | null): OverviewChartItem[] {
|
||||
if (!dailyReport) return [];
|
||||
const colorMap: Record<string, string> = {
|
||||
dr1: '#3b82f6', // 현금성 자산 - blue
|
||||
dr2: '#8b5cf6', // 외국환 - purple
|
||||
dr3: '#10b981', // 입금 - green
|
||||
dr4: '#f97316', // 출금 - orange
|
||||
};
|
||||
return dailyReport.cards.map((card) => ({
|
||||
name: card.label.replace(' 합계', ''),
|
||||
금액: Math.abs(Number(card.amount)),
|
||||
color: colorMap[card.id] ?? '#94a3b8',
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Dashboard Type2 통합 데이터 Hook
|
||||
* 기존 CEO Dashboard API 훅들을 재사용하여 탭별 데이터 제공
|
||||
* API 실패 시 mockData fallback
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
useDailyReport,
|
||||
useReceivable,
|
||||
useDebtCollection,
|
||||
useMonthlyExpense,
|
||||
useCardManagement,
|
||||
useEntertainment,
|
||||
useWelfare,
|
||||
useTodayIssue,
|
||||
} from '@/hooks/useCEODashboard';
|
||||
|
||||
import type { StatCard, TableRow } from '../mockData';
|
||||
import type { TodayIssueListItem } from '@/components/business/CEODashboard/types';
|
||||
|
||||
import {
|
||||
transformOverviewStats,
|
||||
transformSalesStats,
|
||||
transformFinanceStats,
|
||||
transformExpenseManagementStats,
|
||||
transformScheduleStats,
|
||||
transformExpenseChartData,
|
||||
transformReceivableChartData,
|
||||
transformOverviewChartData,
|
||||
transformExpenseTable,
|
||||
transformCardTable,
|
||||
transformDebtTable,
|
||||
transformEntertainmentTable,
|
||||
transformWelfareTable,
|
||||
statsToRollingItems,
|
||||
type ExpenseChartItem,
|
||||
type ReceivableChartItem,
|
||||
type OverviewChartItem,
|
||||
} from './transformers';
|
||||
|
||||
import * as mock from '../mockData';
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
// ============================================
|
||||
|
||||
export interface OverviewTabData {
|
||||
stats: StatCard[];
|
||||
recentOrders: TableRow[];
|
||||
chartData: OverviewChartItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FinanceTabData {
|
||||
stats: StatCard[];
|
||||
expenseData: TableRow[];
|
||||
cardData: TableRow[];
|
||||
expenseChartData: ExpenseChartItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface SalesTabData {
|
||||
stats: StatCard[];
|
||||
receivableData: TableRow[];
|
||||
debtData: TableRow[];
|
||||
receivableChartData: ReceivableChartItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ExpenseTabData {
|
||||
stats: StatCard[];
|
||||
entertainmentData: TableRow[];
|
||||
welfareData: TableRow[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleTabData {
|
||||
stats: StatCard[];
|
||||
todayItems: TodayIssueListItem[];
|
||||
issueItems: TodayIssueListItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardType2Data {
|
||||
overview: OverviewTabData;
|
||||
finance: FinanceTabData;
|
||||
sales: SalesTabData;
|
||||
expense: ExpenseTabData;
|
||||
schedule: ScheduleTabData;
|
||||
refetchAll: () => void;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Hook 구현
|
||||
// ============================================
|
||||
|
||||
export function useDashboardType2(): DashboardType2Data {
|
||||
// 기존 API 훅 호출
|
||||
const dailyReport = useDailyReport();
|
||||
const receivable = useReceivable();
|
||||
const debtCollection = useDebtCollection();
|
||||
const monthlyExpense = useMonthlyExpense();
|
||||
const cardManagement = useCardManagement();
|
||||
const entertainment = useEntertainment();
|
||||
const welfare = useWelfare();
|
||||
const todayIssue = useTodayIssue();
|
||||
|
||||
// 전체요약 탭
|
||||
const overview = useMemo<OverviewTabData>(() => {
|
||||
const loading = dailyReport.loading;
|
||||
const error = dailyReport.error;
|
||||
|
||||
if (error && !dailyReport.data) {
|
||||
return {
|
||||
stats: mock.overviewStats,
|
||||
recentOrders: mock.overviewRecentOrders,
|
||||
chartData: [],
|
||||
loading: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stats: dailyReport.data ? transformOverviewStats(dailyReport.data) : mock.overviewStats,
|
||||
recentOrders: mock.overviewRecentOrders, // 최근 수주는 별도 API 없으므로 mock 유지
|
||||
chartData: dailyReport.data ? transformOverviewChartData(dailyReport.data) : [],
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}, [dailyReport.data, dailyReport.loading, dailyReport.error]);
|
||||
|
||||
// 재무관리 탭
|
||||
const finance = useMemo<FinanceTabData>(() => {
|
||||
const loading = dailyReport.loading || monthlyExpense.loading || cardManagement.loading;
|
||||
const error = dailyReport.error || monthlyExpense.error || cardManagement.error;
|
||||
|
||||
if (error && !dailyReport.data && !monthlyExpense.data && !cardManagement.data) {
|
||||
return {
|
||||
stats: mock.financeStats,
|
||||
expenseData: mock.financeExpenseData,
|
||||
cardData: mock.financeCardData,
|
||||
expenseChartData: mock.financeExpenseChartData,
|
||||
loading: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stats: dailyReport.data ? transformFinanceStats(dailyReport.data) : mock.financeStats,
|
||||
expenseData: monthlyExpense.data ? transformExpenseTable(monthlyExpense.data) : mock.financeExpenseData,
|
||||
cardData: cardManagement.data ? transformCardTable(cardManagement.data) : mock.financeCardData,
|
||||
expenseChartData: monthlyExpense.data ? transformExpenseChartData(monthlyExpense.data) : mock.financeExpenseChartData,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}, [
|
||||
dailyReport.data, dailyReport.loading, dailyReport.error,
|
||||
monthlyExpense.data, monthlyExpense.loading, monthlyExpense.error,
|
||||
cardManagement.data, cardManagement.loading, cardManagement.error,
|
||||
]);
|
||||
|
||||
// 영업/매출 탭
|
||||
const sales = useMemo<SalesTabData>(() => {
|
||||
const loading = receivable.loading || debtCollection.loading;
|
||||
const error = receivable.error || debtCollection.error;
|
||||
|
||||
if (error && !receivable.data && !debtCollection.data) {
|
||||
return {
|
||||
stats: mock.salesStats,
|
||||
receivableData: mock.salesReceivableData,
|
||||
debtData: mock.salesDebtData,
|
||||
receivableChartData: [],
|
||||
loading: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stats: transformSalesStats(receivable.data, debtCollection.data).length > 0
|
||||
? transformSalesStats(receivable.data, debtCollection.data)
|
||||
: mock.salesStats,
|
||||
receivableData: mock.salesReceivableData, // 상세 미수금 목록은 별도 API 없으므로 mock 유지
|
||||
debtData: debtCollection.data ? transformDebtTable(debtCollection.data) : mock.salesDebtData,
|
||||
receivableChartData: receivable.data ? transformReceivableChartData(receivable.data) : [],
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}, [
|
||||
receivable.data, receivable.loading, receivable.error,
|
||||
debtCollection.data, debtCollection.loading, debtCollection.error,
|
||||
]);
|
||||
|
||||
// 경비관리 탭
|
||||
const expense = useMemo<ExpenseTabData>(() => {
|
||||
const loading = entertainment.loading || welfare.loading;
|
||||
const error = entertainment.error || welfare.error;
|
||||
|
||||
if (error && !entertainment.data && !welfare.data) {
|
||||
return {
|
||||
stats: mock.expenseStats,
|
||||
entertainmentData: mock.expenseEntertainmentData,
|
||||
welfareData: mock.expenseWelfareData,
|
||||
loading: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stats: transformExpenseManagementStats(entertainment.data, welfare.data).length > 0
|
||||
? transformExpenseManagementStats(entertainment.data, welfare.data)
|
||||
: mock.expenseStats,
|
||||
entertainmentData: entertainment.data
|
||||
? transformEntertainmentTable(entertainment.data)
|
||||
: mock.expenseEntertainmentData,
|
||||
welfareData: welfare.data
|
||||
? transformWelfareTable(welfare.data)
|
||||
: mock.expenseWelfareData,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}, [
|
||||
entertainment.data, entertainment.loading, entertainment.error,
|
||||
welfare.data, welfare.loading, welfare.error,
|
||||
]);
|
||||
|
||||
// 일정/이슈 탭
|
||||
const schedule = useMemo<ScheduleTabData>(() => {
|
||||
const loading = todayIssue.loading;
|
||||
const error = todayIssue.error;
|
||||
|
||||
if (error && !todayIssue.data) {
|
||||
return {
|
||||
stats: mock.scheduleStats,
|
||||
todayItems: [],
|
||||
issueItems: [],
|
||||
loading: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
const items = todayIssue.data?.items ?? [];
|
||||
const approvalItems = items.filter((i) => i.needsApproval);
|
||||
const regularItems = items.filter((i) => !i.needsApproval);
|
||||
|
||||
return {
|
||||
stats: todayIssue.data
|
||||
? transformScheduleStats(todayIssue.data).length > 0
|
||||
? transformScheduleStats(todayIssue.data)
|
||||
: mock.scheduleStats
|
||||
: mock.scheduleStats,
|
||||
todayItems: regularItems,
|
||||
issueItems: approvalItems,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}, [todayIssue.data, todayIssue.loading, todayIssue.error]);
|
||||
|
||||
// 전체 refetch
|
||||
const refetchAll = useCallback(() => {
|
||||
dailyReport.refetch();
|
||||
receivable.refetch();
|
||||
debtCollection.refetch();
|
||||
monthlyExpense.refetch();
|
||||
cardManagement.refetch();
|
||||
entertainment.refetch();
|
||||
welfare.refetch();
|
||||
todayIssue.refetch();
|
||||
}, [
|
||||
dailyReport, receivable, debtCollection, monthlyExpense,
|
||||
cardManagement, entertainment, welfare, todayIssue,
|
||||
]);
|
||||
|
||||
return {
|
||||
overview,
|
||||
finance,
|
||||
sales,
|
||||
expense,
|
||||
schedule,
|
||||
refetchAll,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 탭별 롤링 아이템 헬퍼
|
||||
// ============================================
|
||||
|
||||
export function getRollingItems(tabKey: string, data: DashboardType2Data) {
|
||||
switch (tabKey) {
|
||||
case 'overview':
|
||||
return statsToRollingItems(data.overview.stats);
|
||||
case 'finance':
|
||||
return statsToRollingItems(data.finance.stats);
|
||||
case 'sales':
|
||||
return statsToRollingItems(data.sales.stats);
|
||||
case 'expense':
|
||||
return statsToRollingItems(data.expense.stats);
|
||||
case 'schedule':
|
||||
return statsToRollingItems(data.schedule.stats);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,15 @@ export const financeCardData: TableRow[] = [
|
||||
{ no: 3, 카드명: '법인카드(하나)', 사용액: '215만', 미정리: '2건', 한도: '500만', 잔여한도: '285만' },
|
||||
];
|
||||
|
||||
// 재무관리 - 차트 목데이터 (지출 항목별 비율)
|
||||
export const financeExpenseChartData = [
|
||||
{ name: '매입', value: 52340000, color: '#3b82f6' },
|
||||
{ name: '카드', value: 9850000, color: '#f97316' },
|
||||
{ name: '인건비', value: 32000000, color: '#10b981' },
|
||||
{ name: '운영비', value: 15800000, color: '#8b5cf6' },
|
||||
{ name: '기타', value: 13500000, color: '#94a3b8' },
|
||||
];
|
||||
|
||||
// === 영업/매출 탭 ===
|
||||
export const salesStats: StatCard[] = [
|
||||
{ label: '수주 건수', value: '7건', color: 'blue' },
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { expenseStats, expenseEntertainmentData, expenseWelfareData } from '../mockData';
|
||||
import { StatCards } from '../StatCards';
|
||||
import type { ExpenseTabData } from '../hooks/useDashboardType2';
|
||||
|
||||
export function ExpenseTab() {
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={expenseStats} />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse bg-muted rounded-xl h-[88px]" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
|
||||
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExpenseTab({ data }: { data: ExpenseTabData }) {
|
||||
if (data.loading) return <LoadingSkeleton />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={data.stats} />
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
@@ -28,7 +46,7 @@ export function ExpenseTab() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expenseEntertainmentData.map((row, i) => (
|
||||
{data.entertainmentData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3">{row['일자']}</td>
|
||||
@@ -62,7 +80,7 @@ export function ExpenseTab() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expenseWelfareData.map((row, i) => (
|
||||
{data.welfareData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['항목']}</td>
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { financeStats, financeExpenseData, financeCardData } from '../mockData';
|
||||
import { StatCards } from '../StatCards';
|
||||
import { ExpenseDonutChart } from '../charts/ExpenseDonutChart';
|
||||
import type { FinanceTabData } from '../hooks/useDashboardType2';
|
||||
|
||||
export function FinanceTab() {
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={financeStats} />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse bg-muted rounded-xl h-[88px]" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
|
||||
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FinanceTab({ data }: { data: FinanceTabData }) {
|
||||
if (data.loading) return <LoadingSkeleton />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={data.stats} />
|
||||
|
||||
<ExpenseDonutChart data={data.expenseChartData} />
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
@@ -27,7 +48,7 @@ export function FinanceTab() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{financeExpenseData.map((row, i) => (
|
||||
{data.expenseData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['항목']}</td>
|
||||
@@ -56,20 +77,18 @@ export function FinanceTab() {
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">카드명</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">사용액</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground">미정리</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">잔여한도</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">항목</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">금액</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{financeCardData.map((row, i) => (
|
||||
{data.cardData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['카드명']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['사용액']}</td>
|
||||
<td className="px-4 py-3 text-center text-orange-500 font-medium">{row['미정리']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['잔여한도']}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['항목']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['금액']}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{row['비고']}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { overviewStats, overviewRecentOrders } from '../mockData';
|
||||
import { StatCards } from '../StatCards';
|
||||
import { OverviewSummaryChart } from '../charts/OverviewSummaryChart';
|
||||
import type { OverviewTabData } from '../hooks/useDashboardType2';
|
||||
|
||||
export function OverviewTab() {
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={overviewStats} />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse bg-muted rounded-xl h-[88px]" />
|
||||
))}
|
||||
</div>
|
||||
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({ data }: { data: OverviewTabData }) {
|
||||
if (data.loading) return <LoadingSkeleton />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={data.stats} />
|
||||
|
||||
<OverviewSummaryChart data={data.chartData} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -27,7 +45,7 @@ export function OverviewTab() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{overviewRecentOrders.map((row, i) => (
|
||||
{data.recentOrders.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
|
||||
|
||||
@@ -1,13 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { salesStats, salesReceivableData, salesDebtData } from '../mockData';
|
||||
import { StatCards } from '../StatCards';
|
||||
import { ReceivableBarChart } from '../charts/ReceivableBarChart';
|
||||
import type { SalesTabData } from '../hooks/useDashboardType2';
|
||||
|
||||
export function SalesTab() {
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={salesStats} />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse bg-muted rounded-xl h-[88px]" />
|
||||
))}
|
||||
</div>
|
||||
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
|
||||
<div className="animate-pulse bg-muted rounded-xl h-[250px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getReceivableRowClass(row: Record<string, string | number>): string {
|
||||
const days = String(row['경과일']);
|
||||
const num = parseInt(days);
|
||||
if (!isNaN(num)) {
|
||||
if (num >= 90) return 'bg-red-50';
|
||||
if (num >= 30) return 'bg-yellow-50';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getDebtRowClass(row: Record<string, string | number>): string {
|
||||
const possibility = String(row['회수가능성']);
|
||||
if (possibility === '하') return 'bg-red-50';
|
||||
if (possibility === '상') return 'bg-green-50';
|
||||
return '';
|
||||
}
|
||||
|
||||
export function SalesTab({ data }: { data: SalesTabData }) {
|
||||
if (data.loading) return <LoadingSkeleton />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={data.stats} />
|
||||
|
||||
<ReceivableBarChart data={data.receivableChartData} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -28,8 +64,8 @@ export function SalesTab() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{salesReceivableData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
{data.receivableData.map((row, i) => (
|
||||
<tr key={i} className={`border-b last:border-0 hover:bg-muted/30 ${getReceivableRowClass(row)}`}>
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['매출액']}</td>
|
||||
@@ -65,8 +101,8 @@ export function SalesTab() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{salesDebtData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
{data.debtData.map((row, i) => (
|
||||
<tr key={i} className={`border-b last:border-0 hover:bg-muted/30 ${getDebtRowClass(row)}`}>
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['채권액']}</td>
|
||||
|
||||
@@ -1,14 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { scheduleStats, scheduleTodayItems, scheduleIssueItems } from '../mockData';
|
||||
import { StatCards } from '../StatCards';
|
||||
import { Clock, AlertTriangle, FileText, Truck, Users } from 'lucide-react';
|
||||
import type { ScheduleTabData } from '../hooks/useDashboardType2';
|
||||
import type { TodayIssueListItem } from '@/components/business/CEODashboard/types';
|
||||
import { scheduleTodayItems, scheduleIssueItems } from '../mockData';
|
||||
|
||||
export function ScheduleTab() {
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={scheduleStats} />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse bg-muted rounded-xl h-[88px]" />
|
||||
))}
|
||||
</div>
|
||||
<div className="animate-pulse bg-muted rounded-xl h-[200px]" />
|
||||
<div className="animate-pulse bg-muted rounded-xl h-[200px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 뱃지 타입 → 아이콘 타입 매핑
|
||||
function getIconType(badge: string): string {
|
||||
if (badge.includes('수주') || badge.includes('결재')) return 'meeting';
|
||||
if (badge.includes('입금') || badge.includes('출금')) return 'task';
|
||||
if (badge.includes('신고') || badge.includes('세금')) return 'report';
|
||||
if (badge.includes('재고') || badge.includes('납품')) return 'delivery';
|
||||
return 'task';
|
||||
}
|
||||
|
||||
// 뱃지 타입 → 우선순위 매핑
|
||||
function getPriority(item: TodayIssueListItem): string {
|
||||
if (item.needsApproval) return 'high';
|
||||
if (item.notificationType === 'bad_debt' || item.notificationType === 'safety_stock') return 'high';
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
export function ScheduleTab({ data }: { data: ScheduleTabData }) {
|
||||
if (data.loading) return <LoadingSkeleton />;
|
||||
|
||||
// API 데이터가 있으면 사용, 없으면 mock fallback
|
||||
const hasApiData = data.todayItems.length > 0 || data.issueItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={data.stats} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -16,20 +53,43 @@ export function ScheduleTab() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
{scheduleTodayItems.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<ScheduleIcon type={item.type} />
|
||||
{hasApiData ? (
|
||||
data.todayItems.length > 0 ? (
|
||||
data.todayItems.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<ScheduleIcon type={getIconType(item.badge)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{item.content}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{item.badge}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">{item.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground col-span-full py-4 text-center">오늘 일정이 없습니다.</p>
|
||||
)
|
||||
) : (
|
||||
scheduleTodayItems.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<ScheduleIcon type={item.type} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{item.person}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">{item.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{item.person}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">{item.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -40,24 +100,49 @@ export function ScheduleTab() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
{scheduleIssueItems.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<PriorityIcon priority={item.priority} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{item.date}</span>
|
||||
<span className="text-xs text-muted-foreground">|</span>
|
||||
<span className="text-xs text-muted-foreground">{item.assignee}</span>
|
||||
{hasApiData ? (
|
||||
data.issueItems.length > 0 ? (
|
||||
data.issueItems.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<PriorityIcon priority={getPriority(item)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{item.content}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{item.time}</span>
|
||||
<span className="text-xs text-muted-foreground">|</span>
|
||||
<span className="text-xs text-muted-foreground">{item.badge}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<PriorityBadge priority={getPriority(item)} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground col-span-full py-4 text-center">미처리 이슈가 없습니다.</p>
|
||||
)
|
||||
) : (
|
||||
scheduleIssueItems.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<PriorityIcon priority={item.priority} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{item.date}</span>
|
||||
<span className="text-xs text-muted-foreground">|</span>
|
||||
<span className="text-xs text-muted-foreground">{item.assignee}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<PriorityBadge priority={item.priority} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<PriorityBadge priority={item.priority} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { LayoutGrid, GripVertical, Plus, X, Maximize2, Minimize2, Square } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
// ============================================
|
||||
// Mock 데이터
|
||||
// ============================================
|
||||
|
||||
const cashFlowData = [
|
||||
{ month: '9월', 입금: 8200, 출금: 6800 },
|
||||
{ month: '10월', 입금: 9500, 출금: 7200 },
|
||||
{ month: '11월', 입금: 7800, 출금: 8100 },
|
||||
{ month: '12월', 입금: 11200, 출금: 7500 },
|
||||
{ month: '1월', 입금: 9800, 출금: 8900 },
|
||||
{ month: '2월', 입금: 10500, 출금: 6764 },
|
||||
];
|
||||
|
||||
const expenseData = [
|
||||
{ name: '매입', value: 5234, color: '#3b82f6' },
|
||||
{ name: '인건비', value: 3200, color: '#10b981' },
|
||||
{ name: '운영비', value: 1580, color: '#8b5cf6' },
|
||||
{ name: '기타', value: 1350, color: '#94a3b8' },
|
||||
{ name: '카드', value: 985, color: '#f97316' },
|
||||
];
|
||||
|
||||
const receivables = [
|
||||
{ name: '(주)대한건설', amount: '4,000만', days: 45, status: '정상' },
|
||||
{ name: '삼성엔지니어링', amount: '1억', days: 92, status: '주의' },
|
||||
{ name: 'SK에코플랜트', amount: '4,400만', days: 30, status: '정상' },
|
||||
{ name: 'LG전자', amount: '3,200만', days: 120, status: '위험' },
|
||||
];
|
||||
|
||||
const todaySchedule = [
|
||||
{ time: '09:00', title: '대한건설 현장 미팅', type: 'meeting' },
|
||||
{ time: '11:00', title: '삼성엔지니어링 견적 검토', type: 'task' },
|
||||
{ time: '14:00', title: '월간 실적 보고', type: 'report' },
|
||||
{ time: '16:00', title: 'SK에코플랜트 납품 확인', type: 'delivery' },
|
||||
];
|
||||
|
||||
const alerts = [
|
||||
{ id: 1, text: 'LG전자 미수금 120일 경과 (위험)', type: 'danger', time: '10분 전' },
|
||||
{ id: 2, text: '출장비 정산 승인 요청 (홍킬동)', type: 'warning', time: '30분 전' },
|
||||
{ id: 3, text: '현대중공업 입금 683만원 확인', type: 'success', time: '1시간 전' },
|
||||
{ id: 4, text: '서울금속 신규 거래처 등록', type: 'info', time: '2시간 전' },
|
||||
{ id: 5, text: '전동개폐기 납기 지연 3일', type: 'warning', time: '3시간 전' },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// 위젯 타입
|
||||
// ============================================
|
||||
|
||||
interface Widget {
|
||||
id: string;
|
||||
title: string;
|
||||
size: 'sm' | 'md' | 'lg';
|
||||
type: 'cashflow' | 'receivable' | 'schedule' | 'expense' | 'kpi' | 'alerts';
|
||||
}
|
||||
|
||||
const defaultWidgets: Widget[] = [
|
||||
{ id: 'kpi', title: '핵심 KPI', size: 'lg', type: 'kpi' },
|
||||
{ id: 'cashflow', title: '현금 흐름 추이', size: 'md', type: 'cashflow' },
|
||||
{ id: 'expense', title: '지출 항목별 비율', size: 'md', type: 'expense' },
|
||||
{ id: 'receivable', title: '미수금 현황', size: 'md', type: 'receivable' },
|
||||
{ id: 'schedule', title: '오늘 일정', size: 'md', type: 'schedule' },
|
||||
{ id: 'alerts', title: '최근 알림', size: 'lg', type: 'alerts' },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// 위젯 컴포넌트들
|
||||
// ============================================
|
||||
|
||||
function KpiWidget() {
|
||||
const kpis = [
|
||||
{ label: '현금성 자산', value: '305억', change: '+5.2%', up: true, color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
{ label: '당월 매출', value: '12.8억', change: '+8.3%', up: true, color: 'text-green-600', bg: 'bg-green-50' },
|
||||
{ label: '당월 미수금', value: '10.1억', change: '+2.1%', up: true, color: 'text-red-600', bg: 'bg-red-50' },
|
||||
{ label: '당월 지출', value: '8.5억', change: '-3.2%', up: false, color: 'text-orange-600', bg: 'bg-orange-50' },
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{kpis.map((kpi) => (
|
||||
<div key={kpi.label} className={`${kpi.bg} rounded-xl p-4`}>
|
||||
<p className="text-xs text-muted-foreground">{kpi.label}</p>
|
||||
<p className={`text-xl font-bold mt-1 ${kpi.color}`}>{kpi.value}</p>
|
||||
<p className={`text-xs mt-1 ${kpi.up ? 'text-green-500' : 'text-red-500'}`}>{kpi.change}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CashflowWidget() {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={cashFlowData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v: number) => `${v}만`} width={50} />
|
||||
<Tooltip formatter={(v: number | string | undefined) => `${Number(v ?? 0).toLocaleString()}만원`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} />
|
||||
<Bar dataKey="입금" fill="#3b82f6" radius={[3, 3, 0, 0]} maxBarSize={20} />
|
||||
<Bar dataKey="출금" fill="#f97316" radius={[3, 3, 0, 0]} maxBarSize={20} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpenseWidget() {
|
||||
const chartData = expenseData.map((d) => ({ name: d.name, value: d.value, color: d.color }));
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<ResponsiveContainer width="50%" height={160}>
|
||||
<PieChart>
|
||||
<Pie data={chartData} cx="50%" cy="50%" innerRadius={40} outerRadius={65} dataKey="value" nameKey="name">
|
||||
{chartData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: number | string | undefined) => `${Number(v ?? 0).toLocaleString()}만원`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex-1 space-y-2">
|
||||
{expenseData.map((item) => (
|
||||
<div key={item.name} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
<span className="font-medium">{item.value.toLocaleString()}만</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceivableWidget() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{receivables.map((r) => (
|
||||
<div key={r.name} className={`flex items-center justify-between p-2.5 rounded-lg border text-sm ${r.days >= 90 ? 'bg-red-50 border-red-200' : r.days >= 30 ? 'bg-yellow-50 border-yellow-200' : 'border-border'}`}>
|
||||
<div>
|
||||
<p className="font-medium">{r.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{r.days}일 경과</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">{r.amount}</p>
|
||||
<StatusBadge status={r.status} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleWidget() {
|
||||
const typeColors: Record<string, string> = { meeting: 'bg-blue-500', task: 'bg-green-500', report: 'bg-orange-500', delivery: 'bg-purple-500' };
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{todaySchedule.map((s) => (
|
||||
<div key={s.time} className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-muted/30 transition-colors">
|
||||
<span className="text-xs text-muted-foreground w-10 flex-shrink-0">{s.time}</span>
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${typeColors[s.type] ?? 'bg-gray-400'}`} />
|
||||
<span className="text-sm">{s.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsWidget() {
|
||||
const typeStyles: Record<string, string> = {
|
||||
danger: 'border-l-red-500 bg-red-50/50',
|
||||
warning: 'border-l-yellow-500 bg-yellow-50/50',
|
||||
success: 'border-l-green-500 bg-green-50/50',
|
||||
info: 'border-l-blue-500 bg-blue-50/50',
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{alerts.map((a) => (
|
||||
<div key={a.id} className={`flex items-center justify-between p-3 rounded-lg border-l-4 ${typeStyles[a.type]}`}>
|
||||
<span className="text-sm">{a.text}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-3">{a.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = { '정상': 'bg-green-100 text-green-700', '주의': 'bg-yellow-100 text-yellow-700', '위험': 'bg-red-100 text-red-700' };
|
||||
return <span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${styles[status] ?? 'bg-gray-100 text-gray-600'}`}>{status}</span>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 위젯 렌더러
|
||||
// ============================================
|
||||
|
||||
function renderWidgetContent(type: Widget['type']) {
|
||||
switch (type) {
|
||||
case 'kpi': return <KpiWidget />;
|
||||
case 'cashflow': return <CashflowWidget />;
|
||||
case 'expense': return <ExpenseWidget />;
|
||||
case 'receivable': return <ReceivableWidget />;
|
||||
case 'schedule': return <ScheduleWidget />;
|
||||
case 'alerts': return <AlertsWidget />;
|
||||
}
|
||||
}
|
||||
|
||||
function sizeClass(size: Widget['size']) {
|
||||
switch (size) {
|
||||
case 'sm': return 'col-span-1';
|
||||
case 'md': return 'col-span-1 xl:col-span-1';
|
||||
case 'lg': return 'col-span-1 xl:col-span-2';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 메인 컴포넌트
|
||||
// ============================================
|
||||
|
||||
export function DashboardType3() {
|
||||
const [widgets, setWidgets] = useState<Widget[]>(defaultWidgets);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [showAddPanel, setShowAddPanel] = useState(false);
|
||||
const dragIndex = useRef<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
|
||||
// 현재 배치되지 않은 위젯 목록
|
||||
const removedWidgets = defaultWidgets.filter((dw) => !widgets.some((w) => w.id === dw.id));
|
||||
|
||||
// sm → md → lg → sm 순환
|
||||
const toggleSize = (id: string) => {
|
||||
const cycle: Record<Widget['size'], Widget['size']> = { sm: 'md', md: 'lg', lg: 'sm' };
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) => (w.id === id ? { ...w, size: cycle[w.size] } : w)),
|
||||
);
|
||||
};
|
||||
|
||||
const removeWidget = (id: string) => {
|
||||
setWidgets((prev) => prev.filter((w) => w.id !== id));
|
||||
};
|
||||
|
||||
const addWidget = (widget: Widget) => {
|
||||
setWidgets((prev) => [...prev, widget]);
|
||||
setShowAddPanel(false);
|
||||
};
|
||||
|
||||
const resetWidgets = () => {
|
||||
setWidgets(defaultWidgets);
|
||||
setShowAddPanel(false);
|
||||
};
|
||||
|
||||
// --- Drag & Drop 핸들러 ---
|
||||
const handleDragStart = (e: React.DragEvent, idx: number) => {
|
||||
dragIndex.current = idx;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'widget', idx }));
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (dragIndex.current !== idx) {
|
||||
setDragOverIdx(idx);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverIdx(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, dropIdx: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverIdx(null);
|
||||
const fromIdx = dragIndex.current;
|
||||
if (fromIdx === null || fromIdx === dropIdx) return;
|
||||
setWidgets((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
next.splice(dropIdx, 0, moved);
|
||||
return next;
|
||||
});
|
||||
dragIndex.current = null;
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
dragIndex.current = null;
|
||||
setDragOverIdx(null);
|
||||
};
|
||||
|
||||
const SizeIcon = ({ size }: { size: Widget['size'] }) => {
|
||||
switch (size) {
|
||||
case 'sm': return <Minimize2 className="w-3.5 h-3.5" />;
|
||||
case 'md': return <Square className="w-3.5 h-3.5" />;
|
||||
case 'lg': return <Maximize2 className="w-3.5 h-3.5" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="p-3 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<PageHeader
|
||||
title="위젯 대시보드"
|
||||
description="원하는 위젯을 자유롭게 배치하세요."
|
||||
icon={LayoutGrid}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{editMode && (
|
||||
<button onClick={resetWidgets} className="text-xs text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md hover:bg-muted">
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditMode(!editMode)}
|
||||
className={`flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-md transition-colors ${editMode ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
|
||||
>
|
||||
{editMode ? '완료' : '편집'}
|
||||
</button>
|
||||
<DashboardSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{widgets.map((widget, idx) => (
|
||||
<Card
|
||||
key={widget.id}
|
||||
draggable={editMode}
|
||||
onDragStart={editMode ? (e) => handleDragStart(e, idx) : undefined}
|
||||
onDragOver={editMode ? (e) => handleDragOver(e, idx) : undefined}
|
||||
onDragLeave={editMode ? handleDragLeave : undefined}
|
||||
onDrop={editMode ? (e) => handleDrop(e, idx) : undefined}
|
||||
onDragEnd={editMode ? handleDragEnd : undefined}
|
||||
className={`${sizeClass(widget.size)} transition-all ${
|
||||
editMode ? 'ring-2 ring-dashed ring-primary/30' : ''
|
||||
} ${editMode && dragIndex.current === idx ? 'opacity-40' : ''} ${
|
||||
editMode && dragOverIdx === idx ? 'ring-2 ring-primary ring-solid' : ''
|
||||
}`}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{editMode && <GripVertical className="w-4 h-4 text-muted-foreground cursor-grab" />}
|
||||
<CardTitle className="text-sm font-semibold">{widget.title}</CardTitle>
|
||||
</div>
|
||||
{editMode && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => toggleSize(widget.id)} className="p-1 rounded hover:bg-muted" title={`크기 변경 (현재: ${widget.size})`}>
|
||||
<SizeIcon size={widget.size} />
|
||||
</button>
|
||||
<button onClick={() => removeWidget(widget.id)} className="p-1 rounded hover:bg-red-50 text-muted-foreground hover:text-red-500" title="삭제">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
{renderWidgetContent(widget.type)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{editMode && (
|
||||
<div className="col-span-1 xl:col-span-2">
|
||||
{showAddPanel && removedWidgets.length > 0 ? (
|
||||
<div className="border-2 border-dashed border-primary/30 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">추가할 위젯 선택</span>
|
||||
<button onClick={() => setShowAddPanel(false)} className="p-1 rounded hover:bg-muted">
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{removedWidgets.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
onClick={() => addWidget(w)}
|
||||
className="flex items-center gap-2 p-3 rounded-lg border hover:bg-muted/50 hover:border-primary/40 transition-colors text-left"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span className="text-sm">{w.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : removedWidgets.length > 0 ? (
|
||||
<button
|
||||
onClick={() => setShowAddPanel(true)}
|
||||
className="w-full border-2 border-dashed border-muted-foreground/20 rounded-xl flex items-center justify-center py-12 hover:border-primary/40 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Plus className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">위젯 추가</span>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="border rounded-xl flex items-center justify-center py-8 bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground">추가할 수 있는 위젯이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
16
src/app/[locale]/(protected)/dashboard_type3/page.tsx
Normal file
16
src/app/[locale]/(protected)/dashboard_type3/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { DashboardType3 } from './_components/DashboardType3';
|
||||
|
||||
/**
|
||||
* Dashboard Type 3 - 위젯 보드형 대시보드
|
||||
*
|
||||
* 사용자가 원하는 위젯을 자유롭게 배치하는 구조
|
||||
* - 현금흐름, 미수금, 오늘일정, 매출추이, 지출비율, 알림피드 위젯
|
||||
* - 위젯별 크기 조절 (small / medium / large)
|
||||
*
|
||||
* URL: /ko/dashboard_type3
|
||||
*/
|
||||
export default function DashboardType3Page() {
|
||||
return <DashboardType3 />;
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Target, ChevronRight, ArrowLeft } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip, Cell } from 'recharts';
|
||||
|
||||
// ============================================
|
||||
// Mock 데이터
|
||||
// ============================================
|
||||
|
||||
interface KpiItem {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
rawValue: number;
|
||||
change: string;
|
||||
up: boolean;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
interface DetailItem {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: string;
|
||||
rawAmount: number;
|
||||
sub: string;
|
||||
status?: string;
|
||||
items?: SubDetailItem[];
|
||||
}
|
||||
|
||||
interface SubDetailItem {
|
||||
label: string;
|
||||
value: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
const kpis: KpiItem[] = [
|
||||
{ id: 'revenue', label: '당월 매출', value: '12.8억', rawValue: 1280000000, change: '+8.3%', up: true, color: '#3b82f6', bgColor: 'bg-blue-50 border-blue-200' },
|
||||
{ id: 'expense', label: '당월 지출', value: '8.5억', rawValue: 850000000, change: '-3.2%', up: false, color: '#f97316', bgColor: 'bg-orange-50 border-orange-200' },
|
||||
{ id: 'receivable', label: '미수금', value: '10.1억', rawValue: 1010000000, change: '+2.1%', up: true, color: '#ef4444', bgColor: 'bg-red-50 border-red-200' },
|
||||
{ id: 'cash', label: '현금성 자산', value: '305억', rawValue: 30500000000, change: '+5.2%', up: true, color: '#10b981', bgColor: 'bg-green-50 border-green-200' },
|
||||
];
|
||||
|
||||
const detailData: Record<string, DetailItem[]> = {
|
||||
revenue: [
|
||||
{ id: 'r1', name: '(주)대한건설', amount: '4.5억', rawAmount: 450000000, sub: '전동개폐기 SET 외 2건', status: '진행중', items: [
|
||||
{ label: '전동개폐기 SET', value: '2.5억', date: '2026-01-15' },
|
||||
{ label: '자동제어 시스템', value: '1.5억', date: '2026-02-01' },
|
||||
{ label: '부자재', value: '5,000만', date: '2026-02-05' },
|
||||
]},
|
||||
{ id: 'r2', name: '삼성엔지니어링', amount: '3.2억', rawAmount: 320000000, sub: '자동제어 시스템', status: '완료', items: [
|
||||
{ label: '자동제어 시스템 본체', value: '2.8억', date: '2026-01-20' },
|
||||
{ label: '설치 용역', value: '4,000만', date: '2026-01-28' },
|
||||
]},
|
||||
{ id: 'r3', name: '현대건설', amount: '2.8억', rawAmount: 280000000, sub: '환기 시스템', status: '진행중', items: [
|
||||
{ label: '환기 시스템 일식', value: '2.8억', date: '2026-02-03' },
|
||||
]},
|
||||
{ id: 'r4', name: 'LG전자', amount: '1.5억', rawAmount: 150000000, sub: '공조기 제어반', status: '대기', items: [
|
||||
{ label: '공조기 제어반', value: '1.5억', date: '2026-02-10' },
|
||||
]},
|
||||
{ id: 'r5', name: 'SK에코플랜트', amount: '0.8억', rawAmount: 80000000, sub: '모터 제어반', status: '진행중', items: [
|
||||
{ label: '모터 제어반', value: '8,000만', date: '2026-02-08' },
|
||||
]},
|
||||
],
|
||||
expense: [
|
||||
{ id: 'e1', name: '매입', amount: '5,234만', rawAmount: 52340000, sub: '원자재 구매', items: [
|
||||
{ label: '동 파이프', value: '2,100만', date: '2026-02-01' },
|
||||
{ label: '전자부품', value: '1,800만', date: '2026-02-03' },
|
||||
{ label: '기타 자재', value: '1,334만', date: '2026-02-05' },
|
||||
]},
|
||||
{ id: 'e2', name: '인건비', amount: '3,200만', rawAmount: 32000000, sub: '정규직 28명', items: [
|
||||
{ label: '급여', value: '2,600만' },
|
||||
{ label: '4대보험', value: '400만' },
|
||||
{ label: '수당', value: '200만' },
|
||||
]},
|
||||
{ id: 'e3', name: '운영비', amount: '1,580만', rawAmount: 15800000, sub: '임대료, 유틸리티 등', items: [
|
||||
{ label: '임대료', value: '800만' },
|
||||
{ label: '유틸리티', value: '380만' },
|
||||
{ label: '통신/IT', value: '250만' },
|
||||
{ label: '기타', value: '150만' },
|
||||
]},
|
||||
{ id: 'e4', name: '카드', amount: '985만', rawAmount: 9850000, sub: '법인카드 3매', items: [
|
||||
{ label: '신한카드', value: '450만' },
|
||||
{ label: '국민카드', value: '320만' },
|
||||
{ label: '하나카드', value: '215만' },
|
||||
]},
|
||||
],
|
||||
receivable: [
|
||||
{ id: 'v1', name: '(주)대한건설', amount: '4,000만', rawAmount: 40000000, sub: '45일 경과', status: '정상', items: [
|
||||
{ label: '매출액', value: '1.2억' },
|
||||
{ label: '입금액', value: '8,000만' },
|
||||
{ label: '미수금', value: '4,000만' },
|
||||
{ label: '최근 입금일', value: '2026-01-25' },
|
||||
]},
|
||||
{ id: 'v2', name: '삼성엔지니어링', amount: '1억', rawAmount: 100000000, sub: '92일 경과', status: '주의', items: [
|
||||
{ label: '매출액', value: '2.5억' },
|
||||
{ label: '입금액', value: '1.5억' },
|
||||
{ label: '미수금', value: '1억' },
|
||||
{ label: '최근 입금일', value: '2025-11-15' },
|
||||
]},
|
||||
{ id: 'v3', name: 'SK에코플랜트', amount: '4,400만', rawAmount: 44000000, sub: '30일 경과', status: '정상', items: [
|
||||
{ label: '매출액', value: '8,900만' },
|
||||
{ label: '입금액', value: '4,500만' },
|
||||
{ label: '미수금', value: '4,400만' },
|
||||
{ label: '최근 입금일', value: '2026-01-10' },
|
||||
]},
|
||||
{ id: 'v4', name: 'LG전자', amount: '3,200만', rawAmount: 32000000, sub: '120일 경과', status: '위험', items: [
|
||||
{ label: '매출액', value: '3,200만' },
|
||||
{ label: '입금액', value: '0원' },
|
||||
{ label: '미수금', value: '3,200만' },
|
||||
{ label: '최근 입금일', value: '없음' },
|
||||
]},
|
||||
],
|
||||
cash: [
|
||||
{ id: 'c1', name: '보통예금', amount: '180억', rawAmount: 18000000000, sub: '신한은행 외 3곳', items: [
|
||||
{ label: '신한은행', value: '82억' },
|
||||
{ label: '국민은행', value: '55억' },
|
||||
{ label: '하나은행', value: '30억' },
|
||||
{ label: '기업은행', value: '13억' },
|
||||
]},
|
||||
{ id: 'c2', name: '정기예금', amount: '100억', rawAmount: 10000000000, sub: '만기 2026-06', items: [
|
||||
{ label: '신한은행 정기예금', value: '50억', date: '만기: 2026-06-15' },
|
||||
{ label: '국민은행 정기예금', value: '30억', date: '만기: 2026-08-20' },
|
||||
{ label: '하나은행 정기예금', value: '20억', date: '만기: 2026-12-01' },
|
||||
]},
|
||||
{ id: 'c3', name: '단기금융상품', amount: '25억', rawAmount: 2500000000, sub: 'MMF, CMA 등', items: [
|
||||
{ label: 'MMF', value: '15억' },
|
||||
{ label: 'CMA', value: '10억' },
|
||||
]},
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 레벨별 컴포넌트
|
||||
// ============================================
|
||||
|
||||
function Level1({ kpiList, onSelect }: { kpiList: KpiItem[]; onSelect: (id: string) => void }) {
|
||||
const chartData = kpiList.map((k) => ({ name: k.label, value: k.rawValue / 100000000, color: k.color }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{kpiList.map((kpi) => (
|
||||
<button
|
||||
key={kpi.id}
|
||||
onClick={() => onSelect(kpi.id)}
|
||||
className={`${kpi.bgColor} rounded-xl p-5 text-left border hover:ring-2 hover:ring-primary/30 transition-all group`}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{kpi.label}</p>
|
||||
<p className="text-2xl lg:text-3xl font-bold mt-2" style={{ color: kpi.color }}>{kpi.value}</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className={`text-xs ${kpi.up ? 'text-green-500' : 'text-red-500'}`}>{kpi.change}</p>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">주요 지표 비교 (억원)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={chartData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} tickFormatter={(v: number) => `${v}억`} />
|
||||
<YAxis dataKey="name" type="category" tick={{ fontSize: 12 }} width={80} />
|
||||
<Tooltip formatter={(v: number | string | undefined) => `${Number(v ?? 0).toFixed(1)}억원`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={28}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">카드를 클릭하면 상세 내역을 확인할 수 있습니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Level2({ kpi, items, onSelect, onBack }: { kpi: KpiItem; items: DetailItem[]; onSelect: (item: DetailItem) => void; onBack: () => void }) {
|
||||
const chartData = items.map((item) => ({ name: item.name, value: item.rawAmount }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<button onClick={onBack} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
전체 요약으로 돌아가기
|
||||
</button>
|
||||
|
||||
<div className={`${kpi.bgColor} rounded-xl p-5 border`}>
|
||||
<p className="text-sm text-muted-foreground">{kpi.label}</p>
|
||||
<p className="text-3xl font-bold mt-1" style={{ color: kpi.color }}>{kpi.value}</p>
|
||||
<p className={`text-sm mt-1 ${kpi.up ? 'text-green-500' : 'text-red-500'}`}>{kpi.change} 전월 대비</p>
|
||||
</div>
|
||||
|
||||
{chartData.length > 0 && chartData[0].value > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<ResponsiveContainer width="100%" height={items.length * 44 + 20}>
|
||||
<BarChart data={chartData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||
<YAxis dataKey="name" type="category" tick={{ fontSize: 11 }} width={100} />
|
||||
<Tooltip formatter={(v: number | string | undefined) => `${Number(v ?? 0).toLocaleString()}원`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} />
|
||||
<Bar dataKey="value" fill={kpi.color} radius={[0, 4, 4, 0]} maxBarSize={22} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item)}
|
||||
className="w-full flex items-center justify-between p-4 rounded-xl border hover:bg-muted/30 hover:ring-1 hover:ring-primary/20 transition-all text-left group"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold">{item.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{item.sub}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="font-bold" style={{ color: kpi.color }}>{item.amount}</p>
|
||||
{item.status && <StatusBadge status={item.status} />}
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Level3({ kpi, item, onBack }: { kpi: KpiItem; item: DetailItem; onBack: () => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<button onClick={onBack} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{kpi.label} 목록으로 돌아가기
|
||||
</button>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{item.name}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">{item.sub}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold" style={{ color: kpi.color }}>{item.amount}</p>
|
||||
{item.status && <StatusBadge status={item.status} />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{item.items && item.items.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{item.items.map((sub, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between py-3 border-b last:border-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{sub.label}</p>
|
||||
{sub.date && <p className="text-xs text-muted-foreground mt-0.5">{sub.date}</p>}
|
||||
</div>
|
||||
<p className="font-semibold text-sm">{sub.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">상세 데이터가 없습니다.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
'정상': 'bg-green-100 text-green-700', '주의': 'bg-yellow-100 text-yellow-700', '위험': 'bg-red-100 text-red-700',
|
||||
'진행중': 'bg-blue-100 text-blue-700', '완료': 'bg-green-100 text-green-700', '대기': 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
return <span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${styles[status] ?? 'bg-gray-100 text-gray-600'}`}>{status}</span>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Breadcrumb
|
||||
// ============================================
|
||||
|
||||
function Breadcrumb({ items }: { items: { label: string; onClick?: () => void }[] }) {
|
||||
return (
|
||||
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-4">
|
||||
{items.map((item, idx) => {
|
||||
const isCurrent = idx === items.length - 1;
|
||||
return (
|
||||
<span key={idx} className="flex items-center gap-1.5">
|
||||
{idx > 0 && <ChevronRight className="w-3.5 h-3.5" />}
|
||||
{item.onClick ? (
|
||||
<button onClick={item.onClick} className="hover:text-foreground transition-colors">{item.label}</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 bg-primary/10 text-primary font-medium px-2 py-0.5 rounded-md">
|
||||
{isCurrent && <ChevronRight className="w-3 h-3" />}
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 메인 컴포넌트
|
||||
// ============================================
|
||||
|
||||
export function DashboardType4() {
|
||||
const [level, setLevel] = useState<1 | 2 | 3>(1);
|
||||
const [selectedKpi, setSelectedKpi] = useState<string | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<DetailItem | null>(null);
|
||||
|
||||
const handleKpiSelect = (id: string) => {
|
||||
setSelectedKpi(id);
|
||||
setLevel(2);
|
||||
};
|
||||
|
||||
const handleItemSelect = (item: DetailItem) => {
|
||||
setSelectedItem(item);
|
||||
setLevel(3);
|
||||
};
|
||||
|
||||
const goToLevel1 = () => {
|
||||
setLevel(1);
|
||||
setSelectedKpi(null);
|
||||
setSelectedItem(null);
|
||||
};
|
||||
|
||||
const goToLevel2 = () => {
|
||||
setLevel(2);
|
||||
setSelectedItem(null);
|
||||
};
|
||||
|
||||
const currentKpi = kpis.find((k) => k.id === selectedKpi);
|
||||
const currentItems = selectedKpi ? detailData[selectedKpi] ?? [] : [];
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: '전체 요약', onClick: level > 1 ? goToLevel1 : undefined },
|
||||
...(level >= 2 && currentKpi ? [{ label: currentKpi.label, onClick: level > 2 ? goToLevel2 : undefined }] : []),
|
||||
...(level === 3 && selectedItem ? [{ label: selectedItem.name }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="p-3 md:p-6">
|
||||
<PageHeader
|
||||
title="KPI 드릴다운"
|
||||
description="숫자를 클릭하면 점점 상세해집니다."
|
||||
icon={Target}
|
||||
actions={<DashboardSwitcher />}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
|
||||
{level === 1 && (
|
||||
<Level1 kpiList={kpis} onSelect={handleKpiSelect} />
|
||||
)}
|
||||
{level === 2 && currentKpi && (
|
||||
<Level2 kpi={currentKpi} items={currentItems} onSelect={handleItemSelect} onBack={goToLevel1} />
|
||||
)}
|
||||
{level === 3 && currentKpi && selectedItem && (
|
||||
<Level3 kpi={currentKpi} item={selectedItem} onBack={goToLevel2} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
17
src/app/[locale]/(protected)/dashboard_type4/page.tsx
Normal file
17
src/app/[locale]/(protected)/dashboard_type4/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { DashboardType4 } from './_components/DashboardType4';
|
||||
|
||||
/**
|
||||
* Dashboard Type 4 - KPI 드릴다운형 대시보드
|
||||
*
|
||||
* 큰 KPI 숫자를 클릭하면 점점 상세해지는 탐색 구조
|
||||
* - Level 1: 전체 요약 (매출, 지출, 미수금, 현금)
|
||||
* - Level 2: 카테고리 상세 (거래처별, 항목별)
|
||||
* - Level 3: 개별 항목 상세
|
||||
*
|
||||
* URL: /ko/dashboard_type4
|
||||
*/
|
||||
export default function DashboardType4Page() {
|
||||
return <DashboardType4 />;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Activity, ArrowDownCircle, ArrowUpCircle, AlertTriangle, FileCheck, Building2, Filter } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
// ============================================
|
||||
// Mock 데이터
|
||||
// ============================================
|
||||
|
||||
type EventType = 'deposit' | 'withdrawal' | 'issue' | 'approval' | 'vendor' | 'order' | 'system';
|
||||
|
||||
interface FeedEvent {
|
||||
id: number;
|
||||
type: EventType;
|
||||
title: string;
|
||||
description?: string;
|
||||
amount?: string;
|
||||
time: string;
|
||||
date: string; // 'today' | 'yesterday' | 'week'
|
||||
priority?: 'high' | 'medium' | 'low';
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const feedEvents: FeedEvent[] = [
|
||||
// 오늘
|
||||
{ id: 1, type: 'vendor', title: '서울금속 신규 거래처 등록', time: '10:35', date: 'today' },
|
||||
{ id: 2, type: 'vendor', title: '주식회사 부산화학 등록', time: '10:34', date: 'today' },
|
||||
{ id: 3, type: 'deposit', title: '현대중공업 입금 확인', amount: '683만원', time: '10:33', date: 'today' },
|
||||
{ id: 4, type: 'approval', title: '출장비 정산 승인 요청', description: '홍킬동 - 부산 출장', time: '10:31', date: 'today', priority: 'high', status: '대기' },
|
||||
{ id: 5, type: 'deposit', title: 'E2E_TEST 판매처 입금', amount: '827만원', time: '10:33', date: 'today' },
|
||||
{ id: 6, type: 'deposit', title: '제주관광 입금 확인', amount: '177만원', time: '10:17', date: 'today' },
|
||||
{ id: 7, type: 'vendor', title: '제주관광 신규 거래처 등록', time: '10:16', date: 'today' },
|
||||
{ id: 8, type: 'system', title: '어제 매출 실적 집계 완료', description: '목표 대비 95% 달성', time: '09:00', date: 'today' },
|
||||
// 어제
|
||||
{ id: 9, type: 'issue', title: 'LG전자 미수금 120일 경과', description: '3,200만원 - 위험 등급 전환', time: '17:30', date: 'yesterday', priority: 'high' },
|
||||
{ id: 10, type: 'deposit', title: '(주)대한건설 입금', amount: '2,000만원', time: '15:20', date: 'yesterday' },
|
||||
{ id: 11, type: 'approval', title: '법인카드 사용 내역 승인', description: '김부장 - 접대비 52만원', time: '14:00', date: 'yesterday', status: '승인완료' },
|
||||
{ id: 12, type: 'order', title: '현대건설 수주 확정', amount: '6,750만원', time: '11:30', date: 'yesterday' },
|
||||
{ id: 13, type: 'issue', title: '전동개폐기 납기 지연 통보', description: '3일 지연 예상 - 대한건설', time: '10:00', date: 'yesterday', priority: 'medium' },
|
||||
{ id: 14, type: 'withdrawal', title: '2월 급여 이체 완료', amount: '3,200만원', time: '09:30', date: 'yesterday' },
|
||||
// 이번주
|
||||
{ id: 15, type: 'order', title: 'SK에코플랜트 수주', amount: '8,900만원', time: '2/7', date: 'week' },
|
||||
{ id: 16, type: 'issue', title: '자재 단가 인상 통보', description: '동 파이프 +15%', time: '2/7', date: 'week', priority: 'medium' },
|
||||
{ id: 17, type: 'deposit', title: '삼성엔지니어링 입금', amount: '5,000만원', time: '2/6', date: 'week' },
|
||||
{ id: 18, type: 'approval', title: '구매 발주 승인', description: '전자부품 일괄 발주 1,800만원', time: '2/6', date: 'week', status: '승인완료' },
|
||||
{ id: 19, type: 'vendor', title: '(주)미래산업 거래처 등록', time: '2/5', date: 'week' },
|
||||
{ id: 20, type: 'issue', title: '품질 검사 부적합 2건', description: '제어반 PCB 불량', time: '2/5', date: 'week', priority: 'high' },
|
||||
];
|
||||
|
||||
const filterConfig: { key: EventType | 'all'; label: string; icon: typeof Activity }[] = [
|
||||
{ key: 'all', label: '전체', icon: Activity },
|
||||
{ key: 'deposit', label: '입금', icon: ArrowDownCircle },
|
||||
{ key: 'issue', label: '이슈', icon: AlertTriangle },
|
||||
{ key: 'approval', label: '결재', icon: FileCheck },
|
||||
{ key: 'vendor', label: '거래처', icon: Building2 },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// 이벤트 아이콘/색상
|
||||
// ============================================
|
||||
|
||||
function getEventStyle(type: EventType) {
|
||||
switch (type) {
|
||||
case 'deposit': return { icon: ArrowDownCircle, color: 'text-green-500', bg: 'bg-green-50', ring: 'ring-green-200' };
|
||||
case 'withdrawal': return { icon: ArrowUpCircle, color: 'text-red-500', bg: 'bg-red-50', ring: 'ring-red-200' };
|
||||
case 'issue': return { icon: AlertTriangle, color: 'text-yellow-600', bg: 'bg-yellow-50', ring: 'ring-yellow-200' };
|
||||
case 'approval': return { icon: FileCheck, color: 'text-blue-500', bg: 'bg-blue-50', ring: 'ring-blue-200' };
|
||||
case 'vendor': return { icon: Building2, color: 'text-purple-500', bg: 'bg-purple-50', ring: 'ring-purple-200' };
|
||||
case 'order': return { icon: ArrowDownCircle, color: 'text-blue-600', bg: 'bg-blue-50', ring: 'ring-blue-200' };
|
||||
case 'system': return { icon: Activity, color: 'text-gray-500', bg: 'bg-gray-50', ring: 'ring-gray-200' };
|
||||
}
|
||||
}
|
||||
|
||||
function PriorityDot({ priority }: { priority?: string }) {
|
||||
if (!priority) return null;
|
||||
const colors: Record<string, string> = { high: 'bg-red-500', medium: 'bg-yellow-500', low: 'bg-green-500' };
|
||||
return <span className={`w-2 h-2 rounded-full ${colors[priority] ?? 'bg-gray-400'}`} />;
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status?: string }) {
|
||||
if (!status) return null;
|
||||
const styles: Record<string, string> = { '대기': 'bg-yellow-100 text-yellow-700', '승인완료': 'bg-green-100 text-green-700' };
|
||||
return <span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${styles[status] ?? 'bg-gray-100 text-gray-600'}`}>{status}</span>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 피드 아이템
|
||||
// ============================================
|
||||
|
||||
function FeedItem({ event }: { event: FeedEvent }) {
|
||||
const style = getEventStyle(event.type);
|
||||
const Icon = style.icon;
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 p-3 rounded-xl hover:bg-muted/30 transition-colors cursor-pointer group">
|
||||
<div className={`flex-shrink-0 w-9 h-9 rounded-full ${style.bg} ring-1 ${style.ring} flex items-center justify-center`}>
|
||||
<Icon className={`w-4 h-4 ${style.color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<PriorityDot priority={event.priority} />
|
||||
<p className="text-sm font-medium truncate">{event.title}</p>
|
||||
</div>
|
||||
{event.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{event.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{event.amount && <span className="text-xs font-semibold text-green-600">{event.amount}</span>}
|
||||
<StatusTag status={event.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<span className="text-xs text-muted-foreground">{event.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 요약 통계
|
||||
// ============================================
|
||||
|
||||
function TodaySummary() {
|
||||
const stats = [
|
||||
{ label: '오늘 입금', value: '1,687만원', color: 'text-green-600', bg: 'bg-green-50' },
|
||||
{ label: '오늘 출금', value: '0원', color: 'text-red-600', bg: 'bg-red-50' },
|
||||
{ label: '미처리 결재', value: '1건', color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
{ label: '신규 이슈', value: '0건', color: 'text-yellow-600', bg: 'bg-yellow-50' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className={`${s.bg} rounded-xl p-4`}>
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
<p className={`text-lg font-bold mt-1 ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 시간 그룹 헤더
|
||||
// ============================================
|
||||
|
||||
function TimeGroupHeader({ label, count }: { label: string; count: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{label} <span className="text-muted-foreground/60">({count})</span>
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 메인 컴포넌트
|
||||
// ============================================
|
||||
|
||||
export function DashboardType5() {
|
||||
const [filter, setFilter] = useState<EventType | 'all'>('all');
|
||||
|
||||
const filtered = filter === 'all'
|
||||
? feedEvents
|
||||
: feedEvents.filter((e) => e.type === filter);
|
||||
|
||||
const todayEvents = filtered.filter((e) => e.date === 'today');
|
||||
const yesterdayEvents = filtered.filter((e) => e.date === 'yesterday');
|
||||
const weekEvents = filtered.filter((e) => e.date === 'week');
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="p-3 md:p-6">
|
||||
<PageHeader
|
||||
title="활동 피드"
|
||||
description="회사에서 일어나는 일을 시간순으로 확인합니다."
|
||||
icon={Activity}
|
||||
actions={<DashboardSwitcher />}
|
||||
/>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
<TodaySummary />
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
{filterConfig.map((f) => {
|
||||
const Icon = f.icon;
|
||||
const isActive = filter === f.key;
|
||||
return (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{f.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 타임라인 */}
|
||||
<Card>
|
||||
<CardContent className="p-2 md:p-4">
|
||||
{todayEvents.length > 0 && (
|
||||
<>
|
||||
<TimeGroupHeader label="오늘" count={todayEvents.length} />
|
||||
<div className="space-y-0.5">
|
||||
{todayEvents.map((e) => <FeedItem key={e.id} event={e} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{yesterdayEvents.length > 0 && (
|
||||
<>
|
||||
<TimeGroupHeader label="어제" count={yesterdayEvents.length} />
|
||||
<div className="space-y-0.5">
|
||||
{yesterdayEvents.map((e) => <FeedItem key={e.id} event={e} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{weekEvents.length > 0 && (
|
||||
<>
|
||||
<TimeGroupHeader label="이번 주" count={weekEvents.length} />
|
||||
<div className="space-y-0.5">
|
||||
{weekEvents.map((e) => <FeedItem key={e.id} event={e} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">
|
||||
해당 필터에 맞는 이벤트가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
17
src/app/[locale]/(protected)/dashboard_type5/page.tsx
Normal file
17
src/app/[locale]/(protected)/dashboard_type5/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { DashboardType5 } from './_components/DashboardType5';
|
||||
|
||||
/**
|
||||
* Dashboard Type 5 - 타임라인 피드형 대시보드
|
||||
*
|
||||
* 회사에서 일어나는 이벤트를 시간순으로 보는 구조
|
||||
* - 오늘/어제/이번주 시간 그룹
|
||||
* - 필터: 전체 | 입금 | 이슈 | 결재 | 거래처
|
||||
* - 각 항목 클릭 시 상세 페이지 이동
|
||||
*
|
||||
* URL: /ko/dashboard_type5
|
||||
*/
|
||||
export default function DashboardType5Page() {
|
||||
return <DashboardType5 />;
|
||||
}
|
||||
@@ -1,37 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ZoomIn, ZoomOut, Download, Printer, AlertCircle, Maximize2 } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { AlertCircle, Loader2, Save } from 'lucide-react';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { Document, DocumentItem } from '../types';
|
||||
import { MOCK_WORK_ORDER, MOCK_SHIPMENT_DETAIL } from '../mockData';
|
||||
import { MOCK_SHIPMENT_DETAIL } from '../mockData';
|
||||
|
||||
// 기존 문서 컴포넌트 import
|
||||
import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation';
|
||||
import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip';
|
||||
|
||||
// 수주서 문서 컴포넌트 import
|
||||
import { SalesOrderDocument } from '@/components/orders/documents/SalesOrderDocument';
|
||||
import type { ProductInfo } from '@/components/orders/documents/OrderDocumentModal';
|
||||
import type { OrderItem } from '@/components/orders/actions';
|
||||
|
||||
// 품질검사 문서 컴포넌트 import
|
||||
import {
|
||||
ImportInspectionDocument,
|
||||
ProductInspectionDocument,
|
||||
ScreenInspectionDocument,
|
||||
BendingInspectionDocument,
|
||||
SlatInspectionDocument,
|
||||
JointbarInspectionDocument,
|
||||
QualityDocumentUploader,
|
||||
} from './documents';
|
||||
|
||||
// 제품검사 성적서 (신규 양식) import
|
||||
import { InspectionReportDocument } from '@/components/quality/InspectionManagement/documents/InspectionReportDocument';
|
||||
import { mockReportInspectionItems } from '@/components/quality/InspectionManagement/mockData';
|
||||
import type { InspectionReportDocument as InspectionReportDocumentType } from '@/components/quality/InspectionManagement/types';
|
||||
import type { ImportInspectionTemplate, ImportInspectionRef, InspectionItemValue } from './documents/ImportInspectionDocument';
|
||||
|
||||
// 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전)
|
||||
import {
|
||||
ScreenWorkLogContent,
|
||||
SlatWorkLogContent,
|
||||
BendingWorkLogContent,
|
||||
ScreenInspectionContent,
|
||||
SlatInspectionContent,
|
||||
BendingInspectionContent,
|
||||
} from '@/components/production/WorkOrders/documents';
|
||||
import type { WorkOrder } from '@/components/production/WorkOrders/types';
|
||||
|
||||
// 검사 템플릿 API
|
||||
import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions';
|
||||
|
||||
/**
|
||||
* 저장된 document.data (field_key 기반)를 ImportInspectionDocument의 initialValues로 변환
|
||||
*
|
||||
* field_key 패턴:
|
||||
* - {itemId}_n{1,2,3} → numeric 측정값
|
||||
* - {itemId}_okng_n{1,2,3} → OK/NG 값
|
||||
* - {itemId}_result → 항목별 판정
|
||||
*/
|
||||
function parseSavedDataToInitialValues(
|
||||
tmpl: ImportInspectionTemplate,
|
||||
docData: Array<{ field_key: string; field_value: string | null }>
|
||||
): InspectionItemValue[] {
|
||||
// field_key → value 맵 생성
|
||||
const dataMap = new Map<string, string>();
|
||||
for (const d of docData) {
|
||||
if (d.field_value) dataMap.set(d.field_key, d.field_value);
|
||||
}
|
||||
|
||||
return tmpl.inspectionItems.map((item) => {
|
||||
const isOkng = item.measurementType === 'okng';
|
||||
const measurements: (number | 'OK' | 'NG' | null)[] = Array(item.measurementCount).fill(null);
|
||||
|
||||
for (let n = 0; n < item.measurementCount; n++) {
|
||||
if (isOkng) {
|
||||
const val = dataMap.get(`${item.id}_okng_n${n + 1}`);
|
||||
if (val === 'ok') measurements[n] = 'OK';
|
||||
else if (val === 'ng') measurements[n] = 'NG';
|
||||
} else {
|
||||
const val = dataMap.get(`${item.id}_n${n + 1}`);
|
||||
if (val) {
|
||||
const num = parseFloat(val);
|
||||
measurements[n] = isNaN(num) ? null : num;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 항목별 판정
|
||||
const resultVal = dataMap.get(`${item.id}_result`);
|
||||
let result: 'OK' | 'NG' | null = null;
|
||||
if (resultVal === 'ok') result = 'OK';
|
||||
else if (resultVal === 'ng') result = 'NG';
|
||||
|
||||
return { itemId: item.id, measurements, result };
|
||||
});
|
||||
}
|
||||
|
||||
interface InspectionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
document: Document | null;
|
||||
documentItem: DocumentItem | null;
|
||||
// 수입검사 템플릿 로드용 추가 props
|
||||
itemId?: number; // 품목 ID (실제 API로 템플릿 조회 시 사용)
|
||||
itemName?: string;
|
||||
specification?: string;
|
||||
supplier?: string;
|
||||
inspector?: string; // 검사자 (현재 로그인 사용자)
|
||||
inspectorDept?: string; // 검사자 부서
|
||||
lotSize?: number; // 로트크기 (입고수량)
|
||||
materialNo?: string; // 자재번호
|
||||
// 읽기 전용 모드 (QMS 심사 확인용)
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
// 문서 타입별 정보
|
||||
@@ -73,351 +147,203 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D
|
||||
);
|
||||
};
|
||||
|
||||
// 수주서 문서 컴포넌트 (간소화 버전 - deprecated, V2 사용)
|
||||
const OrderDocument = () => {
|
||||
const data = { lotNumber: '', orderDate: '', client: '', siteName: '', manager: '', managerContact: '', deliveryRequestDate: '', expectedShipDate: '', deliveryMethod: '', address: '', items: [] as { id: string; name: string; specification: string; unit: string; quantity: number; unitPrice?: number; amount?: number }[], subtotal: 0, discountRate: 0, totalAmount: 0, remarks: '' };
|
||||
// QMS용 수주서 Mock 데이터
|
||||
const QMS_MOCK_PRODUCTS: ProductInfo[] = [
|
||||
{ productName: '방화 스크린 셔터 (표준형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 5, floor: '1F', code: 'FSS-01' },
|
||||
{ productName: '방화 스크린 셔터 (방화형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 3, floor: '2F', code: 'FSS-02' },
|
||||
];
|
||||
const QMS_MOCK_ORDER_ITEMS: OrderItem[] = [
|
||||
{ id: 'mt-1', itemCode: 'MT-001', itemName: '모터(380V 단상)', specification: '150K', type: '모터', quantity: 8, unit: 'EA', unitPrice: 120000, supplyAmount: 960000, taxAmount: 96000, totalAmount: 1056000, sortOrder: 1 },
|
||||
{ id: 'br-1', itemCode: 'BR-001', itemName: '브라켓트', specification: '380X180 [2-4"]', type: '브라켓', quantity: 16, unit: 'EA', unitPrice: 15000, supplyAmount: 240000, taxAmount: 24000, totalAmount: 264000, sortOrder: 2 },
|
||||
{ id: 'gr-1', itemCode: 'GR-001', itemName: '가이드레일 백면형 (120X70)', specification: 'EGI 1.5ST', type: '가이드레일', quantity: 16, unit: 'EA', unitPrice: 25000, supplyAmount: 400000, taxAmount: 40000, totalAmount: 440000, width: 120, height: 2500, sortOrder: 3 },
|
||||
{ id: 'cs-1', itemCode: 'CS-001', itemName: '케이스(셔터박스)', specification: 'EGI 1.5ST 380X180', type: '케이스', quantity: 8, unit: 'EA', unitPrice: 35000, supplyAmount: 280000, taxAmount: 28000, totalAmount: 308000, width: 380, height: 180, sortOrder: 4 },
|
||||
{ id: 'bf-1', itemCode: 'BF-001', itemName: '하단마감재', specification: 'EGI 1.5ST', type: '하단마감재', quantity: 8, unit: 'EA', unitPrice: 18000, supplyAmount: 144000, taxAmount: 14400, totalAmount: 158400, sortOrder: 5 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 w-full text-sm shadow-sm">
|
||||
{/* 헤더 */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-2xl font-bold">KD</div>
|
||||
<div className="text-xs">경동기업</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold tracking-[0.5rem]">수 주 서</div>
|
||||
<table className="text-xs border-collapse">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-gray-100" rowSpan={3}>
|
||||
<div className="flex flex-col items-center">
|
||||
<span>결</span><span>재</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="border px-2 py-1 bg-gray-100 text-center w-16">작성</td>
|
||||
<td className="border px-2 py-1 bg-gray-100 text-center w-16">검토</td>
|
||||
<td className="border px-2 py-1 bg-gray-100 text-center w-16">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 h-10"></td>
|
||||
<td className="border px-2 py-1 h-10"></td>
|
||||
<td className="border px-2 py-1 h-10"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center bg-gray-50">판매</td>
|
||||
<td className="border px-2 py-1 text-center bg-gray-50">생산</td>
|
||||
<td className="border px-2 py-1 text-center bg-gray-50">품질</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<table className="w-full border-collapse mb-6 text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-3 py-2 bg-gray-100 w-24">LOT NO.</td>
|
||||
<td className="border px-3 py-2">{data.lotNumber}</td>
|
||||
<td className="border px-3 py-2 bg-gray-100 w-24">수주일</td>
|
||||
<td className="border px-3 py-2">{data.orderDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-3 py-2 bg-gray-100">발주처</td>
|
||||
<td className="border px-3 py-2">{data.client}</td>
|
||||
<td className="border px-3 py-2 bg-gray-100">현장명</td>
|
||||
<td className="border px-3 py-2">{data.siteName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-3 py-2 bg-gray-100">담당자</td>
|
||||
<td className="border px-3 py-2">{data.manager}</td>
|
||||
<td className="border px-3 py-2 bg-gray-100">연락처</td>
|
||||
<td className="border px-3 py-2">{data.managerContact}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-3 py-2 bg-gray-100">납기요청일</td>
|
||||
<td className="border px-3 py-2">{data.deliveryRequestDate}</td>
|
||||
<td className="border px-3 py-2 bg-gray-100">출고예정일</td>
|
||||
<td className="border px-3 py-2">{data.expectedShipDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-3 py-2 bg-gray-100">배송방법</td>
|
||||
<td className="border px-3 py-2">{data.deliveryMethod}</td>
|
||||
<td className="border px-3 py-2 bg-gray-100">배송지</td>
|
||||
<td className="border px-3 py-2">{data.address}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<table className="w-full border-collapse mb-6 text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border px-2 py-2 w-10">No</th>
|
||||
<th className="border px-2 py-2">품목명</th>
|
||||
<th className="border px-2 py-2 w-24">규격</th>
|
||||
<th className="border px-2 py-2 w-12">단위</th>
|
||||
<th className="border px-2 py-2 w-12">수량</th>
|
||||
<th className="border px-2 py-2 w-20">단가</th>
|
||||
<th className="border px-2 py-2 w-24">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((item, index) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border px-2 py-2 text-center">{index + 1}</td>
|
||||
<td className="border px-2 py-2">{item.name}</td>
|
||||
<td className="border px-2 py-2 text-center">{item.specification}</td>
|
||||
<td className="border px-2 py-2 text-center">{item.unit}</td>
|
||||
<td className="border px-2 py-2 text-center">{item.quantity}</td>
|
||||
<td className="border px-2 py-2 text-right">{item.unitPrice?.toLocaleString()}</td>
|
||||
<td className="border px-2 py-2 text-right">{item.amount?.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-50">소계</td>
|
||||
<td colSpan={2} className="border px-2 py-2 text-right">{data.subtotal.toLocaleString()}원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-50">할인 ({data.discountRate}%)</td>
|
||||
<td colSpan={2} className="border px-2 py-2 text-right text-red-600">-{(data.subtotal * data.discountRate / 100).toLocaleString()}원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-100 font-bold">총액</td>
|
||||
<td colSpan={2} className="border px-2 py-2 text-right font-bold text-blue-600">{data.totalAmount.toLocaleString()}원</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
{/* 비고 */}
|
||||
{data.remarks && (
|
||||
<div className="border p-4">
|
||||
<h3 className="font-medium mb-2 text-xs">비고</h3>
|
||||
<p className="text-xs text-gray-600">{data.remarks}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// QMS용 제품검사 성적서 Mock 데이터
|
||||
const QMS_MOCK_REPORT_DATA: InspectionReportDocumentType = {
|
||||
documentNumber: 'RPT-KD-SS-2024-530',
|
||||
createdDate: '2024-09-24',
|
||||
approvalLine: [
|
||||
{ role: '작성', name: '김검사', department: '품질관리부' },
|
||||
{ role: '승인', name: '박승인', department: '품질관리부' },
|
||||
],
|
||||
productName: '방화스크린',
|
||||
productLotNo: 'KD-SS-240924-19',
|
||||
productCode: 'WY-SC780',
|
||||
lotSize: '8',
|
||||
client: '삼성물산(주)',
|
||||
inspectionDate: '2024-09-26',
|
||||
siteName: '강남 아파트 단지',
|
||||
inspector: '김검사',
|
||||
inspectionItems: mockReportInspectionItems,
|
||||
specialNotes: '',
|
||||
finalJudgment: '합격',
|
||||
};
|
||||
|
||||
// 작업일지 문서 컴포넌트 (간소화 버전)
|
||||
const WorkLogDocument = () => {
|
||||
const order = MOCK_WORK_ORDER;
|
||||
const today = new Date().toLocaleDateString('ko-KR').replace(/\. /g, '-').replace('.', '');
|
||||
const documentNo = `WL-${order.processCode.toUpperCase().slice(0, 3)}`;
|
||||
const lotNo = `KD-TS-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||
// QMS용 작업일지 Mock WorkOrder 생성
|
||||
const createQmsMockWorkOrder = (subType?: string): WorkOrder => ({
|
||||
id: 'qms-wo-1',
|
||||
workOrderNo: 'KD-WO-240924-01',
|
||||
lotNo: 'KD-SS-240924-19',
|
||||
processId: 1,
|
||||
processName: subType === 'slat' ? '슬랫' : subType === 'bending' ? '절곡' : '스크린',
|
||||
processCode: subType || 'screen',
|
||||
processType: (subType || 'screen') as 'screen' | 'slat' | 'bending',
|
||||
status: 'in_progress',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '강남 아파트 단지',
|
||||
dueDate: '2024-10-05',
|
||||
assignee: '김작업',
|
||||
assignees: [
|
||||
{ id: '1', name: '김작업', isPrimary: true },
|
||||
{ id: '2', name: '이생산', isPrimary: false },
|
||||
],
|
||||
orderDate: '2024-09-20',
|
||||
scheduledDate: '2024-09-24',
|
||||
shipmentDate: '2024-10-04',
|
||||
salesOrderDate: '2024-09-18',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 3,
|
||||
priorityLabel: '긴급',
|
||||
shutterCount: 5,
|
||||
department: '생산부',
|
||||
items: [
|
||||
{ id: '1', no: 1, status: 'completed', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '3000×2500', quantity: 2, unit: 'EA', orderNodeId: null, orderNodeName: '' },
|
||||
{ id: '2', no: 2, status: 'in_progress', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '3000×2500', quantity: 3, unit: 'EA', orderNodeId: null, orderNodeName: '' },
|
||||
{ id: '3', no: 3, status: 'waiting', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12000×4500', quantity: 1, unit: 'EA', orderNodeId: null, orderNodeName: '' },
|
||||
],
|
||||
currentStep: 2,
|
||||
issues: [],
|
||||
note: '품질 검수 철저히 진행',
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ no: 1, name: order.productName, location: '1층/A-01', spec: '3000×2500', qty: 1, status: '완료' },
|
||||
{ no: 2, name: order.productName, location: '2층/A-02', spec: '3000×2500', qty: 1, status: '작업중' },
|
||||
{ no: 3, name: order.productName, location: '3층/A-03', spec: '-', qty: 1, status: '대기' },
|
||||
];
|
||||
// 로딩 컴포넌트
|
||||
const LoadingDocument = () => (
|
||||
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
|
||||
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
|
||||
<p className="text-gray-600 text-sm">검사 템플릿을 불러오는 중...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 w-full text-sm shadow-sm">
|
||||
{/* 헤더 */}
|
||||
<div className="flex justify-between items-start mb-6 border border-gray-300">
|
||||
<div className="w-24 border-r border-gray-300 flex flex-col items-center justify-center p-3">
|
||||
<span className="text-2xl font-bold">KD</span>
|
||||
<span className="text-xs text-gray-500">경동기업</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-3 border-r border-gray-300">
|
||||
<h1 className="text-xl font-bold tracking-widest mb-1">작 업 일 지</h1>
|
||||
<p className="text-xs text-gray-500">{documentNo}</p>
|
||||
<p className="text-sm font-medium mt-1">스크린 생산부서</p>
|
||||
</div>
|
||||
<table className="text-xs shrink-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={3} className="w-8 text-center font-medium bg-gray-100 border-r border-b border-gray-300">
|
||||
<div className="flex flex-col items-center"><span>결</span><span>재</span></div>
|
||||
</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300">작성</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300">검토</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-b border-gray-300">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="w-16 p-2 text-center border-r border-b border-gray-300">
|
||||
<div>{order.assignees[0] || '-'}</div>
|
||||
</td>
|
||||
<td className="w-16 p-2 text-center border-r border-b border-gray-300"></td>
|
||||
<td className="w-16 p-2 text-center border-b border-gray-300"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">판매</td>
|
||||
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">생산</td>
|
||||
<td className="w-16 p-2 text-center bg-gray-50">품질</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
// 에러 컴포넌트
|
||||
const ErrorDocument = ({ message, onRetry }: { message: string; onRetry?: () => void }) => (
|
||||
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
||||
<p className="text-gray-800 font-medium mb-2">템플릿 로드 실패</p>
|
||||
<p className="text-gray-500 text-sm mb-4">{message}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">발주처</div>
|
||||
<div className="flex-1 p-3 text-sm">{order.client}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">현장명</div>
|
||||
<div className="flex-1 p-3 text-sm">{order.projectName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">작업일자</div>
|
||||
<div className="flex-1 p-3 text-sm">{today}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">LOT NO.</div>
|
||||
<div className="flex-1 p-3 text-sm">{lotNo}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">납기일</div>
|
||||
<div className="flex-1 p-3 text-sm">{order.dueDate}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">지시수량</div>
|
||||
<div className="flex-1 p-3 text-sm">{order.quantity} EA</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
/**
|
||||
* InspectionModal V2
|
||||
* - DocumentViewer 시스템 사용
|
||||
* - 수입검사: 모달 열릴 때 API로 템플릿 로드 (Lazy Loading)
|
||||
*/
|
||||
export const InspectionModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
document: doc,
|
||||
documentItem,
|
||||
itemId,
|
||||
itemName,
|
||||
specification,
|
||||
supplier,
|
||||
inspector,
|
||||
inspectorDept,
|
||||
lotSize,
|
||||
materialNo,
|
||||
readOnly = false,
|
||||
}: InspectionModalProps) => {
|
||||
// 수입검사 템플릿 상태
|
||||
const [importTemplate, setImportTemplate] = useState<ImportInspectionTemplate | null>(null);
|
||||
const [importInitialValues, setImportInitialValues] = useState<InspectionItemValue[] | undefined>(undefined);
|
||||
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false);
|
||||
const [templateError, setTemplateError] = useState<string | null>(null);
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<div className="grid grid-cols-12 border-b border-gray-300 bg-gray-100">
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">No</div>
|
||||
<div className="col-span-4 p-2 text-sm font-medium text-center border-r border-gray-300">품목명</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">출/부호</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">규격</div>
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">수량</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center">상태</div>
|
||||
</div>
|
||||
{items.map((item, index) => (
|
||||
<div key={item.no} className={`grid grid-cols-12 ${index < items.length - 1 ? 'border-b border-gray-300' : ''}`}>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.no}</div>
|
||||
<div className="col-span-4 p-2 text-sm border-r border-gray-300">{item.name}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.location}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.spec}</div>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.qty}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
item.status === '완료' ? 'bg-green-100 text-green-700' :
|
||||
item.status === '작업중' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>{item.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
// 수입검사 저장용 ref/상태
|
||||
const importDocRef = useRef<ImportInspectionRef>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
{/* 특이사항 */}
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2.5 text-sm font-medium text-center">특이사항</div>
|
||||
<div className="p-4 min-h-[60px] text-sm">{order.instruction || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 줌 레벨 상수
|
||||
const ZOOM_LEVELS = [50, 75, 100, 125, 150, 200];
|
||||
const MIN_ZOOM = 50;
|
||||
const MAX_ZOOM = 200;
|
||||
|
||||
export const InspectionModal = ({ isOpen, onClose, document: doc, documentItem }: InspectionModalProps) => {
|
||||
// 줌 상태
|
||||
const [zoom, setZoom] = useState(100);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
|
||||
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 모달 열릴 때 상태 초기화
|
||||
// 수입검사 템플릿 로드 (모달 열릴 때)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setZoom(100);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
// itemId가 있으면 실제 API로 조회, 없으면 itemName/specification으로 mock 조회
|
||||
if (isOpen && doc?.type === 'import' && (itemId || (itemName && specification))) {
|
||||
loadInspectionTemplate();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 줌 인
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom(prev => {
|
||||
const nextIndex = ZOOM_LEVELS.findIndex(z => z > prev);
|
||||
return nextIndex !== -1 ? ZOOM_LEVELS[nextIndex] : MAX_ZOOM;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 줌 아웃
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom(prev => {
|
||||
const prevIndex = ZOOM_LEVELS.slice().reverse().findIndex(z => z < prev);
|
||||
const index = prevIndex !== -1 ? ZOOM_LEVELS.length - 1 - prevIndex : 0;
|
||||
return ZOOM_LEVELS[index] || MIN_ZOOM;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 줌 리셋
|
||||
const handleZoomReset = useCallback(() => {
|
||||
setZoom(100);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
// 마우스 드래그 시작
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (zoom > 100) {
|
||||
setIsDragging(true);
|
||||
setStartPos({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||||
// 모달 닫힐 때 상태 초기화
|
||||
if (!isOpen) {
|
||||
setImportTemplate(null);
|
||||
setImportInitialValues(undefined);
|
||||
setTemplateError(null);
|
||||
}
|
||||
}, [zoom, position]);
|
||||
}, [isOpen, doc?.type, itemId, itemName, specification]);
|
||||
|
||||
// 마우스 이동
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
setPosition({
|
||||
x: e.clientX - startPos.x,
|
||||
y: e.clientY - startPos.y,
|
||||
});
|
||||
}, [isDragging, startPos]);
|
||||
const loadInspectionTemplate = async () => {
|
||||
// itemId가 있으면 실제 API 호출, 없으면 itemName/specification 필요
|
||||
if (!itemId && (!itemName || !specification)) return;
|
||||
|
||||
// 마우스 드래그 종료
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
setIsLoadingTemplate(true);
|
||||
setTemplateError(null);
|
||||
|
||||
// 터치 드래그 시작
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (zoom > 100 && e.touches.length === 1) {
|
||||
setIsDragging(true);
|
||||
setStartPos({
|
||||
x: e.touches[0].clientX - position.x,
|
||||
y: e.touches[0].clientY - position.y,
|
||||
try {
|
||||
const result = await getInspectionTemplate({
|
||||
itemId,
|
||||
itemName,
|
||||
specification,
|
||||
lotNo: documentItem?.code,
|
||||
supplier,
|
||||
inspector,
|
||||
lotSize,
|
||||
materialNo,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
const tmpl = result.data as ImportInspectionTemplate;
|
||||
setImportTemplate(tmpl);
|
||||
|
||||
// 저장된 측정값을 initialValues로 변환
|
||||
const docData = result.resolveData?.document?.data;
|
||||
if (docData && docData.length > 0) {
|
||||
const values = parseSavedDataToInitialValues(tmpl, docData.map((d: { field_key: string; field_value?: string | null }) => ({ field_key: d.field_key, field_value: d.field_value ?? null })));
|
||||
setImportInitialValues(values);
|
||||
} else {
|
||||
setImportInitialValues(undefined);
|
||||
}
|
||||
} else {
|
||||
setTemplateError(result.error || '템플릿을 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[InspectionModal] loadInspectionTemplate error:', error);
|
||||
setTemplateError('템플릿 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoadingTemplate(false);
|
||||
}
|
||||
}, [zoom, position]);
|
||||
};
|
||||
|
||||
// 터치 이동
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!isDragging || e.touches.length !== 1) return;
|
||||
setPosition({
|
||||
x: e.touches[0].clientX - startPos.x,
|
||||
y: e.touches[0].clientY - startPos.y,
|
||||
});
|
||||
}, [isDragging, startPos]);
|
||||
// 수입검사 저장 핸들러 (hooks는 early return 전에 호출해야 함)
|
||||
const handleImportSave = useCallback(async () => {
|
||||
if (!importDocRef.current) return;
|
||||
|
||||
// 터치 종료
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
const data = importDocRef.current.getInspectionData();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// TODO: 실제 저장 API 연동
|
||||
toast.success('검사 데이터가 저장되었습니다.');
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!doc) return null;
|
||||
@@ -427,158 +353,149 @@ export const InspectionModal = ({ isOpen, onClose, document: doc, documentItem }
|
||||
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` 로트: ${documentItem.code}` : ''}`
|
||||
: docInfo.label;
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
// 중간검사 성적서 서브타입에 따른 렌더링
|
||||
const renderReportDocument = () => {
|
||||
const subType = documentItem?.subType;
|
||||
switch (subType) {
|
||||
case 'screen':
|
||||
return <ScreenInspectionDocument />;
|
||||
case 'bending':
|
||||
return <BendingInspectionDocument />;
|
||||
case 'slat':
|
||||
return <SlatInspectionDocument />;
|
||||
case 'jointbar':
|
||||
return <JointbarInspectionDocument />;
|
||||
default:
|
||||
// 서브타입이 없으면 기본 스크린 문서
|
||||
return <ScreenInspectionDocument />;
|
||||
}
|
||||
};
|
||||
|
||||
// 품질관리서 PDF 업로드 핸들러
|
||||
const handleQualityFileUpload = (file: File) => {
|
||||
// TODO: 실제 API 연동 시 파일 업로드 로직 구현
|
||||
};
|
||||
|
||||
const handleQualityFileDelete = () => {
|
||||
// TODO: 실제 API 연동 시 파일 삭제 로직 구현
|
||||
};
|
||||
|
||||
// 작업일지 공정별 렌더링
|
||||
const renderWorkLogDocument = () => {
|
||||
const subType = documentItem?.subType;
|
||||
const mockOrder = createQmsMockWorkOrder(subType);
|
||||
|
||||
switch (subType) {
|
||||
case 'screen':
|
||||
return <ScreenWorkLogContent data={mockOrder} />;
|
||||
case 'slat':
|
||||
return <SlatWorkLogContent data={mockOrder} />;
|
||||
case 'bending':
|
||||
return <BendingWorkLogContent data={mockOrder} />;
|
||||
default:
|
||||
// subType 미지정 시 스크린 기본
|
||||
return <ScreenWorkLogContent data={mockOrder} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일)
|
||||
const renderReportDocument = () => {
|
||||
const subType = documentItem?.subType;
|
||||
const mockOrder = createQmsMockWorkOrder(subType || 'screen');
|
||||
switch (subType) {
|
||||
case 'screen':
|
||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
||||
case 'bending':
|
||||
return <BendingInspectionContent data={mockOrder} readOnly />;
|
||||
case 'slat':
|
||||
return <SlatInspectionContent data={mockOrder} readOnly />;
|
||||
case 'jointbar':
|
||||
return <JointbarInspectionDocument />;
|
||||
default:
|
||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
||||
}
|
||||
};
|
||||
|
||||
// 수입검사 문서 렌더링 (Lazy Loading)
|
||||
const renderImportInspectionDocument = () => {
|
||||
if (isLoadingTemplate) {
|
||||
return <LoadingDocument />;
|
||||
}
|
||||
|
||||
if (templateError) {
|
||||
return <ErrorDocument message={templateError} onRetry={loadInspectionTemplate} />;
|
||||
}
|
||||
|
||||
// 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용
|
||||
return (
|
||||
<ImportInspectionDocument
|
||||
ref={importDocRef}
|
||||
template={importTemplate || undefined}
|
||||
initialValues={importInitialValues}
|
||||
readOnly={readOnly}
|
||||
inspectorDept={inspectorDept}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 문서 타입에 따른 컨텐츠 렌더링
|
||||
const renderDocumentContent = () => {
|
||||
switch (doc.type) {
|
||||
case 'order':
|
||||
return <OrderDocument />;
|
||||
return (
|
||||
<SalesOrderDocument
|
||||
orderNumber="KD-SS-240924-19"
|
||||
documentNumber="KD-SS-240924-19"
|
||||
certificationNumber="KD-SS-240924-19"
|
||||
orderDate="2024-09-24"
|
||||
client="삼성물산(주)"
|
||||
siteName="강남 아파트 단지"
|
||||
manager="김담당"
|
||||
managerContact="010-1234-5678"
|
||||
deliveryRequestDate="2024-10-05"
|
||||
expectedShipDate="2024-10-04"
|
||||
deliveryMethod="직접배차"
|
||||
address="서울시 강남구 테헤란로 123"
|
||||
recipientName="김인수"
|
||||
recipientContact="010-9876-5432"
|
||||
shutterCount={8}
|
||||
products={QMS_MOCK_PRODUCTS}
|
||||
items={QMS_MOCK_ORDER_ITEMS}
|
||||
remarks="납기일 엄수 요청"
|
||||
/>
|
||||
);
|
||||
case 'log':
|
||||
return <WorkLogDocument />;
|
||||
return renderWorkLogDocument();
|
||||
case 'confirmation':
|
||||
return <DeliveryConfirmation data={MOCK_SHIPMENT_DETAIL} />;
|
||||
case 'shipping':
|
||||
return <ShippingSlip data={MOCK_SHIPMENT_DETAIL} />;
|
||||
case 'import':
|
||||
return <ImportInspectionDocument />;
|
||||
return renderImportInspectionDocument();
|
||||
case 'product':
|
||||
return <ProductInspectionDocument />;
|
||||
return <InspectionReportDocument data={QMS_MOCK_REPORT_DATA} />;
|
||||
case 'report':
|
||||
return renderReportDocument();
|
||||
case 'quality':
|
||||
// 품질관리서는 PDF 업로드/뷰어 사용
|
||||
return (
|
||||
<QualityDocumentUploader
|
||||
onFileUpload={handleQualityFileUpload}
|
||||
onFileDelete={handleQualityFileDelete}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
// 양식 대기 중인 문서
|
||||
return <PlaceholderDocument docType={doc.type} docItem={documentItem} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 다운로드 핸들러 (TODO: 실제 구현)
|
||||
const handleDownload = () => {
|
||||
};
|
||||
|
||||
// 수입검사 저장 버튼 (toolbarExtra) - readOnly일 때 숨김
|
||||
const importToolbarExtra = doc.type === 'import' && !readOnly ? (
|
||||
<Button onClick={handleImportSave} disabled={isSaving} size="sm">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="w-[95vw] max-w-[1200px] sm:max-w-[1200px] h-[90vh] p-0 overflow-hidden bg-gray-50 flex flex-col">
|
||||
<DialogHeader className="p-4 bg-white border-b border-gray-200 flex flex-row items-center justify-between space-y-0 shrink-0">
|
||||
<div>
|
||||
<DialogTitle className="text-lg font-bold text-gray-800">{doc.title}</DialogTitle>
|
||||
<p className="text-xs text-gray-500 mt-1">{subtitle}</p>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-100 shrink-0 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
>
|
||||
<ZoomOut size={14} />
|
||||
<span className="hidden sm:inline">축소</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
<span className="hidden sm:inline">확대</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={handleZoomReset}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
<span className="hidden sm:inline">맞춤</span>
|
||||
</Button>
|
||||
<span className="text-xs font-mono text-gray-600 bg-gray-100 px-2 py-1 rounded min-w-[48px] text-center">
|
||||
{zoom}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs px-2 sm:px-3" onClick={handlePrint}>
|
||||
<Printer size={14} />
|
||||
<span className="hidden sm:inline">인쇄</span>
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1 text-xs px-2 sm:px-3">
|
||||
<Download size={14} />
|
||||
<span className="hidden sm:inline">다운로드</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area - 줌/드래그 가능한 영역 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-auto bg-gray-100 relative"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={{ cursor: zoom > 100 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="p-4 origin-top-left transition-transform duration-150 ease-out"
|
||||
style={{
|
||||
transform: `scale(${zoom / 100}) translate(${position.x / (zoom / 100)}px, ${position.y / (zoom / 100)}px)`,
|
||||
minWidth: '800px',
|
||||
}}
|
||||
>
|
||||
{renderDocumentContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 줌 힌트 */}
|
||||
{zoom === 100 && (
|
||||
<div className="sm:hidden absolute bottom-20 left-1/2 -translate-x-1/2 bg-black/70 text-white text-xs px-3 py-1.5 rounded-full">
|
||||
확대 후 드래그로 이동
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DocumentViewer
|
||||
title={doc.title}
|
||||
subtitle={subtitle}
|
||||
preset="inspection"
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
onDownload={handleDownload}
|
||||
toolbarExtra={importToolbarExtra}
|
||||
>
|
||||
{renderDocumentContent()}
|
||||
</DocumentViewer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { AlertCircle, Loader2, Save } from 'lucide-react';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { Document, DocumentItem } from '../types';
|
||||
import { MOCK_SHIPMENT_DETAIL } from '../mockData';
|
||||
|
||||
// 기존 문서 컴포넌트 import
|
||||
import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation';
|
||||
import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip';
|
||||
|
||||
// 수주서 문서 컴포넌트 import
|
||||
import { SalesOrderDocument } from '@/components/orders/documents/SalesOrderDocument';
|
||||
import type { ProductInfo } from '@/components/orders/documents/OrderDocumentModal';
|
||||
import type { OrderItem } from '@/components/orders/actions';
|
||||
|
||||
// 품질검사 문서 컴포넌트 import
|
||||
import {
|
||||
ImportInspectionDocument,
|
||||
JointbarInspectionDocument,
|
||||
QualityDocumentUploader,
|
||||
} from './documents';
|
||||
|
||||
// 제품검사 성적서 (신규 양식) import
|
||||
import { InspectionReportDocument } from '@/components/quality/InspectionManagement/documents/InspectionReportDocument';
|
||||
import { mockReportInspectionItems } from '@/components/quality/InspectionManagement/mockData';
|
||||
import type { InspectionReportDocument as InspectionReportDocumentType } from '@/components/quality/InspectionManagement/types';
|
||||
import type { ImportInspectionTemplate, ImportInspectionRef, InspectionItemValue } from './documents/ImportInspectionDocument';
|
||||
|
||||
// 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전)
|
||||
import {
|
||||
ScreenWorkLogContent,
|
||||
SlatWorkLogContent,
|
||||
BendingWorkLogContent,
|
||||
ScreenInspectionContent,
|
||||
SlatInspectionContent,
|
||||
BendingInspectionContent,
|
||||
} from '@/components/production/WorkOrders/documents';
|
||||
import type { WorkOrder } from '@/components/production/WorkOrders/types';
|
||||
|
||||
// 검사 템플릿 API
|
||||
import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions';
|
||||
|
||||
/**
|
||||
* 저장된 document.data (field_key 기반)를 ImportInspectionDocument의 initialValues로 변환
|
||||
*
|
||||
* field_key 패턴:
|
||||
* - {itemId}_n{1,2,3} → numeric 측정값
|
||||
* - {itemId}_okng_n{1,2,3} → OK/NG 값
|
||||
* - {itemId}_result → 항목별 판정
|
||||
*/
|
||||
function parseSavedDataToInitialValues(
|
||||
tmpl: ImportInspectionTemplate,
|
||||
docData: Array<{ field_key: string; field_value: string | null }>
|
||||
): InspectionItemValue[] {
|
||||
// field_key → value 맵 생성
|
||||
const dataMap = new Map<string, string>();
|
||||
for (const d of docData) {
|
||||
if (d.field_value) dataMap.set(d.field_key, d.field_value);
|
||||
}
|
||||
|
||||
return tmpl.inspectionItems.map((item) => {
|
||||
const isOkng = item.measurementType === 'okng';
|
||||
const measurements: (number | 'OK' | 'NG' | null)[] = Array(item.measurementCount).fill(null);
|
||||
|
||||
for (let n = 0; n < item.measurementCount; n++) {
|
||||
if (isOkng) {
|
||||
const val = dataMap.get(`${item.id}_okng_n${n + 1}`);
|
||||
if (val === 'ok') measurements[n] = 'OK';
|
||||
else if (val === 'ng') measurements[n] = 'NG';
|
||||
} else {
|
||||
const val = dataMap.get(`${item.id}_n${n + 1}`);
|
||||
if (val) {
|
||||
const num = parseFloat(val);
|
||||
measurements[n] = isNaN(num) ? null : num;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 항목별 판정
|
||||
const resultVal = dataMap.get(`${item.id}_result`);
|
||||
let result: 'OK' | 'NG' | null = null;
|
||||
if (resultVal === 'ok') result = 'OK';
|
||||
else if (resultVal === 'ng') result = 'NG';
|
||||
|
||||
return { itemId: item.id, measurements, result };
|
||||
});
|
||||
}
|
||||
|
||||
interface InspectionModalV2Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
document: Document | null;
|
||||
documentItem: DocumentItem | null;
|
||||
// 수입검사 템플릿 로드용 추가 props
|
||||
itemId?: number; // 품목 ID (실제 API로 템플릿 조회 시 사용)
|
||||
itemName?: string;
|
||||
specification?: string;
|
||||
supplier?: string;
|
||||
inspector?: string; // 검사자 (현재 로그인 사용자)
|
||||
inspectorDept?: string; // 검사자 부서
|
||||
lotSize?: number; // 로트크기 (입고수량)
|
||||
materialNo?: string; // 자재번호
|
||||
// 읽기 전용 모드 (QMS 심사 확인용)
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
// 문서 타입별 정보
|
||||
const DOCUMENT_INFO: Record<string, { label: string; hasTemplate: boolean; color: string }> = {
|
||||
import: { label: '수입검사 성적서', hasTemplate: true, color: 'text-green-600' },
|
||||
order: { label: '수주서', hasTemplate: true, color: 'text-blue-600' },
|
||||
log: { label: '작업일지', hasTemplate: true, color: 'text-orange-500' },
|
||||
report: { label: '중간검사 성적서', hasTemplate: true, color: 'text-blue-500' },
|
||||
confirmation: { label: '납품확인서', hasTemplate: true, color: 'text-red-500' },
|
||||
shipping: { label: '출고증', hasTemplate: true, color: 'text-gray-600' },
|
||||
product: { label: '제품검사 성적서', hasTemplate: true, color: 'text-green-500' },
|
||||
quality: { label: '품질관리서', hasTemplate: false, color: 'text-purple-600' },
|
||||
};
|
||||
|
||||
// Placeholder 컴포넌트 (양식 대기 문서용)
|
||||
const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: DocumentItem | null }) => {
|
||||
const info = DOCUMENT_INFO[docType] || { label: '문서', hasTemplate: false, color: 'text-gray-600' };
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
|
||||
<div className="w-16 h-16 text-amber-500 mb-4 mx-auto">
|
||||
<AlertCircle className="w-full h-full" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-2">{info.label}</h2>
|
||||
<p className="text-gray-500 text-sm mb-2">{docItem?.title || '문서'}</p>
|
||||
{docItem?.date && (
|
||||
<p className="text-gray-400 text-xs mb-2">{docItem.date}</p>
|
||||
)}
|
||||
{docItem?.code && (
|
||||
<p className="text-xs text-green-600 font-mono bg-green-50 px-2 py-1 rounded mb-4">
|
||||
로트 번호: {docItem.code}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 p-4 bg-amber-50 rounded-lg border border-amber-200">
|
||||
<p className="text-amber-700 text-sm font-medium">양식 준비 중</p>
|
||||
<p className="text-amber-600 text-xs mt-1">디자인 파일이 필요합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// QMS용 수주서 Mock 데이터
|
||||
const QMS_MOCK_PRODUCTS: ProductInfo[] = [
|
||||
{ productName: '방화 스크린 셔터 (표준형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 5, floor: '1F', code: 'FSS-01' },
|
||||
{ productName: '방화 스크린 셔터 (방화형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 3, floor: '2F', code: 'FSS-02' },
|
||||
];
|
||||
const QMS_MOCK_ORDER_ITEMS: OrderItem[] = [
|
||||
{ id: 'mt-1', itemCode: 'MT-001', itemName: '모터(380V 단상)', specification: '150K', type: '모터', quantity: 8, unit: 'EA', unitPrice: 120000, supplyAmount: 960000, taxAmount: 96000, totalAmount: 1056000, sortOrder: 1 },
|
||||
{ id: 'br-1', itemCode: 'BR-001', itemName: '브라켓트', specification: '380X180 [2-4"]', type: '브라켓', quantity: 16, unit: 'EA', unitPrice: 15000, supplyAmount: 240000, taxAmount: 24000, totalAmount: 264000, sortOrder: 2 },
|
||||
{ id: 'gr-1', itemCode: 'GR-001', itemName: '가이드레일 백면형 (120X70)', specification: 'EGI 1.5ST', type: '가이드레일', quantity: 16, unit: 'EA', unitPrice: 25000, supplyAmount: 400000, taxAmount: 40000, totalAmount: 440000, width: 120, height: 2500, sortOrder: 3 },
|
||||
{ id: 'cs-1', itemCode: 'CS-001', itemName: '케이스(셔터박스)', specification: 'EGI 1.5ST 380X180', type: '케이스', quantity: 8, unit: 'EA', unitPrice: 35000, supplyAmount: 280000, taxAmount: 28000, totalAmount: 308000, width: 380, height: 180, sortOrder: 4 },
|
||||
{ id: 'bf-1', itemCode: 'BF-001', itemName: '하단마감재', specification: 'EGI 1.5ST', type: '하단마감재', quantity: 8, unit: 'EA', unitPrice: 18000, supplyAmount: 144000, taxAmount: 14400, totalAmount: 158400, sortOrder: 5 },
|
||||
];
|
||||
|
||||
// QMS용 제품검사 성적서 Mock 데이터
|
||||
const QMS_MOCK_REPORT_DATA: InspectionReportDocumentType = {
|
||||
documentNumber: 'RPT-KD-SS-2024-530',
|
||||
createdDate: '2024-09-24',
|
||||
approvalLine: [
|
||||
{ role: '작성', name: '김검사', department: '품질관리부' },
|
||||
{ role: '승인', name: '박승인', department: '품질관리부' },
|
||||
],
|
||||
productName: '방화스크린',
|
||||
productLotNo: 'KD-SS-240924-19',
|
||||
productCode: 'WY-SC780',
|
||||
lotSize: '8',
|
||||
client: '삼성물산(주)',
|
||||
inspectionDate: '2024-09-26',
|
||||
siteName: '강남 아파트 단지',
|
||||
inspector: '김검사',
|
||||
inspectionItems: mockReportInspectionItems,
|
||||
specialNotes: '',
|
||||
finalJudgment: '합격',
|
||||
};
|
||||
|
||||
// QMS용 작업일지 Mock WorkOrder 생성
|
||||
const createQmsMockWorkOrder = (subType?: string): WorkOrder => ({
|
||||
id: 'qms-wo-1',
|
||||
workOrderNo: 'KD-WO-240924-01',
|
||||
lotNo: 'KD-SS-240924-19',
|
||||
processId: 1,
|
||||
processName: subType === 'slat' ? '슬랫' : subType === 'bending' ? '절곡' : '스크린',
|
||||
processCode: subType || 'screen',
|
||||
processType: (subType || 'screen') as 'screen' | 'slat' | 'bending',
|
||||
status: 'in_progress',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '강남 아파트 단지',
|
||||
dueDate: '2024-10-05',
|
||||
assignee: '김작업',
|
||||
assignees: [
|
||||
{ id: '1', name: '김작업', isPrimary: true },
|
||||
{ id: '2', name: '이생산', isPrimary: false },
|
||||
],
|
||||
orderDate: '2024-09-20',
|
||||
scheduledDate: '2024-09-24',
|
||||
shipmentDate: '2024-10-04',
|
||||
salesOrderDate: '2024-09-18',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 3,
|
||||
priorityLabel: '긴급',
|
||||
shutterCount: 5,
|
||||
department: '생산부',
|
||||
items: [
|
||||
{ id: '1', no: 1, status: 'completed', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '3000×2500', quantity: 2, unit: 'EA', orderNodeId: null, orderNodeName: '' },
|
||||
{ id: '2', no: 2, status: 'in_progress', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '3000×2500', quantity: 3, unit: 'EA', orderNodeId: null, orderNodeName: '' },
|
||||
{ id: '3', no: 3, status: 'waiting', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12000×4500', quantity: 1, unit: 'EA', orderNodeId: null, orderNodeName: '' },
|
||||
],
|
||||
currentStep: 2,
|
||||
issues: [],
|
||||
note: '품질 검수 철저히 진행',
|
||||
});
|
||||
|
||||
// 로딩 컴포넌트
|
||||
const LoadingDocument = () => (
|
||||
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
|
||||
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
|
||||
<p className="text-gray-600 text-sm">검사 템플릿을 불러오는 중...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 에러 컴포넌트
|
||||
const ErrorDocument = ({ message, onRetry }: { message: string; onRetry?: () => void }) => (
|
||||
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
||||
<p className="text-gray-800 font-medium mb-2">템플릿 로드 실패</p>
|
||||
<p className="text-gray-500 text-sm mb-4">{message}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* InspectionModal V2
|
||||
* - DocumentViewer 시스템 사용
|
||||
* - 수입검사: 모달 열릴 때 API로 템플릿 로드 (Lazy Loading)
|
||||
*/
|
||||
export const InspectionModalV2 = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
document: doc,
|
||||
documentItem,
|
||||
itemId,
|
||||
itemName,
|
||||
specification,
|
||||
supplier,
|
||||
inspector,
|
||||
inspectorDept,
|
||||
lotSize,
|
||||
materialNo,
|
||||
readOnly = false,
|
||||
}: InspectionModalV2Props) => {
|
||||
// 수입검사 템플릿 상태
|
||||
const [importTemplate, setImportTemplate] = useState<ImportInspectionTemplate | null>(null);
|
||||
const [importInitialValues, setImportInitialValues] = useState<InspectionItemValue[] | undefined>(undefined);
|
||||
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false);
|
||||
const [templateError, setTemplateError] = useState<string | null>(null);
|
||||
|
||||
// 수입검사 저장용 ref/상태
|
||||
const importDocRef = useRef<ImportInspectionRef>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 수입검사 템플릿 로드 (모달 열릴 때)
|
||||
useEffect(() => {
|
||||
// itemId가 있으면 실제 API로 조회, 없으면 itemName/specification으로 mock 조회
|
||||
if (isOpen && doc?.type === 'import' && (itemId || (itemName && specification))) {
|
||||
loadInspectionTemplate();
|
||||
}
|
||||
// 모달 닫힐 때 상태 초기화
|
||||
if (!isOpen) {
|
||||
setImportTemplate(null);
|
||||
setImportInitialValues(undefined);
|
||||
setTemplateError(null);
|
||||
}
|
||||
}, [isOpen, doc?.type, itemId, itemName, specification]);
|
||||
|
||||
const loadInspectionTemplate = async () => {
|
||||
// itemId가 있으면 실제 API 호출, 없으면 itemName/specification 필요
|
||||
if (!itemId && (!itemName || !specification)) return;
|
||||
|
||||
setIsLoadingTemplate(true);
|
||||
setTemplateError(null);
|
||||
|
||||
try {
|
||||
const result = await getInspectionTemplate({
|
||||
itemId,
|
||||
itemName,
|
||||
specification,
|
||||
lotNo: documentItem?.code,
|
||||
supplier,
|
||||
inspector,
|
||||
lotSize,
|
||||
materialNo,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
const tmpl = result.data as ImportInspectionTemplate;
|
||||
setImportTemplate(tmpl);
|
||||
|
||||
// 저장된 측정값을 initialValues로 변환
|
||||
const docData = result.resolveData?.document?.data;
|
||||
if (docData && docData.length > 0) {
|
||||
const values = parseSavedDataToInitialValues(tmpl, docData.map((d: { field_key: string; field_value?: string | null }) => ({ field_key: d.field_key, field_value: d.field_value ?? null })));
|
||||
setImportInitialValues(values);
|
||||
} else {
|
||||
setImportInitialValues(undefined);
|
||||
}
|
||||
} else {
|
||||
setTemplateError(result.error || '템플릿을 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[InspectionModalV2] loadInspectionTemplate error:', error);
|
||||
setTemplateError('템플릿 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoadingTemplate(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 수입검사 저장 핸들러 (hooks는 early return 전에 호출해야 함)
|
||||
const handleImportSave = useCallback(async () => {
|
||||
if (!importDocRef.current) return;
|
||||
|
||||
const data = importDocRef.current.getInspectionData();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// TODO: 실제 저장 API 연동
|
||||
toast.success('검사 데이터가 저장되었습니다.');
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!doc) return null;
|
||||
|
||||
const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' };
|
||||
const subtitle = documentItem
|
||||
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` 로트: ${documentItem.code}` : ''}`
|
||||
: docInfo.label;
|
||||
|
||||
// 품질관리서 PDF 업로드 핸들러
|
||||
const handleQualityFileUpload = (file: File) => {
|
||||
};
|
||||
|
||||
const handleQualityFileDelete = () => {
|
||||
};
|
||||
|
||||
// 작업일지 공정별 렌더링
|
||||
const renderWorkLogDocument = () => {
|
||||
const subType = documentItem?.subType;
|
||||
const mockOrder = createQmsMockWorkOrder(subType);
|
||||
|
||||
switch (subType) {
|
||||
case 'screen':
|
||||
return <ScreenWorkLogContent data={mockOrder} />;
|
||||
case 'slat':
|
||||
return <SlatWorkLogContent data={mockOrder} />;
|
||||
case 'bending':
|
||||
return <BendingWorkLogContent data={mockOrder} />;
|
||||
default:
|
||||
// subType 미지정 시 스크린 기본
|
||||
return <ScreenWorkLogContent data={mockOrder} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일)
|
||||
const renderReportDocument = () => {
|
||||
const subType = documentItem?.subType;
|
||||
const mockOrder = createQmsMockWorkOrder(subType || 'screen');
|
||||
switch (subType) {
|
||||
case 'screen':
|
||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
||||
case 'bending':
|
||||
return <BendingInspectionContent data={mockOrder} readOnly />;
|
||||
case 'slat':
|
||||
return <SlatInspectionContent data={mockOrder} readOnly />;
|
||||
case 'jointbar':
|
||||
return <JointbarInspectionDocument />;
|
||||
default:
|
||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
||||
}
|
||||
};
|
||||
|
||||
// 수입검사 문서 렌더링 (Lazy Loading)
|
||||
const renderImportInspectionDocument = () => {
|
||||
if (isLoadingTemplate) {
|
||||
return <LoadingDocument />;
|
||||
}
|
||||
|
||||
if (templateError) {
|
||||
return <ErrorDocument message={templateError} onRetry={loadInspectionTemplate} />;
|
||||
}
|
||||
|
||||
// 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용
|
||||
return (
|
||||
<ImportInspectionDocument
|
||||
ref={importDocRef}
|
||||
template={importTemplate || undefined}
|
||||
initialValues={importInitialValues}
|
||||
readOnly={readOnly}
|
||||
inspectorDept={inspectorDept}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 문서 타입에 따른 컨텐츠 렌더링
|
||||
const renderDocumentContent = () => {
|
||||
switch (doc.type) {
|
||||
case 'order':
|
||||
return (
|
||||
<SalesOrderDocument
|
||||
orderNumber="KD-SS-240924-19"
|
||||
documentNumber="KD-SS-240924-19"
|
||||
certificationNumber="KD-SS-240924-19"
|
||||
orderDate="2024-09-24"
|
||||
client="삼성물산(주)"
|
||||
siteName="강남 아파트 단지"
|
||||
manager="김담당"
|
||||
managerContact="010-1234-5678"
|
||||
deliveryRequestDate="2024-10-05"
|
||||
expectedShipDate="2024-10-04"
|
||||
deliveryMethod="직접배차"
|
||||
address="서울시 강남구 테헤란로 123"
|
||||
recipientName="김인수"
|
||||
recipientContact="010-9876-5432"
|
||||
shutterCount={8}
|
||||
products={QMS_MOCK_PRODUCTS}
|
||||
items={QMS_MOCK_ORDER_ITEMS}
|
||||
remarks="납기일 엄수 요청"
|
||||
/>
|
||||
);
|
||||
case 'log':
|
||||
return renderWorkLogDocument();
|
||||
case 'confirmation':
|
||||
return <DeliveryConfirmation data={MOCK_SHIPMENT_DETAIL} />;
|
||||
case 'shipping':
|
||||
return <ShippingSlip data={MOCK_SHIPMENT_DETAIL} />;
|
||||
case 'import':
|
||||
return renderImportInspectionDocument();
|
||||
case 'product':
|
||||
return <InspectionReportDocument data={QMS_MOCK_REPORT_DATA} />;
|
||||
case 'report':
|
||||
return renderReportDocument();
|
||||
case 'quality':
|
||||
return (
|
||||
<QualityDocumentUploader
|
||||
onFileUpload={handleQualityFileUpload}
|
||||
onFileDelete={handleQualityFileDelete}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <PlaceholderDocument docType={doc.type} docItem={documentItem} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 다운로드 핸들러 (TODO: 실제 구현)
|
||||
const handleDownload = () => {
|
||||
};
|
||||
|
||||
// 수입검사 저장 버튼 (toolbarExtra) - readOnly일 때 숨김
|
||||
const importToolbarExtra = doc.type === 'import' && !readOnly ? (
|
||||
<Button onClick={handleImportSave} disabled={isSaving} size="sm">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title={doc.title}
|
||||
subtitle={subtitle}
|
||||
preset="inspection"
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
onDownload={handleDownload}
|
||||
toolbarExtra={importToolbarExtra}
|
||||
>
|
||||
{renderDocumentContent()}
|
||||
</DocumentViewer>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { ReportList } from './components/ReportList';
|
||||
import { RouteList } from './components/RouteList';
|
||||
import { DocumentList } from './components/DocumentList';
|
||||
// import { InspectionModal } from './components/InspectionModal';
|
||||
import { InspectionModalV2 as InspectionModal } from './components/InspectionModalV2';
|
||||
import { InspectionModal } from './components/InspectionModal';
|
||||
import { DayTabs } from './components/DayTabs';
|
||||
import { Day1ChecklistPanel } from './components/Day1ChecklistPanel';
|
||||
import { Day1DocumentSection } from './components/Day1DocumentSection';
|
||||
|
||||
@@ -259,6 +259,17 @@ export default function CustomerAccountManagementPage() {
|
||||
setSelectedItems(new Set()); // 페이지 변경 시 선택 초기화
|
||||
}, []);
|
||||
|
||||
// 테이블 컬럼 정의 (Hooks 순서 보장을 위해 조건부 return 전에 정의)
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: "rowNumber", label: "번호", className: "px-4" },
|
||||
{ key: "code", label: "코드", className: "px-4" },
|
||||
{ key: "clientType", label: "구분", className: "px-4" },
|
||||
{ key: "name", label: "거래처명", className: "px-4" },
|
||||
{ key: "representative", label: "대표자", className: "px-4" },
|
||||
{ key: "manager", label: "담당자", className: "px-4" },
|
||||
{ key: "phone", label: "전화번호", className: "px-4" },
|
||||
], []);
|
||||
|
||||
// 핸들러 - 페이지 기반 네비게이션
|
||||
const handleAddNew = () => {
|
||||
router.push("/sales/client-management-sales-admin?mode=new");
|
||||
@@ -434,17 +445,6 @@ export default function CustomerAccountManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: "rowNumber", label: "번호", className: "px-4" },
|
||||
{ key: "code", label: "코드", className: "px-4" },
|
||||
{ key: "clientType", label: "구분", className: "px-4" },
|
||||
{ key: "name", label: "거래처명", className: "px-4" },
|
||||
{ key: "representative", label: "대표자", className: "px-4" },
|
||||
{ key: "manager", label: "담당자", className: "px-4" },
|
||||
{ key: "phone", label: "전화번호", className: "px-4" },
|
||||
], []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
customer: Client,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 견적 상세/수정 페이지 (V2 UI)
|
||||
*
|
||||
* IntegratedDetailTemplate + QuoteRegistrationV2
|
||||
* IntegratedDetailTemplate + QuoteRegistration
|
||||
* URL 패턴:
|
||||
* - /quote-management/[id] → 상세 보기 (view)
|
||||
* - /quote-management/[id]?mode=edit → 수정 모드 (edit)
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { QuoteRegistrationV2 } from "@/components/quotes/QuoteRegistrationV2";
|
||||
import type { QuoteFormDataV2 } from "@/components/quotes/QuoteRegistrationV2";
|
||||
import { QuoteRegistration } from "@/components/quotes/QuoteRegistration";
|
||||
import type { QuoteFormDataV2 } from "@/components/quotes/QuoteRegistration";
|
||||
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
|
||||
import { quoteConfig } from "@/components/quotes/quoteConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -196,7 +196,7 @@ export default function QuoteDetailPage() {
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
<QuoteRegistration
|
||||
mode={isEditMode ? "edit" : "view"}
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
|
||||
@@ -1,688 +0,0 @@
|
||||
/**
|
||||
* 견적 상세/수정 페이지 (V2 통합)
|
||||
* - 기본 정보 표시 (view mode)
|
||||
* - 자동 견적 산출 정보
|
||||
* - 견적서 / 산출내역서 / 발주서 모달
|
||||
* - 수정 모드 (edit mode)
|
||||
*
|
||||
* URL 패턴:
|
||||
* - /quote-management/[id] → 상세 보기 (view)
|
||||
* - /quote-management/[id]?mode=edit → 수정 모드 (edit)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { QuoteRegistration, QuoteFormData } from "@/components/quotes/QuoteRegistration";
|
||||
import { QuoteDocument } from "@/components/quotes/QuoteDocument";
|
||||
import { QuoteCalculationReport } from "@/components/quotes/QuoteCalculationReport";
|
||||
import { PurchaseOrderDocument } from "@/components/quotes/PurchaseOrderDocument";
|
||||
import {
|
||||
getQuoteById,
|
||||
finalizeQuote,
|
||||
convertQuoteToOrder,
|
||||
sendQuoteEmail,
|
||||
sendQuoteKakao,
|
||||
transformQuoteToFormData,
|
||||
updateQuote,
|
||||
transformFormDataToApi,
|
||||
} from "@/components/quotes";
|
||||
import { getCompanyInfo } from "@/components/settings/CompanyInfoManagement/actions";
|
||||
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
|
||||
import { getItemTypeCodes, type CommonCode } from "@/lib/api/common-codes";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { DocumentViewer } from "@/components/document-system";
|
||||
import {
|
||||
FileText,
|
||||
Edit,
|
||||
List,
|
||||
Printer,
|
||||
FileOutput,
|
||||
FileCheck,
|
||||
Package,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { DetailPageSkeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function QuoteDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const quoteId = params.id as string;
|
||||
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get("mode") || "view";
|
||||
const isEditMode = mode === "edit";
|
||||
|
||||
const [quote, setQuote] = useState<QuoteFormData | null>(null);
|
||||
const [companyInfo, setCompanyInfo] = useState<CompanyFormData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [isQuoteDocumentOpen, setIsQuoteDocumentOpen] = useState(false);
|
||||
const [isCalculationReportOpen, setIsCalculationReportOpen] = useState(false);
|
||||
const [isPurchaseOrderOpen, setIsPurchaseOrderOpen] = useState(false);
|
||||
|
||||
// 산출내역서 표시 옵션
|
||||
const [showDetailedBreakdown, setShowDetailedBreakdown] = useState(true);
|
||||
const [showMaterialList, setShowMaterialList] = useState(true);
|
||||
|
||||
// BOM 자재 상세 펼침/접힘 상태
|
||||
const [isBomExpanded, setIsBomExpanded] = useState(true);
|
||||
|
||||
// 공통 코드 (item_type)
|
||||
const [itemTypeCodes, setItemTypeCodes] = useState<CommonCode[]>([]);
|
||||
|
||||
// 견적 데이터 조회
|
||||
const fetchQuote = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getQuoteById(quoteId);
|
||||
if (result.success && result.data) {
|
||||
// 디버깅: Quote 변환 전 데이터
|
||||
console.log('[QuoteDetail] Quote data:', {
|
||||
clientId: result.data.clientId,
|
||||
clientName: result.data.clientName,
|
||||
calculationInputs: result.data.calculationInputs,
|
||||
items: result.data.items?.map(item => ({
|
||||
productName: item.productName,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
totalAmount: item.totalAmount,
|
||||
})),
|
||||
});
|
||||
|
||||
const formData = transformQuoteToFormData(result.data);
|
||||
|
||||
// 디버깅: QuoteFormData 변환 후 데이터
|
||||
console.log('[QuoteDetail] FormData:', {
|
||||
clientId: formData.clientId,
|
||||
clientName: formData.clientName,
|
||||
items: formData.items?.map(item => ({
|
||||
productName: item.productName,
|
||||
quantity: item.quantity,
|
||||
inspectionFee: item.inspectionFee,
|
||||
totalAmount: item.totalAmount,
|
||||
})),
|
||||
});
|
||||
|
||||
setQuote(formData);
|
||||
} else {
|
||||
toast.error(result.error || "견적 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/quote-management");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("견적 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/quote-management");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [quoteId, router]);
|
||||
|
||||
// 회사 정보 조회
|
||||
const fetchCompanyInfo = useCallback(async () => {
|
||||
try {
|
||||
const result = await getCompanyInfo();
|
||||
if (result.success && result.data) {
|
||||
setCompanyInfo(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[QuoteDetail] Failed to fetch company info:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 공통 코드 조회
|
||||
const fetchItemTypeCodes = useCallback(async () => {
|
||||
const result = await getItemTypeCodes();
|
||||
if (result.success && result.data) {
|
||||
setItemTypeCodes(result.data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// item_type 코드 → 이름 변환 헬퍼
|
||||
const getItemTypeLabel = useCallback((code: string | undefined | null): string => {
|
||||
if (!code) return '-';
|
||||
const found = itemTypeCodes.find(item => item.code === code);
|
||||
return found?.name || code;
|
||||
}, [itemTypeCodes]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuote();
|
||||
fetchCompanyInfo();
|
||||
fetchItemTypeCodes();
|
||||
}, [fetchQuote, fetchCompanyInfo, fetchItemTypeCodes]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/quote-management/${quoteId}?mode=edit`);
|
||||
};
|
||||
|
||||
// V2 패턴: 수정 저장 핸들러
|
||||
const handleSave = async (formData: QuoteFormData): Promise<{ success: boolean; error?: string }> => {
|
||||
if (isSaving) return { success: false, error: '저장 중입니다.' };
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const apiData = transformFormDataToApi(formData);
|
||||
const result = await updateQuote(quoteId, apiData as any);
|
||||
|
||||
if (result.success) {
|
||||
// toast는 IntegratedDetailTemplate에서 처리
|
||||
router.push(`/sales/quote-management/${quoteId}`);
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: result.error || "견적 수정에 실패했습니다." };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: "견적 수정에 실패했습니다." };
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (isProcessing) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await finalizeQuote(quoteId);
|
||||
if (result.success) {
|
||||
toast.success("견적이 최종 확정되었습니다.");
|
||||
fetchQuote(); // 데이터 새로고침
|
||||
} else {
|
||||
toast.error(result.error || "견적 확정에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("견적 확정에 실패했습니다.");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvertToOrder = async () => {
|
||||
if (isProcessing) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await convertQuoteToOrder(quoteId);
|
||||
if (result.success) {
|
||||
toast.success("수주로 전환되었습니다.");
|
||||
if (result.orderId) {
|
||||
router.push(`/sales/order-management/${result.orderId}`);
|
||||
} else {
|
||||
router.push("/sales/order-management");
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || "수주 전환에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("수주 전환에 실패했습니다.");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
if (isProcessing) return;
|
||||
// TODO: 이메일 입력 다이얼로그 추가
|
||||
const email = prompt("발송할 이메일 주소를 입력하세요:");
|
||||
if (!email) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await sendQuoteEmail(quoteId, { email });
|
||||
if (result.success) {
|
||||
toast.success("이메일이 발송되었습니다.");
|
||||
} else {
|
||||
toast.error(result.error || "이메일 발송에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("이메일 발송에 실패했습니다.");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendKakao = async () => {
|
||||
if (isProcessing) return;
|
||||
// TODO: 카카오 발송 다이얼로그 추가
|
||||
const phone = prompt("발송할 전화번호를 입력하세요:");
|
||||
if (!phone) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await sendQuoteKakao(quoteId, { phone });
|
||||
if (result.success) {
|
||||
toast.success("카카오톡이 발송되었습니다.");
|
||||
} else {
|
||||
toast.error(result.error || "카카오톡 발송에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("카카오톡 발송에 실패했습니다.");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return "-";
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number | undefined) => {
|
||||
if (!amount) return "0";
|
||||
return amount.toLocaleString("ko-KR");
|
||||
};
|
||||
|
||||
// 총 금액 계산 (실제 금액 우선, 없으면 검사비 사용)
|
||||
const totalAmount =
|
||||
quote?.items?.reduce((sum, item) => {
|
||||
// totalAmount가 있으면 사용, 없으면 unitPrice * quantity, 마지막으로 inspectionFee
|
||||
const itemAmount = item.totalAmount || (item.unitPrice || 0) * (item.quantity || 1) || (item.inspectionFee || 0) * (item.quantity || 1);
|
||||
return sum + itemAmount;
|
||||
}, 0) || 0;
|
||||
|
||||
if (isLoading) {
|
||||
return <DetailPageSkeleton sections={2} fieldsPerSection={6} />;
|
||||
}
|
||||
|
||||
if (!quote) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-gray-500">견적 정보를 찾을 수 없습니다.</p>
|
||||
<Button onClick={handleBack} className="mt-4">
|
||||
목록으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// V2 패턴: Edit 모드일 때 QuoteRegistration 컴포넌트 렌더링
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<QuoteRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
editingQuote={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// View 모드: 상세 보기
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" />
|
||||
견적 상세
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">견적번호: {quote.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* 문서 버튼들 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsQuoteDocumentOpen(true)}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
견적서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCalculationReportOpen(true)}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
산출내역서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsPurchaseOrderOpen(true)}
|
||||
>
|
||||
<FileOutput className="w-4 h-4 mr-2" />
|
||||
발주서
|
||||
</Button>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="w-4 h-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFinalize}
|
||||
disabled={isProcessing}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<FileCheck className="w-4 h-4 mr-2" />
|
||||
최종확정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>견적번호</Label>
|
||||
<Input
|
||||
value={quote.id || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={quote.writer || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>발주처</Label>
|
||||
<Input
|
||||
value={quote.clientName || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>담당자</Label>
|
||||
<Input
|
||||
value={quote.manager || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>연락처</Label>
|
||||
<Input
|
||||
value={quote.contact || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>현장명</Label>
|
||||
<Input
|
||||
value={quote.siteName || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>등록일</Label>
|
||||
<Input
|
||||
value={formatDate(quote.registrationDate || "")}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>납기일</Label>
|
||||
<Input
|
||||
value={formatDate(quote.dueDate || "")}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quote.remarks && (
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={quote.remarks}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자동 견적 산출 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>자동 견적 산출 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{quote.items && quote.items.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{quote.items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border rounded-lg p-4 bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant="outline">항목 {index + 1}</Badge>
|
||||
<Badge variant="secondary">{item.floor}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">제품명</span>
|
||||
<p className="font-medium">{item.productName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">오픈사이즈</span>
|
||||
<p className="font-medium">
|
||||
{item.openWidth} × {item.openHeight} mm
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">수량</span>
|
||||
<p className="font-medium">{item.quantity} SET</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">금액</span>
|
||||
<p className="font-medium text-blue-600">
|
||||
₩{formatAmount(item.totalAmount || (item.unitPrice || 0) * (item.quantity || 1) || (item.inspectionFee || 0) * (item.quantity || 1))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<div className="flex justify-between items-center text-lg font-bold">
|
||||
<span>총 견적금액</span>
|
||||
<span className="text-blue-600">
|
||||
₩{formatAmount(totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
산출 항목이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* BOM 자재 상세 */}
|
||||
{quote.bomMaterials && quote.bomMaterials.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
BOM 자재 상세
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{quote.bomMaterials.length}개 품목
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsBomExpanded(!isBomExpanded)}
|
||||
>
|
||||
{isBomExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isBomExpanded && (
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-2 font-medium">No</th>
|
||||
<th className="text-left p-2 font-medium">품목코드</th>
|
||||
<th className="text-left p-2 font-medium">품목명</th>
|
||||
<th className="text-left p-2 font-medium">유형</th>
|
||||
<th className="text-left p-2 font-medium">규격</th>
|
||||
<th className="text-center p-2 font-medium">단위</th>
|
||||
<th className="text-right p-2 font-medium">수량</th>
|
||||
<th className="text-right p-2 font-medium">단가</th>
|
||||
<th className="text-right p-2 font-medium">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quote.bomMaterials.map((material, index) => (
|
||||
<tr key={index} className="border-b hover:bg-muted/30">
|
||||
<td className="p-2 text-muted-foreground">{index + 1}</td>
|
||||
<td className="p-2 font-mono text-xs">{material.itemCode}</td>
|
||||
<td className="p-2">{material.itemName}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getItemTypeLabel(material.itemType)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-muted-foreground">{material.specification || '-'}</td>
|
||||
<td className="p-2 text-center">{material.unit}</td>
|
||||
<td className="p-2 text-right">{material.quantity.toLocaleString()}</td>
|
||||
<td className="p-2 text-right">₩{material.unitPrice.toLocaleString()}</td>
|
||||
<td className="p-2 text-right font-medium">₩{material.totalPrice.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 bg-muted/30">
|
||||
<td colSpan={8} className="p-2 text-right font-medium">합계</td>
|
||||
<td className="p-2 text-right font-bold text-blue-600">
|
||||
₩{quote.bomMaterials.reduce((sum, m) => sum + m.totalPrice, 0).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 견적서 다이얼로그 */}
|
||||
<DocumentViewer
|
||||
title="견적서"
|
||||
preset="quote"
|
||||
open={isQuoteDocumentOpen}
|
||||
onOpenChange={setIsQuoteDocumentOpen}
|
||||
onPdf={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
onEmail={handleSendEmail}
|
||||
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
|
||||
onKakao={handleSendKakao}
|
||||
>
|
||||
<QuoteDocument quote={quote} companyInfo={companyInfo} />
|
||||
</DocumentViewer>
|
||||
|
||||
{/* 산출내역서 다이얼로그 */}
|
||||
<DocumentViewer
|
||||
title="산출내역서"
|
||||
preset="quote"
|
||||
open={isCalculationReportOpen}
|
||||
onOpenChange={setIsCalculationReportOpen}
|
||||
onPdf={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
onEmail={handleSendEmail}
|
||||
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
|
||||
onKakao={handleSendKakao}
|
||||
toolbarExtra={
|
||||
<>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDetailedBreakdown}
|
||||
onChange={(e) => setShowDetailedBreakdown(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">산출내역서</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMaterialList}
|
||||
onChange={(e) => setShowMaterialList(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">소요자재 내역</span>
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<QuoteCalculationReport
|
||||
quote={quote}
|
||||
companyInfo={companyInfo}
|
||||
documentType="견적산출내역서"
|
||||
showDetailedBreakdown={showDetailedBreakdown}
|
||||
showMaterialList={showMaterialList}
|
||||
/>
|
||||
</DocumentViewer>
|
||||
|
||||
{/* 발주서 다이얼로그 */}
|
||||
<DocumentViewer
|
||||
title="발주서"
|
||||
preset="quote"
|
||||
open={isPurchaseOrderOpen}
|
||||
onOpenChange={setIsPurchaseOrderOpen}
|
||||
onPdf={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
onEmail={handleSendEmail}
|
||||
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
|
||||
onKakao={handleSendKakao}
|
||||
>
|
||||
<PurchaseOrderDocument quote={quote} companyInfo={companyInfo} />
|
||||
</DocumentViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 견적 등록 페이지 (V2 UI)
|
||||
*
|
||||
* IntegratedDetailTemplate + QuoteRegistrationV2
|
||||
* IntegratedDetailTemplate + QuoteRegistration
|
||||
* URL: /sales/quote-management/new
|
||||
*/
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { QuoteRegistrationV2 } from '@/components/quotes/QuoteRegistrationV2';
|
||||
import type { QuoteFormDataV2 } from '@/components/quotes/QuoteRegistrationV2';
|
||||
import { QuoteRegistration } from '@/components/quotes/QuoteRegistration';
|
||||
import type { QuoteFormDataV2 } from '@/components/quotes/QuoteRegistration';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { quoteCreateConfig } from '@/components/quotes/quoteConfig';
|
||||
import { toast } from 'sonner';
|
||||
@@ -58,7 +58,7 @@ export default function QuoteNewPage() {
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
<QuoteRegistration
|
||||
mode="create"
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* 견적 등록 페이지
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { QuoteRegistration, QuoteFormData } from "@/components/quotes/QuoteRegistration";
|
||||
import { createQuote, transformFormDataToApi } from "@/components/quotes";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function QuoteNewPage() {
|
||||
const router = useRouter();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
|
||||
const handleSave = async (formData: QuoteFormData): Promise<{ success: boolean; error?: string }> => {
|
||||
if (isSaving) return { success: false, error: '저장 중입니다.' };
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
// DEBUG: 원본 formData 확인
|
||||
console.log('[QuoteNewPage] formData 원본:', {
|
||||
writer: formData.writer,
|
||||
manager: formData.manager,
|
||||
contact: formData.contact,
|
||||
remarks: formData.remarks,
|
||||
});
|
||||
|
||||
// FormData를 API 요청 형식으로 변환
|
||||
const apiData = transformFormDataToApi(formData);
|
||||
|
||||
// DEBUG: 변환된 apiData 확인
|
||||
console.log('[QuoteNewPage] apiData 변환 후:', {
|
||||
author: (apiData as any).author,
|
||||
manager: (apiData as any).manager,
|
||||
contact: (apiData as any).contact,
|
||||
remarks: (apiData as any).remarks,
|
||||
});
|
||||
|
||||
const result = await createQuote(apiData as any);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// toast는 IntegratedDetailTemplate에서 처리
|
||||
router.push(`/sales/quote-management/${result.data.id}`);
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: result.error || "견적 등록에 실패했습니다." };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: "견적 등록에 실패했습니다." };
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<QuoteRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
} from './types';
|
||||
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
|
||||
|
||||
// ===== 새 훅 import =====
|
||||
import { useDetailData, useCRUDHandlers } from '@/hooks';
|
||||
|
||||
// ===== Props =====
|
||||
interface BillDetailProps {
|
||||
billId: string;
|
||||
@@ -46,157 +49,199 @@ interface ClientOption {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
|
||||
interface BillFormData {
|
||||
billNumber: string;
|
||||
billType: BillType;
|
||||
vendorId: string;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
status: BillStatus;
|
||||
note: string;
|
||||
installments: InstallmentRecord[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM_DATA: BillFormData = {
|
||||
billNumber: '',
|
||||
billType: 'received',
|
||||
vendorId: '',
|
||||
amount: 0,
|
||||
issueDate: '',
|
||||
maturityDate: '',
|
||||
status: 'stored',
|
||||
note: '',
|
||||
installments: [],
|
||||
};
|
||||
|
||||
export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// ===== 거래처 목록 =====
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
|
||||
// ===== 폼 상태 =====
|
||||
const [billNumber, setBillNumber] = useState('');
|
||||
const [billType, setBillType] = useState<BillType>('received');
|
||||
const [vendorId, setVendorId] = useState('');
|
||||
const [amount, setAmount] = useState(0);
|
||||
const [issueDate, setIssueDate] = useState('');
|
||||
const [maturityDate, setMaturityDate] = useState('');
|
||||
const [status, setStatus] = useState<BillStatus>('stored');
|
||||
const [note, setNote] = useState('');
|
||||
const [installments, setInstallments] = useState<InstallmentRecord[]>([]);
|
||||
// ===== 폼 상태 (통합된 단일 state) =====
|
||||
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
|
||||
|
||||
// ===== 초기 데이터 로드 (거래처 + 어음 상세 병렬) =====
|
||||
// ===== 폼 필드 업데이트 헬퍼 =====
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(
|
||||
field: K,
|
||||
value: BillFormData[K]
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// ===== 거래처 목록 로드 =====
|
||||
useEffect(() => {
|
||||
async function loadInitialData() {
|
||||
const isEditMode = billId && billId !== 'new';
|
||||
setIsLoading(!!isEditMode);
|
||||
|
||||
const [clientsResult, billResult] = await Promise.all([
|
||||
getClients(),
|
||||
isEditMode ? getBill(billId) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// 거래처 목록
|
||||
if (clientsResult.success && clientsResult.data) {
|
||||
setClients(clientsResult.data.map(c => ({ id: String(c.id), name: c.name })));
|
||||
async function loadClients() {
|
||||
const result = await getClients();
|
||||
if (result.success && result.data) {
|
||||
setClients(result.data.map(c => ({ id: String(c.id), name: c.name })));
|
||||
}
|
||||
}
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
// 어음 상세
|
||||
if (billResult) {
|
||||
if (billResult.success && billResult.data) {
|
||||
const data = billResult.data;
|
||||
setBillNumber(data.billNumber);
|
||||
setBillType(data.billType);
|
||||
setVendorId(data.vendorId);
|
||||
setAmount(data.amount);
|
||||
setIssueDate(data.issueDate);
|
||||
setMaturityDate(data.maturityDate);
|
||||
setStatus(data.status);
|
||||
setNote(data.note);
|
||||
setInstallments(data.installments);
|
||||
} else {
|
||||
toast.error(billResult.error || '어음 정보를 불러올 수 없습니다.');
|
||||
router.push('/ko/accounting/bills');
|
||||
}
|
||||
}
|
||||
// ===== 새 훅: useDetailData로 데이터 로딩 =====
|
||||
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
|
||||
const fetchBillWrapper = useCallback(
|
||||
(id: string | number) => getBill(String(id)),
|
||||
[]
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
const {
|
||||
data: billData,
|
||||
isLoading,
|
||||
error: loadError,
|
||||
} = useDetailData<BillRecord>(
|
||||
billId !== 'new' ? billId : null,
|
||||
fetchBillWrapper,
|
||||
{ skip: isNewMode }
|
||||
);
|
||||
|
||||
loadInitialData();
|
||||
}, [billId, router]);
|
||||
// ===== 데이터 로드 시 폼에 반영 =====
|
||||
useEffect(() => {
|
||||
if (billData) {
|
||||
setFormData({
|
||||
billNumber: billData.billNumber,
|
||||
billType: billData.billType,
|
||||
vendorId: billData.vendorId,
|
||||
amount: billData.amount,
|
||||
issueDate: billData.issueDate,
|
||||
maturityDate: billData.maturityDate,
|
||||
status: billData.status,
|
||||
note: billData.note,
|
||||
installments: billData.installments,
|
||||
});
|
||||
}
|
||||
}, [billData]);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// 유효성 검사
|
||||
if (!billNumber.trim()) {
|
||||
toast.error('어음번호를 입력해주세요.');
|
||||
return { success: false, error: '어음번호를 입력해주세요.' };
|
||||
// ===== 로드 에러 처리 =====
|
||||
useEffect(() => {
|
||||
if (loadError) {
|
||||
toast.error(loadError);
|
||||
router.push('/ko/accounting/bills');
|
||||
}
|
||||
if (!vendorId) {
|
||||
toast.error('거래처를 선택해주세요.');
|
||||
return { success: false, error: '거래처를 선택해주세요.' };
|
||||
}, [loadError, router]);
|
||||
|
||||
// ===== 유효성 검사 함수 =====
|
||||
const validateForm = useCallback((): { valid: boolean; error?: string } => {
|
||||
if (!formData.billNumber.trim()) {
|
||||
return { valid: false, error: '어음번호를 입력해주세요.' };
|
||||
}
|
||||
if (amount <= 0) {
|
||||
toast.error('금액을 입력해주세요.');
|
||||
return { success: false, error: '금액을 입력해주세요.' };
|
||||
if (!formData.vendorId) {
|
||||
return { valid: false, error: '거래처를 선택해주세요.' };
|
||||
}
|
||||
if (!issueDate) {
|
||||
toast.error('발행일을 입력해주세요.');
|
||||
return { success: false, error: '발행일을 입력해주세요.' };
|
||||
if (formData.amount <= 0) {
|
||||
return { valid: false, error: '금액을 입력해주세요.' };
|
||||
}
|
||||
if (!maturityDate) {
|
||||
toast.error('만기일을 입력해주세요.');
|
||||
return { success: false, error: '만기일을 입력해주세요.' };
|
||||
if (!formData.issueDate) {
|
||||
return { valid: false, error: '발행일을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.maturityDate) {
|
||||
return { valid: false, error: '만기일을 입력해주세요.' };
|
||||
}
|
||||
|
||||
// 차수 유효성 검사
|
||||
for (let i = 0; i < installments.length; i++) {
|
||||
const inst = installments[i];
|
||||
for (let i = 0; i < formData.installments.length; i++) {
|
||||
const inst = formData.installments[i];
|
||||
if (!inst.date) {
|
||||
const errorMsg = `차수 ${i + 1}번의 일자를 입력해주세요.`;
|
||||
toast.error(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
|
||||
}
|
||||
if (inst.amount <= 0) {
|
||||
const errorMsg = `차수 ${i + 1}번의 금액을 입력해주세요.`;
|
||||
toast.error(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}, [formData]);
|
||||
|
||||
// ===== 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음 =====
|
||||
const updateBillWrapper = useCallback(
|
||||
(id: string | number, data: Partial<BillRecord>) => updateBill(String(id), data),
|
||||
[]
|
||||
);
|
||||
|
||||
const deleteBillWrapper = useCallback(
|
||||
(id: string | number) => deleteBill(String(id)),
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== 새 훅: useCRUDHandlers로 CRUD 처리 =====
|
||||
const {
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
handleDelete: crudDelete,
|
||||
isSubmitting,
|
||||
isDeleting,
|
||||
} = useCRUDHandlers<Partial<BillRecord>, Partial<BillRecord>>({
|
||||
onCreate: createBill,
|
||||
onUpdate: updateBillWrapper,
|
||||
onDelete: deleteBillWrapper,
|
||||
successRedirect: '/ko/accounting/bills',
|
||||
successMessages: {
|
||||
create: '어음이 등록되었습니다.',
|
||||
update: '어음이 수정되었습니다.',
|
||||
delete: '어음이 삭제되었습니다.',
|
||||
},
|
||||
// 수정 성공 시 view 모드로 이동
|
||||
disableRedirect: !isNewMode,
|
||||
onSuccess: (action) => {
|
||||
if (action === 'update') {
|
||||
router.push(`/ko/accounting/bills/${billId}?mode=view`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ===== 저장 핸들러 (유효성 검사 + CRUD 훅 사용) =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// 유효성 검사
|
||||
const validation = validateForm();
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.error!);
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
const billData: Partial<BillRecord> = {
|
||||
billNumber,
|
||||
billType,
|
||||
vendorId,
|
||||
vendorName: clients.find(c => c.id === vendorId)?.name || '',
|
||||
amount,
|
||||
issueDate,
|
||||
maturityDate,
|
||||
status,
|
||||
note,
|
||||
installments,
|
||||
...formData,
|
||||
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
|
||||
};
|
||||
|
||||
let result;
|
||||
if (isNewMode) {
|
||||
result = await createBill(billData);
|
||||
return handleCreate(billData);
|
||||
} else {
|
||||
result = await updateBill(billId, billData);
|
||||
return handleUpdate(billId, billData);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast.success(isNewMode ? '어음이 등록되었습니다.' : '어음이 수정되었습니다.');
|
||||
if (isNewMode) {
|
||||
router.push('/ko/accounting/bills');
|
||||
} else {
|
||||
router.push(`/ko/accounting/bills/${billId}?mode=view`);
|
||||
}
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
return { success: false, error: result.error || '저장에 실패했습니다.' };
|
||||
}
|
||||
}, [billId, billNumber, billType, vendorId, amount, issueDate, maturityDate, status, note, installments, router, isNewMode, clients]);
|
||||
}, [formData, clients, isNewMode, billId, handleCreate, handleUpdate, validateForm]);
|
||||
|
||||
// ===== 삭제 핸들러 =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
const result = await deleteBill(billId);
|
||||
return crudDelete(billId);
|
||||
}, [billId, crudDelete]);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('어음이 삭제되었습니다.');
|
||||
router.push('/ko/accounting/bills');
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
return { success: false, error: result.error || '삭제에 실패했습니다.' };
|
||||
}
|
||||
}, [billId, router]);
|
||||
|
||||
// ===== 차수 추가 =====
|
||||
// ===== 차수 관리 핸들러 =====
|
||||
const handleAddInstallment = useCallback(() => {
|
||||
const newInstallment: InstallmentRecord = {
|
||||
id: `inst-${Date.now()}`,
|
||||
@@ -204,23 +249,37 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
amount: 0,
|
||||
note: '',
|
||||
};
|
||||
setInstallments(prev => [...prev, newInstallment]);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [...prev.installments, newInstallment],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 차수 삭제 =====
|
||||
const handleRemoveInstallment = useCallback((id: string) => {
|
||||
setInstallments(prev => prev.filter(inst => inst.id !== id));
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 차수 업데이트 =====
|
||||
const handleUpdateInstallment = useCallback((id: string, field: keyof InstallmentRecord, value: string | number) => {
|
||||
setInstallments(prev => prev.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
));
|
||||
const handleUpdateInstallment = useCallback((
|
||||
id: string,
|
||||
field: keyof InstallmentRecord,
|
||||
value: string | number
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 상태 옵션 (구분에 따라 변경) =====
|
||||
const statusOptions = getBillStatusOptions(billType);
|
||||
const statusOptions = useMemo(
|
||||
() => getBillStatusOptions(formData.billType),
|
||||
[formData.billType]
|
||||
);
|
||||
|
||||
// ===== 폼 콘텐츠 렌더링 =====
|
||||
const renderFormContent = () => (
|
||||
@@ -239,8 +298,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
</Label>
|
||||
<Input
|
||||
id="billNumber"
|
||||
value={billNumber}
|
||||
onChange={(e) => setBillNumber(e.target.value)}
|
||||
value={formData.billNumber}
|
||||
onChange={(e) => updateField('billNumber', e.target.value)}
|
||||
placeholder="어음번호를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
@@ -251,7 +310,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="billType">
|
||||
구분 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={billType} onValueChange={(v) => setBillType(v as BillType)} disabled={isViewMode}>
|
||||
<Select
|
||||
value={formData.billType}
|
||||
onValueChange={(v) => updateField('billType', v as BillType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
@@ -270,7 +333,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="vendorId">
|
||||
거래처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
|
||||
<Select
|
||||
value={formData.vendorId}
|
||||
onValueChange={(v) => updateField('vendorId', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
@@ -291,8 +358,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
id="amount"
|
||||
value={amount}
|
||||
onChange={(value) => setAmount(value ?? 0)}
|
||||
value={formData.amount}
|
||||
onChange={(value) => updateField('amount', value ?? 0)}
|
||||
placeholder="금액을 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
@@ -304,8 +371,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
발행일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={issueDate}
|
||||
onChange={setIssueDate}
|
||||
value={formData.issueDate}
|
||||
onChange={(date) => updateField('issueDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -316,8 +383,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
만기일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={maturityDate}
|
||||
onChange={setMaturityDate}
|
||||
value={formData.maturityDate}
|
||||
onChange={(date) => updateField('maturityDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -327,7 +394,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="status">
|
||||
상태 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as BillStatus)} disabled={isViewMode}>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => updateField('status', v as BillStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
@@ -346,8 +417,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="note">비고</Label>
|
||||
<Input
|
||||
id="note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
value={formData.note}
|
||||
onChange={(e) => updateField('note', e.target.value)}
|
||||
placeholder="비고를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
@@ -386,14 +457,14 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{installments.length === 0 ? (
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
|
||||
등록된 차수가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
installments.map((inst, index) => (
|
||||
formData.installments.map((inst, index) => (
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
@@ -442,8 +513,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
);
|
||||
|
||||
// ===== 템플릿 모드 및 동적 설정 =====
|
||||
// IntegratedDetailTemplate: create → "{title} 등록", view → "{title}", edit → "{title} 수정"
|
||||
// view 모드에서 "어음 상세"로 표시하려면 직접 설정 필요
|
||||
const templateMode = isNewMode ? 'create' : mode;
|
||||
const dynamicConfig = {
|
||||
...billConfig,
|
||||
@@ -460,7 +529,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
mode={templateMode}
|
||||
initialData={{}}
|
||||
itemId={billId}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isSubmitting || isDeleting}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
|
||||
renderView={() => renderFormContent()}
|
||||
|
||||
@@ -1,539 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { billConfig } from './billConfig';
|
||||
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
|
||||
import {
|
||||
BILL_TYPE_OPTIONS,
|
||||
getBillStatusOptions,
|
||||
} from './types';
|
||||
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
|
||||
|
||||
// ===== 새 훅 import =====
|
||||
import { useDetailData, useCRUDHandlers } from '@/hooks';
|
||||
|
||||
// ===== Props =====
|
||||
interface BillDetailProps {
|
||||
billId: string;
|
||||
mode: 'view' | 'edit' | 'new';
|
||||
}
|
||||
|
||||
// ===== 거래처 타입 =====
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
|
||||
interface BillFormData {
|
||||
billNumber: string;
|
||||
billType: BillType;
|
||||
vendorId: string;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
status: BillStatus;
|
||||
note: string;
|
||||
installments: InstallmentRecord[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM_DATA: BillFormData = {
|
||||
billNumber: '',
|
||||
billType: 'received',
|
||||
vendorId: '',
|
||||
amount: 0,
|
||||
issueDate: '',
|
||||
maturityDate: '',
|
||||
status: 'stored',
|
||||
note: '',
|
||||
installments: [],
|
||||
};
|
||||
|
||||
export function BillDetailV2({ billId, mode }: BillDetailProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
|
||||
// ===== 거래처 목록 =====
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
|
||||
// ===== 폼 상태 (통합된 단일 state) =====
|
||||
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
|
||||
|
||||
// ===== 폼 필드 업데이트 헬퍼 =====
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(
|
||||
field: K,
|
||||
value: BillFormData[K]
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// ===== 거래처 목록 로드 =====
|
||||
useEffect(() => {
|
||||
async function loadClients() {
|
||||
const result = await getClients();
|
||||
if (result.success && result.data) {
|
||||
setClients(result.data.map(c => ({ id: String(c.id), name: c.name })));
|
||||
}
|
||||
}
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
// ===== 새 훅: useDetailData로 데이터 로딩 =====
|
||||
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
|
||||
const fetchBillWrapper = useCallback(
|
||||
(id: string | number) => getBill(String(id)),
|
||||
[]
|
||||
);
|
||||
|
||||
const {
|
||||
data: billData,
|
||||
isLoading,
|
||||
error: loadError,
|
||||
} = useDetailData<BillRecord>(
|
||||
billId !== 'new' ? billId : null,
|
||||
fetchBillWrapper,
|
||||
{ skip: isNewMode }
|
||||
);
|
||||
|
||||
// ===== 데이터 로드 시 폼에 반영 =====
|
||||
useEffect(() => {
|
||||
if (billData) {
|
||||
setFormData({
|
||||
billNumber: billData.billNumber,
|
||||
billType: billData.billType,
|
||||
vendorId: billData.vendorId,
|
||||
amount: billData.amount,
|
||||
issueDate: billData.issueDate,
|
||||
maturityDate: billData.maturityDate,
|
||||
status: billData.status,
|
||||
note: billData.note,
|
||||
installments: billData.installments,
|
||||
});
|
||||
}
|
||||
}, [billData]);
|
||||
|
||||
// ===== 로드 에러 처리 =====
|
||||
useEffect(() => {
|
||||
if (loadError) {
|
||||
toast.error(loadError);
|
||||
router.push('/ko/accounting/bills');
|
||||
}
|
||||
}, [loadError, router]);
|
||||
|
||||
// ===== 유효성 검사 함수 =====
|
||||
const validateForm = useCallback((): { valid: boolean; error?: string } => {
|
||||
if (!formData.billNumber.trim()) {
|
||||
return { valid: false, error: '어음번호를 입력해주세요.' };
|
||||
}
|
||||
if (!formData.vendorId) {
|
||||
return { valid: false, error: '거래처를 선택해주세요.' };
|
||||
}
|
||||
if (formData.amount <= 0) {
|
||||
return { valid: false, error: '금액을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.issueDate) {
|
||||
return { valid: false, error: '발행일을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.maturityDate) {
|
||||
return { valid: false, error: '만기일을 입력해주세요.' };
|
||||
}
|
||||
|
||||
// 차수 유효성 검사
|
||||
for (let i = 0; i < formData.installments.length; i++) {
|
||||
const inst = formData.installments[i];
|
||||
if (!inst.date) {
|
||||
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
|
||||
}
|
||||
if (inst.amount <= 0) {
|
||||
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}, [formData]);
|
||||
|
||||
// ===== 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음 =====
|
||||
const updateBillWrapper = useCallback(
|
||||
(id: string | number, data: Partial<BillRecord>) => updateBill(String(id), data),
|
||||
[]
|
||||
);
|
||||
|
||||
const deleteBillWrapper = useCallback(
|
||||
(id: string | number) => deleteBill(String(id)),
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== 새 훅: useCRUDHandlers로 CRUD 처리 =====
|
||||
const {
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
handleDelete: crudDelete,
|
||||
isSubmitting,
|
||||
isDeleting,
|
||||
} = useCRUDHandlers<Partial<BillRecord>, Partial<BillRecord>>({
|
||||
onCreate: createBill,
|
||||
onUpdate: updateBillWrapper,
|
||||
onDelete: deleteBillWrapper,
|
||||
successRedirect: '/ko/accounting/bills',
|
||||
successMessages: {
|
||||
create: '어음이 등록되었습니다.',
|
||||
update: '어음이 수정되었습니다.',
|
||||
delete: '어음이 삭제되었습니다.',
|
||||
},
|
||||
// 수정 성공 시 view 모드로 이동
|
||||
disableRedirect: !isNewMode,
|
||||
onSuccess: (action) => {
|
||||
if (action === 'update') {
|
||||
router.push(`/ko/accounting/bills/${billId}?mode=view`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ===== 저장 핸들러 (유효성 검사 + CRUD 훅 사용) =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// 유효성 검사
|
||||
const validation = validateForm();
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.error!);
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
const billData: Partial<BillRecord> = {
|
||||
...formData,
|
||||
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
|
||||
};
|
||||
|
||||
if (isNewMode) {
|
||||
return handleCreate(billData);
|
||||
} else {
|
||||
return handleUpdate(billId, billData);
|
||||
}
|
||||
}, [formData, clients, isNewMode, billId, handleCreate, handleUpdate, validateForm]);
|
||||
|
||||
// ===== 삭제 핸들러 =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
return crudDelete(billId);
|
||||
}, [billId, crudDelete]);
|
||||
|
||||
// ===== 차수 관리 핸들러 =====
|
||||
const handleAddInstallment = useCallback(() => {
|
||||
const newInstallment: InstallmentRecord = {
|
||||
id: `inst-${Date.now()}`,
|
||||
date: '',
|
||||
amount: 0,
|
||||
note: '',
|
||||
};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [...prev.installments, newInstallment],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveInstallment = useCallback((id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleUpdateInstallment = useCallback((
|
||||
id: string,
|
||||
field: keyof InstallmentRecord,
|
||||
value: string | number
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 상태 옵션 (구분에 따라 변경) =====
|
||||
const statusOptions = useMemo(
|
||||
() => getBillStatusOptions(formData.billType),
|
||||
[formData.billType]
|
||||
);
|
||||
|
||||
// ===== 폼 콘텐츠 렌더링 =====
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* 기본 정보 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 어음번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billNumber">
|
||||
어음번호 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="billNumber"
|
||||
value={formData.billNumber}
|
||||
onChange={(e) => updateField('billNumber', e.target.value)}
|
||||
placeholder="어음번호를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 구분 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billType">
|
||||
구분 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.billType}
|
||||
onValueChange={(v) => updateField('billType', v as BillType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendorId">
|
||||
거래처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.vendorId}
|
||||
onValueChange={(v) => updateField('vendorId', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">
|
||||
금액 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
id="amount"
|
||||
value={formData.amount}
|
||||
onChange={(value) => updateField('amount', value ?? 0)}
|
||||
placeholder="금액을 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issueDate">
|
||||
발행일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.issueDate}
|
||||
onChange={(date) => updateField('issueDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 만기일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maturityDate">
|
||||
만기일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.maturityDate}
|
||||
onChange={(date) => updateField('maturityDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">
|
||||
상태 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => updateField('status', v as BillStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">비고</Label>
|
||||
<Input
|
||||
id="note"
|
||||
value={formData.note}
|
||||
onChange={(e) => updateField('note', e.target.value)}
|
||||
placeholder="비고를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 차수 관리 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span className="text-red-500">*</span> 차수 관리
|
||||
</CardTitle>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddInstallment}
|
||||
className="text-orange-500 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead>일자</TableHead>
|
||||
<TableHead>금액</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[60px]">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
|
||||
등록된 차수가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
formData.installments.map((inst, index) => (
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker
|
||||
value={inst.date}
|
||||
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput
|
||||
value={inst.amount}
|
||||
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={inst.note}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
{!isViewMode && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRemoveInstallment(inst.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
// ===== 템플릿 모드 및 동적 설정 =====
|
||||
const templateMode = isNewMode ? 'create' : mode;
|
||||
const dynamicConfig = {
|
||||
...billConfig,
|
||||
title: isViewMode ? '어음 상세' : '어음',
|
||||
actions: {
|
||||
...billConfig.actions,
|
||||
submitLabel: isNewMode ? '등록' : '저장',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={templateMode}
|
||||
initialData={{}}
|
||||
itemId={billId}
|
||||
isLoading={isLoading || isSubmitting || isDeleting}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
|
||||
renderView={() => renderFormContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import { CommentSection } from '../CommentSection';
|
||||
import { deletePost } from '../actions';
|
||||
import type { Post, Comment } from '../types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { sanitizeHTML } from '@/lib/sanitize';
|
||||
|
||||
interface BoardDetailProps {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { CEODashboardSkeleton } from './skeletons';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
|
||||
import {
|
||||
TodayIssueSection,
|
||||
EnhancedStatusBoardSection,
|
||||
@@ -281,15 +282,18 @@ export function CEODashboard() {
|
||||
description="전체 현황을 조회합니다."
|
||||
icon={LayoutDashboard}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSettingClick}
|
||||
className="gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
항목 설정
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSettingClick}
|
||||
className="gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
항목 설정
|
||||
</Button>
|
||||
<DashboardSwitcher />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
73
src/components/business/DashboardSwitcher.tsx
Normal file
73
src/components/business/DashboardSwitcher.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname, useParams } from 'next/navigation';
|
||||
import { ChevronDown, LayoutDashboard, LayoutGrid, Target, Activity, Columns3 } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
const dashboards = [
|
||||
{ path: '/dashboard', label: '보고서형', icon: LayoutDashboard },
|
||||
{ path: '/dashboard_type2', label: '탭형', icon: Columns3 },
|
||||
{ path: '/dashboard_type3', label: '위젯형', icon: LayoutGrid },
|
||||
{ path: '/dashboard_type4', label: '드릴다운형', icon: Target },
|
||||
{ path: '/dashboard_type5', label: '피드형', icon: Activity },
|
||||
] as const;
|
||||
|
||||
export function DashboardSwitcher() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const locale = (params.locale as string) || 'ko';
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 현재 활성 대시보드 찾기
|
||||
const current = dashboards.find((d) => pathname.endsWith(d.path)) ?? dashboards[0];
|
||||
|
||||
// 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-background text-sm font-medium hover:bg-muted transition-colors"
|
||||
>
|
||||
<current.icon className="w-4 h-4 text-primary" />
|
||||
<span className="hidden sm:inline">{current.label}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-44 rounded-lg border bg-background shadow-lg z-50 py-1">
|
||||
{dashboards.map((d) => {
|
||||
const Icon = d.icon;
|
||||
const isActive = d.path === current.path;
|
||||
return (
|
||||
<button
|
||||
key={d.path}
|
||||
onClick={() => {
|
||||
router.push(`/${locale}${d.path}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${isActive ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
{d.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
import { updateContract, deleteContract, createContract } from './actions';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
// import { ContractDocumentModal } from './modals/ContractDocumentModal';
|
||||
import { ContractDocumentModalV2 as ContractDocumentModal } from './modals/ContractDocumentModalV2';
|
||||
import { ContractDocumentModal } from './modals/ContractDocumentModal';
|
||||
import {
|
||||
ElectronicApprovalModal,
|
||||
type ElectronicApproval,
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Edit,
|
||||
X as XIcon,
|
||||
Printer,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { toast } from 'sonner';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import type { ContractDetail } from '../types';
|
||||
|
||||
interface ContractDocumentModalProps {
|
||||
@@ -23,6 +10,13 @@ interface ContractDocumentModalProps {
|
||||
contract: ContractDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약서 문서 모달 V2
|
||||
*
|
||||
* DocumentViewer를 사용하여 통합 UI 제공
|
||||
* - 줌/드래그 기능 추가
|
||||
* - PDF iframe 지원
|
||||
*/
|
||||
export function ContractDocumentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -38,67 +32,32 @@ export function ContractDocumentModal({
|
||||
toast.info('전자결재 상신 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '계약서 인쇄' });
|
||||
};
|
||||
|
||||
// PDF URL 확인
|
||||
const pdfUrl = contract.contractFile?.fileUrl;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>계약서 상세</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<h2 className="text-lg font-semibold">계약서 상세</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={handleSubmit} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
상신
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* PDF 뷰어 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
|
||||
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
|
||||
{pdfUrl ? (
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-full min-h-[297mm]"
|
||||
title="계약서 PDF"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
|
||||
<p>현재 등록된 계약서가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
<DocumentViewer
|
||||
title="계약서"
|
||||
subtitle={`${contract.partnerName} - ${contract.projectName}`}
|
||||
preset="construction"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onEdit={handleEdit}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
|
||||
{pdfUrl ? (
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-full min-h-[297mm]"
|
||||
title="계약서 PDF"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
|
||||
<p>현재 등록된 계약서가 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { toast } from 'sonner';
|
||||
import type { ContractDetail } from '../types';
|
||||
|
||||
interface ContractDocumentModalV2Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
contract: ContractDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약서 문서 모달 V2
|
||||
*
|
||||
* DocumentViewer를 사용하여 통합 UI 제공
|
||||
* - 줌/드래그 기능 추가
|
||||
* - PDF iframe 지원
|
||||
*/
|
||||
export function ContractDocumentModalV2({
|
||||
open,
|
||||
onOpenChange,
|
||||
contract,
|
||||
}: ContractDocumentModalV2Props) {
|
||||
// 수정
|
||||
const handleEdit = () => {
|
||||
toast.info('수정 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
// 상신 (전자결재)
|
||||
const handleSubmit = () => {
|
||||
toast.info('전자결재 상신 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
// PDF URL 확인
|
||||
const pdfUrl = contract.contractFile?.fileUrl;
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title="계약서"
|
||||
subtitle={`${contract.partnerName} - ${contract.projectName}`}
|
||||
preset="construction"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onEdit={handleEdit}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
|
||||
{pdfUrl ? (
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-full min-h-[297mm]"
|
||||
title="계약서 PDF"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
|
||||
<p>현재 등록된 계약서가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export { ContractDocumentModal } from './ContractDocumentModal';
|
||||
export { ContractDocumentModalV2 } from './ContractDocumentModalV2';
|
||||
|
||||
@@ -1,91 +1,46 @@
|
||||
/**
|
||||
* LaborDetailClient - IntegratedDetailTemplate 기반 노임 상세/등록/수정
|
||||
*
|
||||
* 기존 LaborDetailClient를 IntegratedDetailTemplate으로 마이그레이션
|
||||
* - 6개 필드: 노임번호, 구분, 최소M, 최대M, 노임단가, 상태
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Hammer, ArrowLeft, Trash2, Edit, X, Save, Plus } from 'lucide-react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import type { Labor, LaborFormData, LaborCategory, LaborStatus } from './types';
|
||||
import { CATEGORY_OPTIONS, STATUS_OPTIONS } from './constants';
|
||||
import {
|
||||
IntegratedDetailTemplate,
|
||||
type DetailMode,
|
||||
} from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { laborDetailConfig } from './laborDetailConfig';
|
||||
import type { Labor, LaborFormData } from './types';
|
||||
import { getLabor, createLabor, updateLabor, deleteLabor } from './actions';
|
||||
|
||||
interface LaborDetailClientProps {
|
||||
laborId?: string;
|
||||
isEditMode?: boolean;
|
||||
isNewMode?: boolean;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
const initialFormData: LaborFormData = {
|
||||
laborNumber: '',
|
||||
category: '가로',
|
||||
minM: 0,
|
||||
maxM: 0,
|
||||
laborPrice: null,
|
||||
status: '사용',
|
||||
};
|
||||
|
||||
export default function LaborDetailClient({
|
||||
laborId,
|
||||
isEditMode = false,
|
||||
isNewMode = false,
|
||||
initialMode = 'view',
|
||||
}: LaborDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate, canDelete } = usePermission();
|
||||
|
||||
// 모드 상태
|
||||
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
|
||||
isNewMode ? 'new' : isEditMode ? 'edit' : 'view'
|
||||
);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<LaborFormData>(initialFormData);
|
||||
const [originalData, setOriginalData] = useState<Labor | null>(null);
|
||||
|
||||
// 소수점 입력을 위한 문자열 상태 (입력 중인 값 유지)
|
||||
const [minMInput, setMinMInput] = useState<string>('');
|
||||
const [maxMInput, setMaxMInput] = useState<string>('');
|
||||
|
||||
// 상태
|
||||
const [labor, setLabor] = useState<Labor | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [mode, setMode] = useState<DetailMode>(initialMode);
|
||||
|
||||
// 노임 데이터 로드
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (laborId && !isNewMode) {
|
||||
const loadLabor = async () => {
|
||||
if (laborId && initialMode !== 'create') {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getLabor(laborId);
|
||||
if (result.success && result.data) {
|
||||
setOriginalData(result.data);
|
||||
setFormData({
|
||||
laborNumber: result.data.laborNumber,
|
||||
category: result.data.category,
|
||||
minM: result.data.minM,
|
||||
maxM: result.data.maxM,
|
||||
laborPrice: result.data.laborPrice,
|
||||
status: result.data.status,
|
||||
});
|
||||
// 소수점 입력용 문자열 상태 초기화
|
||||
setMinMInput(result.data.minM === 0 ? '' : result.data.minM.toString());
|
||||
setMaxMInput(result.data.maxM === 0 ? '' : result.data.maxM.toString());
|
||||
setLabor(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/labor');
|
||||
@@ -97,376 +52,69 @@ export default function LaborDetailClient({
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadLabor();
|
||||
loadData();
|
||||
}
|
||||
}, [laborId, isNewMode, router]);
|
||||
}, [laborId, initialMode, router]);
|
||||
|
||||
// 폼 필드 변경
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof LaborFormData, value: string | number | null) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
// 저장 핸들러
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>) => {
|
||||
try {
|
||||
const submitData = laborDetailConfig.transformSubmitData!(formData) as unknown as LaborFormData;
|
||||
|
||||
// 최소 M / 최대 M 입력 핸들러 (소수점 둘째자리까지)
|
||||
const handleMinMChange = useCallback(
|
||||
(value: string) => {
|
||||
// 빈 값 허용
|
||||
if (value === '') {
|
||||
setMinMInput('');
|
||||
handleFieldChange('minM', 0);
|
||||
return;
|
||||
}
|
||||
// 소수점 둘째자리까지 허용하는 정규식
|
||||
const regex = /^\d*\.?\d{0,2}$/;
|
||||
if (regex.test(value)) {
|
||||
setMinMInput(value);
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue)) {
|
||||
handleFieldChange('minM', numValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleFieldChange]
|
||||
);
|
||||
|
||||
const handleMaxMChange = useCallback(
|
||||
(value: string) => {
|
||||
// 빈 값 허용
|
||||
if (value === '') {
|
||||
setMaxMInput('');
|
||||
handleFieldChange('maxM', 0);
|
||||
return;
|
||||
}
|
||||
// 소수점 둘째자리까지 허용하는 정규식
|
||||
const regex = /^\d*\.?\d{0,2}$/;
|
||||
if (regex.test(value)) {
|
||||
setMaxMInput(value);
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue)) {
|
||||
handleFieldChange('maxM', numValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleFieldChange]
|
||||
);
|
||||
|
||||
// 노임단가 입력 핸들러 (정수만)
|
||||
const handleLaborPriceChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === '') {
|
||||
handleFieldChange('laborPrice', null);
|
||||
return;
|
||||
}
|
||||
// 정수만 허용
|
||||
const regex = /^\d*$/;
|
||||
if (regex.test(value)) {
|
||||
const numValue = parseInt(value, 10);
|
||||
if (!isNaN(numValue)) {
|
||||
handleFieldChange('laborPrice', numValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleFieldChange]
|
||||
);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
// 유효성 검사
|
||||
if (!formData.laborNumber.trim()) {
|
||||
toast.error('노임번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (mode === 'new') {
|
||||
const result = await createLabor(formData);
|
||||
if (result.success && result.data) {
|
||||
toast.success('노임이 등록되었습니다.');
|
||||
router.push(`/ko/construction/order/base-info/labor/${result.data.id}?mode=view`);
|
||||
} else {
|
||||
toast.error(result.error || '노임 등록에 실패했습니다.');
|
||||
}
|
||||
} else if (mode === 'edit' && laborId) {
|
||||
const result = await updateLabor(laborId, formData);
|
||||
if (result.success) {
|
||||
toast.success('노임이 수정되었습니다.');
|
||||
setMode('view');
|
||||
// 데이터 다시 로드
|
||||
const reloadResult = await getLabor(laborId);
|
||||
if (reloadResult.success && reloadResult.data) {
|
||||
setOriginalData(reloadResult.data);
|
||||
if (mode === 'create') {
|
||||
const result = await createLabor(submitData);
|
||||
if (result.success && result.data) {
|
||||
return { success: true };
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '노임 수정에 실패했습니다.');
|
||||
return { success: false, error: result.error || '노임 등록에 실패했습니다.' };
|
||||
} else if (mode === 'edit' && laborId) {
|
||||
const result = await updateLabor(laborId, submitData);
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '노임 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '저장 중 오류가 발생했습니다.' };
|
||||
}
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [mode, formData, laborId, router]);
|
||||
},
|
||||
[mode, laborId]
|
||||
);
|
||||
|
||||
// 삭제
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!laborId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(async (id: string | number) => {
|
||||
try {
|
||||
const result = await deleteLabor(laborId);
|
||||
const result = await deleteLabor(String(id));
|
||||
if (result.success) {
|
||||
toast.success('노임이 삭제되었습니다.');
|
||||
router.push('/ko/construction/order/base-info/labor');
|
||||
} else {
|
||||
toast.error(result.error || '노임 삭제에 실패했습니다.');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '노임 삭제에 실패했습니다.' };
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
|
||||
}
|
||||
}, [laborId, router]);
|
||||
}, []);
|
||||
|
||||
// 수정 모드 전환
|
||||
const handleEditMode = useCallback(() => {
|
||||
setMode('edit');
|
||||
router.replace(`/ko/construction/order/base-info/labor/${laborId}?mode=edit`);
|
||||
}, [laborId, router]);
|
||||
|
||||
// 목록으로 이동
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/construction/order/base-info/labor');
|
||||
}, [router]);
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
if (mode === 'new') {
|
||||
router.push('/ko/construction/order/base-info/labor');
|
||||
} else {
|
||||
setMode('view');
|
||||
// 원본 데이터로 복원
|
||||
if (originalData) {
|
||||
setFormData({
|
||||
laborNumber: originalData.laborNumber,
|
||||
category: originalData.category,
|
||||
minM: originalData.minM,
|
||||
maxM: originalData.maxM,
|
||||
laborPrice: originalData.laborPrice,
|
||||
status: originalData.status,
|
||||
});
|
||||
// 소수점 입력용 문자열 상태도 복원
|
||||
setMinMInput(originalData.minM === 0 ? '' : originalData.minM.toString());
|
||||
setMaxMInput(originalData.maxM === 0 ? '' : originalData.maxM.toString());
|
||||
}
|
||||
router.replace(`/ko/construction/order/base-info/labor/${laborId}`);
|
||||
}
|
||||
}, [mode, laborId, originalData, router]);
|
||||
|
||||
// 읽기 전용 여부
|
||||
const isReadOnly = mode === 'view';
|
||||
|
||||
// 페이지 타이틀
|
||||
const pageTitle = mode === 'new' ? '노임 등록' : '노임 상세';
|
||||
|
||||
if (isLoading && !isNewMode) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
description="노임 정보를 등록하고 관리합니다."
|
||||
icon={Hammer}
|
||||
/>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
// 모드 변경 핸들러
|
||||
const handleModeChange = useCallback((newMode: DetailMode) => {
|
||||
setMode(newMode);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
description="노임 정보를 등록하고 관리합니다."
|
||||
icon={Hammer}
|
||||
/>
|
||||
<div className="space-y-6 pb-24">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
기본 정보 <span className="text-destructive">*</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Row 1: 노임번호, 구분 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="laborNumber">노임번호</Label>
|
||||
<Input
|
||||
id="laborNumber"
|
||||
value={formData.laborNumber}
|
||||
onChange={(e) => handleFieldChange('laborNumber', e.target.value)}
|
||||
placeholder="노임번호를 입력하세요"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">구분</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(v) => handleFieldChange('category', v as LaborCategory)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="구분 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 최소 M, 최대 M */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minM">최소 M</Label>
|
||||
<Input
|
||||
id="minM"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={minMInput}
|
||||
onChange={(e) => handleMinMChange(e.target.value)}
|
||||
placeholder="0.00"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxM">최대 M</Label>
|
||||
<Input
|
||||
id="maxM"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={maxMInput}
|
||||
onChange={(e) => handleMaxMChange(e.target.value)}
|
||||
placeholder="0.00"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 노임단가, 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="laborPrice">노임단가</Label>
|
||||
<Input
|
||||
id="laborPrice"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formData.laborPrice === null ? '' : formData.laborPrice.toString()}
|
||||
onChange={(e) => handleLaborPriceChange(e.target.value)}
|
||||
placeholder="0"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => handleFieldChange('status', v as LaborStatus)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 (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`}>
|
||||
<Button variant="outline" onClick={handleBack} size="sm" className="md:size-default">
|
||||
<ArrowLeft className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">목록으로</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{mode === 'view' && (
|
||||
<>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">삭제</span>
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEditMode} size="sm" className="md:size-default">
|
||||
<Edit className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수정</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{mode === 'edit' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
|
||||
<X className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">취소</span>
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving} size="sm" className="md:size-default">
|
||||
<Save className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">{isSaving ? '저장 중...' : '저장'}</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{mode === 'new' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
|
||||
<X className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">취소</span>
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving} size="sm" className="md:size-default">
|
||||
<Plus className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">{isSaving ? '등록 중...' : '등록'}</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
description="이 노임을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
|
||||
loading={isLoading}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</>
|
||||
<IntegratedDetailTemplate
|
||||
config={laborDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
|
||||
mode={mode}
|
||||
initialData={labor as Record<string, unknown> | undefined}
|
||||
itemId={laborId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Named export for backwards compatibility
|
||||
export { LaborDetailClient };
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* LaborDetailClientV2 - IntegratedDetailTemplate 기반 노임 상세/등록/수정
|
||||
*
|
||||
* 기존 LaborDetailClient를 IntegratedDetailTemplate으로 마이그레이션
|
||||
* - 6개 필드: 노임번호, 구분, 최소M, 최대M, 노임단가, 상태
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
IntegratedDetailTemplate,
|
||||
type DetailMode,
|
||||
} from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { laborDetailConfig } from './laborDetailConfig';
|
||||
import type { Labor, LaborFormData } from './types';
|
||||
import { getLabor, createLabor, updateLabor, deleteLabor } from './actions';
|
||||
|
||||
interface LaborDetailClientV2Props {
|
||||
laborId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
export default function LaborDetailClientV2({
|
||||
laborId,
|
||||
initialMode = 'view',
|
||||
}: LaborDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const [labor, setLabor] = useState<Labor | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [mode, setMode] = useState<DetailMode>(initialMode);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (laborId && initialMode !== 'create') {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getLabor(laborId);
|
||||
if (result.success && result.data) {
|
||||
setLabor(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/labor');
|
||||
}
|
||||
} catch {
|
||||
toast.error('노임 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/labor');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}
|
||||
}, [laborId, initialMode, router]);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>) => {
|
||||
try {
|
||||
const submitData = laborDetailConfig.transformSubmitData!(formData) as unknown as LaborFormData;
|
||||
|
||||
if (mode === 'create') {
|
||||
const result = await createLabor(submitData);
|
||||
if (result.success && result.data) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '노임 등록에 실패했습니다.' };
|
||||
} else if (mode === 'edit' && laborId) {
|
||||
const result = await updateLabor(laborId, submitData);
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '노임 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '저장 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
[mode, laborId]
|
||||
);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(async (id: string | number) => {
|
||||
try {
|
||||
const result = await deleteLabor(String(id));
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '노임 삭제에 실패했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 모드 변경 핸들러
|
||||
const handleModeChange = useCallback((newMode: DetailMode) => {
|
||||
setMode(newMode);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={laborDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
|
||||
mode={mode}
|
||||
initialData={labor as Record<string, unknown> | undefined}
|
||||
itemId={laborId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Named export for backwards compatibility
|
||||
export { LaborDetailClientV2 };
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as LaborManagementClient } from './LaborManagementClient';
|
||||
export { default as LaborDetailClient } from './LaborDetailClient';
|
||||
export { default as LaborDetailClientV2 } from './LaborDetailClientV2';
|
||||
export { laborDetailConfig } from './laborDetailConfig';
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
|
||||
@@ -1,464 +1,134 @@
|
||||
/**
|
||||
* PricingDetailClient - IntegratedDetailTemplate 기반 단가 상세/등록/수정
|
||||
*
|
||||
* 기존 PricingDetailClient를 IntegratedDetailTemplate으로 마이그레이션
|
||||
* - 12개 필드: 단가번호, 품목유형, 카테고리명, 품목명, 규격, 무게, 단위, 구분, 거래처, 판매단가, 상태, 비고
|
||||
* - 대부분 필드 readonly, 거래처/판매단가/상태/비고만 edit/create 모드에서 수정 가능
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DollarSign, ArrowLeft, Trash2, Edit, X, Save, Plus, List } from 'lucide-react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import type { Pricing, PricingStatus } from './types';
|
||||
import { PRICING_STATUS_LABELS } from './types';
|
||||
import {
|
||||
getPricingDetail,
|
||||
createPricing,
|
||||
updatePricing,
|
||||
deletePricing,
|
||||
getVendorList,
|
||||
} from './actions';
|
||||
IntegratedDetailTemplate,
|
||||
type DetailMode,
|
||||
} from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { pricingDetailConfig } from './pricingDetailConfig';
|
||||
import type { Pricing, PricingFormData } from './types';
|
||||
import { getPricingDetail, createPricing, updatePricing, deletePricing } from './actions';
|
||||
|
||||
interface PricingDetailClientProps {
|
||||
id?: string;
|
||||
mode: 'view' | 'create' | 'edit';
|
||||
pricingId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
itemType: string;
|
||||
category: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
division: string;
|
||||
vendor: string;
|
||||
purchasePrice: number;
|
||||
marginRate: number;
|
||||
sellingPrice: number;
|
||||
status: PricingStatus;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const initialFormData: FormData = {
|
||||
itemType: '',
|
||||
category: '',
|
||||
itemName: '',
|
||||
spec: '',
|
||||
unit: '',
|
||||
division: '',
|
||||
vendor: '',
|
||||
purchasePrice: 0,
|
||||
marginRate: 0,
|
||||
sellingPrice: 0,
|
||||
status: 'in_use',
|
||||
note: '',
|
||||
};
|
||||
|
||||
export default function PricingDetailClient({ id, mode }: PricingDetailClientProps) {
|
||||
export default function PricingDetailClient({
|
||||
pricingId,
|
||||
initialMode = 'view',
|
||||
}: PricingDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate, canDelete } = usePermission();
|
||||
const [pricing, setPricing] = useState<Pricing | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
|
||||
const [pricing, setPricing] = useState<Pricing | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const isViewMode = mode === 'view';
|
||||
const isCreateMode = mode === 'create';
|
||||
const isEditMode = mode === 'edit';
|
||||
const [mode, setMode] = useState<DetailMode>(initialMode);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 거래처 목록 로드
|
||||
const vendorResult = await getVendorList();
|
||||
if (vendorResult.success && vendorResult.data) {
|
||||
setVendors(vendorResult.data);
|
||||
}
|
||||
|
||||
// 상세 데이터 로드 (수정/보기 모드)
|
||||
if (id && (isViewMode || isEditMode)) {
|
||||
const result = await getPricingDetail(id);
|
||||
if (pricingId && initialMode !== 'create') {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getPricingDetail(pricingId);
|
||||
if (result.success && result.data) {
|
||||
setPricing(result.data);
|
||||
setFormData({
|
||||
itemType: result.data.itemType,
|
||||
category: result.data.category,
|
||||
itemName: result.data.itemName,
|
||||
spec: result.data.spec,
|
||||
unit: result.data.unit,
|
||||
division: result.data.division,
|
||||
vendor: result.data.vendor,
|
||||
purchasePrice: result.data.purchasePrice,
|
||||
marginRate: result.data.marginRate,
|
||||
sellingPrice: result.data.sellingPrice,
|
||||
status: result.data.status,
|
||||
note: '',
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '데이터를 불러올 수 없습니다.');
|
||||
toast.error(result.error || '단가 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/pricing');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [id, mode, isViewMode, isEditMode, router]);
|
||||
|
||||
// 판매단가 변경
|
||||
const handleSellingPriceChange = useCallback((value: string) => {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sellingPrice: numValue,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 거래처 변경
|
||||
const handleVendorChange = useCallback((value: string) => {
|
||||
setFormData((prev) => ({ ...prev, vendor: value }));
|
||||
}, []);
|
||||
|
||||
// 상태 변경
|
||||
const handleStatusChange = useCallback((value: string) => {
|
||||
setFormData((prev) => ({ ...prev, status: value as PricingStatus }));
|
||||
}, []);
|
||||
|
||||
// 비고 변경
|
||||
const handleNoteChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setFormData((prev) => ({ ...prev, note: e.target.value }));
|
||||
}, []);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (isCreateMode) {
|
||||
const result = await createPricing({
|
||||
itemType: formData.itemType,
|
||||
category: formData.category,
|
||||
itemName: formData.itemName,
|
||||
spec: formData.spec,
|
||||
orderItems: [],
|
||||
unit: formData.unit,
|
||||
division: formData.division,
|
||||
vendor: formData.vendor,
|
||||
purchasePrice: formData.purchasePrice,
|
||||
marginRate: formData.marginRate,
|
||||
sellingPrice: formData.sellingPrice,
|
||||
status: formData.status,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('단가가 등록되었습니다.');
|
||||
} catch {
|
||||
toast.error('단가 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/pricing');
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else if (isEditMode && id) {
|
||||
const result = await updatePricing(id, {
|
||||
vendor: formData.vendor,
|
||||
sellingPrice: formData.sellingPrice,
|
||||
status: formData.status,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('단가가 수정되었습니다.');
|
||||
router.push(`/ko/construction/order/base-info/pricing/${id}?mode=view`);
|
||||
} else {
|
||||
toast.error(result.error || '수정에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}
|
||||
}, [isCreateMode, isEditMode, id, formData, router]);
|
||||
}, [pricingId, initialMode, router]);
|
||||
|
||||
// 삭제
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!id) return;
|
||||
// 저장 핸들러
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>) => {
|
||||
try {
|
||||
const submitData = pricingDetailConfig.transformSubmitData!(formData) as unknown as PricingFormData;
|
||||
|
||||
setIsLoading(true);
|
||||
if (mode === 'create') {
|
||||
const result = await createPricing(submitData);
|
||||
if (result.success && result.data) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '단가 등록에 실패했습니다.' };
|
||||
} else if (mode === 'edit' && pricingId) {
|
||||
// edit 모드에서는 수정 가능한 필드만 전송
|
||||
const result = await updatePricing(pricingId, {
|
||||
vendor: submitData.vendor,
|
||||
sellingPrice: submitData.sellingPrice,
|
||||
status: submitData.status,
|
||||
});
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '단가 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '저장 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
[mode, pricingId]
|
||||
);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(async (id: string | number) => {
|
||||
try {
|
||||
const result = await deletePricing(id);
|
||||
const result = await deletePricing(String(id));
|
||||
if (result.success) {
|
||||
toast.success('단가가 삭제되었습니다.');
|
||||
router.push('/ko/construction/order/base-info/pricing');
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '단가 삭제에 실패했습니다.' };
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
|
||||
}
|
||||
}, [id, router]);
|
||||
}, []);
|
||||
|
||||
// 수정 페이지로 이동
|
||||
const handleEdit = useCallback(() => {
|
||||
if (id) {
|
||||
router.push(`/ko/construction/order/base-info/pricing/${id}?mode=edit`);
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isCreateMode) {
|
||||
router.push('/ko/construction/order/base-info/pricing');
|
||||
} else if (isEditMode && id) {
|
||||
router.push(`/ko/construction/order/base-info/pricing/${id}?mode=view`);
|
||||
}
|
||||
}, [isCreateMode, isEditMode, id, router]);
|
||||
|
||||
// 목록으로 이동
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/construction/order/base-info/pricing');
|
||||
}, [router]);
|
||||
|
||||
// 숫자 포맷
|
||||
const formatNumber = (num: number) => num.toLocaleString('ko-KR');
|
||||
|
||||
// 페이지 제목
|
||||
const pageTitle = isCreateMode ? '단가 등록' : isEditMode ? '단가 수정' : '단가 상세';
|
||||
const pageDescription = '단가 정보를 등록하고 관리합니다';
|
||||
|
||||
// 동적 항목 (무게, 두께 등) 가져오기
|
||||
const dynamicOrderItems = pricing?.orderItems || [];
|
||||
// 모드 변경 핸들러
|
||||
const handleModeChange = useCallback(
|
||||
(newMode: DetailMode) => {
|
||||
if (newMode === 'edit' && pricingId) {
|
||||
// edit 모드로 변경 시 별도 페이지로 이동 (기존 라우트 구조 유지)
|
||||
router.push(`/ko/construction/order/base-info/pricing/${pricingId}?mode=edit`);
|
||||
} else {
|
||||
setMode(newMode);
|
||||
}
|
||||
},
|
||||
[pricingId, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
icon={DollarSign}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 pb-24">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 단가번호 / 품목유형 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>단가번호</Label>
|
||||
{isCreateMode ? (
|
||||
<Input value="자동생성" disabled />
|
||||
) : (
|
||||
<Input value={pricing?.pricingNumber || ''} disabled />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>품목유형</Label>
|
||||
<Input value={formData.itemType} disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리명 / 품목명 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>카테고리명</Label>
|
||||
<Input value={formData.category} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>품목명</Label>
|
||||
<Input value={formData.itemName} disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 규격 / 동적항목 (무게 등) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>규격</Label>
|
||||
<Input value={formData.spec} disabled />
|
||||
</div>
|
||||
{dynamicOrderItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{dynamicOrderItems[0]?.name || '무게'}</Label>
|
||||
<Input value={dynamicOrderItems[0]?.value || '-'} disabled />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>무게</Label>
|
||||
<Input value="-" disabled />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 단위 / 구분 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>단위</Label>
|
||||
<Input value={formData.unit} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>구분</Label>
|
||||
<Input value={formData.division} disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 거래처 / 판매단가 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>거래처</Label>
|
||||
{isViewMode ? (
|
||||
<Input value={formData.vendor} disabled />
|
||||
) : (
|
||||
<Select value={formData.vendor} onValueChange={handleVendorChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="거래처 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendors.map((vendor) => (
|
||||
<SelectItem key={vendor.id} value={vendor.name}>
|
||||
{vendor.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>판매단가</Label>
|
||||
{isViewMode ? (
|
||||
<Input value={formatNumber(formData.sellingPrice)} disabled />
|
||||
) : (
|
||||
<CurrencyInput
|
||||
value={formData.sellingPrice}
|
||||
onChange={(value) => handleSellingPriceChange(String(value ?? 0))}
|
||||
placeholder="판매단가 입력"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
{isViewMode ? (
|
||||
<Input value={PRICING_STATUS_LABELS[formData.status]} disabled />
|
||||
) : (
|
||||
<Select value={formData.status} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="in_use">사용</SelectItem>
|
||||
<SelectItem value="stopped">중지</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label>비고</Label>
|
||||
{isViewMode ? (
|
||||
<Textarea value={formData.note || '-'} disabled rows={3} />
|
||||
) : (
|
||||
<Textarea
|
||||
value={formData.note}
|
||||
onChange={handleNoteChange}
|
||||
placeholder="비고 입력"
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 (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`}>
|
||||
<Button variant="outline" onClick={handleBack} size="sm" className="md:size-default">
|
||||
<ArrowLeft className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">목록으로</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{isViewMode && (
|
||||
<>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">삭제</span>
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEdit} size="sm" className="md:size-default">
|
||||
<Edit className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수정</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
|
||||
<X className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">취소</span>
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading} size="sm" className="md:size-default">
|
||||
<Save className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">{isLoading ? '저장 중...' : '저장'}</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isCreateMode && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
|
||||
<X className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">취소</span>
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading} size="sm" className="md:size-default">
|
||||
<Plus className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">{isLoading ? '등록 중...' : '등록'}</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
description="이 단가를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
loading={isLoading}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</PageLayout>
|
||||
<IntegratedDetailTemplate
|
||||
config={pricingDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
|
||||
mode={mode}
|
||||
initialData={pricing as Record<string, unknown> | undefined}
|
||||
itemId={pricingId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Named export for backwards compatibility
|
||||
export { PricingDetailClient };
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* PricingDetailClientV2 - IntegratedDetailTemplate 기반 단가 상세/등록/수정
|
||||
*
|
||||
* 기존 PricingDetailClient를 IntegratedDetailTemplate으로 마이그레이션
|
||||
* - 12개 필드: 단가번호, 품목유형, 카테고리명, 품목명, 규격, 무게, 단위, 구분, 거래처, 판매단가, 상태, 비고
|
||||
* - 대부분 필드 readonly, 거래처/판매단가/상태/비고만 edit/create 모드에서 수정 가능
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
IntegratedDetailTemplate,
|
||||
type DetailMode,
|
||||
} from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { pricingDetailConfig } from './pricingDetailConfig';
|
||||
import type { Pricing, PricingFormData } from './types';
|
||||
import { getPricingDetail, createPricing, updatePricing, deletePricing } from './actions';
|
||||
|
||||
interface PricingDetailClientV2Props {
|
||||
pricingId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
export default function PricingDetailClientV2({
|
||||
pricingId,
|
||||
initialMode = 'view',
|
||||
}: PricingDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const [pricing, setPricing] = useState<Pricing | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [mode, setMode] = useState<DetailMode>(initialMode);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (pricingId && initialMode !== 'create') {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getPricingDetail(pricingId);
|
||||
if (result.success && result.data) {
|
||||
setPricing(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '단가 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/pricing');
|
||||
}
|
||||
} catch {
|
||||
toast.error('단가 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/pricing');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}
|
||||
}, [pricingId, initialMode, router]);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>) => {
|
||||
try {
|
||||
const submitData = pricingDetailConfig.transformSubmitData!(formData) as unknown as PricingFormData;
|
||||
|
||||
if (mode === 'create') {
|
||||
const result = await createPricing(submitData);
|
||||
if (result.success && result.data) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '단가 등록에 실패했습니다.' };
|
||||
} else if (mode === 'edit' && pricingId) {
|
||||
// edit 모드에서는 수정 가능한 필드만 전송
|
||||
const result = await updatePricing(pricingId, {
|
||||
vendor: submitData.vendor,
|
||||
sellingPrice: submitData.sellingPrice,
|
||||
status: submitData.status,
|
||||
});
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '단가 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '저장 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
[mode, pricingId]
|
||||
);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(async (id: string | number) => {
|
||||
try {
|
||||
const result = await deletePricing(String(id));
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '단가 삭제에 실패했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 모드 변경 핸들러
|
||||
const handleModeChange = useCallback(
|
||||
(newMode: DetailMode) => {
|
||||
if (newMode === 'edit' && pricingId) {
|
||||
// edit 모드로 변경 시 별도 페이지로 이동 (기존 라우트 구조 유지)
|
||||
router.push(`/ko/construction/order/base-info/pricing/${pricingId}?mode=edit`);
|
||||
} else {
|
||||
setMode(newMode);
|
||||
}
|
||||
},
|
||||
[pricingId, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={pricingDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
|
||||
mode={mode}
|
||||
initialData={pricing as Record<string, unknown> | undefined}
|
||||
itemId={pricingId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Named export for backwards compatibility
|
||||
export { PricingDetailClientV2 };
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as PricingListClient } from './PricingListClient';
|
||||
export { default as PricingDetailClient } from './PricingDetailClient';
|
||||
export { default as PricingDetailClientV2 } from './PricingDetailClientV2';
|
||||
export { pricingDetailConfig } from './pricingDetailConfig';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { Client } from "../../hooks/useClientList";
|
||||
import { PageLayout } from "../organisms/PageLayout";
|
||||
import { PageHeader } from "../organisms/PageHeader";
|
||||
import { useMenuStore } from "@/store/menuStore";
|
||||
import { useMenuStore } from "@/stores/menuStore";
|
||||
|
||||
interface ClientDetailProps {
|
||||
client: Client;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
randomRemark,
|
||||
tempId,
|
||||
} from './index';
|
||||
import type { QuoteFormData, QuoteItem } from '@/components/quotes/QuoteRegistration';
|
||||
import type { QuoteFormData, QuoteFormItem } from '@/components/quotes/types';
|
||||
import type { Vendor } from '@/components/accounting/VendorManagement/types';
|
||||
import type { FinishedGoods } from '@/components/quotes/actions';
|
||||
|
||||
@@ -40,11 +40,11 @@ const WRITERS = ['드미트리', '김철수', '이영희', '박지민', '최서
|
||||
* @param products 제품 목록 (code, name, category 속성 필요)
|
||||
* @param category 제품 카테고리 (지정하지 않으면 랜덤 선택)
|
||||
*/
|
||||
export function generateQuoteItem(
|
||||
export function generateQuoteFormItem(
|
||||
index: number,
|
||||
products?: Array<{ code: string; name: string; category?: string }>,
|
||||
category?: string
|
||||
): QuoteItem {
|
||||
): QuoteFormItem {
|
||||
const selectedCategory = category || randomPick(PRODUCT_CATEGORIES);
|
||||
|
||||
// 카테고리에 맞는 제품 필터링
|
||||
@@ -106,9 +106,9 @@ export function generateQuoteData(options: GenerateQuoteDataOptions = {}): Quote
|
||||
|
||||
// 품목 생성 (동일 카테고리 사용)
|
||||
const selectedCategory = category || randomPick(PRODUCT_CATEGORIES);
|
||||
const items: QuoteItem[] = [];
|
||||
const items: QuoteFormItem[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
items.push(generateQuoteItem(i, products, selectedCategory));
|
||||
items.push(generateQuoteFormItem(i, products, selectedCategory));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,7 @@ export { useDevFill } from './useDevFill';
|
||||
export { DevToolbar } from './DevToolbar';
|
||||
|
||||
// Generators
|
||||
export { generateQuoteData, generateQuoteItem } from './generators/quoteData';
|
||||
export { generateQuoteData, generateQuoteFormItem } from './generators/quoteData';
|
||||
export { generateOrderData, generateOrderDataFull } from './generators/orderData';
|
||||
export { generateWorkOrderData } from './generators/workOrderData';
|
||||
export { generateShipmentData } from './generators/shipmentData';
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { Save, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMenuStore, type MenuItem } from '@/store/menuStore';
|
||||
import { useMenuStore, type MenuItem } from '@/stores/menuStore';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
|
||||
209
src/components/layout/HeaderFavoritesBar.tsx
Normal file
209
src/components/layout/HeaderFavoritesBar.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useFavoritesStore } from '@/stores/favoritesStore';
|
||||
import { iconMap } from '@/lib/utils/menuTransform';
|
||||
import type { FavoriteItem } from '@/stores/favoritesStore';
|
||||
|
||||
type DisplayMode = 'full' | 'icon-only' | 'overflow';
|
||||
|
||||
interface HeaderFavoritesBarProps {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) {
|
||||
const router = useRouter();
|
||||
const { favorites } = useFavoritesStore();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('full');
|
||||
|
||||
// 반응형: ResizeObserver로 컨테이너 너비 감지
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setDisplayMode('overflow');
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const width = entry.contentRect.width;
|
||||
if (width < 300 || favorites.length > 4) {
|
||||
setDisplayMode('overflow');
|
||||
} else if (width < 600) {
|
||||
setDisplayMode('icon-only');
|
||||
} else {
|
||||
setDisplayMode('full');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile, favorites.length]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(item: FavoriteItem) => {
|
||||
router.push(item.path);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
if (favorites.length === 0) return null;
|
||||
|
||||
const getIcon = (iconName: string) => {
|
||||
const Icon = iconMap[iconName];
|
||||
return Icon || null;
|
||||
};
|
||||
|
||||
// 모바일: 최대 2개 아이콘 + 나머지 드롭다운
|
||||
if (isMobile) {
|
||||
const visible = favorites.slice(0, 2);
|
||||
const overflow = favorites.slice(2);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-0.5 sm:space-x-1">
|
||||
{visible.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
if (!Icon) return null;
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
|
||||
title={item.label}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{overflow.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-slate-600 hover:bg-slate-700 text-white flex items-center justify-center"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{overflow.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱
|
||||
const visibleCount = displayMode === 'overflow' ? 3 : favorites.length;
|
||||
const visibleItems = favorites.slice(0, visibleCount);
|
||||
const overflowItems = favorites.slice(visibleCount);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div ref={containerRef} className="flex items-center space-x-2">
|
||||
{visibleItems.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
if (!Icon) return null;
|
||||
|
||||
if (displayMode === 'full') {
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden xl:inline">{item.label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// icon-only 또는 overflow의 visible 부분
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white p-2 flex items-center justify-center transition-all duration-200"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{overflowItems.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl p-2 flex items-center justify-center"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{overflowItems.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
|
||||
import type { MenuItem } from '@/store/menuStore';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle, Star } from 'lucide-react';
|
||||
import type { MenuItem } from '@/stores/menuStore';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useFavoritesStore } from '@/stores/favoritesStore';
|
||||
import { getIconName } from '@/lib/utils/menuTransform';
|
||||
import type { FavoriteItem } from '@/stores/favoritesStore';
|
||||
|
||||
interface SidebarProps {
|
||||
menuItems: MenuItem[];
|
||||
@@ -45,6 +48,24 @@ function MenuItemComponent({
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expandedMenus.includes(item.id);
|
||||
const isActive = activeMenu === item.id;
|
||||
const isLeaf = !hasChildren;
|
||||
|
||||
// 즐겨찾기 상태
|
||||
const { toggleFavorite, isFavorite } = useFavoritesStore();
|
||||
const isFav = isLeaf ? isFavorite(item.id) : false;
|
||||
|
||||
const handleStarClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const favItem: FavoriteItem = {
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
iconName: getIconName(item.icon),
|
||||
path: item.path,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
toggleFavorite(favItem);
|
||||
}, [item, toggleFavorite]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (hasChildren) {
|
||||
@@ -72,48 +93,63 @@ function MenuItemComponent({
|
||||
className="relative"
|
||||
ref={isActive ? activeMenuRef : null}
|
||||
>
|
||||
{/* 메인 메뉴 버튼 */}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`w-full flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
|
||||
sidebarCollapsed ? 'p-2.5 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
|
||||
} ${
|
||||
isActive
|
||||
? "text-white clean-shadow scale-[0.98]"
|
||||
: "text-foreground hover:bg-accent hover:scale-[1.02] active:scale-[0.98]"
|
||||
}`}
|
||||
style={isActive ? { backgroundColor: '#3B82F6' } : {}}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon aspect-square ${
|
||||
sidebarCollapsed ? 'w-7' : 'w-8'
|
||||
} ${
|
||||
isActive
|
||||
? "bg-white/20"
|
||||
: "bg-primary/10 group-hover:bg-primary/20"
|
||||
}`}>
|
||||
{IconComponent && <IconComponent className={`transition-all duration-200 ${
|
||||
sidebarCollapsed ? 'h-4 w-4' : 'h-5 w-5'
|
||||
{/* 메인 메뉴 버튼 + 별표 래퍼 */}
|
||||
<div className="flex items-center group/row">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`flex-1 min-w-0 flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
|
||||
sidebarCollapsed ? 'p-2.5 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
|
||||
} ${
|
||||
isActive ? "text-white" : "text-primary"
|
||||
}`} />}
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 font-medium transition-all duration-200 opacity-100 text-left text-sm">{item.label}</span>
|
||||
{hasChildren && (
|
||||
<div className={`transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
isActive
|
||||
? "text-white clean-shadow scale-[0.98]"
|
||||
: "text-foreground hover:bg-accent hover:scale-[1.02] active:scale-[0.98]"
|
||||
}`}
|
||||
style={isActive ? { backgroundColor: '#3B82F6' } : {}}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon aspect-square ${
|
||||
sidebarCollapsed ? 'w-7' : 'w-8'
|
||||
} ${
|
||||
isActive
|
||||
? "bg-white/20"
|
||||
: "bg-primary/10 group-hover:bg-primary/20"
|
||||
}`}>
|
||||
{IconComponent && <IconComponent className={`transition-all duration-200 ${
|
||||
sidebarCollapsed ? 'h-4 w-4' : 'h-5 w-5'
|
||||
} ${
|
||||
isActive ? "text-white" : "text-primary"
|
||||
}`} />}
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 font-medium transition-all duration-200 opacity-100 text-left text-sm">{item.label}</span>
|
||||
{hasChildren && (
|
||||
<div className={`transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isActive && !sidebarCollapsed && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
)}
|
||||
</button>
|
||||
{isLeaf && !sidebarCollapsed && (
|
||||
<button
|
||||
onClick={handleStarClick}
|
||||
className={`flex-shrink-0 p-1 rounded transition-all duration-200 ${
|
||||
isFav
|
||||
? 'opacity-100 text-yellow-500'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
{isActive && !sidebarCollapsed && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 자식 메뉴 (재귀) */}
|
||||
{hasChildren && isExpanded && !sidebarCollapsed && (
|
||||
@@ -143,24 +179,39 @@ function MenuItemComponent({
|
||||
if (is2Depth) {
|
||||
return (
|
||||
<div ref={isActive ? activeMenuRef : null}>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2.5 space-x-2.5 group ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{IconComponent && <IconComponent className="h-4 w-4 flex-shrink-0" />}
|
||||
<span className="flex-1 text-sm font-medium text-left">{item.label}</span>
|
||||
{hasChildren && (
|
||||
<div className={`transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex items-center group/row">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`flex-1 min-w-0 flex items-center rounded-lg transition-all duration-200 p-2.5 space-x-2.5 group ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{IconComponent && <IconComponent className="h-4 w-4 flex-shrink-0" />}
|
||||
<span className="flex-1 text-sm font-medium text-left">{item.label}</span>
|
||||
{hasChildren && (
|
||||
<div className={`transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{isLeaf && (
|
||||
<button
|
||||
onClick={handleStarClick}
|
||||
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
|
||||
isFav
|
||||
? 'opacity-100 text-yellow-500'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 자식 메뉴 (3depth) */}
|
||||
{hasChildren && isExpanded && (
|
||||
@@ -190,26 +241,41 @@ function MenuItemComponent({
|
||||
if (is3DepthOrMore) {
|
||||
return (
|
||||
<div ref={isActive ? activeMenuRef : null}>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`w-full flex items-center rounded-md transition-all duration-200 p-2 space-x-2 group ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Circle className={`h-1.5 w-1.5 flex-shrink-0 ${
|
||||
isActive ? 'fill-primary text-primary' : 'fill-muted-foreground/50 text-muted-foreground/50'
|
||||
}`} />
|
||||
<span className="flex-1 text-xs text-left">{item.label}</span>
|
||||
{hasChildren && (
|
||||
<div className={`transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="flex items-center group/row">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`flex-1 min-w-0 flex items-center rounded-md transition-all duration-200 p-2 space-x-2 group ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Circle className={`h-1.5 w-1.5 flex-shrink-0 ${
|
||||
isActive ? 'fill-primary text-primary' : 'fill-muted-foreground/50 text-muted-foreground/50'
|
||||
}`} />
|
||||
<span className="flex-1 text-xs text-left">{item.label}</span>
|
||||
{hasChildren && (
|
||||
<div className={`transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{isLeaf && (
|
||||
<button
|
||||
onClick={handleStarClick}
|
||||
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
|
||||
isFav
|
||||
? 'opacity-100 text-yellow-500'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 자식 메뉴 (4depth 이상 - 재귀) */}
|
||||
{hasChildren && isExpanded && (
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Upload, FileText, Search, X, Plus, ClipboardCheck } 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';
|
||||
import { InspectionModal } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModal';
|
||||
import { ImportInspectionInputModal } from './ImportInspectionInputModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -874,7 +874,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
/>
|
||||
|
||||
{/* 수입검사 성적서 모달 (읽기 전용) */}
|
||||
<InspectionModalV2
|
||||
<InspectionModal
|
||||
isOpen={isInspectionModalOpen}
|
||||
onClose={() => setIsInspectionModalOpen(false)}
|
||||
document={{
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 { useMenuStore } from '@/stores/menuStore';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
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 { useMenuStore } from '@/stores/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { toast } from 'sonner';
|
||||
import { createPricingTable, updatePricingTable, deletePricingTable } from './actions';
|
||||
|
||||
@@ -4,16 +4,11 @@
|
||||
* 중간검사 미리보기 모달
|
||||
*
|
||||
* 설정된 검사 항목들로 실제 성적서가 어떻게 보일지 미리보기
|
||||
* DocumentViewer preset="readonly" 사용 → 줌/드래그/인쇄/PDF 자동 제공
|
||||
*/
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { InspectionSetting } from '@/types/process';
|
||||
|
||||
@@ -30,21 +25,16 @@ export function InspectionPreviewModal({
|
||||
}: InspectionPreviewModalProps) {
|
||||
if (!inspectionSetting) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>중간검사 미리보기</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
검사 설정이 없습니다. 먼저 검사 설정을 완료해주세요.
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DocumentViewer
|
||||
title="중간검사 미리보기"
|
||||
preset="readonly"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
검사 설정이 없습니다. 먼저 검사 설정을 완료해주세요.
|
||||
</div>
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,214 +59,206 @@ export function InspectionPreviewModal({
|
||||
const sampleRows = [1, 2, 3, 4, 5];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] max-w-[1400px] sm:max-w-[1400px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>중간검사 미리보기</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* 헤더 정보 */}
|
||||
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">기준서명:</span>
|
||||
<Badge variant="outline">{inspectionSetting.standardName || '미설정'}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">활성 항목:</span>
|
||||
<Badge>{activeAppearanceItems.length + activeDimensionItems.length}개</Badge>
|
||||
</div>
|
||||
<DocumentViewer
|
||||
title="중간검사 미리보기"
|
||||
preset="readonly"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 헤더 정보 */}
|
||||
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">기준서명:</span>
|
||||
<Badge variant="outline">{inspectionSetting.standardName || '미설정'}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">활성 항목:</span>
|
||||
<Badge>{activeAppearanceItems.length + activeDimensionItems.length}개</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중간검사 기준서 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
|
||||
중간검사 기준서
|
||||
{/* 중간검사 기준서 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
|
||||
중간검사 기준서
|
||||
</div>
|
||||
<div className="p-4 grid grid-cols-2 gap-4">
|
||||
{/* 도해 이미지 영역 */}
|
||||
<div className="border rounded-lg p-4 min-h-[200px] flex items-center justify-center bg-muted/30">
|
||||
{inspectionSetting.schematicImage ? (
|
||||
<img
|
||||
src={inspectionSetting.schematicImage}
|
||||
alt="도해 이미지"
|
||||
className="max-w-full max-h-[180px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">도해 이미지 없음</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 grid grid-cols-2 gap-4">
|
||||
{/* 도해 이미지 영역 */}
|
||||
<div className="border rounded-lg p-4 min-h-[200px] flex items-center justify-center bg-muted/30">
|
||||
{inspectionSetting.schematicImage ? (
|
||||
<img
|
||||
src={inspectionSetting.schematicImage}
|
||||
alt="도해 이미지"
|
||||
className="max-w-full max-h-[180px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">도해 이미지 없음</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검사기준 이미지 또는 검사 항목 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden min-h-[200px] flex items-center justify-center bg-muted/30">
|
||||
{inspectionSetting.inspectionStandardImage ? (
|
||||
<img
|
||||
src={inspectionSetting.inspectionStandardImage}
|
||||
alt="검사기준 이미지"
|
||||
className="max-w-full max-h-[180px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="border-b px-3 py-2 text-left">검사항목</th>
|
||||
<th className="border-b px-3 py-2 text-left">검사방법</th>
|
||||
<th className="border-b px-3 py-2 text-left">포인트</th>
|
||||
{/* 검사기준 이미지 또는 검사 항목 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden min-h-[200px] flex items-center justify-center bg-muted/30">
|
||||
{inspectionSetting.inspectionStandardImage ? (
|
||||
<img
|
||||
src={inspectionSetting.inspectionStandardImage}
|
||||
alt="검사기준 이미지"
|
||||
className="max-w-full max-h-[180px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="border-b px-3 py-2 text-left">검사항목</th>
|
||||
<th className="border-b px-3 py-2 text-left">검사방법</th>
|
||||
<th className="border-b px-3 py-2 text-left">포인트</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeAppearanceItems.map((item) => (
|
||||
<tr key={item.key} className="border-b last:border-b-0">
|
||||
<td className="px-3 py-2">{item.label}</td>
|
||||
<td className="px-3 py-2">양자택일</td>
|
||||
<td className="px-3 py-2">-</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeAppearanceItems.map((item) => (
|
||||
<tr key={item.key} className="border-b last:border-b-0">
|
||||
<td className="px-3 py-2">{item.label}</td>
|
||||
<td className="px-3 py-2">양자택일</td>
|
||||
<td className="px-3 py-2">-</td>
|
||||
</tr>
|
||||
))}
|
||||
{activeDimensionItems.map((item) => (
|
||||
<tr key={item.key} className="border-b last:border-b-0">
|
||||
<td className="px-3 py-2">{item.label}</td>
|
||||
<td className="px-3 py-2">{item.method}</td>
|
||||
<td className="px-3 py-2">{item.point}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{activeDimensionItems.map((item) => (
|
||||
<tr key={item.key} className="border-b last:border-b-0">
|
||||
<td className="px-3 py-2">{item.label}</td>
|
||||
<td className="px-3 py-2">{item.method}</td>
|
||||
<td className="px-3 py-2">{item.point}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중간검사 DATA */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
|
||||
중간검사 DATA
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="border-b border-r px-3 py-2 text-center w-12">No.</th>
|
||||
{/* 겉모양 항목들 */}
|
||||
{/* 중간검사 DATA */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
|
||||
중간검사 DATA
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="border-b border-r px-3 py-2 text-center w-12">No.</th>
|
||||
{/* 겉모양 항목들 */}
|
||||
{activeAppearanceItems.map((item) => (
|
||||
<th
|
||||
key={item.key}
|
||||
className="border-b border-r px-3 py-2 text-center min-w-[80px]"
|
||||
>
|
||||
{item.label}
|
||||
</th>
|
||||
))}
|
||||
{/* 치수 항목들 */}
|
||||
{activeDimensionItems.map((item) => (
|
||||
<th
|
||||
key={item.key}
|
||||
className="border-b border-r px-3 py-2 text-center"
|
||||
colSpan={2}
|
||||
>
|
||||
{item.label} (mm)
|
||||
</th>
|
||||
))}
|
||||
{/* 판정 */}
|
||||
{inspectionSetting.judgment && (
|
||||
<th className="border-b px-3 py-2 text-center w-20">
|
||||
판정
|
||||
<br />
|
||||
<span className="text-xs">(적/부)</span>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
{/* 치수 서브헤더 */}
|
||||
{activeDimensionItems.length > 0 && (
|
||||
<tr className="bg-muted/30">
|
||||
<th className="border-b border-r px-3 py-1"></th>
|
||||
{activeAppearanceItems.map((item) => (
|
||||
<th
|
||||
key={item.key}
|
||||
className="border-b border-r px-3 py-2 text-center min-w-[80px]"
|
||||
>
|
||||
{item.label}
|
||||
<th key={item.key} className="border-b border-r px-3 py-1 text-xs">
|
||||
양호/불량
|
||||
</th>
|
||||
))}
|
||||
{/* 치수 항목들 */}
|
||||
{activeDimensionItems.map((item) => (
|
||||
<th
|
||||
key={item.key}
|
||||
className="border-b border-r px-3 py-2 text-center"
|
||||
colSpan={2}
|
||||
>
|
||||
{item.label} (mm)
|
||||
</th>
|
||||
<Fragment key={`${item.key}-header`}>
|
||||
<th className="border-b border-r px-3 py-1 text-xs">
|
||||
도면치수
|
||||
</th>
|
||||
<th className="border-b border-r px-3 py-1 text-xs">
|
||||
측정값
|
||||
</th>
|
||||
</Fragment>
|
||||
))}
|
||||
{/* 판정 */}
|
||||
{inspectionSetting.judgment && (
|
||||
<th className="border-b px-3 py-2 text-center w-20">
|
||||
판정
|
||||
<br />
|
||||
<span className="text-xs">(적/부)</span>
|
||||
</th>
|
||||
<th className="border-b px-3 py-1"></th>
|
||||
)}
|
||||
</tr>
|
||||
{/* 치수 서브헤더 */}
|
||||
{activeDimensionItems.length > 0 && (
|
||||
<tr className="bg-muted/30">
|
||||
<th className="border-b border-r px-3 py-1"></th>
|
||||
{activeAppearanceItems.map((item) => (
|
||||
<th key={item.key} className="border-b border-r px-3 py-1 text-xs">
|
||||
양호/불량
|
||||
</th>
|
||||
))}
|
||||
{activeDimensionItems.map((item) => (
|
||||
<Fragment key={`${item.key}-header`}>
|
||||
<th className="border-b border-r px-3 py-1 text-xs">
|
||||
도면치수
|
||||
</th>
|
||||
<th className="border-b border-r px-3 py-1 text-xs">
|
||||
측정값
|
||||
</th>
|
||||
</Fragment>
|
||||
))}
|
||||
{inspectionSetting.judgment && (
|
||||
<th className="border-b px-3 py-1"></th>
|
||||
)}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{sampleRows.map((row) => (
|
||||
<tr key={row} className="border-b last:border-b-0 hover:bg-muted/20">
|
||||
<td className="border-r px-3 py-2 text-center">{row}</td>
|
||||
{/* 겉모양 샘플 데이터 */}
|
||||
{activeAppearanceItems.map((item) => (
|
||||
<td key={item.key} className="border-r px-3 py-2 text-center">
|
||||
<span className="text-muted-foreground">☐ 양호</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">☐ 불량</span>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{sampleRows.map((row) => (
|
||||
<tr key={row} className="border-b last:border-b-0 hover:bg-muted/20">
|
||||
<td className="border-r px-3 py-2 text-center">{row}</td>
|
||||
{/* 겉모양 샘플 데이터 */}
|
||||
{activeAppearanceItems.map((item) => (
|
||||
<td key={item.key} className="border-r px-3 py-2 text-center">
|
||||
<span className="text-muted-foreground">☐ 양호</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">☐ 불량</span>
|
||||
</td>
|
||||
))}
|
||||
{/* 치수 샘플 데이터 */}
|
||||
{activeDimensionItems.map((item) => (
|
||||
<Fragment key={`${item.key}-data-${row}`}>
|
||||
<td className="border-r px-3 py-2 text-center text-muted-foreground">
|
||||
-
|
||||
</td>
|
||||
))}
|
||||
{/* 치수 샘플 데이터 */}
|
||||
{activeDimensionItems.map((item) => (
|
||||
<Fragment key={`${item.key}-data-${row}`}>
|
||||
<td className="border-r px-3 py-2 text-center text-muted-foreground">
|
||||
-
|
||||
</td>
|
||||
<td className="border-r px-3 py-2 text-center text-muted-foreground">
|
||||
-
|
||||
</td>
|
||||
</Fragment>
|
||||
))}
|
||||
{/* 판정 샘플 */}
|
||||
{inspectionSetting.judgment && (
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className="text-muted-foreground">-</span>
|
||||
<td className="border-r px-3 py-2 text-center text-muted-foreground">
|
||||
-
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Fragment>
|
||||
))}
|
||||
{/* 판정 샘플 */}
|
||||
{inspectionSetting.judgment && (
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className="text-muted-foreground">-</span>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부적합 내용 */}
|
||||
{inspectionSetting.nonConformingContent && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-r">
|
||||
부적합 내용
|
||||
</div>
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm">
|
||||
종합판정
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="px-4 py-3 border-r min-h-[60px] text-muted-foreground text-sm">
|
||||
(부적합 사항 입력 영역)
|
||||
</div>
|
||||
<div className="px-4 py-3 text-center text-muted-foreground text-sm">
|
||||
합격 / 불합격
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부적합 내용 */}
|
||||
{inspectionSetting.nonConformingContent && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-r">
|
||||
부적합 내용
|
||||
</div>
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm">
|
||||
종합판정
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="px-4 py-3 border-r min-h-[60px] text-muted-foreground text-sm">
|
||||
(부적합 사항 입력 영역)
|
||||
</div>
|
||||
<div className="px-4 py-3 text-center text-muted-foreground text-sm">
|
||||
합격 / 불합격
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { deleteProcessStep } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -41,10 +41,6 @@ export interface ScreenInspectionContentProps {
|
||||
workItems?: WorkItemData[];
|
||||
inspectionDataMap?: InspectionDataMap;
|
||||
inspectionSetting?: InspectionSetting;
|
||||
/** @deprecated inspectionSetting.schematicImage 사용 */
|
||||
schematicImage?: string;
|
||||
/** @deprecated inspectionSetting.inspectionStandardImage 사용 */
|
||||
inspectionStandardImage?: string;
|
||||
}
|
||||
|
||||
interface InspectionRow {
|
||||
@@ -87,9 +83,8 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
|
||||
workItems,
|
||||
inspectionDataMap,
|
||||
inspectionSetting,
|
||||
schematicImage: schematicImageProp,
|
||||
}, ref) {
|
||||
const schematicImage = inspectionSetting?.schematicImage || schematicImageProp;
|
||||
const schematicImage = inspectionSetting?.schematicImage;
|
||||
const fullDate = getFullDate();
|
||||
const today = getToday();
|
||||
const { documentNo, primaryAssignee } = getOrderInfo(order);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp, List } from 'lucide-react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { ItemSearchModal } from "./ItemSearchModal";
|
||||
|
||||
import type { LocationItem } from "./QuoteRegistrationV2";
|
||||
import type { LocationItem } from "./QuoteRegistration";
|
||||
import type { FinishedGoods } from "./actions";
|
||||
import type { BomCalculationResultItem } from "./types";
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
import type { LocationItem } from "./QuoteRegistrationV2";
|
||||
import type { LocationItem } from "./QuoteRegistration";
|
||||
|
||||
// =============================================================================
|
||||
// 상수
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from "../ui/table";
|
||||
import { DeleteConfirmDialog } from "../ui/confirm-dialog";
|
||||
|
||||
import type { LocationItem } from "./QuoteRegistrationV2";
|
||||
import type { LocationItem } from "./QuoteRegistration";
|
||||
import type { FinishedGoods } from "./actions";
|
||||
// xlsx는 동적 로드 (번들 크기 최적화)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - DocumentHeader: quote 레이아웃 + LotApprovalTable
|
||||
*/
|
||||
|
||||
import { QuoteFormData } from "./QuoteRegistration";
|
||||
import { QuoteFormData } from "./types";
|
||||
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
|
||||
import { DocumentHeader, LotApprovalTable } from "@/components/document-system";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* - documentType="견적산출내역서": 상세 산출내역서 + 소요자재 내역
|
||||
*/
|
||||
|
||||
import { QuoteFormData } from "./QuoteRegistration";
|
||||
import { QuoteFormData } from "./types";
|
||||
import type { BomMaterial } from "./types";
|
||||
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - SignatureSection: 서명/도장 영역
|
||||
*/
|
||||
|
||||
import { QuoteFormData } from "./QuoteRegistration";
|
||||
import { QuoteFormData } from "./types";
|
||||
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
|
||||
import { DocumentHeader, SignatureSection } from "@/components/document-system";
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistration';
|
||||
import type { BomCalculationResultItem } from './types';
|
||||
|
||||
// 양식 타입
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistration';
|
||||
import { QuotePreviewContent } from './QuotePreviewContent';
|
||||
|
||||
// 양식 타입: 업체발송용 / 산출내역서
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ import { Coins } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
|
||||
import type { LocationItem } from "./QuoteRegistrationV2";
|
||||
import type { LocationItem } from "./QuoteRegistration";
|
||||
|
||||
// =============================================================================
|
||||
// 목데이터 - 상세별 합계 (공정별 + 품목 상세)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistration';
|
||||
|
||||
interface QuoteTransactionModalProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -549,22 +549,6 @@ export async function getQuoteReferenceData(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated getQuoteReferenceData 사용 */
|
||||
export async function getSiteNames(): Promise<{
|
||||
success: boolean;
|
||||
data: string[];
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const result = await getQuoteReferenceData();
|
||||
return {
|
||||
success: result.success,
|
||||
data: result.data.siteNames,
|
||||
error: result.error,
|
||||
__authError: result.__authError,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 품목 카테고리 트리 조회 =====
|
||||
export interface ItemCategoryNode {
|
||||
id: number;
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
// 클라이언트 컴포넌트
|
||||
export { QuoteManagementClient } from './QuoteManagementClient';
|
||||
|
||||
// 기존 컴포넌트
|
||||
// 컴포넌트
|
||||
export { QuoteDocument } from './QuoteDocument';
|
||||
export { QuoteRegistration, INITIAL_QUOTE_FORM } from './QuoteRegistration';
|
||||
export { QuoteRegistration } from './QuoteRegistration';
|
||||
export { QuoteCalculationReport } from './QuoteCalculationReport';
|
||||
export { PurchaseOrderDocument } from './PurchaseOrderDocument';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Loader2,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { DetailPageSkeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { ReactNode } from 'react';
|
||||
import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
|
||||
export interface DetailActionsProps {
|
||||
/** 현재 모드 */
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import type { SerializableMenuItem } from '@/store/menuStore';
|
||||
import type { MenuItem } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import type { SerializableMenuItem } from '@/stores/menuStore';
|
||||
import type { MenuItem } from '@/stores/menuStore';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
ChevronLeft,
|
||||
Home,
|
||||
X,
|
||||
BarChart3,
|
||||
Award,
|
||||
Bell,
|
||||
Clock,
|
||||
Minus,
|
||||
@@ -42,6 +40,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import HeaderFavoritesBar from '@/components/layout/HeaderFavoritesBar';
|
||||
import CommandMenuSearch, { type CommandMenuSearchRef } from '@/components/layout/CommandMenuSearch';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
@@ -434,6 +433,8 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
];
|
||||
setMenuItems(defaultMenu);
|
||||
}
|
||||
// 즐겨찾기는 사용자가 사이드바에서 별표로 직접 추가
|
||||
// useFavoritesStore.getState().initializeIfEmpty(DEFAULT_FAVORITES);
|
||||
}
|
||||
}, [_hasHydrated, setMenuItems]);
|
||||
|
||||
@@ -766,29 +767,10 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측 영역: 종합분석, 품질인정심사, 유저 드롭다운, 메뉴 */}
|
||||
{/* 우측 영역: 즐겨찾기, 유저 드롭다운, 메뉴 */}
|
||||
<div className="flex items-center space-x-0.5 sm:space-x-1">
|
||||
{/* 종합분석 바로가기 */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => router.push('/reports/comprehensive-analysis')}
|
||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
|
||||
title="종합분석"
|
||||
>
|
||||
<BarChart3 className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
|
||||
{/* 품질인정심사 바로가기 */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => router.push('/quality/qms')}
|
||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white flex items-center justify-center"
|
||||
title="품질인정심사"
|
||||
>
|
||||
<Award className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
{/* 즐겨찾기 바로가기 */}
|
||||
<HeaderFavoritesBar isMobile={true} />
|
||||
|
||||
{/* 알림 버튼 - 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
@@ -1060,27 +1042,8 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 종합분석 바로가기 버튼 */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => router.push('/reports/comprehensive-analysis')}
|
||||
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<span className="hidden xl:inline">종합분석</span>
|
||||
</Button>
|
||||
|
||||
{/* 품질인정심사 바로가기 버튼 */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => router.push('/quality/qms')}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Award className="h-4 w-4" />
|
||||
<span className="hidden xl:inline">품질인정심사</span>
|
||||
</Button>
|
||||
{/* 즐겨찾기 바로가기 */}
|
||||
<HeaderFavoritesBar isMobile={false} />
|
||||
|
||||
{/* 알림 버튼 - 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -536,59 +536,6 @@ export async function deleteItemFile(
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ===== 레거시 파일 업로드 (하위 호환성) =====
|
||||
|
||||
/**
|
||||
* @deprecated uploadItemFile 사용 권장 (ID 기반)
|
||||
* 파일 업로드 (시방서, 인정서, 전개도 등) - 품목 코드 기반
|
||||
*/
|
||||
export async function uploadFile(
|
||||
itemCode: string,
|
||||
file: File,
|
||||
fileType: 'specification' | 'certification' | 'bending_diagram'
|
||||
): Promise<{ url: string; filename: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('type', fileType);
|
||||
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/files`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
const data = await handleApiResponse<ApiResponse<{ url: string; filename: string }>>(response);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated deleteItemFile 사용 권장 (ID 기반)
|
||||
* 파일 삭제 - 품목 코드 기반
|
||||
*/
|
||||
export async function deleteFile(
|
||||
itemCode: string,
|
||||
fileType: 'specification' | 'certification' | 'bending_diagram'
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/files/${fileType}`,
|
||||
createFetchOptions({
|
||||
method: 'DELETE',
|
||||
})
|
||||
);
|
||||
|
||||
await handleApiResponse<ApiResponse<null>>(response);
|
||||
}
|
||||
|
||||
// ===== 검색 및 필터 =====
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
import { transformApiMenusToMenuItems, deserializeMenuItems } from './menuTransform';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import type { SerializableMenuItem } from '@/store/menuStore';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import type { SerializableMenuItem } from '@/stores/menuStore';
|
||||
|
||||
/**
|
||||
* 메뉴 해시 생성 (변경 감지용)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MenuItem, SerializableMenuItem } from '@/store/menuStore';
|
||||
import type { MenuItem, SerializableMenuItem } from '@/stores/menuStore';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Folder,
|
||||
@@ -180,6 +180,42 @@ export const iconMap: Record<string, LucideIcon> = {
|
||||
server: Server,
|
||||
};
|
||||
|
||||
// 역방향 아이콘 맵 (LucideIcon → string name)
|
||||
const reverseIconMap = new Map<LucideIcon, string>();
|
||||
for (const [name, icon] of Object.entries(iconMap)) {
|
||||
// 첫 번째 매핑만 저장 (중복 아이콘 방지)
|
||||
if (!reverseIconMap.has(icon)) {
|
||||
reverseIconMap.set(icon, name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LucideIcon 컴포넌트에서 문자열 이름을 조회
|
||||
*/
|
||||
export function getIconName(icon: LucideIcon): string {
|
||||
return reverseIconMap.get(icon) || 'folder';
|
||||
}
|
||||
|
||||
// 기본 즐겨찾기 항목 (최초 사용자용)
|
||||
import type { FavoriteItem } from '@/stores/favoritesStore';
|
||||
|
||||
export const DEFAULT_FAVORITES: FavoriteItem[] = [
|
||||
{
|
||||
id: 'default-comprehensive-analysis',
|
||||
label: '종합분석',
|
||||
iconName: 'bar-chart-3',
|
||||
path: '/reports/comprehensive-analysis',
|
||||
addedAt: 0,
|
||||
},
|
||||
{
|
||||
id: 'default-qms',
|
||||
label: '품질인정심사',
|
||||
iconName: 'award',
|
||||
path: '/quality/qms',
|
||||
addedAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// API 메뉴 데이터 타입
|
||||
interface ApiMenu {
|
||||
id: number;
|
||||
|
||||
92
src/stores/favoritesStore.ts
Normal file
92
src/stores/favoritesStore.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { safeJsonParse } from '@/lib/utils';
|
||||
|
||||
export interface FavoriteItem {
|
||||
id: string;
|
||||
label: string;
|
||||
iconName: string;
|
||||
path: string;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
const MAX_FAVORITES = 8;
|
||||
|
||||
function getUserId(): string {
|
||||
if (typeof window === 'undefined') return 'default';
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (!userStr) return 'default';
|
||||
const user = safeJsonParse<Record<string, unknown> | null>(userStr, null);
|
||||
return user?.id ? String(user.id) : 'default';
|
||||
}
|
||||
|
||||
function getStorageKey(): string {
|
||||
return `sam-favorites-${getUserId()}`;
|
||||
}
|
||||
|
||||
interface FavoritesState {
|
||||
favorites: FavoriteItem[];
|
||||
toggleFavorite: (item: FavoriteItem) => void;
|
||||
isFavorite: (id: string) => boolean;
|
||||
setFavorites: (items: FavoriteItem[]) => void;
|
||||
initializeIfEmpty: (defaults: FavoriteItem[]) => void;
|
||||
}
|
||||
|
||||
export const useFavoritesStore = create<FavoritesState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
favorites: [],
|
||||
|
||||
toggleFavorite: (item: FavoriteItem) => {
|
||||
const { favorites } = get();
|
||||
const exists = favorites.some((f) => f.id === item.id);
|
||||
|
||||
if (exists) {
|
||||
set({ favorites: favorites.filter((f) => f.id !== item.id) });
|
||||
} else {
|
||||
if (favorites.length >= MAX_FAVORITES) return;
|
||||
set({ favorites: [...favorites, { ...item, addedAt: Date.now() }] });
|
||||
}
|
||||
},
|
||||
|
||||
isFavorite: (id: string) => {
|
||||
return get().favorites.some((f) => f.id === id);
|
||||
},
|
||||
|
||||
setFavorites: (items: FavoriteItem[]) => {
|
||||
set({ favorites: items.slice(0, MAX_FAVORITES) });
|
||||
},
|
||||
|
||||
initializeIfEmpty: (defaults: FavoriteItem[]) => {
|
||||
const { favorites } = get();
|
||||
if (favorites.length === 0) {
|
||||
set({ favorites: defaults.slice(0, MAX_FAVORITES) });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'sam-favorites',
|
||||
// 사용자별 키를 위해 storage 커스텀
|
||||
storage: {
|
||||
getItem: (name) => {
|
||||
const key = getStorageKey();
|
||||
const str = localStorage.getItem(key);
|
||||
if (!str) {
|
||||
// fallback: 기본 키에서도 확인
|
||||
const fallback = localStorage.getItem(name);
|
||||
return fallback ? JSON.parse(fallback) : null;
|
||||
}
|
||||
return JSON.parse(str);
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
const key = getStorageKey();
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (name) => {
|
||||
const key = getStorageKey();
|
||||
localStorage.removeItem(key);
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user