diff --git a/claudedocs/[PLAN-2025-02-10] frontend-improvement-roadmap.md b/claudedocs/[PLAN-2025-02-10] frontend-improvement-roadmap.md new file mode 100644 index 00000000..97409c97 --- /dev/null +++ b/claudedocs/[PLAN-2025-02-10] frontend-improvement-roadmap.md @@ -0,0 +1,207 @@ +# SAM ERP 프론트엔드 개선 로드맵 + +> 작성일: 2025-02-10 +> 분석 기준: src/ 전체 (500+ 파일, ~163K줄) + +--- + +## Phase A: 즉시 개선 (1~2일) + +### 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% 재렌더 감소 + +--- + +## Phase B: 단기 개선 (1주) + +### 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 중복 → 하나로 통합 + +--- + +## Phase C: 중기 개선 (2~3주) + +### 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" 접미사 제거 + +--- + +## 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` 환경변수로 전환 + +--- + +## 이전 리팩토링 완료 항목 (참고) + +| 항목 | 상태 | 날짜 | +|------|------|------| +| Phase 1: 공통 훅 추출 (executeServerAction 등) | ✅ 완료 | 이전 세션 | +| Phase 3: 공용 유틸 추출 (PaginatedApiResponse 등) | ✅ 완료 | 이전 세션 | +| Phase 4: SearchableSelectionModal 공통화 | ✅ 완료 | 이전 세션 | +| Phase 5: any 21건 + memo 3개 정리 | ✅ 완료 | 이전 세션 | +| console.log 524건 → 22건 정리 | ✅ 완료 | 2025-02-10 | +| TODO 주석 정리 (login route) | ✅ 완료 | 2025-02-10 | +| SSR 가드 추가 (ThemeContext, ApiErrorContext, useDetailPageState) | ✅ 완료 | 2025-02-10 | +| 커스텀 훅 불필요 'use client' 15개 제거 | ✅ 완료 | 2025-02-10 | +| formatDate 이름 충돌 해소 → formatCalendarDate | ✅ 완료 | 2025-02-10 | + +--- + +## 우선순위 요약 + +``` +즉시 (Phase A) → img 최적화, DataTable 최적화 +단기 (Phase B) → 코드 스플리팅, API 병렬화, 스토어 통합 +중기 (Phase C) → 가상화, 캐싱, Action 팩토리, V2 정리 +장기 (Phase D) → God 컴포넌트 분리, 타입 안전성, 모달 통합 +``` diff --git a/claudedocs/_index.md b/claudedocs/_index.md index a2ecd922..5ad27d4b 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -1,6 +1,6 @@ # claudedocs 문서 맵 -> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-02-09) +> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-02-10) ## 빠른 참조 @@ -10,6 +10,74 @@ --- +## 프로젝트 기술 결정 사항 + +### `` 태그 사용 — `next/image` 미사용 이유 (2026-02-10) + +**현황**: 프로젝트 전체 `` 태그 10건, `next/image` 0건 + +**결정**: `` 유지, `next/image` 전환 불필요 + +**근거**: +1. **폐쇄형 ERP 시스템** — SEO 불필요, LCP 점수 무의미 +2. **전량 외부 동적 이미지** — 백엔드 API에서 받아오는 URL (정적 내부 이미지 0건) +3. **프린트/문서 레이아웃** — 10건 중 8건이 검사 기준서·도해 등 인쇄용. `next/image`의 `width`/`height` 강제 지정이 프린트 레이아웃을 깰 위험 +4. **blob URL 비호환** — 업로드 미리보기(blob:)는 `next/image`가 지원 안 함 +5. **설정 부담 > 이점** — `remotePatterns` 설정 + 백엔드 도메인 관리 비용이 실질 이점보다 큼 + +**사용처 (9개 파일)**: +| 파일 | 용도 | 이미지 소스 | +|------|------|-------------| +| `DocumentHeader.tsx` (2건) | 문서 헤더 로고 | `logo.imageUrl` (API) | +| `ProductInspectionInputModal.tsx` | 제품검사 사진 미리보기 | blob URL | +| `ProductInspectionDocument.tsx` | 제품검사 문서 | `data.productImage` (API) | +| `inspection-shared.tsx` | 검사 기준서 이미지 | `standardImage` (API) | +| `SlatInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | +| `ScreenInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | +| `BendingInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | +| `SlatJointBarInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | +| `BendingWipInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | + +**참고**: `next/image`가 유효한 케이스는 공개 사이트 + 정적/내부 이미지 + SEO 중요한 상황 + +### `next/dynamic` 코드 스플리팅 적용 (2026-02-10) + +**결정**: 대형 컴포넌트 + 무거운 라이브러리에 `next/dynamic` / 동적 `import()` 적용 + +**핵심 개념 — Suspense vs dynamic()**: +- **`Suspense` + 정적 import** → 코드가 부모와 같은 번들 청크에 포함. 유저가 안 봐도 이미 다운로드됨. UI fallback만 제공하고 **코드 분할은 안 일어남** +- **`dynamic()`** → webpack이 별도 `.js` 청크로 분리. 컴포넌트가 실제 렌더될 때만 네트워크 요청으로 해당 청크 다운로드. **진짜 코드 분할** + +**적용 내역**: + +| 파일 | 대상 | 절감 | +|------|------|------| +| `reports/comprehensive-analysis/page.tsx` | MainDashboard (2,651줄 + recharts) | ~350KB | +| `components/business/Dashboard.tsx` | CEODashboard | ~200KB | +| `construction/ConstructionDashboard.tsx` | ConstructionMainDashboard | ~100KB | +| `production/dashboard/page.tsx` | ProductionDashboard | ~100KB | +| `lib/utils/excel-download.ts` | xlsx 라이브러리 (~400KB) | ~400KB | +| `quotes/LocationListPanel.tsx` | xlsx 직접 import 제거 | (위와 중복) | + +**xlsx 동적 로드 패턴**: +```typescript +// Before: 모든 페이지에 xlsx ~400KB 포함 +import * as XLSX from 'xlsx'; + +// After: 엑셀 버튼 클릭 시에만 로드 +async function loadXLSX() { + return await import('xlsx'); +} +export async function downloadExcel(...) { + const XLSX = await loadXLSX(); + // ... +} +``` + +**총 절감**: 초기 번들에서 ~850KB 제외 (대시보드 미방문 + 엑셀 미사용 시) + +--- + ## 폴더 구조 ``` diff --git a/src/app/[locale]/(protected)/board/[boardCode]/page.tsx b/src/app/[locale]/(protected)/board/[boardCode]/page.tsx index d9a2198d..641231cc 100644 --- a/src/app/[locale]/(protected)/board/[boardCode]/page.tsx +++ b/src/app/[locale]/(protected)/board/[boardCode]/page.tsx @@ -231,14 +231,14 @@ export default function BoardCodePage() { }; // 테이블 컬럼 - const tableColumns: TableColumn[] = [ + const tableColumns: TableColumn[] = useMemo(() => [ { key: 'no', label: 'No.', className: 'w-[60px] text-center' }, { key: 'title', label: '제목', className: 'min-w-[200px]' }, { key: 'author', label: '작성자', className: 'w-[120px]' }, { key: 'views', label: '조회수', className: 'w-[80px] text-center' }, { key: 'status', label: '상태', className: 'w-[100px] text-center' }, { key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' }, - ]; + ], []); // 테이블 행 렌더링 const renderTableRow = useCallback( diff --git a/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx b/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx index d6588c4b..740cdcc4 100644 --- a/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx +++ b/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx @@ -244,14 +244,14 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) { }; // 테이블 컬럼 - const tableColumns: TableColumn[] = [ + const tableColumns: TableColumn[] = useMemo(() => [ { key: 'no', label: 'No.', className: 'w-[60px] text-center' }, { key: 'title', label: '제목', className: 'min-w-[200px]' }, { key: 'author', label: '작성자', className: 'w-[120px]' }, { key: 'views', label: '조회수', className: 'w-[80px] text-center' }, { key: 'status', label: '상태', className: 'w-[100px] text-center' }, { key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' }, - ]; + ], []); // 테이블 행 렌더링 const renderTableRow = useCallback( diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/DashboardType2.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/DashboardType2.tsx new file mode 100644 index 00000000..8bcb683c --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/DashboardType2.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { LayoutDashboard } from 'lucide-react'; +import { PageLayout } from '@/components/organisms/PageLayout'; +import { PageHeader } from '@/components/organisms/PageHeader'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { RollingText, type RollingItem } from './RollingText'; +import { OverviewTab } from './tabs/OverviewTab'; +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'; + +interface TabConfig { + value: string; + label: string; + badge?: number; + 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); + + useEffect(() => { + const timer = setInterval(() => { + setGlobalTick((prev) => prev + 1); + }, 2500); + return () => clearInterval(timer); + }, []); + + return ( + +
+ + +
+ + + {TABS.map((tab) => ( + + + {tab.label} + {tab.badge != null && tab.badge > 0 && ( + + {tab.badge} + + )} + + + | + + + + ))} + + +
+ + + + + +
+
+
+
+
+ ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/RollingText.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/RollingText.tsx new file mode 100644 index 00000000..b8501856 --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/RollingText.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; + +export interface RollingItem { + label: string; + value: string; + color?: 'default' | 'green' | 'blue' | 'red' | 'orange'; +} + +interface RollingTextProps { + items: RollingItem[]; + globalTick: number; +} + +const valueColorMap: Record = { + default: 'text-foreground font-bold', + green: 'text-green-600 font-bold', + blue: 'text-blue-600 font-bold', + red: 'text-red-600 font-bold', + orange: 'text-orange-600 font-bold', +}; + +export function RollingText({ items, globalTick }: RollingTextProps) { + const [displayIndex, setDisplayIndex] = useState(() => + items.length > 0 ? globalTick % items.length : 0, + ); + const [isAnimating, setIsAnimating] = useState(false); + const isPausedRef = useRef(false); + const displayIndexRef = useRef(displayIndex); + + useEffect(() => { + if (items.length <= 1) return; + if (isPausedRef.current) return; + + const targetIndex = globalTick % items.length; + if (targetIndex === displayIndexRef.current) return; + + setIsAnimating(true); + const timeout = setTimeout(() => { + setDisplayIndex(targetIndex); + displayIndexRef.current = targetIndex; + setIsAnimating(false); + }, 300); + + return () => clearTimeout(timeout); + }, [globalTick, items.length]); + + const handleMouseEnter = useCallback(() => { + isPausedRef.current = true; + }, []); + + const handleMouseLeave = useCallback(() => { + isPausedRef.current = false; + const targetIndex = globalTick % items.length; + setDisplayIndex(targetIndex); + displayIndexRef.current = targetIndex; + }, [globalTick, items.length]); + + if (items.length === 0) return null; + + const item = items[displayIndex]; + + return ( + + {item.label} + {item.value} + + ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/StatCards.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/StatCards.tsx new file mode 100644 index 00000000..ce5c07a6 --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/StatCards.tsx @@ -0,0 +1,31 @@ +'use client'; + +import type { StatCard } from './mockData'; + +const colorMap: Record = { + default: 'text-foreground', + green: 'text-green-600', + blue: 'text-blue-600', + red: 'text-red-600', + orange: 'text-orange-600', +}; + +export function StatCards({ stats }: { stats: StatCard[] }) { + return ( +
+ {stats.map((stat, i) => ( +
+

{stat.label}

+

+ {stat.value} +

+ {stat.change && ( +

+ {stat.change} +

+ )} +
+ ))} +
+ ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/mockData.ts b/src/app/[locale]/(protected)/dashboard_type2/_components/mockData.ts new file mode 100644 index 00000000..858c2125 --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/mockData.ts @@ -0,0 +1,118 @@ +/** dashboard_type2 목업 데이터 */ + +export interface StatCard { + label: string; + value: string; + color?: 'default' | 'green' | 'blue' | 'red' | 'orange'; + change?: string; + changeDirection?: 'up' | 'down'; +} + +export interface TableRow { + [key: string]: string | number; +} + +// === 전체 요약 탭 === +export const overviewStats: StatCard[] = [ + { label: '현금성 자산', value: '305억', color: 'blue', change: '+5.2%', changeDirection: 'up' }, + { label: '당월 매출', value: '12.8억', color: 'green', change: '+8.3%', changeDirection: 'up' }, + { label: '당월 미수금', value: '10.1억', color: 'red', change: '+2.1%', changeDirection: 'up' }, + { label: '당월 지출', value: '8.5억', color: 'orange', change: '-3.2%', changeDirection: 'down' }, +]; + +export const overviewRecentOrders: TableRow[] = [ + { no: 1, 거래처: '(주)대한건설', 품목: '전동개폐기 SET', 금액: '45,000,000', 상태: '진행중', 담당자: '김영민' }, + { no: 2, 거래처: '삼성엔지니어링', 품목: '자동제어 시스템', 금액: '128,000,000', 상태: '완료', 담당자: '이수진' }, + { no: 3, 거래처: '현대건설', 품목: '환기 시스템', 금액: '67,500,000', 상태: '진행중', 담당자: '박준혁' }, + { no: 4, 거래처: 'LG전자', 품목: '공조기 제어반', 금액: '32,000,000', 상태: '대기', 담당자: '최민지' }, + { no: 5, 거래처: 'SK에코플랜트', 품목: '모터 제어반', 금액: '89,000,000', 상태: '진행중', 담당자: '정하윤' }, +]; + +// === 재무 관리 탭 === +export const financeStats: StatCard[] = [ + { label: '현금성 자산 합계', value: '-1,392만', color: 'red', change: '+5.2%', changeDirection: 'up' }, + { label: '외국환(USD) 합계', value: '$0', color: 'default', change: '+2.1%', changeDirection: 'up' }, + { label: '입금 합계', value: '0원', color: 'green', change: '+12.0%', changeDirection: 'up' }, + { label: '출금 합계', value: '0원', color: 'default', change: '-8.0%', changeDirection: 'down' }, +]; + +export const financeExpenseData: TableRow[] = [ + { no: 1, 항목: '매입', 금액: '5,234만', 전월대비: '+10.5%', 비율: '42%' }, + { no: 2, 항목: '카드', 금액: '985만', 전월대비: '+3.2%', 비율: '8%' }, + { no: 3, 항목: '발행어음', 금액: '0원', 전월대비: '-', 비율: '0%' }, + { no: 4, 항목: '인건비', 금액: '3,200만', 전월대비: '+1.5%', 비율: '26%' }, + { no: 5, 항목: '운영비', 금액: '1,580만', 전월대비: '-5.3%', 비율: '13%' }, + { no: 6, 항목: '기타', 금액: '1,350만', 전월대비: '+7.8%', 비율: '11%' }, +]; + +export const financeCardData: TableRow[] = [ + { no: 1, 카드명: '법인카드(신한)', 사용액: '450만', 미정리: '2건', 한도: '1,000만', 잔여한도: '550만' }, + { no: 2, 카드명: '법인카드(국민)', 사용액: '320만', 미정리: '1건', 한도: '800만', 잔여한도: '480만' }, + { no: 3, 카드명: '법인카드(하나)', 사용액: '215만', 미정리: '2건', 한도: '500만', 잔여한도: '285만' }, +]; + +// === 영업/매출 탭 === +export const salesStats: StatCard[] = [ + { label: '수주 건수', value: '7건', color: 'blue' }, + { label: '누적 미수금', value: '10억 3,186만', color: 'red' }, + { label: '당월 미수금', value: '10억 2,586만', color: 'orange' }, + { label: '채권추심 중', value: '4,782만', color: 'red' }, +]; + +export const salesReceivableData: TableRow[] = [ + { no: 1, 거래처: '(주)대한건설', 매출액: '120,000,000', 입금액: '80,000,000', 미수금: '40,000,000', 경과일: '45일', 상태: '정상' }, + { no: 2, 거래처: '삼성엔지니어링', 매출액: '250,000,000', 입금액: '150,000,000', 미수금: '100,000,000', 경과일: '92일', 상태: '주의' }, + { no: 3, 거래처: '현대건설', 매출액: '67,500,000', 입금액: '67,500,000', 미수금: '0', 경과일: '-', 상태: '완료' }, + { no: 4, 거래처: 'SK에코플랜트', 매출액: '89,000,000', 입금액: '45,000,000', 미수금: '44,000,000', 경과일: '30일', 상태: '정상' }, + { no: 5, 거래처: 'LG전자', 매출액: '32,000,000', 입금액: '0', 미수금: '32,000,000', 경과일: '120일', 상태: '위험' }, +]; + +export const salesDebtData: TableRow[] = [ + { no: 1, 거래처: '(주)동양전자', 채권액: '1,500만', 추심단계: '내용증명 발송', 경과일: '180일', 회수가능성: '중' }, + { no: 2, 거래처: '(주)한국테크', 채권액: '2,300만', 추심단계: '법적조치 진행', 경과일: '250일', 회수가능성: '하' }, + { no: 3, 거래처: '(주)미래산업', 채권액: '982만', 추심단계: '분할상환 협의', 경과일: '95일', 회수가능성: '상' }, +]; + +// === 경비 관리 탭 === +export const expenseStats: StatCard[] = [ + { label: '접대비 사용', value: '1,000만', color: 'blue' }, + { label: '접대비 잔여한도', value: '2,190만', color: 'green' }, + { label: '복리후생비 사용', value: '0원', color: 'default' }, + { label: '복리후생비 잔여한도', value: '960만', color: 'green' }, +]; + +export const expenseEntertainmentData: TableRow[] = [ + { no: 1, 일자: '2026-02-03', 사용자: '김대표', 거래처: '(주)대한건설', 금액: '350,000', 용도: '식사', 결제수단: '법인카드' }, + { no: 2, 일자: '2026-02-05', 사용자: '이부장', 거래처: '삼성엔지니어링', 금액: '520,000', 용도: '골프', 결제수단: '법인카드' }, + { no: 3, 일자: '2026-02-07', 사용자: '김대표', 거래처: 'LG전자', 금액: '180,000', 용도: '식사', 결제수단: '법인카드' }, + { no: 4, 일자: '2026-02-08', 사용자: '박이사', 거래처: 'SK에코플랜트', 금액: '450,000', 용도: '선물', 결제수단: '현금' }, +]; + +export const expenseWelfareData: TableRow[] = [ + { no: 1, 항목: '식대', 월한도: '200,000', 사용액: '180,000', 잔여: '20,000', 비고: '비과세 한도 내' }, + { no: 2, 항목: '교통비', 월한도: '100,000', 사용액: '85,000', 잔여: '15,000', 비고: '-' }, + { no: 3, 항목: '경조사비', 월한도: '200,000', 사용액: '100,000', 잔여: '100,000', 비고: '-' }, + { no: 4, 항목: '체육문화비', 월한도: '100,000', 사용액: '0', 잔여: '100,000', 비고: '-' }, +]; + +// === 일정/이슈 탭 === +export const scheduleStats: StatCard[] = [ + { label: '오늘 일정', value: '4건', color: 'blue' }, + { label: '이번 주 일정', value: '12건', color: 'default' }, + { label: '미처리 이슈', value: '3건', color: 'red' }, + { label: '이번 달 마감', value: '2건', color: 'orange' }, +]; + +export const scheduleTodayItems = [ + { id: 1, time: '09:00', title: '대한건설 현장 미팅', type: 'meeting' as const, person: '김영민' }, + { id: 2, time: '11:00', title: '삼성엔지니어링 견적 검토', type: 'task' as const, person: '이수진' }, + { id: 3, time: '14:00', title: '월간 실적 보고', type: 'report' as const, person: '박준혁' }, + { id: 4, time: '16:00', title: 'SK에코플랜트 납품 확인', type: 'delivery' as const, person: '정하윤' }, +]; + +export const scheduleIssueItems = [ + { id: 1, date: '2026-02-08', title: '전동개폐기 납기 지연 (3일)', priority: 'high' as const, assignee: '김영민' }, + { id: 2, date: '2026-02-09', title: '자재 단가 인상 통보 (동 파이프)', priority: 'medium' as const, assignee: '최민지' }, + { id: 3, date: '2026-02-10', title: '품질 검사 부적합 2건 발생', priority: 'high' as const, assignee: '박준혁' }, + { id: 4, date: '2026-02-10', title: '현대건설 설계 변경 요청', priority: 'medium' as const, assignee: '이수진' }, +]; diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ExpenseTab.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ExpenseTab.tsx new file mode 100644 index 00000000..144aacf1 --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ExpenseTab.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { expenseStats, expenseEntertainmentData, expenseWelfareData } from '../mockData'; +import { StatCards } from '../StatCards'; + +export function ExpenseTab() { + return ( +
+ + +
+ + + 접대비 사용 내역 + + +
+ + + + + + + + + + + + + {expenseEntertainmentData.map((row, i) => ( + + + + + + + + + ))} + +
No일자사용자거래처금액용도
{row.no}{row['일자']}{row['사용자']}{row['거래처']}{row['금액']}{row['용도']}
+
+
+
+ + + + 복리후생비 항목별 현황 + + +
+ + + + + + + + + + + + + {expenseWelfareData.map((row, i) => ( + + + + + + + + + ))} + +
No항목월한도사용액잔여비고
{row.no}{row['항목']}{row['월한도']}{row['사용액']}{row['잔여']}{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 new file mode 100644 index 00000000..42906d6d --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/FinanceTab.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { financeStats, financeExpenseData, financeCardData } from '../mockData'; +import { StatCards } from '../StatCards'; + +export function FinanceTab() { + return ( +
+ + +
+ + + 당월 예상 지출 내역 + + +
+ + + + + + + + + + + + {financeExpenseData.map((row, i) => ( + + + + + + + + ))} + +
No항목금액전월대비비율
{row.no}{row['항목']}{row['금액']} + + {row['전월대비']} + + {row['비율']}
+
+
+
+ + + + 카드 사용 현황 + + +
+ + + + + + + + + + + + {financeCardData.map((row, i) => ( + + + + + + + + ))} + +
No카드명사용액미정리잔여한도
{row.no}{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 new file mode 100644 index 00000000..9b644f29 --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/OverviewTab.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { overviewStats, overviewRecentOrders } from '../mockData'; +import { StatCards } from '../StatCards'; + +export function OverviewTab() { + return ( +
+ + + + + 최근 수주 현황 + + +
+ + + + + + + + + + + + + {overviewRecentOrders.map((row, i) => ( + + + + + + + + + ))} + +
No거래처품목금액상태담당자
{row.no}{row['거래처']}{row['품목']}{row['금액']} + + {row['담당자']}
+
+
+
+
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const styles: Record = { + '진행중': 'bg-blue-100 text-blue-700', + '완료': 'bg-green-100 text-green-700', + '대기': 'bg-gray-100 text-gray-600', + '주의': 'bg-yellow-100 text-yellow-700', + '위험': 'bg-red-100 text-red-700', + }; + return ( + + {status} + + ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/SalesTab.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/SalesTab.tsx new file mode 100644 index 00000000..bf5bb8d8 --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/SalesTab.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { salesStats, salesReceivableData, salesDebtData } from '../mockData'; +import { StatCards } from '../StatCards'; + +export function SalesTab() { + return ( +
+ + + + + 미수금 현황 + + +
+ + + + + + + + + + + + + + {salesReceivableData.map((row, i) => ( + + + + + + + + + + ))} + +
No거래처매출액입금액미수금경과일상태
{row.no}{row['거래처']}{row['매출액']}{row['입금액']}{row['미수금']}{row['경과일']} + +
+
+
+
+ + + + 채권추심 현황 + + +
+ + + + + + + + + + + + + {salesDebtData.map((row, i) => ( + + + + + + + + + ))} + +
No거래처채권액추심단계경과일회수가능성
{row.no}{row['거래처']}{row['채권액']}{row['추심단계']}{row['경과일']} + +
+
+
+
+
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const styles: Record = { + '정상': 'bg-green-100 text-green-700', + '완료': 'bg-green-100 text-green-700', + '주의': 'bg-yellow-100 text-yellow-700', + '위험': 'bg-red-100 text-red-700', + }; + return ( + + {status} + + ); +} + +function PossibilityBadge({ level }: { level: string }) { + const styles: Record = { + '상': 'bg-green-100 text-green-700', + '중': 'bg-yellow-100 text-yellow-700', + '하': 'bg-red-100 text-red-700', + }; + return ( + + {level} + + ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ScheduleTab.tsx b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ScheduleTab.tsx new file mode 100644 index 00000000..62731f3e --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/_components/tabs/ScheduleTab.tsx @@ -0,0 +1,96 @@ +'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'; + +export function ScheduleTab() { + return ( +
+ + + + + 오늘 일정 + + +
+ {scheduleTodayItems.map((item) => ( +
+
+ +
+
+

{item.title}

+

{item.person}

+
+
+ {item.time} +
+
+ ))} +
+
+
+ + + + 미처리 이슈 + + +
+ {scheduleIssueItems.map((item) => ( +
+
+ +
+
+

{item.title}

+
+ {item.date} + | + {item.assignee} +
+
+
+ +
+
+ ))} +
+
+
+
+ ); +} + +function ScheduleIcon({ type }: { type: string }) { + const iconClass = "w-4 h-4"; + switch (type) { + case 'meeting': return ; + case 'task': return ; + case 'report': return ; + case 'delivery': return ; + default: return ; + } +} + +function PriorityIcon({ priority }: { priority: string }) { + if (priority === 'high') return ; + return ; +} + +function PriorityBadge({ priority }: { priority: string }) { + const styles: Record = { + high: 'bg-red-100 text-red-700', + medium: 'bg-yellow-100 text-yellow-700', + low: 'bg-green-100 text-green-700', + }; + const labels: Record = { high: '긴급', medium: '보통', low: '낮음' }; + return ( + + {labels[priority] ?? priority} + + ); +} diff --git a/src/app/[locale]/(protected)/dashboard_type2/page.tsx b/src/app/[locale]/(protected)/dashboard_type2/page.tsx new file mode 100644 index 00000000..8c5c41d8 --- /dev/null +++ b/src/app/[locale]/(protected)/dashboard_type2/page.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { DashboardType2 } from './_components/DashboardType2'; + +/** + * Dashboard Type 2 - 탭 기반 대시보드 + * + * 기존 보고서형 대시보드(dashboard)와 달리 탭으로 세부 항목을 나눈 구성 + * - 전체 요약: 핵심 KPI + 최근 수주 + * - 재무 관리: 일일일보 + 지출/카드 현황 + * - 영업/매출: 미수금 + 채권추심 + * - 경비 관리: 접대비 + 복리후생비 + * - 일정/이슈: 오늘 일정 + 미처리 이슈 + * + * URL: /ko/dashboard_type2 + */ +export default function DashboardType2Page() { + return ; +} diff --git a/src/app/[locale]/(protected)/production/dashboard/page.tsx b/src/app/[locale]/(protected)/production/dashboard/page.tsx index f4fa9a9b..c7fdd036 100644 --- a/src/app/[locale]/(protected)/production/dashboard/page.tsx +++ b/src/app/[locale]/(protected)/production/dashboard/page.tsx @@ -4,19 +4,16 @@ * 경로: /[locale]/(protected)/production/dashboard */ -import { Suspense } from 'react'; -import ProductionDashboard from '@/components/production/ProductionDashboard'; +'use client'; + +import dynamic from 'next/dynamic'; import { ListPageSkeleton } from '@/components/ui/skeleton'; -export default function ProductionDashboardPage() { - return ( - }> - - - ); -} +const ProductionDashboard = dynamic( + () => import('@/components/production/ProductionDashboard'), + { loading: () => } +); -export const metadata = { - title: '생산 현황판', - description: '공장별 작업 현황을 확인합니다.', -}; \ No newline at end of file +export default function ProductionDashboardPage() { + return ; +} diff --git a/src/app/[locale]/(protected)/reports/comprehensive-analysis/page.tsx b/src/app/[locale]/(protected)/reports/comprehensive-analysis/page.tsx index ca97e647..0c80a667 100644 --- a/src/app/[locale]/(protected)/reports/comprehensive-analysis/page.tsx +++ b/src/app/[locale]/(protected)/reports/comprehensive-analysis/page.tsx @@ -1,7 +1,13 @@ 'use client'; -import { MainDashboard } from '@/components/business/MainDashboard'; +import dynamic from 'next/dynamic'; +import { ListPageSkeleton } from '@/components/ui/skeleton'; + +const MainDashboard = dynamic( + () => import('@/components/business/MainDashboard').then(mod => ({ default: mod.MainDashboard })), + { loading: () => } +); export default function ComprehensiveAnalysisPage() { return ; -} \ No newline at end of file +} 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 be6996b1..f0270f7c 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 @@ -15,7 +15,7 @@ * - 페이지 기반 CRUD (등록/수정/상세 → 별도 페이지로 이동) */ -import { useState, useRef, useEffect, useCallback, useTransition } from "react"; +import { useState, useRef, useEffect, useCallback, useTransition, useMemo } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { ClientDetailClientV2 } from '@/components/clients/ClientDetailClientV2'; import { useClientList, Client } from "@/hooks/useClientList"; @@ -435,7 +435,7 @@ export default function CustomerAccountManagementPage() { }; // 테이블 컬럼 정의 - const tableColumns: TableColumn[] = [ + const tableColumns: TableColumn[] = useMemo(() => [ { key: "rowNumber", label: "번호", className: "px-4" }, { key: "code", label: "코드", className: "px-4" }, { key: "clientType", label: "구분", className: "px-4" }, @@ -443,7 +443,7 @@ export default function CustomerAccountManagementPage() { { key: "representative", label: "대표자", className: "px-4" }, { key: "manager", label: "담당자", className: "px-4" }, { key: "phone", label: "전화번호", className: "px-4" }, - ]; + ], []); // 테이블 행 렌더링 const renderTableRow = ( diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx index 40e2291a..3117eb6c 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx @@ -11,7 +11,7 @@ * - API 연동 완료 (2025-01-08) */ -import { useState, useRef, useEffect, useCallback, useTransition } from "react"; +import { useState, useRef, useEffect, useCallback, useTransition, useMemo } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { OrderRegistration, OrderFormData, createOrder } from "@/components/orders"; import { @@ -473,7 +473,7 @@ function OrderListContent() { }, []); // 테이블 컬럼 정의 (16개: 체크박스, 번호, 로트번호, 현장명, 출고예정일, 접수일, 수주처, 제품명, 수신자, 수신주소, 수신처, 배송, 담당자, 틀수, 상태, 비고) - const tableColumns: TableColumn[] = [ + const tableColumns: TableColumn[] = useMemo(() => [ { key: "rowNumber", label: "번호", className: "px-2 text-center" }, { key: "lotNumber", label: "로트번호", className: "px-2" }, { key: "siteName", label: "현장명", className: "px-2" }, @@ -489,7 +489,7 @@ function OrderListContent() { { key: "frameCount", label: "틀수", className: "px-2 text-center" }, { key: "status", label: "상태", className: "px-2" }, { key: "remarks", label: "비고", className: "px-2" }, - ]; + ], []); // 테이블 행 렌더링 (16개 컬럼: 체크박스, 번호, 로트번호, 현장명, 출고예정일, 접수일, 수주처, 제품명, 수신자, 수신주소, 수신처, 배송, 담당자, 틀수, 상태, 비고) const renderTableRow = ( diff --git a/src/components/accounting/BillManagement/BillDetail.tsx b/src/components/accounting/BillManagement/BillDetail.tsx index 009ef295..4fd9cec7 100644 --- a/src/components/accounting/BillManagement/BillDetail.tsx +++ b/src/components/accounting/BillManagement/BillDetail.tsx @@ -68,44 +68,45 @@ export function BillDetail({ billId, mode }: BillDetailProps) { const [note, setNote] = useState(''); const [installments, setInstallments] = useState([]); - // ===== 거래처 목록 로드 ===== + // ===== 초기 데이터 로드 (거래처 + 어음 상세 병렬) ===== useEffect(() => { - async function loadClients() { - const result = await getClients(); - if (result.success && result.data) { - setClients(result.data.map(c => ({ id: String(c.id), name: c.name }))); + async function loadInitialData() { + const isEditMode = billId && billId !== 'new'; + setIsLoading(!!isEditMode); + + const [clientsResult, billResult] = await Promise.all([ + getClients(), + isEditMode ? getBill(billId) : Promise.resolve(null), + ]); + + // 거래처 목록 + if (clientsResult.success && clientsResult.data) { + setClients(clientsResult.data.map(c => ({ id: String(c.id), name: c.name }))); } - } - loadClients(); - }, []); - // ===== 데이터 로드 ===== - useEffect(() => { - async function loadBill() { - if (!billId || billId === 'new') return; + // 어음 상세 + if (billResult) { + if (billResult.success && billResult.data) { + const data = billResult.data; + setBillNumber(data.billNumber); + setBillType(data.billType); + setVendorId(data.vendorId); + setAmount(data.amount); + setIssueDate(data.issueDate); + setMaturityDate(data.maturityDate); + setStatus(data.status); + setNote(data.note); + setInstallments(data.installments); + } else { + toast.error(billResult.error || '어음 정보를 불러올 수 없습니다.'); + router.push('/ko/accounting/bills'); + } + } - setIsLoading(true); - const result = await getBill(billId); setIsLoading(false); - - if (result.success && result.data) { - const data = result.data; - setBillNumber(data.billNumber); - setBillType(data.billType); - setVendorId(data.vendorId); - setAmount(data.amount); - setIssueDate(data.issueDate); - setMaturityDate(data.maturityDate); - setStatus(data.status); - setNote(data.note); - setInstallments(data.installments); - } else { - toast.error(result.error || '어음 정보를 불러올 수 없습니다.'); - router.push('/ko/accounting/bills'); - } } - loadBill(); + loadInitialData(); }, [billId, router]); // ===== 저장 핸들러 ===== diff --git a/src/components/accounting/DepositManagement/DepositDetail.tsx b/src/components/accounting/DepositManagement/DepositDetail.tsx index ed28a8ae..74391c42 100644 --- a/src/components/accounting/DepositManagement/DepositDetail.tsx +++ b/src/components/accounting/DepositManagement/DepositDetail.tsx @@ -55,38 +55,39 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) { const [isLoading, setIsLoading] = useState(false); const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]); - // ===== 거래처 목록 로드 ===== + // ===== 초기 데이터 로드 (거래처 + 입금 상세 병렬) ===== useEffect(() => { - const loadVendors = async () => { - const result = await getVendors(); - if (result.success) { - setVendors(result.data); - } - }; - loadVendors(); - }, []); + const loadInitialData = async () => { + const isEditMode = depositId && !isNewMode; + if (isEditMode) setIsLoading(true); - // ===== 데이터 로드 ===== - useEffect(() => { - const loadDeposit = async () => { - if (depositId && !isNewMode) { - setIsLoading(true); - const result = await getDepositById(depositId); - if (result.success && result.data) { - setDepositDate(result.data.depositDate); - setAccountName(result.data.accountName); - setDepositorName(result.data.depositorName); - setDepositAmount(result.data.depositAmount); - setNote(result.data.note); - setVendorId(result.data.vendorId); - setDepositType(result.data.depositType); + const [vendorsResult, depositResult] = await Promise.all([ + getVendors(), + isEditMode ? getDepositById(depositId) : Promise.resolve(null), + ]); + + // 거래처 목록 + if (vendorsResult.success) { + setVendors(vendorsResult.data); + } + + // 입금 상세 + if (depositResult) { + if (depositResult.success && depositResult.data) { + setDepositDate(depositResult.data.depositDate); + setAccountName(depositResult.data.accountName); + setDepositorName(depositResult.data.depositorName); + setDepositAmount(depositResult.data.depositAmount); + setNote(depositResult.data.note); + setVendorId(depositResult.data.vendorId); + setDepositType(depositResult.data.depositType); } else { - toast.error(result.error || '입금 내역을 불러오는데 실패했습니다.'); + toast.error(depositResult.error || '입금 내역을 불러오는데 실패했습니다.'); } setIsLoading(false); } }; - loadDeposit(); + loadInitialData(); }, [depositId, isNewMode]); // ===== 저장 핸들러 ===== diff --git a/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx b/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx index f33ffbd6..960907e3 100644 --- a/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx +++ b/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx @@ -93,28 +93,29 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { // ===== 다이얼로그 상태 ===== const [documentModalOpen, setDocumentModalOpen] = useState(false); - // ===== 거래처 목록 로드 ===== + // ===== 초기 데이터 로드 (거래처 + 매입 상세 병렬) ===== useEffect(() => { - async function loadClients() { - const result = await getClients({ size: 1000, only_active: true }); - if (result.success) { - setClients(result.data.map(v => ({ + async function loadInitialData() { + const isEditMode = purchaseId && mode !== 'new'; + setIsLoading(true); + + const [clientsResult, purchaseResult] = await Promise.all([ + getClients({ size: 1000, only_active: true }), + isEditMode ? getPurchaseById(purchaseId) : Promise.resolve(null), + ]); + + // 거래처 목록 + if (clientsResult.success) { + setClients(clientsResult.data.map(v => ({ id: v.id, name: v.vendorName, }))); } - } - loadClients(); - }, []); - // ===== 매입 상세 데이터 로드 ===== - useEffect(() => { - async function loadPurchaseDetail() { - if (purchaseId && mode !== 'new') { - setIsLoading(true); - const result = await getPurchaseById(purchaseId); - if (result.success && result.data) { - const data = result.data; + // 매입 상세 + if (purchaseResult) { + if (purchaseResult.success && purchaseResult.data) { + const data = purchaseResult.data; setPurchaseNo(data.purchaseNo); setPurchaseDate(data.purchaseDate); setVendorId(data.vendorId); @@ -126,16 +127,13 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { setWithdrawalAccount(data.withdrawalAccount); setCreatedAt(data.createdAt); } - setIsLoading(false); } else if (isNewMode) { - // 신규: 매입번호는 서버에서 자동 생성 setPurchaseNo('(자동생성)'); - setIsLoading(false); - } else { - setIsLoading(false); } + + setIsLoading(false); } - loadPurchaseDetail(); + loadInitialData(); }, [purchaseId, mode, isNewMode]); // ===== 합계 계산 ===== diff --git a/src/components/accounting/SalesManagement/SalesDetail.tsx b/src/components/accounting/SalesManagement/SalesDetail.tsx index f923ca46..c5714aa7 100644 --- a/src/components/accounting/SalesManagement/SalesDetail.tsx +++ b/src/components/accounting/SalesManagement/SalesDetail.tsx @@ -99,29 +99,30 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { const [showEmailAlert, setShowEmailAlert] = useState(false); const [emailAlertMessage, setEmailAlertMessage] = useState(''); - // ===== 거래처 목록 로드 ===== + // ===== 초기 데이터 로드 (거래처 + 매출 상세 병렬) ===== useEffect(() => { - async function loadClients() { - const result = await getClients({ size: 1000, only_active: true }); - if (result.success) { - setClients(result.data.map(v => ({ + async function loadInitialData() { + const isEditMode = salesId && mode !== 'new'; + setIsLoading(true); + + const [clientsResult, saleResult] = await Promise.all([ + getClients({ size: 1000, only_active: true }), + isEditMode ? getSaleById(salesId) : Promise.resolve(null), + ]); + + // 거래처 목록 + if (clientsResult.success) { + setClients(clientsResult.data.map(v => ({ id: v.id, name: v.vendorName, email: v.email, }))); } - } - loadClients(); - }, []); - // ===== 매출 상세 데이터 로드 ===== - useEffect(() => { - async function loadSaleDetail() { - if (salesId && mode !== 'new') { - setIsLoading(true); - const result = await getSaleById(salesId); - if (result.success && result.data) { - const data = result.data; + // 매출 상세 + if (saleResult) { + if (saleResult.success && saleResult.data) { + const data = saleResult.data; setSalesNo(data.salesNo); setSalesDate(data.salesDate); setVendorId(data.vendorId); @@ -132,16 +133,13 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { setTransactionStatementIssued(data.transactionStatementIssued); setNote(data.note || ''); } - setIsLoading(false); } else if (isNewMode) { - // 신규: 매출번호는 서버에서 자동 생성 setSalesNo('(자동생성)'); - setIsLoading(false); - } else { - setIsLoading(false); } + + setIsLoading(false); } - loadSaleDetail(); + loadInitialData(); }, [salesId, mode, isNewMode]); // ===== 선택된 거래처 정보 ===== diff --git a/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx b/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx index 34d6819d..5ed9801c 100644 --- a/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx +++ b/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx @@ -55,38 +55,39 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps) const [isLoading, setIsLoading] = useState(false); const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]); - // ===== 거래처 목록 로드 ===== + // ===== 초기 데이터 로드 (거래처 + 출금 상세 병렬) ===== useEffect(() => { - const loadVendors = async () => { - const result = await getVendors(); - if (result.success) { - setVendors(result.data); - } - }; - loadVendors(); - }, []); + const loadInitialData = async () => { + const isEditMode = withdrawalId && !isNewMode; + if (isEditMode) setIsLoading(true); - // ===== 데이터 로드 ===== - useEffect(() => { - const loadWithdrawal = async () => { - if (withdrawalId && !isNewMode) { - setIsLoading(true); - const result = await getWithdrawalById(withdrawalId); - if (result.success && result.data) { - setWithdrawalDate(result.data.withdrawalDate); - setAccountName(result.data.accountName); - setRecipientName(result.data.recipientName); - setWithdrawalAmount(result.data.withdrawalAmount); - setNote(result.data.note); - setVendorId(result.data.vendorId); - setWithdrawalType(result.data.withdrawalType); + const [vendorsResult, withdrawalResult] = await Promise.all([ + getVendors(), + isEditMode ? getWithdrawalById(withdrawalId) : Promise.resolve(null), + ]); + + // 거래처 목록 + if (vendorsResult.success) { + setVendors(vendorsResult.data); + } + + // 출금 상세 + if (withdrawalResult) { + if (withdrawalResult.success && withdrawalResult.data) { + setWithdrawalDate(withdrawalResult.data.withdrawalDate); + setAccountName(withdrawalResult.data.accountName); + setRecipientName(withdrawalResult.data.recipientName); + setWithdrawalAmount(withdrawalResult.data.withdrawalAmount); + setNote(withdrawalResult.data.note); + setVendorId(withdrawalResult.data.vendorId); + setWithdrawalType(withdrawalResult.data.withdrawalType); } else { - toast.error(result.error || '출금 내역을 불러오는데 실패했습니다.'); + toast.error(withdrawalResult.error || '출금 내역을 불러오는데 실패했습니다.'); } setIsLoading(false); } }; - loadWithdrawal(); + loadInitialData(); }, [withdrawalId, isNewMode]); // ===== 저장 핸들러 ===== diff --git a/src/components/business/Dashboard.tsx b/src/components/business/Dashboard.tsx index e08ed3f3..c7f3edcc 100644 --- a/src/components/business/Dashboard.tsx +++ b/src/components/business/Dashboard.tsx @@ -1,9 +1,13 @@ 'use client'; -import { Suspense } from "react"; -import { CEODashboard } from "./CEODashboard"; +import dynamic from 'next/dynamic'; import { DetailPageSkeleton } from "@/components/ui/skeleton"; +const CEODashboard = dynamic( + () => import('./CEODashboard').then(mod => ({ default: mod.CEODashboard })), + { loading: () => } +); + /** * Dashboard - 대표님 전용 대시보드 * @@ -24,9 +28,5 @@ import { DetailPageSkeleton } from "@/components/ui/skeleton"; */ export function Dashboard() { - return ( - }> - - - ); -} \ No newline at end of file + return ; +} diff --git a/src/components/business/construction/ConstructionDashboard.tsx b/src/components/business/construction/ConstructionDashboard.tsx index db59443b..00b9c4fe 100644 --- a/src/components/business/construction/ConstructionDashboard.tsx +++ b/src/components/business/construction/ConstructionDashboard.tsx @@ -1,18 +1,18 @@ 'use client'; -import { Suspense } from "react"; -import { ConstructionMainDashboard } from "./ConstructionMainDashboard"; +import dynamic from 'next/dynamic'; import { DetailPageSkeleton } from "@/components/ui/skeleton"; +const ConstructionMainDashboard = dynamic( + () => import('./ConstructionMainDashboard').then(mod => ({ default: mod.ConstructionMainDashboard })), + { loading: () => } +); + /** * ConstructionDashboard - 주일기업 전용 대시보드 - * + * * 건설/공사 프로젝트 중심의 메트릭과 현황을 보여줍니다. */ export function ConstructionDashboard() { - return ( - }> - - - ); + return ; } diff --git a/src/components/items/ItemListClient.tsx b/src/components/items/ItemListClient.tsx index be9e0982..4220c4a0 100644 --- a/src/components/items/ItemListClient.tsx +++ b/src/components/items/ItemListClient.tsx @@ -288,8 +288,8 @@ export default function ItemListClient() { ]; // 양식 다운로드 - const handleTemplateDownload = () => { - downloadExcelTemplate({ + const handleTemplateDownload = async () => { + await downloadExcelTemplate({ columns: templateColumns, filename: '품목등록_양식', sheetName: '품목등록', diff --git a/src/components/material/StockStatus/StockStatusList.tsx b/src/components/material/StockStatus/StockStatusList.tsx index 073b0fdc..5e39272e 100644 --- a/src/components/material/StockStatus/StockStatusList.tsx +++ b/src/components/material/StockStatus/StockStatusList.tsx @@ -10,7 +10,7 @@ * - 테이블 푸터 (요약 정보) */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { Package, @@ -230,7 +230,7 @@ export function StockStatusList() { ]; // ===== 테이블 컬럼 ===== - const tableColumns = [ + const tableColumns = useMemo(() => [ { key: 'no', label: 'No.', className: 'w-[60px] text-center' }, { key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' }, { key: 'itemType', label: '품목유형', className: 'w-[80px]' }, @@ -241,7 +241,7 @@ export function StockStatusList() { { key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' }, { key: 'wipQty', label: '재공품', className: 'w-[80px] text-center' }, { key: 'useStatus', label: '상태', className: 'w-[80px] text-center' }, - ]; + ], []); // ===== 테이블 행 렌더링 ===== const renderTableRow = ( diff --git a/src/components/organisms/DataTable.tsx b/src/components/organisms/DataTable.tsx index d624ab64..08bad834 100644 --- a/src/components/organisms/DataTable.tsx +++ b/src/components/organisms/DataTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { ReactNode } from "react"; +import { ReactNode, memo, useCallback } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -204,6 +204,62 @@ function getAlignClass(column: Column): string { } } +// 메모이즈드 행 컴포넌트 — 행 데이터가 변경되지 않으면 리렌더링 스킵 +interface DataTableRowProps { + row: T; + rowIndex: number; + columns: Column[]; + onRowClick?: (row: T) => void; + hoverable: boolean; + striped: boolean; + compact: boolean; + rowKey: string; +} + +function DataTableRowInner({ + row, + rowIndex, + columns, + onRowClick, + hoverable, + striped, + compact, +}: DataTableRowProps) { + const handleClick = useCallback(() => { + onRowClick?.(row); + }, [onRowClick, row]); + + return ( + + {columns.map((column) => { + const value = column.key in row ? row[column.key as keyof T] : null; + return ( + + {renderCell(column, value, row, rowIndex)} + + ); + })} + + ); +} + +const MemoizedDataTableRow = memo(DataTableRowInner) as typeof DataTableRowInner; + export function DataTable({ columns, data, @@ -216,6 +272,11 @@ export function DataTable({ hoverable = true, compact = false }: DataTableProps) { + const stableOnRowClick = useCallback( + (row: T) => onRowClick?.(row), + [onRowClick] + ); + return ( @@ -252,32 +313,17 @@ export function DataTable({ ) : ( data.map((row, rowIndex) => ( - key={row[keyField] ? String(row[keyField]) : `row-${rowIndex}`} - onClick={() => onRowClick?.(row)} - className={cn( - onRowClick && "cursor-pointer", - hoverable && "hover:bg-muted/50", - striped && rowIndex % 2 === 1 && "bg-muted/20", - compact && "h-10" - )} - > - {columns.map((column) => { - const value = column.key in row ? row[column.key as keyof T] : null; - return ( - - {renderCell(column, value, row, rowIndex)} - - ); - })} - + row={row} + rowIndex={rowIndex} + columns={columns} + onRowClick={onRowClick ? stableOnRowClick : undefined} + hoverable={hoverable} + striped={striped} + compact={compact} + rowKey={row[keyField] ? String(row[keyField]) : `row-${rowIndex}`} + /> )) )} diff --git a/src/components/pricing-distribution/PriceDistributionList.tsx b/src/components/pricing-distribution/PriceDistributionList.tsx index 715a5d64..30a8d006 100644 --- a/src/components/pricing-distribution/PriceDistributionList.tsx +++ b/src/components/pricing-distribution/PriceDistributionList.tsx @@ -8,7 +8,7 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { FileText, FilePlus } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -147,14 +147,14 @@ export function PriceDistributionList() { }; // 테이블 컬럼 - const tableColumns: TableColumn[] = [ + const tableColumns: TableColumn[] = useMemo(() => [ { key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' }, { key: 'distributionNo', label: '단가배포번호', className: 'min-w-[120px]' }, { key: 'distributionName', label: '단가배포명', className: 'min-w-[150px]' }, { key: 'status', label: '상태', className: 'min-w-[100px]' }, { key: 'author', label: '작성자', className: 'min-w-[100px]' }, { key: 'createdAt', label: '등록일', className: 'min-w-[120px]' }, - ]; + ], []); // 테이블 행 렌더링 const renderTableRow = ( diff --git a/src/components/pricing/PricingListClient.tsx b/src/components/pricing/PricingListClient.tsx index 9577cb4b..13070d2e 100644 --- a/src/components/pricing/PricingListClient.tsx +++ b/src/components/pricing/PricingListClient.tsx @@ -196,7 +196,7 @@ export function PricingListClient({ ]; // 테이블 컬럼 정의 - const tableColumns: TableColumn[] = [ + const tableColumns: TableColumn[] = useMemo(() => [ { key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' }, { key: 'itemType', label: '품목유형', className: 'min-w-[100px]' }, { key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' }, @@ -209,7 +209,7 @@ export function PricingListClient({ { key: 'marginRate', label: '마진율', className: 'min-w-[80px] text-right', hideOnMobile: true }, { key: 'effectiveDate', label: '적용일', className: 'min-w-[100px]', hideOnMobile: true }, { key: 'status', label: '상태', className: 'min-w-[80px]' }, - ]; + ], []); // 테이블 행 렌더링 const renderTableRow = ( diff --git a/src/components/quotes/LocationListPanel.tsx b/src/components/quotes/LocationListPanel.tsx index f4d963ff..f8d3852f 100644 --- a/src/components/quotes/LocationListPanel.tsx +++ b/src/components/quotes/LocationListPanel.tsx @@ -35,7 +35,7 @@ import { DeleteConfirmDialog } from "../ui/confirm-dialog"; import type { LocationItem } from "./QuoteRegistrationV2"; import type { FinishedGoods } from "./actions"; -import * as XLSX from "xlsx"; +// xlsx는 동적 로드 (번들 크기 최적화) // ============================================================================= // 상수 @@ -181,7 +181,8 @@ export function LocationListPanel({ }, [formData, finishedGoods, onAddLocation]); // 엑셀 양식 다운로드 - const handleDownloadTemplate = useCallback(() => { + const handleDownloadTemplate = useCallback(async () => { + const XLSX = await import("xlsx"); const templateData = [ { 층: "1층", @@ -219,10 +220,11 @@ export function LocationListPanel({ // 엑셀 업로드 const handleFileUpload = useCallback( - (event: React.ChangeEvent) => { + async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; + const XLSX = await import("xlsx"); const reader = new FileReader(); reader.onload = (e) => { try { diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx index 787a616b..62fdff69 100644 --- a/src/components/templates/UniversalListPage/index.tsx +++ b/src/components/templates/UniversalListPage/index.tsx @@ -628,7 +628,7 @@ export function UniversalListPage({ return; } - downloadExcel({ + await downloadExcel({ data: dataToDownload as Record[], columns: columns as ExcelColumn>[], filename, @@ -645,7 +645,7 @@ export function UniversalListPage({ }, [config.excelDownload, config.clientSideFiltering, filteredData, rawData, activeTab, filters, debouncedSearchValue]); // 선택 항목 엑셀 다운로드 - const handleSelectedExcelDownload = useCallback(() => { + const handleSelectedExcelDownload = useCallback(async () => { if (!config.excelDownload) return; const { columns, filename = 'export', sheetName = 'Sheet1' } = config.excelDownload; @@ -659,7 +659,7 @@ export function UniversalListPage({ // 현재 데이터에서 선택된 항목 필터링 const selectedData = rawData.filter((item) => selectedIds.includes(getItemId(item))); - downloadSelectedExcel({ + await downloadSelectedExcel({ data: selectedData as Record[], columns: columns as ExcelColumn>[], selectedIds, diff --git a/src/lib/utils/excel-download.ts b/src/lib/utils/excel-download.ts index 97fb8ec9..ec569d65 100644 --- a/src/lib/utils/excel-download.ts +++ b/src/lib/utils/excel-download.ts @@ -22,7 +22,11 @@ * ``` */ -import * as XLSX from 'xlsx'; +// xlsx는 ~400KB로 무거워서, 실제 사용 시점에 동적 로드 +async function loadXLSX() { + const XLSX = await import('xlsx'); + return XLSX; +} /** * 엑셀 컬럼 정의 @@ -84,19 +88,21 @@ function generateFilename(baseName: string, appendDate: boolean): string { /** * 데이터를 엑셀 파일로 다운로드 */ -export function downloadExcel>({ +export async function downloadExcel>({ data, columns, filename = 'export', sheetName = 'Sheet1', appendDate = true, -}: ExcelDownloadOptions): void { +}: ExcelDownloadOptions): Promise { if (!data || data.length === 0) { console.warn('[Excel] 다운로드할 데이터가 없습니다.'); return; } try { + const XLSX = await loadXLSX(); + // 1. 헤더 행 생성 const headers = columns.map((col) => col.header); @@ -162,7 +168,7 @@ export function downloadExcel>({ /** * 선택된 항목만 엑셀로 다운로드 */ -export function downloadSelectedExcel>({ +export async function downloadSelectedExcel>({ data, selectedIds, idField = 'id', @@ -170,7 +176,7 @@ export function downloadSelectedExcel>({ }: ExcelDownloadOptions & { selectedIds: string[]; idField?: keyof T | string; -}): void { +}): Promise { const selectedData = data.filter((item) => { const id = getNestedValue(item as Record, idField as string); return selectedIds.includes(String(id)); @@ -181,7 +187,7 @@ export function downloadSelectedExcel>({ return; } - downloadExcel({ + await downloadExcel({ ...options, data: selectedData, }); @@ -244,14 +250,15 @@ export interface TemplateDownloadOptions { * }); * ``` */ -export function downloadExcelTemplate({ +export async function downloadExcelTemplate({ columns, filename = '업로드_양식', sheetName = 'Sheet1', includeSampleRow = true, includeGuideRow = true, -}: TemplateDownloadOptions): void { +}: TemplateDownloadOptions): Promise { try { + const XLSX = await loadXLSX(); const wsData: (string | number | boolean)[][] = []; // 1. 헤더 행 (필수 표시 포함) @@ -384,6 +391,7 @@ export async function parseExcelFile>( } ): Promise> { const { columns, skipRows = 1, sheetIndex = 0 } = options; + const XLSX = await loadXLSX(); return new Promise((resolve) => { const reader = new FileReader();