From a38996b751d7c61ee43ef6b9a1e42eff72668dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 11 Feb 2026 15:09:51 +0900 Subject: [PATCH] =?UTF-8?q?refactor(WEB):=20V2=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9,=20store=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ...025-02-10] frontend-improvement-roadmap.md | 191 +- claudedocs/_index.md | 56 + .../accounting/bills/[id]/page.tsx | 2 +- .../(protected)/accounting/bills/new/page.tsx | 2 +- .../(protected)/accounting/bills/page.tsx | 2 +- .../(protected)/board/[boardCode]/page.tsx | 8 +- .../order/base-info/labor/[id]/page.tsx | 4 +- .../order/base-info/labor/new/page.tsx | 4 +- .../order/base-info/labor/page.tsx | 4 +- .../order/base-info/pricing/[id]/page.tsx | 4 +- .../order/base-info/pricing/new/page.tsx | 4 +- .../order/base-info/pricing/page.tsx | 4 +- .../_components/DashboardType2.tsx | 147 +- .../dashboard_type2/_components/StatCards.tsx | 10 +- .../_components/charts/ExpenseDonutChart.tsx | 54 + .../charts/OverviewSummaryChart.tsx | 43 + .../_components/charts/ReceivableBarChart.tsx | 41 + .../_components/hooks/transformers.ts | 430 ++++ .../_components/hooks/useDashboardType2.ts | 310 +++ .../dashboard_type2/_components/mockData.ts | 9 + .../_components/tabs/ExpenseTab.tsx | 28 +- .../_components/tabs/FinanceTab.tsx | 45 +- .../_components/tabs/OverviewTab.tsx | 26 +- .../_components/tabs/SalesTab.tsx | 50 +- .../_components/tabs/ScheduleTab.tsx | 149 +- .../_components/DashboardType3.tsx | 410 ++++ .../(protected)/dashboard_type3/page.tsx | 16 + .../_components/DashboardType4.tsx | 390 ++++ .../(protected)/dashboard_type4/page.tsx | 17 + .../_components/DashboardType5.tsx | 255 +++ .../(protected)/dashboard_type5/page.tsx | 17 + .../qms/components/InspectionModal.tsx | 839 ++++--- .../qms/components/InspectionModalV2.tsx | 501 ----- .../[locale]/(protected)/quality/qms/page.tsx | 2 +- .../client-management-sales-admin/page.tsx | 22 +- .../sales/quote-management/[id]/page.tsx | 8 +- .../quote-management/[id]/page.tsx.v1-backup | 688 ------ .../sales/quote-management/new/page.tsx | 8 +- .../quote-management/new/page.tsx.v1-backup | 67 - .../accounting/BillManagement/BillDetail.tsx | 351 +-- .../BillManagement/BillDetailV2.tsx | 539 ----- src/components/board/BoardDetail/index.tsx | 2 +- .../business/CEODashboard/CEODashboard.tsx | 22 +- src/components/business/DashboardSwitcher.tsx | 73 + .../contract/ContractDetailForm.tsx | 2 +- .../contract/modals/ContractDocumentModal.tsx | 103 +- .../modals/ContractDocumentModalV2.tsx | 63 - .../construction/contract/modals/index.ts | 1 - .../labor-management/LaborDetailClient.tsx | 494 +--- .../labor-management/LaborDetailClientV2.tsx | 120 - .../construction/labor-management/index.tsx | 1 - .../PricingDetailClient.tsx | 528 +---- .../PricingDetailClientV2.tsx | 134 -- .../construction/pricing-management/index.ts | 1 - .../checklist-management/ChecklistDetail.tsx | 2 +- .../checklist-management/ItemDetail.tsx | 2 +- src/components/clients/ClientDetail.tsx | 2 +- src/components/dev/generators/quoteData.ts | 10 +- src/components/dev/index.ts | 2 +- .../items/DynamicItemForm/index.tsx | 2 +- src/components/items/ItemDetailClient.tsx | 2 +- src/components/layout/CommandMenuSearch.tsx | 2 +- src/components/layout/HeaderFavoritesBar.tsx | 209 ++ src/components/layout/Sidebar.tsx | 224 +- .../ReceivingManagement/ReceivingDetail.tsx | 4 +- .../PriceDistributionDetail.tsx | 2 +- .../PricingTableForm.tsx | 2 +- .../InspectionPreviewModal.tsx | 404 ++-- .../process-management/ProcessDetail.tsx | 2 +- .../process-management/StepDetail.tsx | 2 +- .../documents/ScreenInspectionContent.tsx | 7 +- .../production/WorkerScreen/index.tsx | 2 +- src/components/quotes/LocationDetailPanel.tsx | 2 +- src/components/quotes/LocationEditModal.tsx | 2 +- src/components/quotes/LocationListPanel.tsx | 2 +- .../quotes/PurchaseOrderDocument.tsx | 2 +- .../quotes/QuoteCalculationReport.tsx | 2 +- src/components/quotes/QuoteDocument.tsx | 2 +- src/components/quotes/QuotePreviewContent.tsx | 2 +- src/components/quotes/QuotePreviewModal.tsx | 2 +- src/components/quotes/QuoteRegistration.tsx | 1985 ++++++++--------- src/components/quotes/QuoteRegistrationV2.tsx | 1022 --------- src/components/quotes/QuoteSummaryPanel.tsx | 2 +- .../quotes/QuoteTransactionModal.tsx | 2 +- src/components/quotes/actions.ts | 16 - src/components/quotes/index.ts | 4 +- .../PermissionDetailClient.tsx | 2 +- .../components/DetailActions.tsx | 2 +- src/layouts/AuthenticatedLayout.tsx | 59 +- src/lib/api/items.ts | 53 - src/lib/utils/menuRefresh.ts | 4 +- src/lib/utils/menuTransform.ts | 38 +- src/{store => stores}/demoStore.ts | 0 src/stores/favoritesStore.ts | 92 + src/{store => stores}/menuStore.ts | 0 src/{store => stores}/themeStore.ts | 0 96 files changed, 4930 insertions(+), 6550 deletions(-) create mode 100644 src/app/[locale]/(protected)/dashboard_type2/_components/charts/ExpenseDonutChart.tsx create mode 100644 src/app/[locale]/(protected)/dashboard_type2/_components/charts/OverviewSummaryChart.tsx create mode 100644 src/app/[locale]/(protected)/dashboard_type2/_components/charts/ReceivableBarChart.tsx create mode 100644 src/app/[locale]/(protected)/dashboard_type2/_components/hooks/transformers.ts create mode 100644 src/app/[locale]/(protected)/dashboard_type2/_components/hooks/useDashboardType2.ts create mode 100644 src/app/[locale]/(protected)/dashboard_type3/_components/DashboardType3.tsx create mode 100644 src/app/[locale]/(protected)/dashboard_type3/page.tsx create mode 100644 src/app/[locale]/(protected)/dashboard_type4/_components/DashboardType4.tsx create mode 100644 src/app/[locale]/(protected)/dashboard_type4/page.tsx create mode 100644 src/app/[locale]/(protected)/dashboard_type5/_components/DashboardType5.tsx create mode 100644 src/app/[locale]/(protected)/dashboard_type5/page.tsx delete mode 100644 src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx delete mode 100644 src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx.v1-backup delete mode 100644 src/app/[locale]/(protected)/sales/quote-management/new/page.tsx.v1-backup delete mode 100644 src/components/accounting/BillManagement/BillDetailV2.tsx create mode 100644 src/components/business/DashboardSwitcher.tsx delete mode 100644 src/components/business/construction/contract/modals/ContractDocumentModalV2.tsx delete mode 100644 src/components/business/construction/labor-management/LaborDetailClientV2.tsx delete mode 100644 src/components/business/construction/pricing-management/PricingDetailClientV2.tsx create mode 100644 src/components/layout/HeaderFavoritesBar.tsx delete mode 100644 src/components/quotes/QuoteRegistrationV2.tsx rename src/{store => stores}/demoStore.ts (100%) create mode 100644 src/stores/favoritesStore.ts rename src/{store => stores}/menuStore.ts (100%) rename src/{store => stores}/themeStore.ts (100%) diff --git a/claudedocs/[PLAN-2025-02-10] frontend-improvement-roadmap.md b/claudedocs/[PLAN-2025-02-10] frontend-improvement-roadmap.md index 97409c97..14ac3fb4 100644 --- a/claudedocs/[PLAN-2025-02-10] frontend-improvement-roadmap.md +++ b/claudedocs/[PLAN-2025-02-10] frontend-improvement-roadmap.md @@ -5,179 +5,46 @@ --- -## Phase A: 즉시 개선 (1~2일) +## Phase A: 즉시 개선 — ✅ 완료 -### A-1. `` → `next/image` 전환 -- **문제**: raw `` 태그 ~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` -- **작업**: `` → `{}` 전환 -- **주의**: 외부 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 | `` → `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: () => , 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 ``` diff --git a/claudedocs/_index.md b/claudedocs/_index.md index 5ad27d4b..2a52494a 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -76,6 +76,62 @@ export async function downloadExcel(...) { **총 절감**: 초기 번들에서 ~850KB 제외 (대시보드 미방문 + 엑셀 미사용 시) +### 테이블 가상화 (react-window) — 보류 (2026-02-10) + +**결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토 + +**근거**: +1. **페이지네이션 사용 중** — 리스트 페이지 대부분 서버 사이드 페이지네이션 (20~50건/페이지). 50개 ``은 브라우저가 문제없이 처리 +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({ + 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시간 대비 기능 변화 없음. 시간 대비 효율 낮음 + --- ## 폴더 구조 diff --git a/src/app/[locale]/(protected)/accounting/bills/[id]/page.tsx b/src/app/[locale]/(protected)/accounting/bills/[id]/page.tsx index 215c5152..d770be40 100644 --- a/src/app/[locale]/(protected)/accounting/bills/[id]/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bills/[id]/page.tsx @@ -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(); diff --git a/src/app/[locale]/(protected)/accounting/bills/new/page.tsx b/src/app/[locale]/(protected)/accounting/bills/new/page.tsx index 832c33b1..f7b9171c 100644 --- a/src/app/[locale]/(protected)/accounting/bills/new/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bills/new/page.tsx @@ -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 ; diff --git a/src/app/[locale]/(protected)/accounting/bills/page.tsx b/src/app/[locale]/(protected)/accounting/bills/page.tsx index ecb2407a..14c9b946 100644 --- a/src/app/[locale]/(protected)/accounting/bills/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bills/page.tsx @@ -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'; diff --git a/src/app/[locale]/(protected)/board/[boardCode]/page.tsx b/src/app/[locale]/(protected)/board/[boardCode]/page.tsx index 641231cc..ee9b3857 100644 --- a/src/app/[locale]/(protected)/board/[boardCode]/page.tsx +++ b/src/app/[locale]/(protected)/board/[boardCode]/page.tsx @@ -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 ; } + return ; +} + +// 실제 목록 컴포넌트 (자체 hooks 사용 - React 훅 규칙 준수) +function BoardListContent({ boardCode }: { boardCode: string }) { + const router = useRouter(); + // 게시판 정보 const [boardName, setBoardName] = useState('게시판'); const [boardDescription, setBoardDescription] = useState(''); diff --git a/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx index 8bb8feb7..d33b7707 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx @@ -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 ; + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/base-info/labor/new/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/labor/new/page.tsx index 8a4064ff..1225a2de 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/labor/new/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/labor/new/page.tsx @@ -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 ; + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/base-info/labor/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/labor/page.tsx index 87a6fa6a..2d49edcf 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/labor/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/labor/page.tsx @@ -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 ; + return ; } return ; diff --git a/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx index da09fbc3..bfce5657 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx @@ -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 ; + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/base-info/pricing/new/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/pricing/new/page.tsx index 9b008da7..ad2c81c8 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/pricing/new/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/pricing/new/page.tsx @@ -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 ; + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/base-info/pricing/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/pricing/page.tsx index 00778274..d45f65fa 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/pricing/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/pricing/page.tsx @@ -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 ; + return ; } return ; diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/DashboardType2.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/DashboardType2.tsx index 8bcb683c..02a37a20 100644 --- a/src/app/[locale]/(protected)/dashboard_type2/_components/DashboardType2.tsx +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/DashboardType2.tsx @@ -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(() => [ + { + 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 (
- +
+ +
+ + +
+
+ + {hasError && ( +
+ 일부 데이터를 불러오지 못했습니다. 임시 데이터가 표시될 수 있습니다. + +
+ )}
- - {TABS.map((tab) => ( + + {tabs.map((tab) => ( - + {tab.label} {tab.badge != null && tab.badge > 0 && ( - + {tab.badge} )} - + | @@ -106,11 +125,21 @@ export function DashboardType2() {
- - - - - + + + + + + + + + + + + + + +
diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/StatCards.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/StatCards.tsx index ce5c07a6..85ac2737 100644 --- a/src/app/[locale]/(protected)/dashboard_type2/_components/StatCards.tsx +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/StatCards.tsx @@ -10,11 +10,19 @@ const colorMap: Record = { orange: 'text-orange-600', }; +const bgMap: Record = { + 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 (
{stats.map((stat, i) => ( -
+

{stat.label}

{stat.value} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/charts/ExpenseDonutChart.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/charts/ExpenseDonutChart.tsx new file mode 100644 index 00000000..8451e859 --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/charts/ExpenseDonutChart.tsx @@ -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 ( + + + 지출 항목별 비율 + + + + + + {data.map((entry, index) => ( + + ))} + + formatTooltipValue(Number(value ?? 0))} + contentStyle={{ fontSize: '12px', borderRadius: '8px' }} + /> + + + + + + ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/charts/OverviewSummaryChart.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/charts/OverviewSummaryChart.tsx new file mode 100644 index 00000000..70e7cdbf --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/charts/OverviewSummaryChart.tsx @@ -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 ( + + + 주요 재무 지표 + + + + + + formatTooltipValue(v)} /> + + formatTooltipValue(Number(value ?? 0))} + contentStyle={{ fontSize: '12px', borderRadius: '8px' }} + /> + + {chartData.map((entry, index) => ( + + ))} + + + + + + ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/charts/ReceivableBarChart.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/charts/ReceivableBarChart.tsx new file mode 100644 index 00000000..457f10ba --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/charts/ReceivableBarChart.tsx @@ -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 ( + + + 매출 vs 미수금 비교 + + + + + + + formatTooltipValue(v)} width={70} /> + formatTooltipValue(Number(value ?? 0))} + contentStyle={{ fontSize: '12px', borderRadius: '8px' }} + /> + + + + + + + + ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/hooks/transformers.ts b/src/app/[locale]/(protected)/dashboard_type2/_components/hooks/transformers.ts new file mode 100644 index 00000000..4acade83 --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/hooks/transformers.ts @@ -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 = { + 매입: '#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 = { + 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', + })); +} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/hooks/useDashboardType2.ts b/src/app/[locale]/(protected)/dashboard_type2/_components/hooks/useDashboardType2.ts new file mode 100644 index 00000000..be140633 --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/hooks/useDashboardType2.ts @@ -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(() => { + 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(() => { + 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(() => { + 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(() => { + 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(() => { + 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 []; + } +} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/mockData.ts b/src/app/[locale]/(protected)/dashboard_type2/_components/mockData.ts index 858c2125..fcb3419e 100644 --- a/src/app/[locale]/(protected)/dashboard_type2/_components/mockData.ts +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/mockData.ts @@ -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' }, diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ExpenseTab.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ExpenseTab.tsx index 144aacf1..6e94512c 100644 --- a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ExpenseTab.tsx +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ExpenseTab.tsx @@ -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 (

- +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+
+
+
+ ); +} + +export function ExpenseTab({ data }: { data: ExpenseTabData }) { + if (data.loading) return ; + + return ( +
+
@@ -28,7 +46,7 @@ export function ExpenseTab() { - {expenseEntertainmentData.map((row, i) => ( + {data.entertainmentData.map((row, i) => ( {row.no} {row['일자']} @@ -62,7 +80,7 @@ export function ExpenseTab() { - {expenseWelfareData.map((row, i) => ( + {data.welfareData.map((row, i) => ( {row.no} {row['항목']} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/FinanceTab.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/FinanceTab.tsx index 42906d6d..e9dd746c 100644 --- a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/FinanceTab.tsx +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/FinanceTab.tsx @@ -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 (
- +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+
+
+
+ ); +} + +export function FinanceTab({ data }: { data: FinanceTabData }) { + if (data.loading) return ; + + return ( +
+ + +
@@ -27,7 +48,7 @@ export function FinanceTab() { - {financeExpenseData.map((row, i) => ( + {data.expenseData.map((row, i) => ( {row.no} {row['항목']} @@ -56,20 +77,18 @@ export function FinanceTab() { No - 카드명 - 사용액 - 미정리 - 잔여한도 + 항목 + 금액 + 비고 - {financeCardData.map((row, i) => ( + {data.cardData.map((row, i) => ( {row.no} - {row['카드명']} - {row['사용액']} - {row['미정리']} - {row['잔여한도']} + {row['항목']} + {row['금액']} + {row['비고']} ))} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/OverviewTab.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/OverviewTab.tsx index 9b644f29..e048ff18 100644 --- a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/OverviewTab.tsx +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/OverviewTab.tsx @@ -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 (
- +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+ ); +} + +export function OverviewTab({ data }: { data: OverviewTabData }) { + if (data.loading) return ; + + return ( +
+ + + @@ -27,7 +45,7 @@ export function OverviewTab() { - {overviewRecentOrders.map((row, i) => ( + {data.recentOrders.map((row, i) => ( {row.no} {row['거래처']} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/SalesTab.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/SalesTab.tsx index bf5bb8d8..73699b76 100644 --- a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/SalesTab.tsx +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/SalesTab.tsx @@ -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 (
- +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+
+ ); +} + +function getReceivableRowClass(row: Record): 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 { + 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 ; + + return ( +
+ + + @@ -28,8 +64,8 @@ export function SalesTab() { - {salesReceivableData.map((row, i) => ( - + {data.receivableData.map((row, i) => ( + {row.no} {row['거래처']} {row['매출액']} @@ -65,8 +101,8 @@ export function SalesTab() { - {salesDebtData.map((row, i) => ( - + {data.debtData.map((row, i) => ( + {row.no} {row['거래처']} {row['채권액']} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ScheduleTab.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ScheduleTab.tsx index 62731f3e..476a608a 100644 --- a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ScheduleTab.tsx +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ScheduleTab.tsx @@ -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 (
- +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+
+ ); +} + +// 뱃지 타입 → 아이콘 타입 매핑 +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 ; + + // API 데이터가 있으면 사용, 없으면 mock fallback + const hasApiData = data.todayItems.length > 0 || data.issueItems.length > 0; + + return ( +
+ @@ -16,20 +53,43 @@ export function ScheduleTab() {
- {scheduleTodayItems.map((item) => ( -
-
- + {hasApiData ? ( + data.todayItems.length > 0 ? ( + data.todayItems.map((item) => ( +
+
+ +
+
+

{item.content}

+
+ {item.badge} +
+
+
+ {item.time} +
+
+ )) + ) : ( +

오늘 일정이 없습니다.

+ ) + ) : ( + scheduleTodayItems.map((item) => ( +
+
+ +
+
+

{item.title}

+

{item.person}

+
+
+ {item.time} +
-
-

{item.title}

-

{item.person}

-
-
- {item.time} -
-
- ))} + )) + )}
@@ -40,24 +100,49 @@ export function ScheduleTab() {
- {scheduleIssueItems.map((item) => ( -
-
- -
-
-

{item.title}

-
- {item.date} - | - {item.assignee} + {hasApiData ? ( + data.issueItems.length > 0 ? ( + data.issueItems.map((item) => ( +
+
+ +
+
+

{item.content}

+
+ {item.time} + | + {item.badge} +
+
+
+ +
+
+ )) + ) : ( +

미처리 이슈가 없습니다.

+ ) + ) : ( + scheduleIssueItems.map((item) => ( +
+
+ +
+
+

{item.title}

+
+ {item.date} + | + {item.assignee} +
+
+
+
-
- -
-
- ))} + )) + )}
diff --git a/src/app/[locale]/(protected)/dashboard_type3/_components/DashboardType3.tsx b/src/app/[locale]/(protected)/dashboard_type3/_components/DashboardType3.tsx new file mode 100644 index 00000000..36ccae5d --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type3/_components/DashboardType3.tsx @@ -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 ( +
+ {kpis.map((kpi) => ( +
+

{kpi.label}

+

{kpi.value}

+

{kpi.change}

+
+ ))} +
+ ); +} + +function CashflowWidget() { + return ( + + + + + `${v}만`} width={50} /> + `${Number(v ?? 0).toLocaleString()}만원`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} /> + + + + + ); +} + +function ExpenseWidget() { + const chartData = expenseData.map((d) => ({ name: d.name, value: d.value, color: d.color })); + return ( +
+ + + + {chartData.map((entry, i) => )} + + `${Number(v ?? 0).toLocaleString()}만원`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} /> + + +
+ {expenseData.map((item) => ( +
+
+
+ {item.name} +
+ {item.value.toLocaleString()}만 +
+ ))} +
+
+ ); +} + +function ReceivableWidget() { + return ( +
+ {receivables.map((r) => ( +
= 90 ? 'bg-red-50 border-red-200' : r.days >= 30 ? 'bg-yellow-50 border-yellow-200' : 'border-border'}`}> +
+

{r.name}

+

{r.days}일 경과

+
+
+

{r.amount}

+ +
+
+ ))} +
+ ); +} + +function ScheduleWidget() { + const typeColors: Record = { meeting: 'bg-blue-500', task: 'bg-green-500', report: 'bg-orange-500', delivery: 'bg-purple-500' }; + return ( +
+ {todaySchedule.map((s) => ( +
+ {s.time} +
+ {s.title} +
+ ))} +
+ ); +} + +function AlertsWidget() { + const typeStyles: Record = { + 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 ( +
+ {alerts.map((a) => ( +
+ {a.text} + {a.time} +
+ ))} +
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const styles: Record = { '정상': 'bg-green-100 text-green-700', '주의': 'bg-yellow-100 text-yellow-700', '위험': 'bg-red-100 text-red-700' }; + return {status}; +} + +// ============================================ +// 위젯 렌더러 +// ============================================ + +function renderWidgetContent(type: Widget['type']) { + switch (type) { + case 'kpi': return ; + case 'cashflow': return ; + case 'expense': return ; + case 'receivable': return ; + case 'schedule': return ; + case 'alerts': return ; + } +} + +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(defaultWidgets); + const [editMode, setEditMode] = useState(false); + const [showAddPanel, setShowAddPanel] = useState(false); + const dragIndex = useRef(null); + const [dragOverIdx, setDragOverIdx] = useState(null); + + // 현재 배치되지 않은 위젯 목록 + const removedWidgets = defaultWidgets.filter((dw) => !widgets.some((w) => w.id === dw.id)); + + // sm → md → lg → sm 순환 + const toggleSize = (id: string) => { + const cycle: Record = { 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 ; + case 'md': return ; + case 'lg': return ; + } + }; + + return ( + +
+
+ +
+ {editMode && ( + + )} + + +
+
+ +
+ {widgets.map((widget, idx) => ( + 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' : '' + }`} + > + +
+ {editMode && } + {widget.title} +
+ {editMode && ( +
+ + +
+ )} +
+ + {renderWidgetContent(widget.type)} + +
+ ))} + + {editMode && ( +
+ {showAddPanel && removedWidgets.length > 0 ? ( +
+
+ 추가할 위젯 선택 + +
+
+ {removedWidgets.map((w) => ( + + ))} +
+
+ ) : removedWidgets.length > 0 ? ( + + ) : ( +
+

추가할 수 있는 위젯이 없습니다.

+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type3/page.tsx b/src/app/[locale]/(protected)/dashboard_type3/page.tsx new file mode 100644 index 00000000..aaa1a85d --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type3/page.tsx @@ -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 ; +} diff --git a/src/app/[locale]/(protected)/dashboard_type4/_components/DashboardType4.tsx b/src/app/[locale]/(protected)/dashboard_type4/_components/DashboardType4.tsx new file mode 100644 index 00000000..3509f9f9 --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type4/_components/DashboardType4.tsx @@ -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 = { + 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 ( +
+
+ {kpiList.map((kpi) => ( + + ))} +
+ + + + 주요 지표 비교 (억원) + + + + + + `${v}억`} /> + + `${Number(v ?? 0).toFixed(1)}억원`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} /> + + {chartData.map((entry, index) => ( + + ))} + + + + + + +

카드를 클릭하면 상세 내역을 확인할 수 있습니다

+
+ ); +} + +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 ( +
+ + +
+

{kpi.label}

+

{kpi.value}

+

{kpi.change} 전월 대비

+
+ + {chartData.length > 0 && chartData[0].value > 0 && ( + + + + + + + + `${Number(v ?? 0).toLocaleString()}원`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} /> + + + + + + )} + +
+ {items.map((item) => ( + + ))} +
+
+ ); +} + +function Level3({ kpi, item, onBack }: { kpi: KpiItem; item: DetailItem; onBack: () => void }) { + return ( +
+ + + + +
+
+ {item.name} +

{item.sub}

+
+
+

{item.amount}

+ {item.status && } +
+
+
+ + {item.items && item.items.length > 0 ? ( +
+ {item.items.map((sub, idx) => ( +
+
+

{sub.label}

+ {sub.date &&

{sub.date}

} +
+

{sub.value}

+
+ ))} +
+ ) : ( +

상세 데이터가 없습니다.

+ )} +
+
+
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const styles: Record = { + '정상': '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 {status}; +} + +// ============================================ +// Breadcrumb +// ============================================ + +function Breadcrumb({ items }: { items: { label: string; onClick?: () => void }[] }) { + return ( + + ); +} + +// ============================================ +// 메인 컴포넌트 +// ============================================ + +export function DashboardType4() { + const [level, setLevel] = useState<1 | 2 | 3>(1); + const [selectedKpi, setSelectedKpi] = useState(null); + const [selectedItem, setSelectedItem] = useState(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 ( + +
+ } + /> + +
+ + + {level === 1 && ( + + )} + {level === 2 && currentKpi && ( + + )} + {level === 3 && currentKpi && selectedItem && ( + + )} +
+
+
+ ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type4/page.tsx b/src/app/[locale]/(protected)/dashboard_type4/page.tsx new file mode 100644 index 00000000..696b06fa --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type4/page.tsx @@ -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 ; +} diff --git a/src/app/[locale]/(protected)/dashboard_type5/_components/DashboardType5.tsx b/src/app/[locale]/(protected)/dashboard_type5/_components/DashboardType5.tsx new file mode 100644 index 00000000..fd50d7af --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type5/_components/DashboardType5.tsx @@ -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 = { high: 'bg-red-500', medium: 'bg-yellow-500', low: 'bg-green-500' }; + return ; +} + +function StatusTag({ status }: { status?: string }) { + if (!status) return null; + const styles: Record = { '대기': 'bg-yellow-100 text-yellow-700', '승인완료': 'bg-green-100 text-green-700' }; + return {status}; +} + +// ============================================ +// 피드 아이템 +// ============================================ + +function FeedItem({ event }: { event: FeedEvent }) { + const style = getEventStyle(event.type); + const Icon = style.icon; + + return ( +
+
+ +
+
+
+ +

{event.title}

+
+ {event.description && ( +

{event.description}

+ )} +
+ {event.amount && {event.amount}} + +
+
+
+ {event.time} +
+
+ ); +} + +// ============================================ +// 요약 통계 +// ============================================ + +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 ( +
+ {stats.map((s) => ( +
+

{s.label}

+

{s.value}

+
+ ))} +
+ ); +} + +// ============================================ +// 시간 그룹 헤더 +// ============================================ + +function TimeGroupHeader({ label, count }: { label: string; count: number }) { + return ( +
+
+ + {label} ({count}) + +
+
+ ); +} + +// ============================================ +// 메인 컴포넌트 +// ============================================ + +export function DashboardType5() { + const [filter, setFilter] = useState('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 ( + +
+ } + /> + +
+ + + {/* 필터 */} +
+ + {filterConfig.map((f) => { + const Icon = f.icon; + const isActive = filter === f.key; + return ( + + ); + })} +
+ + {/* 타임라인 */} + + + {todayEvents.length > 0 && ( + <> + +
+ {todayEvents.map((e) => )} +
+ + )} + + {yesterdayEvents.length > 0 && ( + <> + +
+ {yesterdayEvents.map((e) => )} +
+ + )} + + {weekEvents.length > 0 && ( + <> + +
+ {weekEvents.map((e) => )} +
+ + )} + + {filtered.length === 0 && ( +
+ 해당 필터에 맞는 이벤트가 없습니다. +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type5/page.tsx b/src/app/[locale]/(protected)/dashboard_type5/page.tsx new file mode 100644 index 00000000..aaf7f1cb --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type5/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { DashboardType5 } from './_components/DashboardType5'; + +/** + * Dashboard Type 5 - 타임라인 피드형 대시보드 + * + * 회사에서 일어나는 이벤트를 시간순으로 보는 구조 + * - 오늘/어제/이번주 시간 그룹 + * - 필터: 전체 | 입금 | 이슈 | 결재 | 거래처 + * - 각 항목 클릭 시 상세 페이지 이동 + * + * URL: /ko/dashboard_type5 + */ +export default function DashboardType5Page() { + return ; +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx b/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx index 002ba4c8..df6569ee 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx @@ -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(); + 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 ( -
- {/* 헤더 */} -
-
-
KD
-
경동기업
-
-
수 주 서
- - - - - - - - - - - - - - - - - - - -
-
- -
-
작성검토승인
판매생산품질
-
- - {/* 기본 정보 */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LOT NO.{data.lotNumber}수주일{data.orderDate}
발주처{data.client}현장명{data.siteName}
담당자{data.manager}연락처{data.managerContact}
납기요청일{data.deliveryRequestDate}출고예정일{data.expectedShipDate}
배송방법{data.deliveryMethod}배송지{data.address}
- - {/* 품목 테이블 */} - - - - - - - - - - - - - - {data.items.map((item, index) => ( - - - - - - - - - - ))} - - - - - - - - - - - - - - - -
No품목명규격단위수량단가금액
{index + 1}{item.name}{item.specification}{item.unit}{item.quantity}{item.unitPrice?.toLocaleString()}{item.amount?.toLocaleString()}
소계{data.subtotal.toLocaleString()}원
할인 ({data.discountRate}%)-{(data.subtotal * data.discountRate / 100).toLocaleString()}원
총액{data.totalAmount.toLocaleString()}원
- - {/* 비고 */} - {data.remarks && ( -
-

비고

-

{data.remarks}

-
- )} -
- ); +// 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 = () => ( +
+ +

검사 템플릿을 불러오는 중...

+
+); - return ( -
- {/* 헤더 */} -
-
- KD - 경동기업 -
-
-

작 업 일 지

-

{documentNo}

-

스크린 생산부서

-
- - - - - - - - - - - - - - - - - - - -
-
-
작성검토승인
-
{order.assignees[0] || '-'}
-
판매생산품질
-
+// 에러 컴포넌트 +const ErrorDocument = ({ message, onRetry }: { message: string; onRetry?: () => void }) => ( +
+ +

템플릿 로드 실패

+

{message}

+ {onRetry && ( + + )} +
+); - {/* 기본 정보 */} -
-
-
-
발주처
-
{order.client}
-
-
-
현장명
-
{order.projectName}
-
-
-
-
-
작업일자
-
{today}
-
-
-
LOT NO.
-
{lotNo}
-
-
-
-
-
납기일
-
{order.dueDate}
-
-
-
지시수량
-
{order.quantity} EA
-
-
-
+/** + * 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(null); + const [importInitialValues, setImportInitialValues] = useState(undefined); + const [isLoadingTemplate, setIsLoadingTemplate] = useState(false); + const [templateError, setTemplateError] = useState(null); - {/* 품목 테이블 */} -
-
-
No
-
품목명
-
출/부호
-
규격
-
수량
-
상태
-
- {items.map((item, index) => ( -
-
{item.no}
-
{item.name}
-
{item.location}
-
{item.spec}
-
{item.qty}
-
- {item.status} -
-
- ))} -
+ // 수입검사 저장용 ref/상태 + const importDocRef = useRef(null); + const [isSaving, setIsSaving] = useState(false); - {/* 특이사항 */} -
-
특이사항
-
{order.instruction || '-'}
-
-
- ); -}; - -// 줌 레벨 상수 -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(null); - const contentRef = useRef(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 ; - case 'bending': - return ; - case 'slat': - return ; - case 'jointbar': - return ; - default: - // 서브타입이 없으면 기본 스크린 문서 - return ; - } - }; - // 품질관리서 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 ; + case 'slat': + return ; + case 'bending': + return ; + default: + // subType 미지정 시 스크린 기본 + return ; + } + }; + + // 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일) + const renderReportDocument = () => { + const subType = documentItem?.subType; + const mockOrder = createQmsMockWorkOrder(subType || 'screen'); + switch (subType) { + case 'screen': + return ; + case 'bending': + return ; + case 'slat': + return ; + case 'jointbar': + return ; + default: + return ; + } + }; + + // 수입검사 문서 렌더링 (Lazy Loading) + const renderImportInspectionDocument = () => { + if (isLoadingTemplate) { + return ; + } + + if (templateError) { + return ; + } + + // 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용 + return ( + + ); }; // 문서 타입에 따른 컨텐츠 렌더링 const renderDocumentContent = () => { switch (doc.type) { case 'order': - return ; + return ( + + ); case 'log': - return ; + return renderWorkLogDocument(); case 'confirmation': return ; case 'shipping': return ; case 'import': - return ; + return renderImportInspectionDocument(); case 'product': - return ; + return ; case 'report': return renderReportDocument(); case 'quality': - // 품질관리서는 PDF 업로드/뷰어 사용 return ( ); default: - // 양식 대기 중인 문서 return ; } }; + // 다운로드 핸들러 (TODO: 실제 구현) + const handleDownload = () => { + }; + + // 수입검사 저장 버튼 (toolbarExtra) - readOnly일 때 숨김 + const importToolbarExtra = doc.type === 'import' && !readOnly ? ( + + ) : undefined; + return ( - !open && onClose()}> - - -
- {doc.title} -

{subtitle}

-
-
- - {/* Toolbar */} -
-
- - - - - {zoom}% - -
-
- - -
-
- - {/* Content Area - 줌/드래그 가능한 영역 */} -
100 ? (isDragging ? 'grabbing' : 'grab') : 'default' }} - > -
- {renderDocumentContent()} -
-
- - {/* 모바일 줌 힌트 */} - {zoom === 100 && ( -
- 확대 후 드래그로 이동 -
- )} -
-
+ !open && onClose()} + onDownload={handleDownload} + toolbarExtra={importToolbarExtra} + > + {renderDocumentContent()} + ); }; diff --git a/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx b/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx deleted file mode 100644 index 0b443a26..00000000 --- a/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx +++ /dev/null @@ -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(); - 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 = { - 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 ( -
-
- -
-

{info.label}

-

{docItem?.title || '문서'}

- {docItem?.date && ( -

{docItem.date}

- )} - {docItem?.code && ( -

- 로트 번호: {docItem.code} -

- )} -
-

양식 준비 중

-

디자인 파일이 필요합니다

-
-
- ); -}; - -// 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 = () => ( -
- -

검사 템플릿을 불러오는 중...

-
-); - -// 에러 컴포넌트 -const ErrorDocument = ({ message, onRetry }: { message: string; onRetry?: () => void }) => ( -
- -

템플릿 로드 실패

-

{message}

- {onRetry && ( - - )} -
-); - -/** - * 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(null); - const [importInitialValues, setImportInitialValues] = useState(undefined); - const [isLoadingTemplate, setIsLoadingTemplate] = useState(false); - const [templateError, setTemplateError] = useState(null); - - // 수입검사 저장용 ref/상태 - const importDocRef = useRef(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 ; - case 'slat': - return ; - case 'bending': - return ; - default: - // subType 미지정 시 스크린 기본 - return ; - } - }; - - // 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일) - const renderReportDocument = () => { - const subType = documentItem?.subType; - const mockOrder = createQmsMockWorkOrder(subType || 'screen'); - switch (subType) { - case 'screen': - return ; - case 'bending': - return ; - case 'slat': - return ; - case 'jointbar': - return ; - default: - return ; - } - }; - - // 수입검사 문서 렌더링 (Lazy Loading) - const renderImportInspectionDocument = () => { - if (isLoadingTemplate) { - return ; - } - - if (templateError) { - return ; - } - - // 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용 - return ( - - ); - }; - - // 문서 타입에 따른 컨텐츠 렌더링 - const renderDocumentContent = () => { - switch (doc.type) { - case 'order': - return ( - - ); - case 'log': - return renderWorkLogDocument(); - case 'confirmation': - return ; - case 'shipping': - return ; - case 'import': - return renderImportInspectionDocument(); - case 'product': - return ; - case 'report': - return renderReportDocument(); - case 'quality': - return ( - - ); - default: - return ; - } - }; - - // 다운로드 핸들러 (TODO: 실제 구현) - const handleDownload = () => { - }; - - // 수입검사 저장 버튼 (toolbarExtra) - readOnly일 때 숨김 - const importToolbarExtra = doc.type === 'import' && !readOnly ? ( - - ) : undefined; - - return ( - !open && onClose()} - onDownload={handleDownload} - toolbarExtra={importToolbarExtra} - > - {renderDocumentContent()} - - ); -}; diff --git a/src/app/[locale]/(protected)/quality/qms/page.tsx b/src/app/[locale]/(protected)/quality/qms/page.tsx index f480e80b..7cafb88f 100644 --- a/src/app/[locale]/(protected)/quality/qms/page.tsx +++ b/src/app/[locale]/(protected)/quality/qms/page.tsx @@ -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'; diff --git a/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx b/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx index f0270f7c..5646adc2 100644 --- a/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx +++ b/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx @@ -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, diff --git a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx index 0c2858b0..c218c28c 100644 --- a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx @@ -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 ( - (null); - const [companyInfo, setCompanyInfo] = useState(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([]); - - // 견적 데이터 조회 - 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 ; - } - - if (!quote) { - return ( -
-

견적 정보를 찾을 수 없습니다.

- -
- ); - } - - // V2 패턴: Edit 모드일 때 QuoteRegistration 컴포넌트 렌더링 - if (isEditMode) { - return ( - - ); - } - - // View 모드: 상세 보기 - return ( -
- {/* 헤더 */} -
-
-

- - 견적 상세 -

-

견적번호: {quote.id}

-
- -
- {/* 문서 버튼들 */} - - - - - {/* 액션 버튼들 */} - - - -
-
- - {/* 기본 정보 */} - - - 기본 정보 - - -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - {quote.remarks && ( -
- -