Merge remote-tracking branch 'origin/master'
This commit is contained in:
207
claudedocs/[PLAN-2025-02-10] frontend-improvement-roadmap.md
Normal file
207
claudedocs/[PLAN-2025-02-10] frontend-improvement-roadmap.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# SAM ERP 프론트엔드 개선 로드맵
|
||||
|
||||
> 작성일: 2025-02-10
|
||||
> 분석 기준: src/ 전체 (500+ 파일, ~163K줄)
|
||||
|
||||
---
|
||||
|
||||
## Phase A: 즉시 개선 (1~2일)
|
||||
|
||||
### A-1. `<img>` → `next/image` 전환
|
||||
- **문제**: raw `<img>` 태그 ~10건 — 이미지 최적화/lazy loading 미적용
|
||||
- **대상 파일**:
|
||||
- `src/app/[locale]/(protected)/quality/qms/components/documents/ProductInspectionDocument.tsx`
|
||||
- `src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx`
|
||||
- `src/components/vehicle-management/VehicleLogDetail/config.tsx` (2건)
|
||||
- `src/components/process-management/InspectionPreviewModal.tsx` (2건)
|
||||
- `src/app/[locale]/(protected)/dev/dashboard/_components/AIPoweredDashboard.tsx`
|
||||
- `src/components/ui/image-upload.tsx`
|
||||
- **작업**: `<img src={...}>` → `<Image src={...} width={} height={} alt={} />` 전환
|
||||
- **주의**: 외부 URL 이미지는 `next.config.ts`의 `images.remotePatterns` 설정 필요
|
||||
- **효과**: LCP 개선, 자동 lazy loading, WebP 변환
|
||||
|
||||
### A-2. DataTable 렌더링 최적화
|
||||
- **문제**: `src/components/organisms/DataTable.tsx:254` — 행마다 인라인 함수 생성
|
||||
```tsx
|
||||
// 현재: 매 렌더마다 새 함수 100개 생성
|
||||
onClick={() => onRowClick?.(row)}
|
||||
```
|
||||
- **작업**:
|
||||
1. TableRow를 별도 컴포넌트로 추출 + `React.memo` 적용
|
||||
2. `onRowClick` 핸들러를 `useCallback`으로 감싸기
|
||||
3. 행 데이터 비교를 위한 커스텀 비교 함수 작성
|
||||
- **관련 파일**:
|
||||
- `src/components/organisms/DataTable.tsx`
|
||||
- `src/components/business/construction/estimates/sections/EstimateDetailTableSection.tsx` (행당 6+ 인라인 함수)
|
||||
- `src/components/business/construction/progress-billing/tables/PhotoTable.tsx`
|
||||
- **효과**: 대형 테이블 30~50% 재렌더 감소
|
||||
|
||||
---
|
||||
|
||||
## 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: () => <DashboardSkeleton />, ssr: false }
|
||||
);
|
||||
```
|
||||
- **효과**: 초기 번들 사이즈 대폭 감소, 페이지별 로딩 속도 개선
|
||||
|
||||
### B-2. API 병렬 호출 적용
|
||||
- **문제**: `Promise.all` 사용 0건 — 독립적인 API 호출이 순차 실행될 가능성
|
||||
- **우선 점검 대상**:
|
||||
- 대시보드 초기 데이터 로딩 (5~10개 API)
|
||||
- 상세 페이지 초기 데이터 + 공통코드 + 마스터데이터
|
||||
- 폼 페이지 초기값 + 선택지 목록
|
||||
- **작업**:
|
||||
```tsx
|
||||
// Before: 순차
|
||||
const categories = await fetchCategories();
|
||||
const units = await fetchUnits();
|
||||
const codes = await fetchCommonCodes();
|
||||
|
||||
// After: 병렬
|
||||
const [categories, units, codes] = await Promise.all([
|
||||
fetchCategories(),
|
||||
fetchUnits(),
|
||||
fetchCommonCodes(),
|
||||
]);
|
||||
```
|
||||
- **효과**: 초기 데이터 로딩 30~40% 단축
|
||||
|
||||
### B-3. `store/` vs `stores/` 디렉토리 통합
|
||||
- **문제**: Zustand 스토어가 두 디렉토리에 분산
|
||||
- `src/store/` — menuStore, themeStore, demoStore (3개)
|
||||
- `src/stores/` — itemStore, masterDataStore, useItemMasterStore (3개)
|
||||
- **작업**:
|
||||
1. `src/store/` 내용을 `src/stores/`로 이동
|
||||
2. import 경로 일괄 수정
|
||||
3. `src/store/` 디렉토리 삭제
|
||||
- **추가 점검**: ThemeContext ↔ themeStore 중복 → 하나로 통합
|
||||
|
||||
---
|
||||
|
||||
## 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 컴포넌트 분리, 타입 안전성, 모달 통합
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
# claudedocs 문서 맵
|
||||
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-02-09)
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-02-10)
|
||||
|
||||
## 빠른 참조
|
||||
|
||||
@@ -10,6 +10,74 @@
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 기술 결정 사항
|
||||
|
||||
### `<img>` 태그 사용 — `next/image` 미사용 이유 (2026-02-10)
|
||||
|
||||
**현황**: 프로젝트 전체 `<img>` 태그 10건, `next/image` 0건
|
||||
|
||||
**결정**: `<img>` 유지, `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 제외 (대시보드 미방문 + 엑셀 미사용 시)
|
||||
|
||||
---
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
<PageLayout>
|
||||
<div className="p-3 md:p-6">
|
||||
<PageHeader
|
||||
title="대시보드"
|
||||
description="주요 경영 지표를 한눈에 확인합니다."
|
||||
icon={LayoutDashboard}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="bg-transparent border-b rounded-none w-full gap-0 p-0 h-auto justify-start flex-wrap">
|
||||
{TABS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="relative flex-none rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-3 py-2.5 lg:px-5 lg:py-3.5 text-sm lg:text-base font-medium text-muted-foreground data-[state=active]:text-primary whitespace-nowrap"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>{tab.label}</span>
|
||||
{tab.badge != null && tab.badge > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-bold leading-none">
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`hidden xl:inline-flex items-center gap-1.5 ml-1.5 w-[180px] overflow-hidden ${activeTab === tab.value ? 'invisible' : ''}`}>
|
||||
<span className="text-muted-foreground/40 flex-shrink-0">|</span>
|
||||
<span className="flex-1 overflow-hidden"><RollingText items={tab.rollingItems} globalTick={globalTick} /></span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-6">
|
||||
<TabsContent value="schedule"><ScheduleTab /></TabsContent>
|
||||
<TabsContent value="overview"><OverviewTab /></TabsContent>
|
||||
<TabsContent value="finance"><FinanceTab /></TabsContent>
|
||||
<TabsContent value="sales"><SalesTab /></TabsContent>
|
||||
<TabsContent value="expense"><ExpenseTab /></TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-sm transition-all duration-300 cursor-default ${
|
||||
isAnimating ? 'opacity-0 translate-y-1' : 'opacity-100 translate-y-0'
|
||||
}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span className="text-muted-foreground">{item.label}</span>
|
||||
<span className={valueColorMap[item.color ?? 'default']}>{item.value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import type { StatCard } from './mockData';
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
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 (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((stat, i) => (
|
||||
<div key={i} className="bg-card border rounded-xl p-4">
|
||||
<p className="text-xs md:text-sm text-muted-foreground mb-1 whitespace-nowrap">{stat.label}</p>
|
||||
<p className={`text-lg md:text-xl font-bold ${colorMap[stat.color ?? 'default']}`}>
|
||||
{stat.value}
|
||||
</p>
|
||||
{stat.change && (
|
||||
<p className={`text-xs mt-1 ${stat.changeDirection === 'up' ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{stat.change}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: '이수진' },
|
||||
];
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={expenseStats} />
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">접대비 사용 내역</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">일자</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">사용자</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">거래처</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">금액</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">용도</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expenseEntertainmentData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3">{row['일자']}</td>
|
||||
<td className="px-4 py-3">{row['사용자']}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['금액']}</td>
|
||||
<td className="px-4 py-3">{row['용도']}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">복리후생비 항목별 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">항목</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">월한도</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">사용액</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">잔여</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expenseWelfareData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['항목']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['월한도']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['사용액']}</td>
|
||||
<td className="px-4 py-3 text-right text-green-600 font-medium">{row['잔여']}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{row['비고']}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={financeStats} />
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">당월 예상 지출 내역</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">항목</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">금액</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">전월대비</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">비율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{financeExpenseData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['항목']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['금액']}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={String(row['전월대비']).startsWith('+') ? 'text-red-500' : String(row['전월대비']).startsWith('-') ? 'text-green-500' : ''}>
|
||||
{row['전월대비']}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-muted-foreground">{row['비율']}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">카드 사용 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">카드명</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">사용액</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground">미정리</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">잔여한도</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{financeCardData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['카드명']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['사용액']}</td>
|
||||
<td className="px-4 py-3 text-center text-orange-500 font-medium">{row['미정리']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['잔여한도']}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={overviewStats} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">최근 수주 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">거래처</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">품목</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">금액</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground">상태</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">담당자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{overviewRecentOrders.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
|
||||
<td className="px-4 py-3">{row['품목']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['금액']}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<StatusBadge status={row['상태'] as string} />
|
||||
</td>
|
||||
<td className="px-4 py-3">{row['담당자']}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
'진행중': '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 (
|
||||
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] ?? 'bg-gray-100 text-gray-600'}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={salesStats} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">미수금 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">거래처</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">매출액</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">입금액</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">미수금</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground">경과일</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{salesReceivableData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['매출액']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['입금액']}</td>
|
||||
<td className="px-4 py-3 text-right font-medium">{row['미수금']}</td>
|
||||
<td className="px-4 py-3 text-center">{row['경과일']}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<StatusBadge status={row['상태'] as string} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">채권추심 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">거래처</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">채권액</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">추심단계</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground">경과일</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground">회수가능성</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{salesDebtData.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
|
||||
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
|
||||
<td className="px-4 py-3 text-right">{row['채권액']}</td>
|
||||
<td className="px-4 py-3">{row['추심단계']}</td>
|
||||
<td className="px-4 py-3 text-center">{row['경과일']}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<PossibilityBadge level={row['회수가능성'] as string} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
'정상': '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 (
|
||||
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] ?? 'bg-gray-100 text-gray-600'}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PossibilityBadge({ level }: { level: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
'상': 'bg-green-100 text-green-700',
|
||||
'중': 'bg-yellow-100 text-yellow-700',
|
||||
'하': 'bg-red-100 text-red-700',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[level] ?? 'bg-gray-100 text-gray-600'}`}>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<StatCards stats={scheduleStats} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">오늘 일정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
{scheduleTodayItems.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<ScheduleIcon type={item.type} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{item.person}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">{item.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">미처리 이슈</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
{scheduleIssueItems.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<PriorityIcon priority={item.priority} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{item.date}</span>
|
||||
<span className="text-xs text-muted-foreground">|</span>
|
||||
<span className="text-xs text-muted-foreground">{item.assignee}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<PriorityBadge priority={item.priority} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleIcon({ type }: { type: string }) {
|
||||
const iconClass = "w-4 h-4";
|
||||
switch (type) {
|
||||
case 'meeting': return <Users className={`${iconClass} text-blue-500`} />;
|
||||
case 'task': return <FileText className={`${iconClass} text-green-500`} />;
|
||||
case 'report': return <Clock className={`${iconClass} text-orange-500`} />;
|
||||
case 'delivery': return <Truck className={`${iconClass} text-purple-500`} />;
|
||||
default: return <Clock className={`${iconClass} text-gray-500`} />;
|
||||
}
|
||||
}
|
||||
|
||||
function PriorityIcon({ priority }: { priority: string }) {
|
||||
if (priority === 'high') return <AlertTriangle className="w-4 h-4 text-red-500" />;
|
||||
return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
|
||||
}
|
||||
|
||||
function PriorityBadge({ priority }: { priority: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
high: 'bg-red-100 text-red-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
low: 'bg-green-100 text-green-700',
|
||||
};
|
||||
const labels: Record<string, string> = { high: '긴급', medium: '보통', low: '낮음' };
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${styles[priority] ?? 'bg-gray-100 text-gray-600'}`}>
|
||||
{labels[priority] ?? priority}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
19
src/app/[locale]/(protected)/dashboard_type2/page.tsx
Normal file
19
src/app/[locale]/(protected)/dashboard_type2/page.tsx
Normal file
@@ -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 <DashboardType2 />;
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense fallback={<ListPageSkeleton showHeader={false} showStats={true} statsCount={6} />}>
|
||||
<ProductionDashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
const ProductionDashboard = dynamic(
|
||||
() => import('@/components/production/ProductionDashboard'),
|
||||
{ loading: () => <ListPageSkeleton showHeader={false} showStats={true} statsCount={6} /> }
|
||||
);
|
||||
|
||||
export const metadata = {
|
||||
title: '생산 현황판',
|
||||
description: '공장별 작업 현황을 확인합니다.',
|
||||
};
|
||||
export default function ProductionDashboardPage() {
|
||||
return <ProductionDashboard />;
|
||||
}
|
||||
|
||||
@@ -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: () => <ListPageSkeleton showHeader={false} showStats={true} statsCount={4} /> }
|
||||
);
|
||||
|
||||
export default function ComprehensiveAnalysisPage() {
|
||||
return <MainDashboard />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -68,44 +68,45 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
const [note, setNote] = useState('');
|
||||
const [installments, setInstallments] = useState<InstallmentRecord[]>([]);
|
||||
|
||||
// ===== 거래처 목록 로드 =====
|
||||
// ===== 초기 데이터 로드 (거래처 + 어음 상세 병렬) =====
|
||||
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]);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
|
||||
@@ -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]);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
|
||||
@@ -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]);
|
||||
|
||||
// ===== 합계 계산 =====
|
||||
|
||||
@@ -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]);
|
||||
|
||||
// ===== 선택된 거래처 정보 =====
|
||||
|
||||
@@ -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]);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
|
||||
@@ -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: () => <DetailPageSkeleton /> }
|
||||
);
|
||||
|
||||
/**
|
||||
* Dashboard - 대표님 전용 대시보드
|
||||
*
|
||||
@@ -24,9 +28,5 @@ import { DetailPageSkeleton } from "@/components/ui/skeleton";
|
||||
*/
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<CEODashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return <CEODashboard />;
|
||||
}
|
||||
|
||||
@@ -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: () => <DetailPageSkeleton /> }
|
||||
);
|
||||
|
||||
/**
|
||||
* ConstructionDashboard - 주일기업 전용 대시보드
|
||||
*
|
||||
*
|
||||
* 건설/공사 프로젝트 중심의 메트릭과 현황을 보여줍니다.
|
||||
*/
|
||||
export function ConstructionDashboard() {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<ConstructionMainDashboard />
|
||||
</Suspense>
|
||||
);
|
||||
return <ConstructionMainDashboard />;
|
||||
}
|
||||
|
||||
@@ -288,8 +288,8 @@ export default function ItemListClient() {
|
||||
];
|
||||
|
||||
// 양식 다운로드
|
||||
const handleTemplateDownload = () => {
|
||||
downloadExcelTemplate({
|
||||
const handleTemplateDownload = async () => {
|
||||
await downloadExcelTemplate({
|
||||
columns: templateColumns,
|
||||
filename: '품목등록_양식',
|
||||
sheetName: '품목등록',
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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<T>(column: Column<T>): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 메모이즈드 행 컴포넌트 — 행 데이터가 변경되지 않으면 리렌더링 스킵
|
||||
interface DataTableRowProps<T extends object> {
|
||||
row: T;
|
||||
rowIndex: number;
|
||||
columns: Column<T>[];
|
||||
onRowClick?: (row: T) => void;
|
||||
hoverable: boolean;
|
||||
striped: boolean;
|
||||
compact: boolean;
|
||||
rowKey: string;
|
||||
}
|
||||
|
||||
function DataTableRowInner<T extends object>({
|
||||
row,
|
||||
rowIndex,
|
||||
columns,
|
||||
onRowClick,
|
||||
hoverable,
|
||||
striped,
|
||||
compact,
|
||||
}: DataTableRowProps<T>) {
|
||||
const handleClick = useCallback(() => {
|
||||
onRowClick?.(row);
|
||||
}, [onRowClick, row]);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
onClick={onRowClick ? handleClick : undefined}
|
||||
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 (
|
||||
<TableCell
|
||||
key={String(column.key)}
|
||||
className={cn(
|
||||
getAlignClass(column),
|
||||
column.className,
|
||||
compact && "py-2"
|
||||
)}
|
||||
>
|
||||
{renderCell(column, value, row, rowIndex)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const MemoizedDataTableRow = memo(DataTableRowInner) as typeof DataTableRowInner;
|
||||
|
||||
export function DataTable<T extends object>({
|
||||
columns,
|
||||
data,
|
||||
@@ -216,6 +272,11 @@ export function DataTable<T extends object>({
|
||||
hoverable = true,
|
||||
compact = false
|
||||
}: DataTableProps<T>) {
|
||||
const stableOnRowClick = useCallback(
|
||||
(row: T) => onRowClick?.(row),
|
||||
[onRowClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="hidden md:block">
|
||||
<CardContent className="p-0">
|
||||
@@ -252,32 +313,17 @@ export function DataTable<T extends object>({
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row, rowIndex) => (
|
||||
<TableRow
|
||||
<MemoizedDataTableRow<T>
|
||||
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 (
|
||||
<TableCell
|
||||
key={String(column.key)}
|
||||
className={cn(
|
||||
getAlignClass(column),
|
||||
column.className,
|
||||
compact && "py-2"
|
||||
)}
|
||||
>
|
||||
{renderCell(column, value, row, rowIndex)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
row={row}
|
||||
rowIndex={rowIndex}
|
||||
columns={columns}
|
||||
onRowClick={onRowClick ? stableOnRowClick : undefined}
|
||||
hoverable={hoverable}
|
||||
striped={striped}
|
||||
compact={compact}
|
||||
rowKey={row[keyField] ? String(row[keyField]) : `row-${rowIndex}`}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const XLSX = await import("xlsx");
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
|
||||
@@ -628,7 +628,7 @@ export function UniversalListPage<T>({
|
||||
return;
|
||||
}
|
||||
|
||||
downloadExcel({
|
||||
await downloadExcel({
|
||||
data: dataToDownload as Record<string, unknown>[],
|
||||
columns: columns as ExcelColumn<Record<string, unknown>>[],
|
||||
filename,
|
||||
@@ -645,7 +645,7 @@ export function UniversalListPage<T>({
|
||||
}, [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<T>({
|
||||
// 현재 데이터에서 선택된 항목 필터링
|
||||
const selectedData = rawData.filter((item) => selectedIds.includes(getItemId(item)));
|
||||
|
||||
downloadSelectedExcel({
|
||||
await downloadSelectedExcel({
|
||||
data: selectedData as Record<string, unknown>[],
|
||||
columns: columns as ExcelColumn<Record<string, unknown>>[],
|
||||
selectedIds,
|
||||
|
||||
@@ -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<T extends Record<string, unknown>>({
|
||||
export async function downloadExcel<T extends Record<string, unknown>>({
|
||||
data,
|
||||
columns,
|
||||
filename = 'export',
|
||||
sheetName = 'Sheet1',
|
||||
appendDate = true,
|
||||
}: ExcelDownloadOptions<T>): void {
|
||||
}: ExcelDownloadOptions<T>): Promise<void> {
|
||||
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<T extends Record<string, unknown>>({
|
||||
/**
|
||||
* 선택된 항목만 엑셀로 다운로드
|
||||
*/
|
||||
export function downloadSelectedExcel<T extends Record<string, unknown>>({
|
||||
export async function downloadSelectedExcel<T extends Record<string, unknown>>({
|
||||
data,
|
||||
selectedIds,
|
||||
idField = 'id',
|
||||
@@ -170,7 +176,7 @@ export function downloadSelectedExcel<T extends Record<string, unknown>>({
|
||||
}: ExcelDownloadOptions<T> & {
|
||||
selectedIds: string[];
|
||||
idField?: keyof T | string;
|
||||
}): void {
|
||||
}): Promise<void> {
|
||||
const selectedData = data.filter((item) => {
|
||||
const id = getNestedValue(item as Record<string, unknown>, idField as string);
|
||||
return selectedIds.includes(String(id));
|
||||
@@ -181,7 +187,7 @@ export function downloadSelectedExcel<T extends Record<string, unknown>>({
|
||||
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<void> {
|
||||
try {
|
||||
const XLSX = await loadXLSX();
|
||||
const wsData: (string | number | boolean)[][] = [];
|
||||
|
||||
// 1. 헤더 행 (필수 표시 포함)
|
||||
@@ -384,6 +391,7 @@ export async function parseExcelFile<T = Record<string, unknown>>(
|
||||
}
|
||||
): Promise<ExcelParseResult<T>> {
|
||||
const { columns, skipRows = 1, sheetIndex = 0 } = options;
|
||||
const XLSX = await loadXLSX();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
Reference in New Issue
Block a user