refactor(WEB): V2 파일 통합, store 구조 정리 및 대시보드 개선

- V2 컴포넌트를 원본에 통합 후 V2 파일 삭제 (InspectionModal, BillDetail, ContractDocumentModal, LaborDetailClient, PricingDetailClient, QuoteRegistration)
- store → stores 디렉토리 이동 및 favoritesStore 추가
- dashboard_type3~5 추가 및 기존 대시보드 차트/훅 분리
- Sidebar 리팩토링 및 HeaderFavoritesBar 추가
- DashboardSwitcher 컴포넌트 추가
- 백업 파일(.v1-backup) 및 불필요 코드 정리
- InspectionPreviewModal 레이아웃 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-11 15:09:51 +09:00
parent e14335b635
commit a38996b751
96 changed files with 4930 additions and 6550 deletions

View File

@@ -5,179 +5,46 @@
---
## Phase A: 즉시 개선 (1~2일)
## Phase A: 즉시 개선 — ✅ 완료
### A-1. `<img>` → `next/image` 전환
- **문제**: raw `<img>` 태그 ~10건 — 이미지 최적화/lazy loading 미적용
- **대상 파일**:
- `src/app/[locale]/(protected)/quality/qms/components/documents/ProductInspectionDocument.tsx`
- `src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx`
- `src/components/vehicle-management/VehicleLogDetail/config.tsx` (2건)
- `src/components/process-management/InspectionPreviewModal.tsx` (2건)
- `src/app/[locale]/(protected)/dev/dashboard/_components/AIPoweredDashboard.tsx`
- `src/components/ui/image-upload.tsx`
- **작업**: `<img src={...}>``<Image src={...} width={} height={} alt={} />` 전환
- **주의**: 외부 URL 이미지는 `next.config.ts``images.remotePatterns` 설정 필요
- **효과**: LCP 개선, 자동 lazy loading, WebP 변환
### A-2. DataTable 렌더링 최적화
- **문제**: `src/components/organisms/DataTable.tsx:254` — 행마다 인라인 함수 생성
```tsx
// 현재: 매 렌더마다 새 함수 100개 생성
onClick={() => onRowClick?.(row)}
```
- **작업**:
1. TableRow를 별도 컴포넌트로 추출 + `React.memo` 적용
2. `onRowClick` 핸들러를 `useCallback`으로 감싸기
3. 행 데이터 비교를 위한 커스텀 비교 함수 작성
- **관련 파일**:
- `src/components/organisms/DataTable.tsx`
- `src/components/business/construction/estimates/sections/EstimateDetailTableSection.tsx` (행당 6+ 인라인 함수)
- `src/components/business/construction/progress-billing/tables/PhotoTable.tsx`
- **효과**: 대형 테이블 30~50% 재렌더 감소
| # | 항목 | 상태 | 비고 |
|---|------|------|------|
| A-1 | `<img>``next/image` 전환 | ✅ **전환 불필요 결정** | 폐쇄형 ERP, 전량 외부 동적 이미지, blob URL 비호환 (`_index.md` 참조) |
| A-2 | DataTable 렌더링 최적화 | ⏳ 대기 | TableRow memo + useCallback |
---
## Phase B: 단기 개선 (1주)
## Phase B: 단기 개선 — ✅ 완료
### B-1. `next/dynamic` 코드 스플리팅 도입
- **문제**: `dynamic()` 사용 0건 — 전체 앱이 단일 번들로 로드
- **우선 적용 대상**:
| 컴포넌트 | 줄 수 | 이유 |
|---------|-------|------|
| `MainDashboard.tsx` | 2,651 | recharts (~60KB) 포함, 대시보드 미방문 시 불필요 |
| `WorkerScreen/index.tsx` | 1,439 | 생산직 전용, 일반 사용자 불필요 |
| 각종 모달 컴포넌트 | 다수 | 열기 전까지 불필요 |
- **작업 예시**:
```tsx
import dynamic from 'next/dynamic';
const MainDashboard = dynamic(
() => import('@/components/business/MainDashboard'),
{ loading: () => <DashboardSkeleton />, ssr: false }
);
```
- **효과**: 초기 번들 사이즈 대폭 감소, 페이지별 로딩 속도 개선
### B-2. API 병렬 호출 적용
- **문제**: `Promise.all` 사용 0건 — 독립적인 API 호출이 순차 실행될 가능성
- **우선 점검 대상**:
- 대시보드 초기 데이터 로딩 (5~10개 API)
- 상세 페이지 초기 데이터 + 공통코드 + 마스터데이터
- 폼 페이지 초기값 + 선택지 목록
- **작업**:
```tsx
// Before: 순차
const categories = await fetchCategories();
const units = await fetchUnits();
const codes = await fetchCommonCodes();
// After: 병렬
const [categories, units, codes] = await Promise.all([
fetchCategories(),
fetchUnits(),
fetchCommonCodes(),
]);
```
- **효과**: 초기 데이터 로딩 30~40% 단축
### B-3. `store/` vs `stores/` 디렉토리 통합
- **문제**: Zustand 스토어가 두 디렉토리에 분산
- `src/store/` — menuStore, themeStore, demoStore (3개)
- `src/stores/` — itemStore, masterDataStore, useItemMasterStore (3개)
- **작업**:
1. `src/store/` 내용을 `src/stores/`로 이동
2. import 경로 일괄 수정
3. `src/store/` 디렉토리 삭제
- **추가 점검**: ThemeContext ↔ themeStore 중복 → 하나로 통합
| # | 항목 | 상태 | 비고 |
|---|------|------|------|
| B-1 | `next/dynamic` 코드 스플리팅 | ✅ **완료** | 대시보드 4개 + xlsx 동적 로드, ~850KB 절감 (`_index.md` 참조) |
| B-2 | API 병렬 호출 (`Promise.all`) | ✅ **완료** | |
| B-3 | `store/` vs `stores/` 통합 | ✅ **완료** | |
---
## Phase C: 중기 개선 (2~3주)
## Phase C: 중기 개선 — ✅ 완료
### C-1. 대형 테이블 가상화 (react-window)
- **문제**: 100행 이상 테이블에서 전체 DOM 렌더 — 스크롤 성능 저하
- **대상**:
- `src/components/templates/IntegratedListTemplateV2.tsx` (1,086줄)
- `src/components/templates/UniversalListPage/index.tsx` (1,006줄)
- `src/components/organisms/DataTable.tsx`
- **작업**:
1. `react-window` 패키지 설치
2. DataTable 내부에 `FixedSizeList` 또는 `VariableSizeList` 적용
3. 기존 페이지네이션과 조합 (50건/페이지 + 가상화)
- **주의**: 테이블 헤더 고정, 체크박스 선택, rowSpan 등 기존 기능 호환 필요
- **효과**: 대용량 테이블 렌더 10배 이상 개선
### C-2. SWR 또는 React Query 도입
- **문제**: API 캐싱 전략 없음 — 중복 요청, stale 데이터 가능
- **권장**: SWR (가볍고 Next.js 팀 제작)
- **적용 범위**:
1. **1단계**: 공통코드/마스터데이터 (변경 빈도 낮음, 캐싱 효과 큼)
2. **2단계**: 리스트 페이지 데이터
3. **3단계**: 상세 페이지 데이터
- **기대 효과**:
- 자동 중복 요청 제거
- stale-while-revalidate로 체감 속도 개선
- 포커스 복귀 시 자동 재검증
- **작업량**: 6~8시간 (기본 설정 + 공통코드 적용)
### C-3. Action 팩토리 패턴 확대
- **문제**: 81개 `actions.ts` 파일에서 15~20% 코드 중복
- **현황**: `src/lib/api/create-crud-service.ts` (177줄) 존재하지만 미활용
- **작업**:
1. 기존 팩토리 분석 및 확장
2. 도메인별 actions를 팩토리 기반으로 전환
3. 커스텀 로직만 오버라이드
- **우선 적용**: 가장 단순한 CRUD 도메인부터 (clients, vendors 등)
### C-4. V1/V2 컴포넌트 정리
- **문제**: 12+개 파일이 V1/V2 중복 존재
- **대상 파일** (예시):
- `ClientDetailClient.tsx` ↔ `ClientDetailClientV2.tsx`
- `BadDebtDetail.tsx` ↔ `BadDebtDetailClientV2.tsx`
- `QuoteRegistration.tsx` ↔ `QuoteRegistrationV2.tsx`
- `InspectionModal.tsx` ↔ `InspectionModalV2.tsx`
- **작업**:
1. 각 V1/V2 쌍의 실제 사용처 확인 (import 추적)
2. V2를 최종본으로 확정
3. V1 참조를 V2로 전환
4. V1 파일 삭제 + V2에서 "V2" 접미사 제거
| # | 항목 | 상태 | 비고 |
|---|------|------|------|
| C-1 | 테이블 가상화 (react-window) | ✅ **보류 결정** | 페이지네이션 사용 중, YAGNI (`_index.md` 참조) |
| C-2 | SWR / React Query | ✅ **보류 결정** | Zustand 캐싱 충족 (`_index.md` 참조) |
| C-3 | Action 팩토리 패턴 확대 | ✅ **규칙 확정** | 신규 CRUD만 팩토리 사용 (`_index.md` 참조) |
| C-4 | V1/V2 컴포넌트 정리 | ✅ **완료** | V2 최종본 확정, V1 삭제, 접미사 제거 |
---
## Phase D: 장기 개선 (필요 시)
### D-1. God 컴포넌트 분리
| 파일 | 줄 수 | 분리 방안 |
|------|-------|----------|
| `MainDashboard.tsx` | 2,651 | DashboardShell + ChartSection + StatSection + FilterSection |
| `ItemMasterContext.tsx` | 2,701 | PageContext + SectionContext + FieldContext + BOMContext |
| `item-master.ts` (API) | 2,232 | pages.ts + sections.ts + fields.ts + bom.ts |
| `QuoteRegistration.tsx` | 1,251 | LocationPanel + PricingPanel + LineItemsPanel |
| `WorkerScreen/index.tsx` | 1,439 | ProcessSection + MaterialSection + IssueSection |
### D-2. `as` 타입 캐스트 점진적 제거 (926건)
- 주요 집중 영역: API 트랜스포머, 컴포넌트 props
- 제네릭 타입 활용으로 캐스트 대체
- 도메인별 점진적 개선 (items → quotes → accounting 순)
### D-3. `@deprecated` 함수 정리 (13파일)
- deprecated 선언했지만 아직 import되는 함수들 제거
- ItemMasterContext 내 deprecated 메서드 마이그레이션
### D-4. Molecules 레이어 활성화
- 현재 8개만 존재, 대부분 도메인 컴포넌트가 UI 직접 사용
- 반복되는 UI 패턴을 Molecules로 추출
- FormField, StatusBadge, DateRangeSelector 활용도 높이기
### D-5. 모달 컴포넌트 통합 (47+개)
- SearchableSelectionModal 패턴으로 통합 가능한 모달 식별
- 도메인별 재구현된 유사 모달을 공통 컴포넌트로 전환
### D-6. 기타
- TODO/FIXME 102건 정리 (useItemMasterStore 15건, DraftBox 24건 집중)
- `reactStrictMode: true`로 복원 (개발 환경)
- puppeteer/chromium 패키지 활성 사용 여부 확인 → 미사용 시 제거
- error-handler.ts의 `SHOW_ERROR_CODE` 환경변수로 전환
| # | 항목 | 상태 |
|---|------|------|
| D-1 | God 컴포넌트 분리 (5개, 1200~2700줄) | ⏳ 대기 |
| D-2 | `as` 타입 캐스트 점진적 제거 (926건) | ✅ **보류 결정** | 실제 ~200건만 actionable, 신규 코드에서 제네릭 활용 (2026-02-11) |
| D-3 | `@deprecated` 함수 정리 (13파일) | ✅ **즉시 삭제분 완료** | uploadFile/deleteFile/getSiteNames/deprecated props 삭제 (2026-02-11) |
| D-4 | Molecules 레이어 활성화 | ✅ **보류 결정** | 사용률 ~0%, organisms/templates로 충분 (2026-02-11) |
| D-5 | 모달 컴포넌트 통합 | ✅ **완료** | InspectionPreviewModal → DocumentViewer 전환 (2026-02-11) |
| D-6 | 기타 (TODO 102건, strictMode 등) | ⏳ 대기 |
---
@@ -200,8 +67,6 @@
## 우선순위 요약
```
즉시 (Phase A) → img 최적화, DataTable 최적화
단기 (Phase B) → 코드 스플리팅, API 병렬화, 스토어 통합
중기 (Phase C) → 가상화, 캐싱, Action 팩토리, V2 정리
장기 (Phase D) → God 컴포넌트 분리, 타입 안전성, 모달 통합
Phase A~C: ✅ 전체 완료/결정 완료 (A-2 DataTable 최적화만 대기)
Phase D: 남은 작업 → A-2, D-1, D-6
```

View File

@@ -76,6 +76,62 @@ export async function downloadExcel(...) {
**총 절감**: 초기 번들에서 ~850KB 제외 (대시보드 미방문 + 엑셀 미사용 시)
### 테이블 가상화 (react-window) — 보류 (2026-02-10)
**결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토
**근거**:
1. **페이지네이션 사용 중** — 리스트 페이지 대부분 서버 사이드 페이지네이션 (20~50건/페이지). 50개 `<tr>`은 브라우저가 문제없이 처리
2. **적용 복잡도 높음** — 테이블 헤더 고정, 체크박스 선택, `rowSpan/colSpan` 병합 등 기존 기능과 충돌 가능. DataTable + IntegratedListTemplateV2 + UniversalListPage 전부 수정 필요
3. **YAGNI** — 500건 이상 한 번에 렌더링하는 페이지가 현재 없음
**도입 시점**: 한 페이지에 200건+ 데이터를 페이지네이션 없이 표시해야 하는 요구가 생길 때
### SWR / React Query — 보류 (2026-02-10)
**결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토
**근거**:
1. **기존 패턴 안정화 완료**`useEffect` + Server Action 호출 패턴이 전 페이지에 일관 적용됨
2. **캐싱 니즈 낮음** — 폐쇄형 ERP 특성상 항상 최신 데이터 필요. stale 데이터 표시는 오히려 위험
3. **마스터데이터 캐싱 구현됨** — Zustand (`stores/masterDataStore`)로 변경 빈도 낮은 데이터는 이미 캐싱 중
4. **도입 비용 과다** — 수십 개 페이지 `useState`+`useEffect` 패턴 전면 리팩토링 + 팀 학습 비용
**도입 시점**: 동일 데이터를 여러 컴포넌트에서 동시 요구하거나, 목록 ↔ 상세 이동 시 재로딩이 체감될 때
### Action 팩토리 패턴 — 신규 CRUD 적용 규칙 (2026-02-10)
**결정**: 기존 84개 actions.ts 전면 전환은 하지 않음. **신규 CRUD 도메인에만 팩토리 사용**
**현황**:
- `src/lib/api/create-crud-service.ts` (177줄) — CRUD 보일러플레이트 자동 생성 팩토리
- 현재 사용 중: TitleManagement, RankManagement (2개)
- 전환 가능: 15~20개 / 전환 불가 (커스텀 로직): 50+개
**규칙**:
- 신규 도메인 추가 시 단순 CRUD → `createCrudService` 사용 필수
- 기존 actions.ts는 잘 동작하므로 무리하게 전환하지 않음
- 커스텀 비즈니스 로직이 있는 도메인(견적, 수주, 생산 등)은 팩토리 비적합
**사용 예시**:
```typescript
import { createCrudService } from '@/lib/api/create-crud-service';
const service = createCrudService<ApiData, FrontendType>({
basePath: '/api/v1/resources',
transform: (api) => ({ id: api.id, name: api.name }),
entityName: '리소스',
});
export const getList = service.getList;
export const getById = service.getById;
export const create = service.create;
export const update = service.update;
export const remove = service.remove;
```
**미전환 사유**: 84개 중 전환 가능 15~20개, 작업 2~4시간 대비 기능 변화 없음. 시간 대비 효율 낮음
---
## 폴더 구조

View File

@@ -2,7 +2,7 @@
import { useParams, useSearchParams } from 'next/navigation';
// V2 테스트: 새 훅 적용 버전
import { BillDetailV2 as BillDetail } from '@/components/accounting/BillManagement/BillDetailV2';
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
export default function BillDetailPage() {
const params = useParams();

View File

@@ -1,7 +1,7 @@
'use client';
// V2: 새 훅 적용 버전
import { BillDetailV2 as BillDetail } from '@/components/accounting/BillManagement/BillDetailV2';
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
export default function BillNewPage() {
return <BillDetail billId="new" mode="new" />;

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { BillManagementClient } from '@/components/accounting/BillManagement/BillManagementClient';
// V2 테스트: 새 훅 적용 버전
import { BillDetailV2 as BillDetail } from '@/components/accounting/BillManagement/BillDetailV2';
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
import { getBills } from '@/components/accounting/BillManagement/actions';
import type { BillRecord } from '@/components/accounting/BillManagement/types';

View File

@@ -80,7 +80,6 @@ function transformApiToPost(apiData: PostApiData): BoardPost {
}
export default function BoardCodePage() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const boardCode = params.boardCode as string;
@@ -91,6 +90,13 @@ export default function BoardCodePage() {
return <BoardDetail {...{ boardCode, mode: 'create' } as any} />;
}
return <BoardListContent boardCode={boardCode} />;
}
// 실제 목록 컴포넌트 (자체 hooks 사용 - React 훅 규칙 준수)
function BoardListContent({ boardCode }: { boardCode: string }) {
const router = useRouter();
// 게시판 정보
const [boardName, setBoardName] = useState<string>('게시판');
const [boardDescription, setBoardDescription] = useState<string>('');

View File

@@ -2,7 +2,7 @@
import { use } from 'react';
import { useSearchParams } from 'next/navigation';
import { LaborDetailClientV2 } from '@/components/business/construction/labor-management';
import { LaborDetailClient } from '@/components/business/construction/labor-management';
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
interface LaborDetailPageProps {
@@ -15,5 +15,5 @@ export default function LaborDetailPage({ params }: LaborDetailPageProps) {
const mode = searchParams.get('mode');
const initialMode: DetailMode = mode === 'edit' ? 'edit' : 'view';
return <LaborDetailClientV2 laborId={id} initialMode={initialMode} />;
return <LaborDetailClient laborId={id} initialMode={initialMode} />;
}

View File

@@ -1,7 +1,7 @@
'use client';
import { LaborDetailClientV2 } from '@/components/business/construction/labor-management';
import { LaborDetailClient } from '@/components/business/construction/labor-management';
export default function LaborNewPage() {
return <LaborDetailClientV2 initialMode="create" />;
return <LaborDetailClient initialMode="create" />;
}

View File

@@ -1,14 +1,14 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { LaborManagementClient, LaborDetailClientV2 } from '@/components/business/construction/labor-management';
import { LaborManagementClient, LaborDetailClient } from '@/components/business/construction/labor-management';
export default function LaborManagementPage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
if (mode === 'new') {
return <LaborDetailClientV2 initialMode="create" />;
return <LaborDetailClient initialMode="create" />;
}
return <LaborManagementClient />;

View File

@@ -7,7 +7,7 @@
import { use } from 'react';
import { useSearchParams } from 'next/navigation';
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
import { PricingDetailClient } from '@/components/business/construction/pricing-management';
interface PageProps {
params: Promise<{ id: string }>;
@@ -18,5 +18,5 @@ export default function PricingDetailPage({ params }: PageProps) {
const searchParams = useSearchParams();
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
return <PricingDetailClientV2 pricingId={id} initialMode={mode} />;
return <PricingDetailClient pricingId={id} initialMode={mode} />;
}

View File

@@ -1,7 +1,7 @@
'use client';
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
import { PricingDetailClient } from '@/components/business/construction/pricing-management';
export default function PricingNewPage() {
return <PricingDetailClientV2 initialMode="create" />;
return <PricingDetailClient initialMode="create" />;
}

View File

@@ -2,14 +2,14 @@
import { useSearchParams } from 'next/navigation';
import PricingListClient from '@/components/business/construction/pricing-management/PricingListClient';
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
import { PricingDetailClient } from '@/components/business/construction/pricing-management';
export default function PricingPage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
if (mode === 'new') {
return <PricingDetailClientV2 initialMode="create" />;
return <PricingDetailClient initialMode="create" />;
}
return <PricingListClient />;

View File

@@ -1,9 +1,10 @@
'use client';
import { useState, useEffect } from 'react';
import { LayoutDashboard } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { LayoutDashboard, RefreshCw } from 'lucide-react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { RollingText, type RollingItem } from './RollingText';
import { OverviewTab } from './tabs/OverviewTab';
@@ -11,15 +12,7 @@ import { FinanceTab } from './tabs/FinanceTab';
import { SalesTab } from './tabs/SalesTab';
import { ExpenseTab } from './tabs/ExpenseTab';
import { ScheduleTab } from './tabs/ScheduleTab';
import {
scheduleStats,
overviewStats,
financeStats,
salesStats,
expenseStats,
scheduleTodayItems,
scheduleIssueItems,
} from './mockData';
import { useDashboardType2, getRollingItems } from './hooks/useDashboardType2';
interface TabConfig {
value: string;
@@ -28,42 +21,12 @@ interface TabConfig {
rollingItems: RollingItem[];
}
const toRolling = (stats: { label: string; value: string; color?: string }[]): RollingItem[] =>
stats.map((s) => ({ label: s.label, value: s.value, color: (s.color ?? 'default') as RollingItem['color'] }));
const TABS: TabConfig[] = [
{
value: 'schedule',
label: '일정/이슈',
badge: scheduleTodayItems.length + scheduleIssueItems.length,
rollingItems: toRolling(scheduleStats),
},
{
value: 'overview',
label: '전체 요약',
rollingItems: toRolling(overviewStats),
},
{
value: 'finance',
label: '재무 관리',
rollingItems: toRolling(financeStats),
},
{
value: 'sales',
label: '영업/매출',
rollingItems: toRolling(salesStats),
},
{
value: 'expense',
label: '경비 관리',
rollingItems: toRolling(expenseStats),
},
];
export function DashboardType2() {
const [activeTab, setActiveTab] = useState('schedule');
const [globalTick, setGlobalTick] = useState(0);
const data = useDashboardType2();
useEffect(() => {
const timer = setInterval(() => {
setGlobalTick((prev) => prev + 1);
@@ -71,33 +34,89 @@ export function DashboardType2() {
return () => clearInterval(timer);
}, []);
const tabs = useMemo<TabConfig[]>(() => [
{
value: 'schedule',
label: '일정/이슈',
badge: data.schedule.todayItems.length + data.schedule.issueItems.length || undefined,
rollingItems: getRollingItems('schedule', data),
},
{
value: 'overview',
label: '전체 요약',
rollingItems: getRollingItems('overview', data),
},
{
value: 'finance',
label: '재무 관리',
rollingItems: getRollingItems('finance', data),
},
{
value: 'sales',
label: '영업/매출',
rollingItems: getRollingItems('sales', data),
},
{
value: 'expense',
label: '경비 관리',
rollingItems: getRollingItems('expense', data),
},
], [data]);
const hasError = data.overview.error || data.finance.error || data.sales.error || data.expense.error || data.schedule.error;
return (
<PageLayout>
<div className="p-3 md:p-6">
<PageHeader
title="대시보드"
description="주요 경영 지표를 한눈에 확인합니다."
icon={LayoutDashboard}
/>
<div className="flex items-center justify-between">
<PageHeader
title="대시보드"
description="주요 경영 지표를 한눈에 확인합니다."
icon={LayoutDashboard}
/>
<div className="flex items-center gap-2">
<button
onClick={data.refetchAll}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5 rounded-md hover:bg-muted"
title="데이터 새로고침"
>
<RefreshCw className="w-4 h-4" />
<span className="hidden sm:inline"></span>
</button>
<DashboardSwitcher />
</div>
</div>
{hasError && (
<div className="mt-4 p-3 rounded-lg bg-yellow-50 border border-yellow-200 text-yellow-800 text-sm flex items-center justify-between">
<span> . .</span>
<button
onClick={data.refetchAll}
className="text-yellow-700 hover:text-yellow-900 font-medium underline ml-2"
>
</button>
</div>
)}
<div className="mt-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-transparent border-b rounded-none w-full gap-0 p-0 h-auto justify-start flex-wrap">
{TABS.map((tab) => (
<TabsList className="bg-transparent border-b-2 border-border rounded-none w-full gap-0 p-0 h-auto justify-start flex-wrap">
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="relative flex-none rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-3 py-2.5 lg:px-5 lg:py-3.5 text-sm lg:text-base font-medium text-muted-foreground data-[state=active]:text-primary whitespace-nowrap"
className="relative flex-1 md:flex-none rounded-none border-b-[3px] border-transparent data-[state=active]:border-primary data-[state=active]:bg-primary/5 data-[state=active]:shadow-none px-2 py-2.5 md:px-5 md:py-4 lg:px-7 lg:py-5 xl:px-8 xl:py-5 text-xs md:text-sm lg:text-base xl:text-lg font-semibold text-muted-foreground data-[state=active]:text-primary whitespace-nowrap transition-colors"
>
<span className="flex items-center gap-1.5">
<span className="flex items-center gap-1 md:gap-1.5 lg:gap-2">
<span>{tab.label}</span>
{tab.badge != null && tab.badge > 0 && (
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-bold leading-none">
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] md:min-w-[20px] md:h-[20px] lg:min-w-[22px] lg:h-[22px] px-1 rounded-full bg-red-500 text-white text-[10px] md:text-[11px] lg:text-xs font-bold leading-none">
{tab.badge}
</span>
)}
</span>
<span className={`hidden xl:inline-flex items-center gap-1.5 ml-1.5 w-[180px] overflow-hidden ${activeTab === tab.value ? 'invisible' : ''}`}>
<span className={`hidden xl:inline-flex items-center gap-1.5 ml-2 w-[200px] overflow-hidden ${activeTab === tab.value ? 'invisible' : ''}`}>
<span className="text-muted-foreground/40 flex-shrink-0">|</span>
<span className="flex-1 overflow-hidden"><RollingText items={tab.rollingItems} globalTick={globalTick} /></span>
</span>
@@ -106,11 +125,21 @@ export function DashboardType2() {
</TabsList>
<div className="mt-6">
<TabsContent value="schedule"><ScheduleTab /></TabsContent>
<TabsContent value="overview"><OverviewTab /></TabsContent>
<TabsContent value="finance"><FinanceTab /></TabsContent>
<TabsContent value="sales"><SalesTab /></TabsContent>
<TabsContent value="expense"><ExpenseTab /></TabsContent>
<TabsContent value="schedule">
<ScheduleTab data={data.schedule} />
</TabsContent>
<TabsContent value="overview">
<OverviewTab data={data.overview} />
</TabsContent>
<TabsContent value="finance">
<FinanceTab data={data.finance} />
</TabsContent>
<TabsContent value="sales">
<SalesTab data={data.sales} />
</TabsContent>
<TabsContent value="expense">
<ExpenseTab data={data.expense} />
</TabsContent>
</div>
</Tabs>
</div>

View File

@@ -10,11 +10,19 @@ const colorMap: Record<string, string> = {
orange: 'text-orange-600',
};
const bgMap: Record<string, string> = {
default: 'bg-card border',
green: 'bg-green-50 border-green-200',
blue: 'bg-blue-50 border-blue-200',
red: 'bg-red-50 border-red-200',
orange: 'bg-orange-50 border-orange-200',
};
export function StatCards({ stats }: { stats: StatCard[] }) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat, i) => (
<div key={i} className="bg-card border rounded-xl p-4">
<div key={i} className={`${bgMap[stat.color ?? 'default']} rounded-xl p-4`}>
<p className="text-xs md:text-sm text-muted-foreground mb-1 whitespace-nowrap">{stat.label}</p>
<p className={`text-lg md:text-xl font-bold ${colorMap[stat.color ?? 'default']}`}>
{stat.value}

View File

@@ -0,0 +1,54 @@
'use client';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { ExpenseChartItem } from '../hooks/transformers';
function formatTooltipValue(value: number): string {
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억원`;
if (value >= 10000) return `${Math.round(value / 10000).toLocaleString()}만원`;
return `${value.toLocaleString()}`;
}
export function ExpenseDonutChart({ data }: { data: ExpenseChartItem[] }) {
if (data.length === 0) return null;
// recharts expects plain objects with index signature
const chartData = data.map((d) => ({ name: d.name, value: d.value, color: d.color }));
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> </CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
dataKey="value"
nameKey="name"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: number | string | undefined) => formatTooltipValue(Number(value ?? 0))}
contentStyle={{ fontSize: '12px', borderRadius: '8px' }}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip, Cell } from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { OverviewChartItem } from '../hooks/transformers';
function formatTooltipValue(value: number): string {
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억원`;
if (value >= 10000) return `${Math.round(value / 10000).toLocaleString()}만원`;
return `${value.toLocaleString()}`;
}
export function OverviewSummaryChart({ data }: { data: OverviewChartItem[] }) {
if (data.length === 0) return null;
const chartData = data.map((d) => ({ name: d.name, 금액: d.금액, color: d.color }));
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> </CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11 }} tickFormatter={(v: number) => formatTooltipValue(v)} />
<YAxis dataKey="name" type="category" tick={{ fontSize: 12 }} width={80} />
<Tooltip
formatter={(value: number | string | undefined) => formatTooltipValue(Number(value ?? 0))}
contentStyle={{ fontSize: '12px', borderRadius: '8px' }}
/>
<Bar dataKey="금액" radius={[0, 4, 4, 0]} maxBarSize={24}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip, Legend } from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { ReceivableChartItem } from '../hooks/transformers';
function formatTooltipValue(value: number): string {
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억원`;
if (value >= 10000) return `${Math.round(value / 10000).toLocaleString()}만원`;
return `${value.toLocaleString()}`;
}
export function ReceivableBarChart({ data }: { data: ReceivableChartItem[] }) {
if (data.length === 0) return null;
const chartData = data.map((d) => ({ name: d.name, 매출액: d.매출액, 미수금: d.미수금 }));
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> vs </CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v: number) => formatTooltipValue(v)} width={70} />
<Tooltip
formatter={(value: number | string | undefined) => formatTooltipValue(Number(value ?? 0))}
contentStyle={{ fontSize: '12px', borderRadius: '8px' }}
/>
<Legend wrapperStyle={{ fontSize: '12px' }} />
<Bar dataKey="매출액" fill="#3b82f6" radius={[4, 4, 0, 0]} maxBarSize={30} />
<Bar dataKey="미수금" fill="#f97316" radius={[4, 4, 0, 0]} maxBarSize={30} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,430 @@
/**
* Dashboard Type2 데이터 변환 함수
* CEO Dashboard API 데이터 → dashboard_type2 StatCard/TableRow 변환
*/
import type { StatCard, TableRow } from '../mockData';
import type {
AmountCard,
DailyReportData,
ReceivableData,
DebtCollectionData,
MonthlyExpenseData,
CardManagementData,
EntertainmentData,
WelfareData,
} from '@/components/business/CEODashboard/types';
import type { TodayIssueData } from '@/hooks/useCEODashboard';
// ============================================
// 금액 포맷 헬퍼
// ============================================
function formatAmount(amount: number): string {
const absAmount = Math.abs(amount);
const sign = amount < 0 ? '-' : '';
if (absAmount >= 100000000) {
const value = (absAmount / 100000000).toFixed(1);
return `${sign}${value}`;
} else if (absAmount >= 10000) {
return `${sign}${Math.round(absAmount / 10000).toLocaleString()}`;
}
return `${sign}${absAmount.toLocaleString()}`;
}
function formatAmountWon(amount: number): string {
const absAmount = Math.abs(amount);
const sign = amount < 0 ? '-' : '';
if (absAmount >= 100000000) {
const value = (absAmount / 100000000).toFixed(1);
return `${sign}${value}억원`;
} else if (absAmount >= 10000) {
return `${sign}${Math.round(absAmount / 10000).toLocaleString()}만원`;
}
return `${sign}${absAmount.toLocaleString()}`;
}
function formatCurrency(amount: number): string {
return amount.toLocaleString();
}
// ============================================
// AmountCard → StatCard 변환
// ============================================
function cardColorFromId(id: string): StatCard['color'] {
// 기본 색상 매핑 - ID 패턴 기반
if (id.includes('cash') || id === 'dr1') return 'blue';
if (id.includes('income') || id === 'dr3') return 'green';
if (id.includes('expense') || id === 'dr4' || id.includes('debt')) return 'red';
if (id.includes('foreign') || id === 'dr2') return 'default';
return 'default';
}
function amountCardToStatCard(card: AmountCard, colorOverride?: StatCard['color']): StatCard {
let value: string;
if (card.currency === 'USD') {
value = card.amount === 0 ? '$0' : `$${formatCurrency(card.amount)}`;
} else if (card.unit === '곳' || card.unit === '건') {
value = `${card.amount}${card.unit}`;
} else {
value = card.amount === 0 ? '0원' : formatAmountWon(card.amount);
}
return {
label: card.label,
value,
color: colorOverride ?? cardColorFromId(card.id),
change: card.changeRate,
changeDirection: card.changeDirection,
};
}
// ============================================
// 전체요약 탭 변환
// ============================================
export function transformOverviewStats(dailyReport: DailyReportData | null): StatCard[] {
if (!dailyReport) return [];
return dailyReport.cards.map((card) => {
let color: StatCard['color'] = 'default';
if (card.id === 'dr1') color = 'blue';
else if (card.id === 'dr3') color = 'green';
else if (card.id === 'dr4') color = 'orange';
return amountCardToStatCard(card, color);
});
}
export function transformOverviewRecentOrders(
receivable: ReceivableData | null,
): TableRow[] {
if (!receivable) return [];
// 미수금 체크포인트에서 간단한 요약 테이블 생성
// 실제로는 API에서 최근 수주 데이터를 가져와야 하지만,
// 현재 API 구조상 미수금 카드 데이터로 요약 생성
return receivable.cards.map((card, idx) => ({
no: idx + 1,
항목: card.label,
금액: card.amount === 0 ? '0원' : formatAmountWon(card.amount),
비고: card.subLabel ?? '-',
}));
}
// ============================================
// 영업/매출 탭 변환
// ============================================
export function transformSalesStats(
receivable: ReceivableData | null,
debtCollection: DebtCollectionData | null,
): StatCard[] {
const stats: StatCard[] = [];
if (receivable) {
// 누적 미수금
const cumulative = receivable.cards.find((c) => c.id === 'rv1');
if (cumulative) {
stats.push({
label: cumulative.label,
value: formatAmount(cumulative.amount),
color: 'red',
});
}
// 당월 미수금
const monthly = receivable.cards.find((c) => c.id === 'rv2');
if (monthly) {
stats.push({
label: monthly.label,
value: formatAmount(monthly.amount),
color: 'orange',
});
}
// 거래처 현황
const vendors = receivable.cards.find((c) => c.id === 'rv3');
if (vendors) {
stats.push({
label: vendors.label,
value: `${vendors.amount}${vendors.unit ?? '곳'}`,
color: 'blue',
});
}
}
if (debtCollection) {
const collecting = debtCollection.cards.find((c) => c.id === 'dc2');
if (collecting) {
stats.push({
label: '채권추심 중',
value: formatAmount(collecting.amount),
color: 'red',
});
}
}
return stats;
}
export function transformReceivableTable(receivable: ReceivableData | null): TableRow[] {
if (!receivable) return [];
// 체크포인트에서 미수금 정보 추출하여 테이블 구성
// 실제 미수금 상세 데이터가 없으므로 카드 데이터에서 생성
const rows: TableRow[] = [];
receivable.cards.forEach((card, idx) => {
if (card.subItems) {
card.subItems.forEach((sub) => {
rows.push({
no: rows.length + 1,
: `${card.label} - ${sub.label}`,
금액: typeof sub.value === 'number' ? formatCurrency(sub.value) : String(sub.value),
});
});
} else {
rows.push({
no: idx + 1,
항목: card.label,
금액: formatCurrency(card.amount),
});
}
});
return rows;
}
export function transformDebtTable(debtCollection: DebtCollectionData | null): TableRow[] {
if (!debtCollection) return [];
return debtCollection.cards.map((card, idx) => ({
no: idx + 1,
항목: card.label,
금액: formatAmountWon(card.amount),
비고: card.subLabel ?? '-',
}));
}
// ============================================
// 재무관리 탭 변환
// ============================================
export function transformFinanceStats(dailyReport: DailyReportData | null): StatCard[] {
if (!dailyReport) return [];
return dailyReport.cards.map((card) => {
let color: StatCard['color'] = 'default';
if (card.id === 'dr1') color = card.amount < 0 ? 'red' : 'blue';
else if (card.id === 'dr3') color = 'green';
return amountCardToStatCard(card, color);
});
}
export function transformExpenseTable(monthlyExpense: MonthlyExpenseData | null): TableRow[] {
if (!monthlyExpense) return [];
return monthlyExpense.cards.map((card, idx) => ({
no: idx + 1,
항목: card.label,
금액: card.amount === 0 ? '0원' : formatAmountWon(card.amount),
전월대비: card.previousLabel ?? '-',
: '-',
}));
}
export function transformCardTable(cardManagement: CardManagementData | null): TableRow[] {
if (!cardManagement) return [];
return cardManagement.cards.map((card, idx) => ({
no: idx + 1,
항목: card.label,
금액: card.amount === 0 ? '0원' : formatAmountWon(card.amount),
비고: card.previousLabel ?? card.subLabel ?? '-',
}));
}
// ============================================
// 경비관리 탭 변환
// ============================================
export function transformExpenseManagementStats(
entertainment: EntertainmentData | null,
welfare: WelfareData | null,
): StatCard[] {
const stats: StatCard[] = [];
if (entertainment) {
const used = entertainment.cards.find((c) => c.id === 'et_used');
const remaining = entertainment.cards.find((c) => c.id === 'et_remaining');
if (used) {
stats.push({
label: '접대비 사용',
value: formatAmountWon(used.amount),
color: 'blue',
});
}
if (remaining) {
stats.push({
label: '접대비 잔여한도',
value: formatAmountWon(remaining.amount),
color: 'green',
});
}
}
if (welfare) {
const used = welfare.cards.find((c) => c.id === 'wf_used');
const remaining = welfare.cards.find((c) => c.id === 'wf_remaining');
if (used) {
stats.push({
label: '복리후생비 사용',
value: used.amount === 0 ? '0원' : formatAmountWon(used.amount),
color: 'default',
});
}
if (remaining) {
stats.push({
label: '복리후생비 잔여한도',
value: formatAmountWon(remaining.amount),
color: 'green',
});
}
}
return stats;
}
export function transformEntertainmentTable(entertainment: EntertainmentData | null): TableRow[] {
if (!entertainment) return [];
return entertainment.cards.map((card, idx) => ({
no: idx + 1,
항목: card.label,
금액: card.amount === 0 ? '0원' : formatAmountWon(card.amount),
비고: card.subLabel ?? '-',
}));
}
export function transformWelfareTable(welfare: WelfareData | null): TableRow[] {
if (!welfare) return [];
return welfare.cards.map((card, idx) => ({
no: idx + 1,
항목: card.label,
금액: card.amount === 0 ? '0원' : formatAmountWon(card.amount),
비고: card.subLabel ?? '-',
}));
}
// ============================================
// 일정/이슈 탭 변환
// ============================================
export function transformScheduleStats(
todayIssue: TodayIssueData | null,
): StatCard[] {
if (!todayIssue) return [];
return [
{
label: '오늘 이슈',
value: `${todayIssue.totalCount}`,
color: 'blue' as const,
},
{
label: '미처리 건수',
value: `${todayIssue.items.filter((i) => i.needsApproval).length}`,
color: 'red' as const,
},
];
}
// ============================================
// 롤링 텍스트용 변환
// ============================================
export function statsToRollingItems(stats: StatCard[]) {
return stats.map((s) => ({
label: s.label,
value: s.value,
color: (s.color ?? 'default') as 'default' | 'green' | 'blue' | 'red' | 'orange',
}));
}
// ============================================
// 차트 데이터 변환
// ============================================
export interface ExpenseChartItem {
name: string;
value: number;
color: string;
}
export function transformExpenseChartData(monthlyExpense: MonthlyExpenseData | null): ExpenseChartItem[] {
if (!monthlyExpense) return [];
const colorMap: Record<string, string> = {
: '#3b82f6',
: '#f97316',
: '#8b5cf6',
'총 예상 지출 합계': '#94a3b8',
: '#10b981',
: '#8b5cf6',
: '#94a3b8',
};
return monthlyExpense.cards
.filter((card) => card.id !== 'me4') // 합계 제외
.filter((card) => Number(card.amount) > 0) // 0원 제외
.map((card) => ({
name: card.label,
value: Number(card.amount),
color: colorMap[card.label] ?? '#94a3b8',
}));
}
export interface ReceivableChartItem {
name: string;
매출액: number;
미수금: number;
}
export function transformReceivableChartData(receivable: ReceivableData | null): ReceivableChartItem[] {
if (!receivable) return [];
// 카드별 데이터에서 매출/미수금 비교 차트 생성
const rv1 = receivable.cards.find((c) => c.id === 'rv1');
const rv2 = receivable.cards.find((c) => c.id === 'rv2');
if (!rv1?.subItems || !rv2?.subItems) return [];
const sales = rv1.subItems.find((s) => s.label === '매출');
const deposits = rv1.subItems.find((s) => s.label === '입금');
if (!sales || !deposits) return [];
return [
{
name: '매출',
매출액: Number(sales.value) || 0,
미수금: 0,
},
{
name: '입금',
매출액: Number(deposits.value) || 0,
미수금: 0,
},
{
name: '미수금',
매출액: 0,
미수금: Number(rv2.amount),
},
];
}
export interface OverviewChartItem {
name: string;
금액: number;
color: string;
}
export function transformOverviewChartData(dailyReport: DailyReportData | null): OverviewChartItem[] {
if (!dailyReport) return [];
const colorMap: Record<string, string> = {
dr1: '#3b82f6', // 현금성 자산 - blue
dr2: '#8b5cf6', // 외국환 - purple
dr3: '#10b981', // 입금 - green
dr4: '#f97316', // 출금 - orange
};
return dailyReport.cards.map((card) => ({
name: card.label.replace(' 합계', ''),
금액: Math.abs(Number(card.amount)),
color: colorMap[card.id] ?? '#94a3b8',
}));
}

View File

@@ -0,0 +1,310 @@
'use client';
/**
* Dashboard Type2 통합 데이터 Hook
* 기존 CEO Dashboard API 훅들을 재사용하여 탭별 데이터 제공
* API 실패 시 mockData fallback
*/
import { useCallback, useMemo } from 'react';
import {
useDailyReport,
useReceivable,
useDebtCollection,
useMonthlyExpense,
useCardManagement,
useEntertainment,
useWelfare,
useTodayIssue,
} from '@/hooks/useCEODashboard';
import type { StatCard, TableRow } from '../mockData';
import type { TodayIssueListItem } from '@/components/business/CEODashboard/types';
import {
transformOverviewStats,
transformSalesStats,
transformFinanceStats,
transformExpenseManagementStats,
transformScheduleStats,
transformExpenseChartData,
transformReceivableChartData,
transformOverviewChartData,
transformExpenseTable,
transformCardTable,
transformDebtTable,
transformEntertainmentTable,
transformWelfareTable,
statsToRollingItems,
type ExpenseChartItem,
type ReceivableChartItem,
type OverviewChartItem,
} from './transformers';
import * as mock from '../mockData';
// ============================================
// 타입 정의
// ============================================
export interface OverviewTabData {
stats: StatCard[];
recentOrders: TableRow[];
chartData: OverviewChartItem[];
loading: boolean;
error: string | null;
}
export interface FinanceTabData {
stats: StatCard[];
expenseData: TableRow[];
cardData: TableRow[];
expenseChartData: ExpenseChartItem[];
loading: boolean;
error: string | null;
}
export interface SalesTabData {
stats: StatCard[];
receivableData: TableRow[];
debtData: TableRow[];
receivableChartData: ReceivableChartItem[];
loading: boolean;
error: string | null;
}
export interface ExpenseTabData {
stats: StatCard[];
entertainmentData: TableRow[];
welfareData: TableRow[];
loading: boolean;
error: string | null;
}
export interface ScheduleTabData {
stats: StatCard[];
todayItems: TodayIssueListItem[];
issueItems: TodayIssueListItem[];
loading: boolean;
error: string | null;
}
export interface DashboardType2Data {
overview: OverviewTabData;
finance: FinanceTabData;
sales: SalesTabData;
expense: ExpenseTabData;
schedule: ScheduleTabData;
refetchAll: () => void;
}
// ============================================
// Hook 구현
// ============================================
export function useDashboardType2(): DashboardType2Data {
// 기존 API 훅 호출
const dailyReport = useDailyReport();
const receivable = useReceivable();
const debtCollection = useDebtCollection();
const monthlyExpense = useMonthlyExpense();
const cardManagement = useCardManagement();
const entertainment = useEntertainment();
const welfare = useWelfare();
const todayIssue = useTodayIssue();
// 전체요약 탭
const overview = useMemo<OverviewTabData>(() => {
const loading = dailyReport.loading;
const error = dailyReport.error;
if (error && !dailyReport.data) {
return {
stats: mock.overviewStats,
recentOrders: mock.overviewRecentOrders,
chartData: [],
loading: false,
error,
};
}
return {
stats: dailyReport.data ? transformOverviewStats(dailyReport.data) : mock.overviewStats,
recentOrders: mock.overviewRecentOrders, // 최근 수주는 별도 API 없으므로 mock 유지
chartData: dailyReport.data ? transformOverviewChartData(dailyReport.data) : [],
loading,
error,
};
}, [dailyReport.data, dailyReport.loading, dailyReport.error]);
// 재무관리 탭
const finance = useMemo<FinanceTabData>(() => {
const loading = dailyReport.loading || monthlyExpense.loading || cardManagement.loading;
const error = dailyReport.error || monthlyExpense.error || cardManagement.error;
if (error && !dailyReport.data && !monthlyExpense.data && !cardManagement.data) {
return {
stats: mock.financeStats,
expenseData: mock.financeExpenseData,
cardData: mock.financeCardData,
expenseChartData: mock.financeExpenseChartData,
loading: false,
error,
};
}
return {
stats: dailyReport.data ? transformFinanceStats(dailyReport.data) : mock.financeStats,
expenseData: monthlyExpense.data ? transformExpenseTable(monthlyExpense.data) : mock.financeExpenseData,
cardData: cardManagement.data ? transformCardTable(cardManagement.data) : mock.financeCardData,
expenseChartData: monthlyExpense.data ? transformExpenseChartData(monthlyExpense.data) : mock.financeExpenseChartData,
loading,
error,
};
}, [
dailyReport.data, dailyReport.loading, dailyReport.error,
monthlyExpense.data, monthlyExpense.loading, monthlyExpense.error,
cardManagement.data, cardManagement.loading, cardManagement.error,
]);
// 영업/매출 탭
const sales = useMemo<SalesTabData>(() => {
const loading = receivable.loading || debtCollection.loading;
const error = receivable.error || debtCollection.error;
if (error && !receivable.data && !debtCollection.data) {
return {
stats: mock.salesStats,
receivableData: mock.salesReceivableData,
debtData: mock.salesDebtData,
receivableChartData: [],
loading: false,
error,
};
}
return {
stats: transformSalesStats(receivable.data, debtCollection.data).length > 0
? transformSalesStats(receivable.data, debtCollection.data)
: mock.salesStats,
receivableData: mock.salesReceivableData, // 상세 미수금 목록은 별도 API 없으므로 mock 유지
debtData: debtCollection.data ? transformDebtTable(debtCollection.data) : mock.salesDebtData,
receivableChartData: receivable.data ? transformReceivableChartData(receivable.data) : [],
loading,
error,
};
}, [
receivable.data, receivable.loading, receivable.error,
debtCollection.data, debtCollection.loading, debtCollection.error,
]);
// 경비관리 탭
const expense = useMemo<ExpenseTabData>(() => {
const loading = entertainment.loading || welfare.loading;
const error = entertainment.error || welfare.error;
if (error && !entertainment.data && !welfare.data) {
return {
stats: mock.expenseStats,
entertainmentData: mock.expenseEntertainmentData,
welfareData: mock.expenseWelfareData,
loading: false,
error,
};
}
return {
stats: transformExpenseManagementStats(entertainment.data, welfare.data).length > 0
? transformExpenseManagementStats(entertainment.data, welfare.data)
: mock.expenseStats,
entertainmentData: entertainment.data
? transformEntertainmentTable(entertainment.data)
: mock.expenseEntertainmentData,
welfareData: welfare.data
? transformWelfareTable(welfare.data)
: mock.expenseWelfareData,
loading,
error,
};
}, [
entertainment.data, entertainment.loading, entertainment.error,
welfare.data, welfare.loading, welfare.error,
]);
// 일정/이슈 탭
const schedule = useMemo<ScheduleTabData>(() => {
const loading = todayIssue.loading;
const error = todayIssue.error;
if (error && !todayIssue.data) {
return {
stats: mock.scheduleStats,
todayItems: [],
issueItems: [],
loading: false,
error,
};
}
const items = todayIssue.data?.items ?? [];
const approvalItems = items.filter((i) => i.needsApproval);
const regularItems = items.filter((i) => !i.needsApproval);
return {
stats: todayIssue.data
? transformScheduleStats(todayIssue.data).length > 0
? transformScheduleStats(todayIssue.data)
: mock.scheduleStats
: mock.scheduleStats,
todayItems: regularItems,
issueItems: approvalItems,
loading,
error,
};
}, [todayIssue.data, todayIssue.loading, todayIssue.error]);
// 전체 refetch
const refetchAll = useCallback(() => {
dailyReport.refetch();
receivable.refetch();
debtCollection.refetch();
monthlyExpense.refetch();
cardManagement.refetch();
entertainment.refetch();
welfare.refetch();
todayIssue.refetch();
}, [
dailyReport, receivable, debtCollection, monthlyExpense,
cardManagement, entertainment, welfare, todayIssue,
]);
return {
overview,
finance,
sales,
expense,
schedule,
refetchAll,
};
}
// ============================================
// 탭별 롤링 아이템 헬퍼
// ============================================
export function getRollingItems(tabKey: string, data: DashboardType2Data) {
switch (tabKey) {
case 'overview':
return statsToRollingItems(data.overview.stats);
case 'finance':
return statsToRollingItems(data.finance.stats);
case 'sales':
return statsToRollingItems(data.sales.stats);
case 'expense':
return statsToRollingItems(data.expense.stats);
case 'schedule':
return statsToRollingItems(data.schedule.stats);
default:
return [];
}
}

View File

@@ -51,6 +51,15 @@ export const financeCardData: TableRow[] = [
{ no: 3, : '법인카드(하나)', : '215만', : '2건', : '500만', : '285만' },
];
// 재무관리 - 차트 목데이터 (지출 항목별 비율)
export const financeExpenseChartData = [
{ name: '매입', value: 52340000, color: '#3b82f6' },
{ name: '카드', value: 9850000, color: '#f97316' },
{ name: '인건비', value: 32000000, color: '#10b981' },
{ name: '운영비', value: 15800000, color: '#8b5cf6' },
{ name: '기타', value: 13500000, color: '#94a3b8' },
];
// === 영업/매출 탭 ===
export const salesStats: StatCard[] = [
{ label: '수주 건수', value: '7건', color: 'blue' },

View File

@@ -1,13 +1,31 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { expenseStats, expenseEntertainmentData, expenseWelfareData } from '../mockData';
import { StatCards } from '../StatCards';
import type { ExpenseTabData } from '../hooks/useDashboardType2';
export function ExpenseTab() {
function LoadingSkeleton() {
return (
<div className="space-y-6">
<StatCards stats={expenseStats} />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="animate-pulse bg-muted rounded-xl h-[88px]" />
))}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
</div>
</div>
);
}
export function ExpenseTab({ data }: { data: ExpenseTabData }) {
if (data.loading) return <LoadingSkeleton />;
return (
<div className="space-y-6">
<StatCards stats={data.stats} />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card>
@@ -28,7 +46,7 @@ export function ExpenseTab() {
</tr>
</thead>
<tbody>
{expenseEntertainmentData.map((row, i) => (
{data.entertainmentData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3">{row['일자']}</td>
@@ -62,7 +80,7 @@ export function ExpenseTab() {
</tr>
</thead>
<tbody>
{expenseWelfareData.map((row, i) => (
{data.welfareData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['항목']}</td>

View File

@@ -1,13 +1,34 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { financeStats, financeExpenseData, financeCardData } from '../mockData';
import { StatCards } from '../StatCards';
import { ExpenseDonutChart } from '../charts/ExpenseDonutChart';
import type { FinanceTabData } from '../hooks/useDashboardType2';
export function FinanceTab() {
function LoadingSkeleton() {
return (
<div className="space-y-6">
<StatCards stats={financeStats} />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="animate-pulse bg-muted rounded-xl h-[88px]" />
))}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
</div>
</div>
);
}
export function FinanceTab({ data }: { data: FinanceTabData }) {
if (data.loading) return <LoadingSkeleton />;
return (
<div className="space-y-6">
<StatCards stats={data.stats} />
<ExpenseDonutChart data={data.expenseChartData} />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card>
@@ -27,7 +48,7 @@ export function FinanceTab() {
</tr>
</thead>
<tbody>
{financeExpenseData.map((row, i) => (
{data.expenseData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['항목']}</td>
@@ -56,20 +77,18 @@ export function FinanceTab() {
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{financeCardData.map((row, i) => (
{data.cardData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['카드명']}</td>
<td className="px-4 py-3 text-right">{row['사용액']}</td>
<td className="px-4 py-3 text-center text-orange-500 font-medium">{row['미정리']}</td>
<td className="px-4 py-3 text-right">{row['잔여한도']}</td>
<td className="px-4 py-3 font-medium">{row['항목']}</td>
<td className="px-4 py-3 text-right">{row['액']}</td>
<td className="px-4 py-3 text-muted-foreground">{row['비고']}</td>
</tr>
))}
</tbody>

View File

@@ -1,13 +1,31 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { overviewStats, overviewRecentOrders } from '../mockData';
import { StatCards } from '../StatCards';
import { OverviewSummaryChart } from '../charts/OverviewSummaryChart';
import type { OverviewTabData } from '../hooks/useDashboardType2';
export function OverviewTab() {
function LoadingSkeleton() {
return (
<div className="space-y-6">
<StatCards stats={overviewStats} />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="animate-pulse bg-muted rounded-xl h-[88px]" />
))}
</div>
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
</div>
);
}
export function OverviewTab({ data }: { data: OverviewTabData }) {
if (data.loading) return <LoadingSkeleton />;
return (
<div className="space-y-6">
<StatCards stats={data.stats} />
<OverviewSummaryChart data={data.chartData} />
<Card>
<CardHeader>
@@ -27,7 +45,7 @@ export function OverviewTab() {
</tr>
</thead>
<tbody>
{overviewRecentOrders.map((row, i) => (
{data.recentOrders.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['거래처']}</td>

View File

@@ -1,13 +1,49 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { salesStats, salesReceivableData, salesDebtData } from '../mockData';
import { StatCards } from '../StatCards';
import { ReceivableBarChart } from '../charts/ReceivableBarChart';
import type { SalesTabData } from '../hooks/useDashboardType2';
export function SalesTab() {
function LoadingSkeleton() {
return (
<div className="space-y-6">
<StatCards stats={salesStats} />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="animate-pulse bg-muted rounded-xl h-[88px]" />
))}
</div>
<div className="animate-pulse bg-muted rounded-xl h-[300px]" />
<div className="animate-pulse bg-muted rounded-xl h-[250px]" />
</div>
);
}
function getReceivableRowClass(row: Record<string, string | number>): string {
const days = String(row['경과일']);
const num = parseInt(days);
if (!isNaN(num)) {
if (num >= 90) return 'bg-red-50';
if (num >= 30) return 'bg-yellow-50';
}
return '';
}
function getDebtRowClass(row: Record<string, string | number>): string {
const possibility = String(row['회수가능성']);
if (possibility === '하') return 'bg-red-50';
if (possibility === '상') return 'bg-green-50';
return '';
}
export function SalesTab({ data }: { data: SalesTabData }) {
if (data.loading) return <LoadingSkeleton />;
return (
<div className="space-y-6">
<StatCards stats={data.stats} />
<ReceivableBarChart data={data.receivableChartData} />
<Card>
<CardHeader>
@@ -28,8 +64,8 @@ export function SalesTab() {
</tr>
</thead>
<tbody>
{salesReceivableData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
{data.receivableData.map((row, i) => (
<tr key={i} className={`border-b last:border-0 hover:bg-muted/30 ${getReceivableRowClass(row)}`}>
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
<td className="px-4 py-3 text-right">{row['매출액']}</td>
@@ -65,8 +101,8 @@ export function SalesTab() {
</tr>
</thead>
<tbody>
{salesDebtData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
{data.debtData.map((row, i) => (
<tr key={i} className={`border-b last:border-0 hover:bg-muted/30 ${getDebtRowClass(row)}`}>
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
<td className="px-4 py-3 text-right">{row['채권액']}</td>

View File

@@ -1,14 +1,51 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { scheduleStats, scheduleTodayItems, scheduleIssueItems } from '../mockData';
import { StatCards } from '../StatCards';
import { Clock, AlertTriangle, FileText, Truck, Users } from 'lucide-react';
import type { ScheduleTabData } from '../hooks/useDashboardType2';
import type { TodayIssueListItem } from '@/components/business/CEODashboard/types';
import { scheduleTodayItems, scheduleIssueItems } from '../mockData';
export function ScheduleTab() {
function LoadingSkeleton() {
return (
<div className="space-y-6">
<StatCards stats={scheduleStats} />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="animate-pulse bg-muted rounded-xl h-[88px]" />
))}
</div>
<div className="animate-pulse bg-muted rounded-xl h-[200px]" />
<div className="animate-pulse bg-muted rounded-xl h-[200px]" />
</div>
);
}
// 뱃지 타입 → 아이콘 타입 매핑
function getIconType(badge: string): string {
if (badge.includes('수주') || badge.includes('결재')) return 'meeting';
if (badge.includes('입금') || badge.includes('출금')) return 'task';
if (badge.includes('신고') || badge.includes('세금')) return 'report';
if (badge.includes('재고') || badge.includes('납품')) return 'delivery';
return 'task';
}
// 뱃지 타입 → 우선순위 매핑
function getPriority(item: TodayIssueListItem): string {
if (item.needsApproval) return 'high';
if (item.notificationType === 'bad_debt' || item.notificationType === 'safety_stock') return 'high';
return 'medium';
}
export function ScheduleTab({ data }: { data: ScheduleTabData }) {
if (data.loading) return <LoadingSkeleton />;
// API 데이터가 있으면 사용, 없으면 mock fallback
const hasApiData = data.todayItems.length > 0 || data.issueItems.length > 0;
return (
<div className="space-y-6">
<StatCards stats={data.stats} />
<Card>
<CardHeader>
@@ -16,20 +53,43 @@ export function ScheduleTab() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-3">
{scheduleTodayItems.map((item) => (
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
<div className="flex-shrink-0 mt-0.5">
<ScheduleIcon type={item.type} />
{hasApiData ? (
data.todayItems.length > 0 ? (
data.todayItems.map((item) => (
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
<div className="flex-shrink-0 mt-0.5">
<ScheduleIcon type={getIconType(item.badge)} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{item.content}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{item.badge}</span>
</div>
</div>
<div className="flex-shrink-0">
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">{item.time}</span>
</div>
</div>
))
) : (
<p className="text-sm text-muted-foreground col-span-full py-4 text-center"> .</p>
)
) : (
scheduleTodayItems.map((item) => (
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
<div className="flex-shrink-0 mt-0.5">
<ScheduleIcon type={item.type} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{item.person}</p>
</div>
<div className="flex-shrink-0">
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">{item.time}</span>
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{item.person}</p>
</div>
<div className="flex-shrink-0">
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">{item.time}</span>
</div>
</div>
))}
))
)}
</div>
</CardContent>
</Card>
@@ -40,24 +100,49 @@ export function ScheduleTab() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-3">
{scheduleIssueItems.map((item) => (
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
<div className="flex-shrink-0 mt-0.5">
<PriorityIcon priority={item.priority} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{item.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{item.date}</span>
<span className="text-xs text-muted-foreground">|</span>
<span className="text-xs text-muted-foreground">{item.assignee}</span>
{hasApiData ? (
data.issueItems.length > 0 ? (
data.issueItems.map((item) => (
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
<div className="flex-shrink-0 mt-0.5">
<PriorityIcon priority={getPriority(item)} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{item.content}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{item.time}</span>
<span className="text-xs text-muted-foreground">|</span>
<span className="text-xs text-muted-foreground">{item.badge}</span>
</div>
</div>
<div className="flex-shrink-0">
<PriorityBadge priority={getPriority(item)} />
</div>
</div>
))
) : (
<p className="text-sm text-muted-foreground col-span-full py-4 text-center"> .</p>
)
) : (
scheduleIssueItems.map((item) => (
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
<div className="flex-shrink-0 mt-0.5">
<PriorityIcon priority={item.priority} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{item.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{item.date}</span>
<span className="text-xs text-muted-foreground">|</span>
<span className="text-xs text-muted-foreground">{item.assignee}</span>
</div>
</div>
<div className="flex-shrink-0">
<PriorityBadge priority={item.priority} />
</div>
</div>
<div className="flex-shrink-0">
<PriorityBadge priority={item.priority} />
</div>
</div>
))}
))
)}
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,410 @@
'use client';
import { useState, useRef } from 'react';
import { LayoutGrid, GripVertical, Plus, X, Maximize2, Minimize2, Square } from 'lucide-react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts';
// ============================================
// Mock 데이터
// ============================================
const cashFlowData = [
{ month: '9월', 입금: 8200, 출금: 6800 },
{ month: '10월', 입금: 9500, 출금: 7200 },
{ month: '11월', 입금: 7800, 출금: 8100 },
{ month: '12월', 입금: 11200, 출금: 7500 },
{ month: '1월', 입금: 9800, 출금: 8900 },
{ month: '2월', 입금: 10500, 출금: 6764 },
];
const expenseData = [
{ name: '매입', value: 5234, color: '#3b82f6' },
{ name: '인건비', value: 3200, color: '#10b981' },
{ name: '운영비', value: 1580, color: '#8b5cf6' },
{ name: '기타', value: 1350, color: '#94a3b8' },
{ name: '카드', value: 985, color: '#f97316' },
];
const receivables = [
{ name: '(주)대한건설', amount: '4,000만', days: 45, status: '정상' },
{ name: '삼성엔지니어링', amount: '1억', days: 92, status: '주의' },
{ name: 'SK에코플랜트', amount: '4,400만', days: 30, status: '정상' },
{ name: 'LG전자', amount: '3,200만', days: 120, status: '위험' },
];
const todaySchedule = [
{ time: '09:00', title: '대한건설 현장 미팅', type: 'meeting' },
{ time: '11:00', title: '삼성엔지니어링 견적 검토', type: 'task' },
{ time: '14:00', title: '월간 실적 보고', type: 'report' },
{ time: '16:00', title: 'SK에코플랜트 납품 확인', type: 'delivery' },
];
const alerts = [
{ id: 1, text: 'LG전자 미수금 120일 경과 (위험)', type: 'danger', time: '10분 전' },
{ id: 2, text: '출장비 정산 승인 요청 (홍킬동)', type: 'warning', time: '30분 전' },
{ id: 3, text: '현대중공업 입금 683만원 확인', type: 'success', time: '1시간 전' },
{ id: 4, text: '서울금속 신규 거래처 등록', type: 'info', time: '2시간 전' },
{ id: 5, text: '전동개폐기 납기 지연 3일', type: 'warning', time: '3시간 전' },
];
// ============================================
// 위젯 타입
// ============================================
interface Widget {
id: string;
title: string;
size: 'sm' | 'md' | 'lg';
type: 'cashflow' | 'receivable' | 'schedule' | 'expense' | 'kpi' | 'alerts';
}
const defaultWidgets: Widget[] = [
{ id: 'kpi', title: '핵심 KPI', size: 'lg', type: 'kpi' },
{ id: 'cashflow', title: '현금 흐름 추이', size: 'md', type: 'cashflow' },
{ id: 'expense', title: '지출 항목별 비율', size: 'md', type: 'expense' },
{ id: 'receivable', title: '미수금 현황', size: 'md', type: 'receivable' },
{ id: 'schedule', title: '오늘 일정', size: 'md', type: 'schedule' },
{ id: 'alerts', title: '최근 알림', size: 'lg', type: 'alerts' },
];
// ============================================
// 위젯 컴포넌트들
// ============================================
function KpiWidget() {
const kpis = [
{ label: '현금성 자산', value: '305억', change: '+5.2%', up: true, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: '당월 매출', value: '12.8억', change: '+8.3%', up: true, color: 'text-green-600', bg: 'bg-green-50' },
{ label: '당월 미수금', value: '10.1억', change: '+2.1%', up: true, color: 'text-red-600', bg: 'bg-red-50' },
{ label: '당월 지출', value: '8.5억', change: '-3.2%', up: false, color: 'text-orange-600', bg: 'bg-orange-50' },
];
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{kpis.map((kpi) => (
<div key={kpi.label} className={`${kpi.bg} rounded-xl p-4`}>
<p className="text-xs text-muted-foreground">{kpi.label}</p>
<p className={`text-xl font-bold mt-1 ${kpi.color}`}>{kpi.value}</p>
<p className={`text-xs mt-1 ${kpi.up ? 'text-green-500' : 'text-red-500'}`}>{kpi.change}</p>
</div>
))}
</div>
);
}
function CashflowWidget() {
return (
<ResponsiveContainer width="100%" height={180}>
<BarChart data={cashFlowData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v: number) => `${v}`} width={50} />
<Tooltip formatter={(v: number | string | undefined) => `${Number(v ?? 0).toLocaleString()}만원`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} />
<Bar dataKey="입금" fill="#3b82f6" radius={[3, 3, 0, 0]} maxBarSize={20} />
<Bar dataKey="출금" fill="#f97316" radius={[3, 3, 0, 0]} maxBarSize={20} />
</BarChart>
</ResponsiveContainer>
);
}
function ExpenseWidget() {
const chartData = expenseData.map((d) => ({ name: d.name, value: d.value, color: d.color }));
return (
<div className="flex items-center gap-4">
<ResponsiveContainer width="50%" height={160}>
<PieChart>
<Pie data={chartData} cx="50%" cy="50%" innerRadius={40} outerRadius={65} dataKey="value" nameKey="name">
{chartData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
</Pie>
<Tooltip formatter={(v: number | string | undefined) => `${Number(v ?? 0).toLocaleString()}만원`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-2">
{expenseData.map((item) => (
<div key={item.name} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: item.color }} />
<span>{item.name}</span>
</div>
<span className="font-medium">{item.value.toLocaleString()}</span>
</div>
))}
</div>
</div>
);
}
function ReceivableWidget() {
return (
<div className="space-y-2">
{receivables.map((r) => (
<div key={r.name} className={`flex items-center justify-between p-2.5 rounded-lg border text-sm ${r.days >= 90 ? 'bg-red-50 border-red-200' : r.days >= 30 ? 'bg-yellow-50 border-yellow-200' : 'border-border'}`}>
<div>
<p className="font-medium">{r.name}</p>
<p className="text-xs text-muted-foreground">{r.days} </p>
</div>
<div className="text-right">
<p className="font-semibold">{r.amount}</p>
<StatusBadge status={r.status} />
</div>
</div>
))}
</div>
);
}
function ScheduleWidget() {
const typeColors: Record<string, string> = { meeting: 'bg-blue-500', task: 'bg-green-500', report: 'bg-orange-500', delivery: 'bg-purple-500' };
return (
<div className="space-y-2">
{todaySchedule.map((s) => (
<div key={s.time} className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-muted/30 transition-colors">
<span className="text-xs text-muted-foreground w-10 flex-shrink-0">{s.time}</span>
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${typeColors[s.type] ?? 'bg-gray-400'}`} />
<span className="text-sm">{s.title}</span>
</div>
))}
</div>
);
}
function AlertsWidget() {
const typeStyles: Record<string, string> = {
danger: 'border-l-red-500 bg-red-50/50',
warning: 'border-l-yellow-500 bg-yellow-50/50',
success: 'border-l-green-500 bg-green-50/50',
info: 'border-l-blue-500 bg-blue-50/50',
};
return (
<div className="space-y-2">
{alerts.map((a) => (
<div key={a.id} className={`flex items-center justify-between p-3 rounded-lg border-l-4 ${typeStyles[a.type]}`}>
<span className="text-sm">{a.text}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-3">{a.time}</span>
</div>
))}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = { '정상': 'bg-green-100 text-green-700', '주의': 'bg-yellow-100 text-yellow-700', '위험': 'bg-red-100 text-red-700' };
return <span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${styles[status] ?? 'bg-gray-100 text-gray-600'}`}>{status}</span>;
}
// ============================================
// 위젯 렌더러
// ============================================
function renderWidgetContent(type: Widget['type']) {
switch (type) {
case 'kpi': return <KpiWidget />;
case 'cashflow': return <CashflowWidget />;
case 'expense': return <ExpenseWidget />;
case 'receivable': return <ReceivableWidget />;
case 'schedule': return <ScheduleWidget />;
case 'alerts': return <AlertsWidget />;
}
}
function sizeClass(size: Widget['size']) {
switch (size) {
case 'sm': return 'col-span-1';
case 'md': return 'col-span-1 xl:col-span-1';
case 'lg': return 'col-span-1 xl:col-span-2';
}
}
// ============================================
// 메인 컴포넌트
// ============================================
export function DashboardType3() {
const [widgets, setWidgets] = useState<Widget[]>(defaultWidgets);
const [editMode, setEditMode] = useState(false);
const [showAddPanel, setShowAddPanel] = useState(false);
const dragIndex = useRef<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
// 현재 배치되지 않은 위젯 목록
const removedWidgets = defaultWidgets.filter((dw) => !widgets.some((w) => w.id === dw.id));
// sm → md → lg → sm 순환
const toggleSize = (id: string) => {
const cycle: Record<Widget['size'], Widget['size']> = { sm: 'md', md: 'lg', lg: 'sm' };
setWidgets((prev) =>
prev.map((w) => (w.id === id ? { ...w, size: cycle[w.size] } : w)),
);
};
const removeWidget = (id: string) => {
setWidgets((prev) => prev.filter((w) => w.id !== id));
};
const addWidget = (widget: Widget) => {
setWidgets((prev) => [...prev, widget]);
setShowAddPanel(false);
};
const resetWidgets = () => {
setWidgets(defaultWidgets);
setShowAddPanel(false);
};
// --- Drag & Drop 핸들러 ---
const handleDragStart = (e: React.DragEvent, idx: number) => {
dragIndex.current = idx;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'widget', idx }));
};
const handleDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragIndex.current !== idx) {
setDragOverIdx(idx);
}
};
const handleDragLeave = () => {
setDragOverIdx(null);
};
const handleDrop = (e: React.DragEvent, dropIdx: number) => {
e.preventDefault();
setDragOverIdx(null);
const fromIdx = dragIndex.current;
if (fromIdx === null || fromIdx === dropIdx) return;
setWidgets((prev) => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
next.splice(dropIdx, 0, moved);
return next;
});
dragIndex.current = null;
};
const handleDragEnd = () => {
dragIndex.current = null;
setDragOverIdx(null);
};
const SizeIcon = ({ size }: { size: Widget['size'] }) => {
switch (size) {
case 'sm': return <Minimize2 className="w-3.5 h-3.5" />;
case 'md': return <Square className="w-3.5 h-3.5" />;
case 'lg': return <Maximize2 className="w-3.5 h-3.5" />;
}
};
return (
<PageLayout>
<div className="p-3 md:p-6">
<div className="flex items-center justify-between">
<PageHeader
title="위젯 대시보드"
description="원하는 위젯을 자유롭게 배치하세요."
icon={LayoutGrid}
/>
<div className="flex items-center gap-2">
{editMode && (
<button onClick={resetWidgets} className="text-xs text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md hover:bg-muted">
</button>
)}
<button
onClick={() => setEditMode(!editMode)}
className={`flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-md transition-colors ${editMode ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
>
{editMode ? '완료' : '편집'}
</button>
<DashboardSwitcher />
</div>
</div>
<div className="mt-6 grid grid-cols-1 xl:grid-cols-2 gap-4">
{widgets.map((widget, idx) => (
<Card
key={widget.id}
draggable={editMode}
onDragStart={editMode ? (e) => handleDragStart(e, idx) : undefined}
onDragOver={editMode ? (e) => handleDragOver(e, idx) : undefined}
onDragLeave={editMode ? handleDragLeave : undefined}
onDrop={editMode ? (e) => handleDrop(e, idx) : undefined}
onDragEnd={editMode ? handleDragEnd : undefined}
className={`${sizeClass(widget.size)} transition-all ${
editMode ? 'ring-2 ring-dashed ring-primary/30' : ''
} ${editMode && dragIndex.current === idx ? 'opacity-40' : ''} ${
editMode && dragOverIdx === idx ? 'ring-2 ring-primary ring-solid' : ''
}`}
>
<CardHeader className="flex flex-row items-center justify-between py-3 px-4">
<div className="flex items-center gap-2">
{editMode && <GripVertical className="w-4 h-4 text-muted-foreground cursor-grab" />}
<CardTitle className="text-sm font-semibold">{widget.title}</CardTitle>
</div>
{editMode && (
<div className="flex items-center gap-1">
<button onClick={() => toggleSize(widget.id)} className="p-1 rounded hover:bg-muted" title={`크기 변경 (현재: ${widget.size})`}>
<SizeIcon size={widget.size} />
</button>
<button onClick={() => removeWidget(widget.id)} className="p-1 rounded hover:bg-red-50 text-muted-foreground hover:text-red-500" title="삭제">
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
</CardHeader>
<CardContent className="px-4 pb-4">
{renderWidgetContent(widget.type)}
</CardContent>
</Card>
))}
{editMode && (
<div className="col-span-1 xl:col-span-2">
{showAddPanel && removedWidgets.length > 0 ? (
<div className="border-2 border-dashed border-primary/30 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-muted-foreground"> </span>
<button onClick={() => setShowAddPanel(false)} className="p-1 rounded hover:bg-muted">
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{removedWidgets.map((w) => (
<button
key={w.id}
onClick={() => addWidget(w)}
className="flex items-center gap-2 p-3 rounded-lg border hover:bg-muted/50 hover:border-primary/40 transition-colors text-left"
>
<Plus className="w-4 h-4 text-primary flex-shrink-0" />
<span className="text-sm">{w.title}</span>
</button>
))}
</div>
</div>
) : removedWidgets.length > 0 ? (
<button
onClick={() => setShowAddPanel(true)}
className="w-full border-2 border-dashed border-muted-foreground/20 rounded-xl flex items-center justify-center py-12 hover:border-primary/40 cursor-pointer transition-colors"
>
<div className="flex items-center gap-2 text-muted-foreground">
<Plus className="w-5 h-5" />
<span className="text-sm font-medium"> </span>
</div>
</button>
) : (
<div className="border rounded-xl flex items-center justify-center py-8 bg-muted/30">
<p className="text-sm text-muted-foreground"> .</p>
</div>
)}
</div>
)}
</div>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { DashboardType3 } from './_components/DashboardType3';
/**
* Dashboard Type 3 - 위젯 보드형 대시보드
*
* 사용자가 원하는 위젯을 자유롭게 배치하는 구조
* - 현금흐름, 미수금, 오늘일정, 매출추이, 지출비율, 알림피드 위젯
* - 위젯별 크기 조절 (small / medium / large)
*
* URL: /ko/dashboard_type3
*/
export default function DashboardType3Page() {
return <DashboardType3 />;
}

View File

@@ -0,0 +1,390 @@
'use client';
import { useState } from 'react';
import { Target, ChevronRight, ArrowLeft } from 'lucide-react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip, Cell } from 'recharts';
// ============================================
// Mock 데이터
// ============================================
interface KpiItem {
id: string;
label: string;
value: string;
rawValue: number;
change: string;
up: boolean;
color: string;
bgColor: string;
}
interface DetailItem {
id: string;
name: string;
amount: string;
rawAmount: number;
sub: string;
status?: string;
items?: SubDetailItem[];
}
interface SubDetailItem {
label: string;
value: string;
date?: string;
}
const kpis: KpiItem[] = [
{ id: 'revenue', label: '당월 매출', value: '12.8억', rawValue: 1280000000, change: '+8.3%', up: true, color: '#3b82f6', bgColor: 'bg-blue-50 border-blue-200' },
{ id: 'expense', label: '당월 지출', value: '8.5억', rawValue: 850000000, change: '-3.2%', up: false, color: '#f97316', bgColor: 'bg-orange-50 border-orange-200' },
{ id: 'receivable', label: '미수금', value: '10.1억', rawValue: 1010000000, change: '+2.1%', up: true, color: '#ef4444', bgColor: 'bg-red-50 border-red-200' },
{ id: 'cash', label: '현금성 자산', value: '305억', rawValue: 30500000000, change: '+5.2%', up: true, color: '#10b981', bgColor: 'bg-green-50 border-green-200' },
];
const detailData: Record<string, DetailItem[]> = {
revenue: [
{ id: 'r1', name: '(주)대한건설', amount: '4.5억', rawAmount: 450000000, sub: '전동개폐기 SET 외 2건', status: '진행중', items: [
{ label: '전동개폐기 SET', value: '2.5억', date: '2026-01-15' },
{ label: '자동제어 시스템', value: '1.5억', date: '2026-02-01' },
{ label: '부자재', value: '5,000만', date: '2026-02-05' },
]},
{ id: 'r2', name: '삼성엔지니어링', amount: '3.2억', rawAmount: 320000000, sub: '자동제어 시스템', status: '완료', items: [
{ label: '자동제어 시스템 본체', value: '2.8억', date: '2026-01-20' },
{ label: '설치 용역', value: '4,000만', date: '2026-01-28' },
]},
{ id: 'r3', name: '현대건설', amount: '2.8억', rawAmount: 280000000, sub: '환기 시스템', status: '진행중', items: [
{ label: '환기 시스템 일식', value: '2.8억', date: '2026-02-03' },
]},
{ id: 'r4', name: 'LG전자', amount: '1.5억', rawAmount: 150000000, sub: '공조기 제어반', status: '대기', items: [
{ label: '공조기 제어반', value: '1.5억', date: '2026-02-10' },
]},
{ id: 'r5', name: 'SK에코플랜트', amount: '0.8억', rawAmount: 80000000, sub: '모터 제어반', status: '진행중', items: [
{ label: '모터 제어반', value: '8,000만', date: '2026-02-08' },
]},
],
expense: [
{ id: 'e1', name: '매입', amount: '5,234만', rawAmount: 52340000, sub: '원자재 구매', items: [
{ label: '동 파이프', value: '2,100만', date: '2026-02-01' },
{ label: '전자부품', value: '1,800만', date: '2026-02-03' },
{ label: '기타 자재', value: '1,334만', date: '2026-02-05' },
]},
{ id: 'e2', name: '인건비', amount: '3,200만', rawAmount: 32000000, sub: '정규직 28명', items: [
{ label: '급여', value: '2,600만' },
{ label: '4대보험', value: '400만' },
{ label: '수당', value: '200만' },
]},
{ id: 'e3', name: '운영비', amount: '1,580만', rawAmount: 15800000, sub: '임대료, 유틸리티 등', items: [
{ label: '임대료', value: '800만' },
{ label: '유틸리티', value: '380만' },
{ label: '통신/IT', value: '250만' },
{ label: '기타', value: '150만' },
]},
{ id: 'e4', name: '카드', amount: '985만', rawAmount: 9850000, sub: '법인카드 3매', items: [
{ label: '신한카드', value: '450만' },
{ label: '국민카드', value: '320만' },
{ label: '하나카드', value: '215만' },
]},
],
receivable: [
{ id: 'v1', name: '(주)대한건설', amount: '4,000만', rawAmount: 40000000, sub: '45일 경과', status: '정상', items: [
{ label: '매출액', value: '1.2억' },
{ label: '입금액', value: '8,000만' },
{ label: '미수금', value: '4,000만' },
{ label: '최근 입금일', value: '2026-01-25' },
]},
{ id: 'v2', name: '삼성엔지니어링', amount: '1억', rawAmount: 100000000, sub: '92일 경과', status: '주의', items: [
{ label: '매출액', value: '2.5억' },
{ label: '입금액', value: '1.5억' },
{ label: '미수금', value: '1억' },
{ label: '최근 입금일', value: '2025-11-15' },
]},
{ id: 'v3', name: 'SK에코플랜트', amount: '4,400만', rawAmount: 44000000, sub: '30일 경과', status: '정상', items: [
{ label: '매출액', value: '8,900만' },
{ label: '입금액', value: '4,500만' },
{ label: '미수금', value: '4,400만' },
{ label: '최근 입금일', value: '2026-01-10' },
]},
{ id: 'v4', name: 'LG전자', amount: '3,200만', rawAmount: 32000000, sub: '120일 경과', status: '위험', items: [
{ label: '매출액', value: '3,200만' },
{ label: '입금액', value: '0원' },
{ label: '미수금', value: '3,200만' },
{ label: '최근 입금일', value: '없음' },
]},
],
cash: [
{ id: 'c1', name: '보통예금', amount: '180억', rawAmount: 18000000000, sub: '신한은행 외 3곳', items: [
{ label: '신한은행', value: '82억' },
{ label: '국민은행', value: '55억' },
{ label: '하나은행', value: '30억' },
{ label: '기업은행', value: '13억' },
]},
{ id: 'c2', name: '정기예금', amount: '100억', rawAmount: 10000000000, sub: '만기 2026-06', items: [
{ label: '신한은행 정기예금', value: '50억', date: '만기: 2026-06-15' },
{ label: '국민은행 정기예금', value: '30억', date: '만기: 2026-08-20' },
{ label: '하나은행 정기예금', value: '20억', date: '만기: 2026-12-01' },
]},
{ id: 'c3', name: '단기금융상품', amount: '25억', rawAmount: 2500000000, sub: 'MMF, CMA 등', items: [
{ label: 'MMF', value: '15억' },
{ label: 'CMA', value: '10억' },
]},
],
};
// ============================================
// 레벨별 컴포넌트
// ============================================
function Level1({ kpiList, onSelect }: { kpiList: KpiItem[]; onSelect: (id: string) => void }) {
const chartData = kpiList.map((k) => ({ name: k.label, value: k.rawValue / 100000000, color: k.color }));
return (
<div className="space-y-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{kpiList.map((kpi) => (
<button
key={kpi.id}
onClick={() => onSelect(kpi.id)}
className={`${kpi.bgColor} rounded-xl p-5 text-left border hover:ring-2 hover:ring-primary/30 transition-all group`}
>
<p className="text-xs text-muted-foreground">{kpi.label}</p>
<p className="text-2xl lg:text-3xl font-bold mt-2" style={{ color: kpi.color }}>{kpi.value}</p>
<div className="flex items-center justify-between mt-2">
<p className={`text-xs ${kpi.up ? 'text-green-500' : 'text-red-500'}`}>{kpi.change}</p>
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</button>
))}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> ()</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={chartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11 }} tickFormatter={(v: number) => `${v}`} />
<YAxis dataKey="name" type="category" tick={{ fontSize: 12 }} width={80} />
<Tooltip formatter={(v: number | string | undefined) => `${Number(v ?? 0).toFixed(1)}억원`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} />
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={28}>
{chartData.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<p className="text-center text-sm text-muted-foreground"> </p>
</div>
);
}
function Level2({ kpi, items, onSelect, onBack }: { kpi: KpiItem; items: DetailItem[]; onSelect: (item: DetailItem) => void; onBack: () => void }) {
const chartData = items.map((item) => ({ name: item.name, value: item.rawAmount }));
return (
<div className="space-y-6">
<button onClick={onBack} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="w-4 h-4" />
</button>
<div className={`${kpi.bgColor} rounded-xl p-5 border`}>
<p className="text-sm text-muted-foreground">{kpi.label}</p>
<p className="text-3xl font-bold mt-1" style={{ color: kpi.color }}>{kpi.value}</p>
<p className={`text-sm mt-1 ${kpi.up ? 'text-green-500' : 'text-red-500'}`}>{kpi.change} </p>
</div>
{chartData.length > 0 && chartData[0].value > 0 && (
<Card>
<CardContent className="pt-4">
<ResponsiveContainer width="100%" height={items.length * 44 + 20}>
<BarChart data={chartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis dataKey="name" type="category" tick={{ fontSize: 11 }} width={100} />
<Tooltip formatter={(v: number | string | undefined) => `${Number(v ?? 0).toLocaleString()}`} contentStyle={{ fontSize: '12px', borderRadius: '8px' }} />
<Bar dataKey="value" fill={kpi.color} radius={[0, 4, 4, 0]} maxBarSize={22} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
<div className="space-y-2">
{items.map((item) => (
<button
key={item.id}
onClick={() => onSelect(item)}
className="w-full flex items-center justify-between p-4 rounded-xl border hover:bg-muted/30 hover:ring-1 hover:ring-primary/20 transition-all text-left group"
>
<div>
<p className="font-semibold">{item.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">{item.sub}</p>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="font-bold" style={{ color: kpi.color }}>{item.amount}</p>
{item.status && <StatusBadge status={item.status} />}
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</button>
))}
</div>
</div>
);
}
function Level3({ kpi, item, onBack }: { kpi: KpiItem; item: DetailItem; onBack: () => void }) {
return (
<div className="space-y-6">
<button onClick={onBack} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="w-4 h-4" />
{kpi.label}
</button>
<Card>
<CardHeader className="border-b">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{item.name}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">{item.sub}</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold" style={{ color: kpi.color }}>{item.amount}</p>
{item.status && <StatusBadge status={item.status} />}
</div>
</div>
</CardHeader>
<CardContent className="pt-4">
{item.items && item.items.length > 0 ? (
<div className="space-y-3">
{item.items.map((sub, idx) => (
<div key={idx} className="flex items-center justify-between py-3 border-b last:border-0">
<div>
<p className="text-sm font-medium">{sub.label}</p>
{sub.date && <p className="text-xs text-muted-foreground mt-0.5">{sub.date}</p>}
</div>
<p className="font-semibold text-sm">{sub.value}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-8"> .</p>
)}
</CardContent>
</Card>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
'정상': 'bg-green-100 text-green-700', '주의': 'bg-yellow-100 text-yellow-700', '위험': 'bg-red-100 text-red-700',
'진행중': 'bg-blue-100 text-blue-700', '완료': 'bg-green-100 text-green-700', '대기': 'bg-gray-100 text-gray-600',
};
return <span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${styles[status] ?? 'bg-gray-100 text-gray-600'}`}>{status}</span>;
}
// ============================================
// Breadcrumb
// ============================================
function Breadcrumb({ items }: { items: { label: string; onClick?: () => void }[] }) {
return (
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-4">
{items.map((item, idx) => {
const isCurrent = idx === items.length - 1;
return (
<span key={idx} className="flex items-center gap-1.5">
{idx > 0 && <ChevronRight className="w-3.5 h-3.5" />}
{item.onClick ? (
<button onClick={item.onClick} className="hover:text-foreground transition-colors">{item.label}</button>
) : (
<span className="inline-flex items-center gap-1 bg-primary/10 text-primary font-medium px-2 py-0.5 rounded-md">
{isCurrent && <ChevronRight className="w-3 h-3" />}
{item.label}
</span>
)}
</span>
);
})}
</nav>
);
}
// ============================================
// 메인 컴포넌트
// ============================================
export function DashboardType4() {
const [level, setLevel] = useState<1 | 2 | 3>(1);
const [selectedKpi, setSelectedKpi] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<DetailItem | null>(null);
const handleKpiSelect = (id: string) => {
setSelectedKpi(id);
setLevel(2);
};
const handleItemSelect = (item: DetailItem) => {
setSelectedItem(item);
setLevel(3);
};
const goToLevel1 = () => {
setLevel(1);
setSelectedKpi(null);
setSelectedItem(null);
};
const goToLevel2 = () => {
setLevel(2);
setSelectedItem(null);
};
const currentKpi = kpis.find((k) => k.id === selectedKpi);
const currentItems = selectedKpi ? detailData[selectedKpi] ?? [] : [];
const breadcrumbItems = [
{ label: '전체 요약', onClick: level > 1 ? goToLevel1 : undefined },
...(level >= 2 && currentKpi ? [{ label: currentKpi.label, onClick: level > 2 ? goToLevel2 : undefined }] : []),
...(level === 3 && selectedItem ? [{ label: selectedItem.name }] : []),
];
return (
<PageLayout>
<div className="p-3 md:p-6">
<PageHeader
title="KPI 드릴다운"
description="숫자를 클릭하면 점점 상세해집니다."
icon={Target}
actions={<DashboardSwitcher />}
/>
<div className="mt-6">
<Breadcrumb items={breadcrumbItems} />
{level === 1 && (
<Level1 kpiList={kpis} onSelect={handleKpiSelect} />
)}
{level === 2 && currentKpi && (
<Level2 kpi={currentKpi} items={currentItems} onSelect={handleItemSelect} onBack={goToLevel1} />
)}
{level === 3 && currentKpi && selectedItem && (
<Level3 kpi={currentKpi} item={selectedItem} onBack={goToLevel2} />
)}
</div>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
import { DashboardType4 } from './_components/DashboardType4';
/**
* Dashboard Type 4 - KPI 드릴다운형 대시보드
*
* 큰 KPI 숫자를 클릭하면 점점 상세해지는 탐색 구조
* - Level 1: 전체 요약 (매출, 지출, 미수금, 현금)
* - Level 2: 카테고리 상세 (거래처별, 항목별)
* - Level 3: 개별 항목 상세
*
* URL: /ko/dashboard_type4
*/
export default function DashboardType4Page() {
return <DashboardType4 />;
}

View File

@@ -0,0 +1,255 @@
'use client';
import { useState } from 'react';
import { Activity, ArrowDownCircle, ArrowUpCircle, AlertTriangle, FileCheck, Building2, Filter } from 'lucide-react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
import { Card, CardContent } from '@/components/ui/card';
// ============================================
// Mock 데이터
// ============================================
type EventType = 'deposit' | 'withdrawal' | 'issue' | 'approval' | 'vendor' | 'order' | 'system';
interface FeedEvent {
id: number;
type: EventType;
title: string;
description?: string;
amount?: string;
time: string;
date: string; // 'today' | 'yesterday' | 'week'
priority?: 'high' | 'medium' | 'low';
status?: string;
}
const feedEvents: FeedEvent[] = [
// 오늘
{ id: 1, type: 'vendor', title: '서울금속 신규 거래처 등록', time: '10:35', date: 'today' },
{ id: 2, type: 'vendor', title: '주식회사 부산화학 등록', time: '10:34', date: 'today' },
{ id: 3, type: 'deposit', title: '현대중공업 입금 확인', amount: '683만원', time: '10:33', date: 'today' },
{ id: 4, type: 'approval', title: '출장비 정산 승인 요청', description: '홍킬동 - 부산 출장', time: '10:31', date: 'today', priority: 'high', status: '대기' },
{ id: 5, type: 'deposit', title: 'E2E_TEST 판매처 입금', amount: '827만원', time: '10:33', date: 'today' },
{ id: 6, type: 'deposit', title: '제주관광 입금 확인', amount: '177만원', time: '10:17', date: 'today' },
{ id: 7, type: 'vendor', title: '제주관광 신규 거래처 등록', time: '10:16', date: 'today' },
{ id: 8, type: 'system', title: '어제 매출 실적 집계 완료', description: '목표 대비 95% 달성', time: '09:00', date: 'today' },
// 어제
{ id: 9, type: 'issue', title: 'LG전자 미수금 120일 경과', description: '3,200만원 - 위험 등급 전환', time: '17:30', date: 'yesterday', priority: 'high' },
{ id: 10, type: 'deposit', title: '(주)대한건설 입금', amount: '2,000만원', time: '15:20', date: 'yesterday' },
{ id: 11, type: 'approval', title: '법인카드 사용 내역 승인', description: '김부장 - 접대비 52만원', time: '14:00', date: 'yesterday', status: '승인완료' },
{ id: 12, type: 'order', title: '현대건설 수주 확정', amount: '6,750만원', time: '11:30', date: 'yesterday' },
{ id: 13, type: 'issue', title: '전동개폐기 납기 지연 통보', description: '3일 지연 예상 - 대한건설', time: '10:00', date: 'yesterday', priority: 'medium' },
{ id: 14, type: 'withdrawal', title: '2월 급여 이체 완료', amount: '3,200만원', time: '09:30', date: 'yesterday' },
// 이번주
{ id: 15, type: 'order', title: 'SK에코플랜트 수주', amount: '8,900만원', time: '2/7', date: 'week' },
{ id: 16, type: 'issue', title: '자재 단가 인상 통보', description: '동 파이프 +15%', time: '2/7', date: 'week', priority: 'medium' },
{ id: 17, type: 'deposit', title: '삼성엔지니어링 입금', amount: '5,000만원', time: '2/6', date: 'week' },
{ id: 18, type: 'approval', title: '구매 발주 승인', description: '전자부품 일괄 발주 1,800만원', time: '2/6', date: 'week', status: '승인완료' },
{ id: 19, type: 'vendor', title: '(주)미래산업 거래처 등록', time: '2/5', date: 'week' },
{ id: 20, type: 'issue', title: '품질 검사 부적합 2건', description: '제어반 PCB 불량', time: '2/5', date: 'week', priority: 'high' },
];
const filterConfig: { key: EventType | 'all'; label: string; icon: typeof Activity }[] = [
{ key: 'all', label: '전체', icon: Activity },
{ key: 'deposit', label: '입금', icon: ArrowDownCircle },
{ key: 'issue', label: '이슈', icon: AlertTriangle },
{ key: 'approval', label: '결재', icon: FileCheck },
{ key: 'vendor', label: '거래처', icon: Building2 },
];
// ============================================
// 이벤트 아이콘/색상
// ============================================
function getEventStyle(type: EventType) {
switch (type) {
case 'deposit': return { icon: ArrowDownCircle, color: 'text-green-500', bg: 'bg-green-50', ring: 'ring-green-200' };
case 'withdrawal': return { icon: ArrowUpCircle, color: 'text-red-500', bg: 'bg-red-50', ring: 'ring-red-200' };
case 'issue': return { icon: AlertTriangle, color: 'text-yellow-600', bg: 'bg-yellow-50', ring: 'ring-yellow-200' };
case 'approval': return { icon: FileCheck, color: 'text-blue-500', bg: 'bg-blue-50', ring: 'ring-blue-200' };
case 'vendor': return { icon: Building2, color: 'text-purple-500', bg: 'bg-purple-50', ring: 'ring-purple-200' };
case 'order': return { icon: ArrowDownCircle, color: 'text-blue-600', bg: 'bg-blue-50', ring: 'ring-blue-200' };
case 'system': return { icon: Activity, color: 'text-gray-500', bg: 'bg-gray-50', ring: 'ring-gray-200' };
}
}
function PriorityDot({ priority }: { priority?: string }) {
if (!priority) return null;
const colors: Record<string, string> = { high: 'bg-red-500', medium: 'bg-yellow-500', low: 'bg-green-500' };
return <span className={`w-2 h-2 rounded-full ${colors[priority] ?? 'bg-gray-400'}`} />;
}
function StatusTag({ status }: { status?: string }) {
if (!status) return null;
const styles: Record<string, string> = { '대기': 'bg-yellow-100 text-yellow-700', '승인완료': 'bg-green-100 text-green-700' };
return <span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${styles[status] ?? 'bg-gray-100 text-gray-600'}`}>{status}</span>;
}
// ============================================
// 피드 아이템
// ============================================
function FeedItem({ event }: { event: FeedEvent }) {
const style = getEventStyle(event.type);
const Icon = style.icon;
return (
<div className="flex gap-3 p-3 rounded-xl hover:bg-muted/30 transition-colors cursor-pointer group">
<div className={`flex-shrink-0 w-9 h-9 rounded-full ${style.bg} ring-1 ${style.ring} flex items-center justify-center`}>
<Icon className={`w-4 h-4 ${style.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<PriorityDot priority={event.priority} />
<p className="text-sm font-medium truncate">{event.title}</p>
</div>
{event.description && (
<p className="text-xs text-muted-foreground mt-0.5">{event.description}</p>
)}
<div className="flex items-center gap-2 mt-1">
{event.amount && <span className="text-xs font-semibold text-green-600">{event.amount}</span>}
<StatusTag status={event.status} />
</div>
</div>
<div className="flex-shrink-0 text-right">
<span className="text-xs text-muted-foreground">{event.time}</span>
</div>
</div>
);
}
// ============================================
// 요약 통계
// ============================================
function TodaySummary() {
const stats = [
{ label: '오늘 입금', value: '1,687만원', color: 'text-green-600', bg: 'bg-green-50' },
{ label: '오늘 출금', value: '0원', color: 'text-red-600', bg: 'bg-red-50' },
{ label: '미처리 결재', value: '1건', color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: '신규 이슈', value: '0건', color: 'text-yellow-600', bg: 'bg-yellow-50' },
];
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{stats.map((s) => (
<div key={s.label} className={`${s.bg} rounded-xl p-4`}>
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={`text-lg font-bold mt-1 ${s.color}`}>{s.value}</p>
</div>
))}
</div>
);
}
// ============================================
// 시간 그룹 헤더
// ============================================
function TimeGroupHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex items-center gap-3 py-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{label} <span className="text-muted-foreground/60">({count})</span>
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}
// ============================================
// 메인 컴포넌트
// ============================================
export function DashboardType5() {
const [filter, setFilter] = useState<EventType | 'all'>('all');
const filtered = filter === 'all'
? feedEvents
: feedEvents.filter((e) => e.type === filter);
const todayEvents = filtered.filter((e) => e.date === 'today');
const yesterdayEvents = filtered.filter((e) => e.date === 'yesterday');
const weekEvents = filtered.filter((e) => e.date === 'week');
return (
<PageLayout>
<div className="p-3 md:p-6">
<PageHeader
title="활동 피드"
description="회사에서 일어나는 일을 시간순으로 확인합니다."
icon={Activity}
actions={<DashboardSwitcher />}
/>
<div className="mt-6 space-y-6">
<TodaySummary />
{/* 필터 */}
<div className="flex items-center gap-2 overflow-x-auto pb-1">
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" />
{filterConfig.map((f) => {
const Icon = f.icon;
const isActive = filter === f.key;
return (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
<Icon className="w-3.5 h-3.5" />
{f.label}
</button>
);
})}
</div>
{/* 타임라인 */}
<Card>
<CardContent className="p-2 md:p-4">
{todayEvents.length > 0 && (
<>
<TimeGroupHeader label="오늘" count={todayEvents.length} />
<div className="space-y-0.5">
{todayEvents.map((e) => <FeedItem key={e.id} event={e} />)}
</div>
</>
)}
{yesterdayEvents.length > 0 && (
<>
<TimeGroupHeader label="어제" count={yesterdayEvents.length} />
<div className="space-y-0.5">
{yesterdayEvents.map((e) => <FeedItem key={e.id} event={e} />)}
</div>
</>
)}
{weekEvents.length > 0 && (
<>
<TimeGroupHeader label="이번 주" count={weekEvents.length} />
<div className="space-y-0.5">
{weekEvents.map((e) => <FeedItem key={e.id} event={e} />)}
</div>
</>
)}
{filtered.length === 0 && (
<div className="py-16 text-center text-sm text-muted-foreground">
.
</div>
)}
</CardContent>
</Card>
</div>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
import { DashboardType5 } from './_components/DashboardType5';
/**
* Dashboard Type 5 - 타임라인 피드형 대시보드
*
* 회사에서 일어나는 이벤트를 시간순으로 보는 구조
* - 오늘/어제/이번주 시간 그룹
* - 필터: 전체 | 입금 | 이슈 | 결재 | 거래처
* - 각 항목 클릭 시 상세 페이지 이동
*
* URL: /ko/dashboard_type5
*/
export default function DashboardType5Page() {
return <DashboardType5 />;
}

View File

@@ -1,37 +1,111 @@
"use client";
import React, { useState, useRef, useCallback, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ZoomIn, ZoomOut, Download, Printer, AlertCircle, Maximize2 } from 'lucide-react';
import { Button } from "@/components/ui/button";
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { AlertCircle, Loader2, Save } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Document, DocumentItem } from '../types';
import { MOCK_WORK_ORDER, MOCK_SHIPMENT_DETAIL } from '../mockData';
import { MOCK_SHIPMENT_DETAIL } from '../mockData';
// 기존 문서 컴포넌트 import
import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation';
import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip';
// 수주서 문서 컴포넌트 import
import { SalesOrderDocument } from '@/components/orders/documents/SalesOrderDocument';
import type { ProductInfo } from '@/components/orders/documents/OrderDocumentModal';
import type { OrderItem } from '@/components/orders/actions';
// 품질검사 문서 컴포넌트 import
import {
ImportInspectionDocument,
ProductInspectionDocument,
ScreenInspectionDocument,
BendingInspectionDocument,
SlatInspectionDocument,
JointbarInspectionDocument,
QualityDocumentUploader,
} from './documents';
// 제품검사 성적서 (신규 양식) import
import { InspectionReportDocument } from '@/components/quality/InspectionManagement/documents/InspectionReportDocument';
import { mockReportInspectionItems } from '@/components/quality/InspectionManagement/mockData';
import type { InspectionReportDocument as InspectionReportDocumentType } from '@/components/quality/InspectionManagement/types';
import type { ImportInspectionTemplate, ImportInspectionRef, InspectionItemValue } from './documents/ImportInspectionDocument';
// 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전)
import {
ScreenWorkLogContent,
SlatWorkLogContent,
BendingWorkLogContent,
ScreenInspectionContent,
SlatInspectionContent,
BendingInspectionContent,
} from '@/components/production/WorkOrders/documents';
import type { WorkOrder } from '@/components/production/WorkOrders/types';
// 검사 템플릿 API
import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions';
/**
* 저장된 document.data (field_key 기반)를 ImportInspectionDocument의 initialValues로 변환
*
* field_key 패턴:
* - {itemId}_n{1,2,3} → numeric 측정값
* - {itemId}_okng_n{1,2,3} → OK/NG 값
* - {itemId}_result → 항목별 판정
*/
function parseSavedDataToInitialValues(
tmpl: ImportInspectionTemplate,
docData: Array<{ field_key: string; field_value: string | null }>
): InspectionItemValue[] {
// field_key → value 맵 생성
const dataMap = new Map<string, string>();
for (const d of docData) {
if (d.field_value) dataMap.set(d.field_key, d.field_value);
}
return tmpl.inspectionItems.map((item) => {
const isOkng = item.measurementType === 'okng';
const measurements: (number | 'OK' | 'NG' | null)[] = Array(item.measurementCount).fill(null);
for (let n = 0; n < item.measurementCount; n++) {
if (isOkng) {
const val = dataMap.get(`${item.id}_okng_n${n + 1}`);
if (val === 'ok') measurements[n] = 'OK';
else if (val === 'ng') measurements[n] = 'NG';
} else {
const val = dataMap.get(`${item.id}_n${n + 1}`);
if (val) {
const num = parseFloat(val);
measurements[n] = isNaN(num) ? null : num;
}
}
}
// 항목별 판정
const resultVal = dataMap.get(`${item.id}_result`);
let result: 'OK' | 'NG' | null = null;
if (resultVal === 'ok') result = 'OK';
else if (resultVal === 'ng') result = 'NG';
return { itemId: item.id, measurements, result };
});
}
interface InspectionModalProps {
isOpen: boolean;
onClose: () => void;
document: Document | null;
documentItem: DocumentItem | null;
// 수입검사 템플릿 로드용 추가 props
itemId?: number; // 품목 ID (실제 API로 템플릿 조회 시 사용)
itemName?: string;
specification?: string;
supplier?: string;
inspector?: string; // 검사자 (현재 로그인 사용자)
inspectorDept?: string; // 검사자 부서
lotSize?: number; // 로트크기 (입고수량)
materialNo?: string; // 자재번호
// 읽기 전용 모드 (QMS 심사 확인용)
readOnly?: boolean;
}
// 문서 타입별 정보
@@ -73,351 +147,203 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D
);
};
// 수주서 문서 컴포넌트 (간소화 버전 - deprecated, V2 사용)
const OrderDocument = () => {
const data = { lotNumber: '', orderDate: '', client: '', siteName: '', manager: '', managerContact: '', deliveryRequestDate: '', expectedShipDate: '', deliveryMethod: '', address: '', items: [] as { id: string; name: string; specification: string; unit: string; quantity: number; unitPrice?: number; amount?: number }[], subtotal: 0, discountRate: 0, totalAmount: 0, remarks: '' };
// QMS용 수주서 Mock 데이터
const QMS_MOCK_PRODUCTS: ProductInfo[] = [
{ productName: '방화 스크린 셔터 (표준형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 5, floor: '1F', code: 'FSS-01' },
{ productName: '방화 스크린 셔터 (방화형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 3, floor: '2F', code: 'FSS-02' },
];
const QMS_MOCK_ORDER_ITEMS: OrderItem[] = [
{ id: 'mt-1', itemCode: 'MT-001', itemName: '모터(380V 단상)', specification: '150K', type: '모터', quantity: 8, unit: 'EA', unitPrice: 120000, supplyAmount: 960000, taxAmount: 96000, totalAmount: 1056000, sortOrder: 1 },
{ id: 'br-1', itemCode: 'BR-001', itemName: '브라켓트', specification: '380X180 [2-4"]', type: '브라켓', quantity: 16, unit: 'EA', unitPrice: 15000, supplyAmount: 240000, taxAmount: 24000, totalAmount: 264000, sortOrder: 2 },
{ id: 'gr-1', itemCode: 'GR-001', itemName: '가이드레일 백면형 (120X70)', specification: 'EGI 1.5ST', type: '가이드레일', quantity: 16, unit: 'EA', unitPrice: 25000, supplyAmount: 400000, taxAmount: 40000, totalAmount: 440000, width: 120, height: 2500, sortOrder: 3 },
{ id: 'cs-1', itemCode: 'CS-001', itemName: '케이스(셔터박스)', specification: 'EGI 1.5ST 380X180', type: '케이스', quantity: 8, unit: 'EA', unitPrice: 35000, supplyAmount: 280000, taxAmount: 28000, totalAmount: 308000, width: 380, height: 180, sortOrder: 4 },
{ id: 'bf-1', itemCode: 'BF-001', itemName: '하단마감재', specification: 'EGI 1.5ST', type: '하단마감재', quantity: 8, unit: 'EA', unitPrice: 18000, supplyAmount: 144000, taxAmount: 14400, totalAmount: 158400, sortOrder: 5 },
];
return (
<div className="bg-white p-8 w-full text-sm shadow-sm">
{/* 헤더 */}
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-4">
<div className="text-2xl font-bold">KD</div>
<div className="text-xs"></div>
</div>
<div className="text-2xl font-bold tracking-[0.5rem]"> </div>
<table className="text-xs border-collapse">
<tbody>
<tr>
<td className="border px-2 py-1 bg-gray-100" rowSpan={3}>
<div className="flex flex-col items-center">
<span></span><span></span>
</div>
</td>
<td className="border px-2 py-1 bg-gray-100 text-center w-16"></td>
<td className="border px-2 py-1 bg-gray-100 text-center w-16"></td>
<td className="border px-2 py-1 bg-gray-100 text-center w-16"></td>
</tr>
<tr>
<td className="border px-2 py-1 h-10"></td>
<td className="border px-2 py-1 h-10"></td>
<td className="border px-2 py-1 h-10"></td>
</tr>
<tr>
<td className="border px-2 py-1 text-center bg-gray-50"></td>
<td className="border px-2 py-1 text-center bg-gray-50"></td>
<td className="border px-2 py-1 text-center bg-gray-50"></td>
</tr>
</tbody>
</table>
</div>
{/* 기본 정보 */}
<table className="w-full border-collapse mb-6 text-xs">
<tbody>
<tr>
<td className="border px-3 py-2 bg-gray-100 w-24">LOT NO.</td>
<td className="border px-3 py-2">{data.lotNumber}</td>
<td className="border px-3 py-2 bg-gray-100 w-24"></td>
<td className="border px-3 py-2">{data.orderDate}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.client}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.siteName}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.manager}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.managerContact}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.deliveryRequestDate}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.expectedShipDate}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.deliveryMethod}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.address}</td>
</tr>
</tbody>
</table>
{/* 품목 테이블 */}
<table className="w-full border-collapse mb-6 text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border px-2 py-2 w-10">No</th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2 w-24"></th>
<th className="border px-2 py-2 w-12"></th>
<th className="border px-2 py-2 w-12"></th>
<th className="border px-2 py-2 w-20"></th>
<th className="border px-2 py-2 w-24"></th>
</tr>
</thead>
<tbody>
{data.items.map((item, index) => (
<tr key={item.id}>
<td className="border px-2 py-2 text-center">{index + 1}</td>
<td className="border px-2 py-2">{item.name}</td>
<td className="border px-2 py-2 text-center">{item.specification}</td>
<td className="border px-2 py-2 text-center">{item.unit}</td>
<td className="border px-2 py-2 text-center">{item.quantity}</td>
<td className="border px-2 py-2 text-right">{item.unitPrice?.toLocaleString()}</td>
<td className="border px-2 py-2 text-right">{item.amount?.toLocaleString()}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-50"></td>
<td colSpan={2} className="border px-2 py-2 text-right">{data.subtotal.toLocaleString()}</td>
</tr>
<tr>
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-50"> ({data.discountRate}%)</td>
<td colSpan={2} className="border px-2 py-2 text-right text-red-600">-{(data.subtotal * data.discountRate / 100).toLocaleString()}</td>
</tr>
<tr>
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-100 font-bold"></td>
<td colSpan={2} className="border px-2 py-2 text-right font-bold text-blue-600">{data.totalAmount.toLocaleString()}</td>
</tr>
</tfoot>
</table>
{/* 비고 */}
{data.remarks && (
<div className="border p-4">
<h3 className="font-medium mb-2 text-xs"></h3>
<p className="text-xs text-gray-600">{data.remarks}</p>
</div>
)}
</div>
);
// QMS용 제품검사 성적서 Mock 데이터
const QMS_MOCK_REPORT_DATA: InspectionReportDocumentType = {
documentNumber: 'RPT-KD-SS-2024-530',
createdDate: '2024-09-24',
approvalLine: [
{ role: '작성', name: '김검사', department: '품질관리부' },
{ role: '승인', name: '박승인', department: '품질관리부' },
],
productName: '방화스크린',
productLotNo: 'KD-SS-240924-19',
productCode: 'WY-SC780',
lotSize: '8',
client: '삼성물산(주)',
inspectionDate: '2024-09-26',
siteName: '강남 아파트 단지',
inspector: '김검사',
inspectionItems: mockReportInspectionItems,
specialNotes: '',
finalJudgment: '합격',
};
// 작업일지 문서 컴포넌트 (간소화 버전)
const WorkLogDocument = () => {
const order = MOCK_WORK_ORDER;
const today = new Date().toLocaleDateString('ko-KR').replace(/\. /g, '-').replace('.', '');
const documentNo = `WL-${order.processCode.toUpperCase().slice(0, 3)}`;
const lotNo = `KD-TS-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
// QMS용 작업일지 Mock WorkOrder 생성
const createQmsMockWorkOrder = (subType?: string): WorkOrder => ({
id: 'qms-wo-1',
workOrderNo: 'KD-WO-240924-01',
lotNo: 'KD-SS-240924-19',
processId: 1,
processName: subType === 'slat' ? '슬랫' : subType === 'bending' ? '절곡' : '스크린',
processCode: subType || 'screen',
processType: (subType || 'screen') as 'screen' | 'slat' | 'bending',
status: 'in_progress',
client: '삼성물산(주)',
projectName: '강남 아파트 단지',
dueDate: '2024-10-05',
assignee: '김작업',
assignees: [
{ id: '1', name: '김작업', isPrimary: true },
{ id: '2', name: '이생산', isPrimary: false },
],
orderDate: '2024-09-20',
scheduledDate: '2024-09-24',
shipmentDate: '2024-10-04',
salesOrderDate: '2024-09-18',
isAssigned: true,
isStarted: true,
priority: 3,
priorityLabel: '긴급',
shutterCount: 5,
department: '생산부',
items: [
{ id: '1', no: 1, status: 'completed', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '3000×2500', quantity: 2, unit: 'EA', orderNodeId: null, orderNodeName: '' },
{ id: '2', no: 2, status: 'in_progress', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '3000×2500', quantity: 3, unit: 'EA', orderNodeId: null, orderNodeName: '' },
{ id: '3', no: 3, status: 'waiting', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12000×4500', quantity: 1, unit: 'EA', orderNodeId: null, orderNodeName: '' },
],
currentStep: 2,
issues: [],
note: '품질 검수 철저히 진행',
});
const items = [
{ no: 1, name: order.productName, location: '1층/A-01', spec: '3000×2500', qty: 1, status: '완료' },
{ no: 2, name: order.productName, location: '2층/A-02', spec: '3000×2500', qty: 1, status: '작업중' },
{ no: 3, name: order.productName, location: '3층/A-03', spec: '-', qty: 1, status: '대기' },
];
// 로딩 컴포넌트
const LoadingDocument = () => (
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
<p className="text-gray-600 text-sm"> 릿 ...</p>
</div>
);
return (
<div className="bg-white p-8 w-full text-sm shadow-sm">
{/* 헤더 */}
<div className="flex justify-between items-start mb-6 border border-gray-300">
<div className="w-24 border-r border-gray-300 flex flex-col items-center justify-center p-3">
<span className="text-2xl font-bold">KD</span>
<span className="text-xs text-gray-500"></span>
</div>
<div className="flex-1 flex flex-col items-center justify-center p-3 border-r border-gray-300">
<h1 className="text-xl font-bold tracking-widest mb-1"> </h1>
<p className="text-xs text-gray-500">{documentNo}</p>
<p className="text-sm font-medium mt-1"> </p>
</div>
<table className="text-xs shrink-0">
<tbody>
<tr>
<td rowSpan={3} className="w-8 text-center font-medium bg-gray-100 border-r border-b border-gray-300">
<div className="flex flex-col items-center"><span></span><span></span></div>
</td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-b border-gray-300"></td>
</tr>
<tr>
<td className="w-16 p-2 text-center border-r border-b border-gray-300">
<div>{order.assignees[0] || '-'}</div>
</td>
<td className="w-16 p-2 text-center border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center border-b border-gray-300"></td>
</tr>
<tr>
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300"></td>
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300"></td>
<td className="w-16 p-2 text-center bg-gray-50"></td>
</tr>
</tbody>
</table>
</div>
// 에러 컴포넌트
const ErrorDocument = ({ message, onRetry }: { message: string; onRetry?: () => void }) => (
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<p className="text-gray-800 font-medium mb-2">릿 </p>
<p className="text-gray-500 text-sm mb-4">{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
>
</button>
)}
</div>
);
{/* 기본 정보 */}
<div className="border border-gray-300 mb-6">
<div className="grid grid-cols-2 border-b border-gray-300">
<div className="flex border-r border-gray-300">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300"></div>
<div className="flex-1 p-3 text-sm">{order.client}</div>
</div>
<div className="flex">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300"></div>
<div className="flex-1 p-3 text-sm">{order.projectName}</div>
</div>
</div>
<div className="grid grid-cols-2 border-b border-gray-300">
<div className="flex border-r border-gray-300">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300"></div>
<div className="flex-1 p-3 text-sm">{today}</div>
</div>
<div className="flex">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">LOT NO.</div>
<div className="flex-1 p-3 text-sm">{lotNo}</div>
</div>
</div>
<div className="grid grid-cols-2">
<div className="flex border-r border-gray-300">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300"></div>
<div className="flex-1 p-3 text-sm">{order.dueDate}</div>
</div>
<div className="flex">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300"></div>
<div className="flex-1 p-3 text-sm">{order.quantity} EA</div>
</div>
</div>
</div>
/**
* InspectionModal V2
* - DocumentViewer 시스템 사용
* - 수입검사: 모달 열릴 때 API로 템플릿 로드 (Lazy Loading)
*/
export const InspectionModal = ({
isOpen,
onClose,
document: doc,
documentItem,
itemId,
itemName,
specification,
supplier,
inspector,
inspectorDept,
lotSize,
materialNo,
readOnly = false,
}: InspectionModalProps) => {
// 수입검사 템플릿 상태
const [importTemplate, setImportTemplate] = useState<ImportInspectionTemplate | null>(null);
const [importInitialValues, setImportInitialValues] = useState<InspectionItemValue[] | undefined>(undefined);
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false);
const [templateError, setTemplateError] = useState<string | null>(null);
{/* 품목 테이블 */}
<div className="border border-gray-300 mb-6">
<div className="grid grid-cols-12 border-b border-gray-300 bg-gray-100">
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">No</div>
<div className="col-span-4 p-2 text-sm font-medium text-center border-r border-gray-300"></div>
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">/</div>
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300"></div>
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300"></div>
<div className="col-span-2 p-2 text-sm font-medium text-center"></div>
</div>
{items.map((item, index) => (
<div key={item.no} className={`grid grid-cols-12 ${index < items.length - 1 ? 'border-b border-gray-300' : ''}`}>
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.no}</div>
<div className="col-span-4 p-2 text-sm border-r border-gray-300">{item.name}</div>
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.location}</div>
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.spec}</div>
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.qty}</div>
<div className="col-span-2 p-2 text-sm text-center">
<span className={`px-2 py-0.5 rounded text-xs ${
item.status === '완료' ? 'bg-green-100 text-green-700' :
item.status === '작업중' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-700'
}`}>{item.status}</span>
</div>
</div>
))}
</div>
// 수입검사 저장용 ref/상태
const importDocRef = useRef<ImportInspectionRef>(null);
const [isSaving, setIsSaving] = useState(false);
{/* 특이사항 */}
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2.5 text-sm font-medium text-center"></div>
<div className="p-4 min-h-[60px] text-sm">{order.instruction || '-'}</div>
</div>
</div>
);
};
// 줌 레벨 상수
const ZOOM_LEVELS = [50, 75, 100, 125, 150, 200];
const MIN_ZOOM = 50;
const MAX_ZOOM = 200;
export const InspectionModal = ({ isOpen, onClose, document: doc, documentItem }: InspectionModalProps) => {
// 줌 상태
const [zoom, setZoom] = useState(100);
// 드래그 상태
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
// refs
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 모달 열릴 때 상태 초기화
// 수입검사 템플릿 로드 (모달 열릴 때)
useEffect(() => {
if (isOpen) {
setZoom(100);
setPosition({ x: 0, y: 0 });
// itemId가 있으면 실제 API로 조회, 없으면 itemName/specification으로 mock 조회
if (isOpen && doc?.type === 'import' && (itemId || (itemName && specification))) {
loadInspectionTemplate();
}
}, [isOpen]);
// 줌 인
const handleZoomIn = useCallback(() => {
setZoom(prev => {
const nextIndex = ZOOM_LEVELS.findIndex(z => z > prev);
return nextIndex !== -1 ? ZOOM_LEVELS[nextIndex] : MAX_ZOOM;
});
}, []);
// 줌 아웃
const handleZoomOut = useCallback(() => {
setZoom(prev => {
const prevIndex = ZOOM_LEVELS.slice().reverse().findIndex(z => z < prev);
const index = prevIndex !== -1 ? ZOOM_LEVELS.length - 1 - prevIndex : 0;
return ZOOM_LEVELS[index] || MIN_ZOOM;
});
}, []);
// 줌 리셋
const handleZoomReset = useCallback(() => {
setZoom(100);
setPosition({ x: 0, y: 0 });
}, []);
// 마우스 드래그 시작
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (zoom > 100) {
setIsDragging(true);
setStartPos({ x: e.clientX - position.x, y: e.clientY - position.y });
// 모달 닫힐 때 상태 초기화
if (!isOpen) {
setImportTemplate(null);
setImportInitialValues(undefined);
setTemplateError(null);
}
}, [zoom, position]);
}, [isOpen, doc?.type, itemId, itemName, specification]);
// 마우스 이동
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging) return;
setPosition({
x: e.clientX - startPos.x,
y: e.clientY - startPos.y,
});
}, [isDragging, startPos]);
const loadInspectionTemplate = async () => {
// itemId가 있으면 실제 API 호출, 없으면 itemName/specification 필요
if (!itemId && (!itemName || !specification)) return;
// 마우스 드래그 종료
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
setIsLoadingTemplate(true);
setTemplateError(null);
// 터치 드래그 시작
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (zoom > 100 && e.touches.length === 1) {
setIsDragging(true);
setStartPos({
x: e.touches[0].clientX - position.x,
y: e.touches[0].clientY - position.y,
try {
const result = await getInspectionTemplate({
itemId,
itemName,
specification,
lotNo: documentItem?.code,
supplier,
inspector,
lotSize,
materialNo,
});
if (result.success && result.data) {
const tmpl = result.data as ImportInspectionTemplate;
setImportTemplate(tmpl);
// 저장된 측정값을 initialValues로 변환
const docData = result.resolveData?.document?.data;
if (docData && docData.length > 0) {
const values = parseSavedDataToInitialValues(tmpl, docData.map((d: { field_key: string; field_value?: string | null }) => ({ field_key: d.field_key, field_value: d.field_value ?? null })));
setImportInitialValues(values);
} else {
setImportInitialValues(undefined);
}
} else {
setTemplateError(result.error || '템플릿을 불러올 수 없습니다.');
}
} catch (error) {
console.error('[InspectionModal] loadInspectionTemplate error:', error);
setTemplateError('템플릿 로드 중 오류가 발생했습니다.');
} finally {
setIsLoadingTemplate(false);
}
}, [zoom, position]);
};
// 터치 이동
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!isDragging || e.touches.length !== 1) return;
setPosition({
x: e.touches[0].clientX - startPos.x,
y: e.touches[0].clientY - startPos.y,
});
}, [isDragging, startPos]);
// 수입검사 저장 핸들러 (hooks는 early return 전에 호출해야 함)
const handleImportSave = useCallback(async () => {
if (!importDocRef.current) return;
// 터치 종료
const handleTouchEnd = useCallback(() => {
setIsDragging(false);
const data = importDocRef.current.getInspectionData();
setIsSaving(true);
try {
// TODO: 실제 저장 API 연동
toast.success('검사 데이터가 저장되었습니다.');
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, []);
if (!doc) return null;
@@ -427,158 +353,149 @@ export const InspectionModal = ({ isOpen, onClose, document: doc, documentItem }
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` 로트: ${documentItem.code}` : ''}`
: docInfo.label;
const handlePrint = () => {
window.print();
};
// 중간검사 성적서 서브타입에 따른 렌더링
const renderReportDocument = () => {
const subType = documentItem?.subType;
switch (subType) {
case 'screen':
return <ScreenInspectionDocument />;
case 'bending':
return <BendingInspectionDocument />;
case 'slat':
return <SlatInspectionDocument />;
case 'jointbar':
return <JointbarInspectionDocument />;
default:
// 서브타입이 없으면 기본 스크린 문서
return <ScreenInspectionDocument />;
}
};
// 품질관리서 PDF 업로드 핸들러
const handleQualityFileUpload = (file: File) => {
// TODO: 실제 API 연동 시 파일 업로드 로직 구현
};
const handleQualityFileDelete = () => {
// TODO: 실제 API 연동 시 파일 삭제 로직 구현
};
// 작업일지 공정별 렌더링
const renderWorkLogDocument = () => {
const subType = documentItem?.subType;
const mockOrder = createQmsMockWorkOrder(subType);
switch (subType) {
case 'screen':
return <ScreenWorkLogContent data={mockOrder} />;
case 'slat':
return <SlatWorkLogContent data={mockOrder} />;
case 'bending':
return <BendingWorkLogContent data={mockOrder} />;
default:
// subType 미지정 시 스크린 기본
return <ScreenWorkLogContent data={mockOrder} />;
}
};
// 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일)
const renderReportDocument = () => {
const subType = documentItem?.subType;
const mockOrder = createQmsMockWorkOrder(subType || 'screen');
switch (subType) {
case 'screen':
return <ScreenInspectionContent data={mockOrder} readOnly />;
case 'bending':
return <BendingInspectionContent data={mockOrder} readOnly />;
case 'slat':
return <SlatInspectionContent data={mockOrder} readOnly />;
case 'jointbar':
return <JointbarInspectionDocument />;
default:
return <ScreenInspectionContent data={mockOrder} readOnly />;
}
};
// 수입검사 문서 렌더링 (Lazy Loading)
const renderImportInspectionDocument = () => {
if (isLoadingTemplate) {
return <LoadingDocument />;
}
if (templateError) {
return <ErrorDocument message={templateError} onRetry={loadInspectionTemplate} />;
}
// 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용
return (
<ImportInspectionDocument
ref={importDocRef}
template={importTemplate || undefined}
initialValues={importInitialValues}
readOnly={readOnly}
inspectorDept={inspectorDept}
/>
);
};
// 문서 타입에 따른 컨텐츠 렌더링
const renderDocumentContent = () => {
switch (doc.type) {
case 'order':
return <OrderDocument />;
return (
<SalesOrderDocument
orderNumber="KD-SS-240924-19"
documentNumber="KD-SS-240924-19"
certificationNumber="KD-SS-240924-19"
orderDate="2024-09-24"
client="삼성물산(주)"
siteName="강남 아파트 단지"
manager="김담당"
managerContact="010-1234-5678"
deliveryRequestDate="2024-10-05"
expectedShipDate="2024-10-04"
deliveryMethod="직접배차"
address="서울시 강남구 테헤란로 123"
recipientName="김인수"
recipientContact="010-9876-5432"
shutterCount={8}
products={QMS_MOCK_PRODUCTS}
items={QMS_MOCK_ORDER_ITEMS}
remarks="납기일 엄수 요청"
/>
);
case 'log':
return <WorkLogDocument />;
return renderWorkLogDocument();
case 'confirmation':
return <DeliveryConfirmation data={MOCK_SHIPMENT_DETAIL} />;
case 'shipping':
return <ShippingSlip data={MOCK_SHIPMENT_DETAIL} />;
case 'import':
return <ImportInspectionDocument />;
return renderImportInspectionDocument();
case 'product':
return <ProductInspectionDocument />;
return <InspectionReportDocument data={QMS_MOCK_REPORT_DATA} />;
case 'report':
return renderReportDocument();
case 'quality':
// 품질관리서는 PDF 업로드/뷰어 사용
return (
<QualityDocumentUploader
onFileUpload={handleQualityFileUpload}
onFileDelete={handleQualityFileDelete}
disabled={readOnly}
/>
);
default:
// 양식 대기 중인 문서
return <PlaceholderDocument docType={doc.type} docItem={documentItem} />;
}
};
// 다운로드 핸들러 (TODO: 실제 구현)
const handleDownload = () => {
};
// 수입검사 저장 버튼 (toolbarExtra) - readOnly일 때 숨김
const importToolbarExtra = doc.type === 'import' && !readOnly ? (
<Button onClick={handleImportSave} disabled={isSaving} size="sm">
{isSaving ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1.5" />
)}
{isSaving ? '저장 중...' : '저장'}
</Button>
) : undefined;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="w-[95vw] max-w-[1200px] sm:max-w-[1200px] h-[90vh] p-0 overflow-hidden bg-gray-50 flex flex-col">
<DialogHeader className="p-4 bg-white border-b border-gray-200 flex flex-row items-center justify-between space-y-0 shrink-0">
<div>
<DialogTitle className="text-lg font-bold text-gray-800">{doc.title}</DialogTitle>
<p className="text-xs text-gray-500 mt-1">{subtitle}</p>
</div>
</DialogHeader>
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-100 shrink-0 flex-wrap gap-2">
<div className="flex items-center gap-1 sm:gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 text-xs px-2 sm:px-3"
onClick={handleZoomOut}
disabled={zoom <= MIN_ZOOM}
>
<ZoomOut size={14} />
<span className="hidden sm:inline"></span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 text-xs px-2 sm:px-3"
onClick={handleZoomIn}
disabled={zoom >= MAX_ZOOM}
>
<ZoomIn size={14} />
<span className="hidden sm:inline"></span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 text-xs px-2 sm:px-3"
onClick={handleZoomReset}
>
<Maximize2 size={14} />
<span className="hidden sm:inline"></span>
</Button>
<span className="text-xs font-mono text-gray-600 bg-gray-100 px-2 py-1 rounded min-w-[48px] text-center">
{zoom}%
</span>
</div>
<div className="flex items-center gap-1 sm:gap-2">
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs px-2 sm:px-3" onClick={handlePrint}>
<Printer size={14} />
<span className="hidden sm:inline"></span>
</Button>
<Button size="sm" className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1 text-xs px-2 sm:px-3">
<Download size={14} />
<span className="hidden sm:inline"></span>
</Button>
</div>
</div>
{/* Content Area - 줌/드래그 가능한 영역 */}
<div
ref={containerRef}
className="flex-1 overflow-auto bg-gray-100 relative"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{ cursor: zoom > 100 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
>
<div
ref={contentRef}
className="p-4 origin-top-left transition-transform duration-150 ease-out"
style={{
transform: `scale(${zoom / 100}) translate(${position.x / (zoom / 100)}px, ${position.y / (zoom / 100)}px)`,
minWidth: '800px',
}}
>
{renderDocumentContent()}
</div>
</div>
{/* 모바일 줌 힌트 */}
{zoom === 100 && (
<div className="sm:hidden absolute bottom-20 left-1/2 -translate-x-1/2 bg-black/70 text-white text-xs px-3 py-1.5 rounded-full">
</div>
)}
</DialogContent>
</Dialog>
<DocumentViewer
title={doc.title}
subtitle={subtitle}
preset="inspection"
open={isOpen}
onOpenChange={(open) => !open && onClose()}
onDownload={handleDownload}
toolbarExtra={importToolbarExtra}
>
{renderDocumentContent()}
</DocumentViewer>
);
};

View File

@@ -1,501 +0,0 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { AlertCircle, Loader2, Save } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Document, DocumentItem } from '../types';
import { MOCK_SHIPMENT_DETAIL } from '../mockData';
// 기존 문서 컴포넌트 import
import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation';
import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip';
// 수주서 문서 컴포넌트 import
import { SalesOrderDocument } from '@/components/orders/documents/SalesOrderDocument';
import type { ProductInfo } from '@/components/orders/documents/OrderDocumentModal';
import type { OrderItem } from '@/components/orders/actions';
// 품질검사 문서 컴포넌트 import
import {
ImportInspectionDocument,
JointbarInspectionDocument,
QualityDocumentUploader,
} from './documents';
// 제품검사 성적서 (신규 양식) import
import { InspectionReportDocument } from '@/components/quality/InspectionManagement/documents/InspectionReportDocument';
import { mockReportInspectionItems } from '@/components/quality/InspectionManagement/mockData';
import type { InspectionReportDocument as InspectionReportDocumentType } from '@/components/quality/InspectionManagement/types';
import type { ImportInspectionTemplate, ImportInspectionRef, InspectionItemValue } from './documents/ImportInspectionDocument';
// 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전)
import {
ScreenWorkLogContent,
SlatWorkLogContent,
BendingWorkLogContent,
ScreenInspectionContent,
SlatInspectionContent,
BendingInspectionContent,
} from '@/components/production/WorkOrders/documents';
import type { WorkOrder } from '@/components/production/WorkOrders/types';
// 검사 템플릿 API
import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions';
/**
* 저장된 document.data (field_key 기반)를 ImportInspectionDocument의 initialValues로 변환
*
* field_key 패턴:
* - {itemId}_n{1,2,3} → numeric 측정값
* - {itemId}_okng_n{1,2,3} → OK/NG 값
* - {itemId}_result → 항목별 판정
*/
function parseSavedDataToInitialValues(
tmpl: ImportInspectionTemplate,
docData: Array<{ field_key: string; field_value: string | null }>
): InspectionItemValue[] {
// field_key → value 맵 생성
const dataMap = new Map<string, string>();
for (const d of docData) {
if (d.field_value) dataMap.set(d.field_key, d.field_value);
}
return tmpl.inspectionItems.map((item) => {
const isOkng = item.measurementType === 'okng';
const measurements: (number | 'OK' | 'NG' | null)[] = Array(item.measurementCount).fill(null);
for (let n = 0; n < item.measurementCount; n++) {
if (isOkng) {
const val = dataMap.get(`${item.id}_okng_n${n + 1}`);
if (val === 'ok') measurements[n] = 'OK';
else if (val === 'ng') measurements[n] = 'NG';
} else {
const val = dataMap.get(`${item.id}_n${n + 1}`);
if (val) {
const num = parseFloat(val);
measurements[n] = isNaN(num) ? null : num;
}
}
}
// 항목별 판정
const resultVal = dataMap.get(`${item.id}_result`);
let result: 'OK' | 'NG' | null = null;
if (resultVal === 'ok') result = 'OK';
else if (resultVal === 'ng') result = 'NG';
return { itemId: item.id, measurements, result };
});
}
interface InspectionModalV2Props {
isOpen: boolean;
onClose: () => void;
document: Document | null;
documentItem: DocumentItem | null;
// 수입검사 템플릿 로드용 추가 props
itemId?: number; // 품목 ID (실제 API로 템플릿 조회 시 사용)
itemName?: string;
specification?: string;
supplier?: string;
inspector?: string; // 검사자 (현재 로그인 사용자)
inspectorDept?: string; // 검사자 부서
lotSize?: number; // 로트크기 (입고수량)
materialNo?: string; // 자재번호
// 읽기 전용 모드 (QMS 심사 확인용)
readOnly?: boolean;
}
// 문서 타입별 정보
const DOCUMENT_INFO: Record<string, { label: string; hasTemplate: boolean; color: string }> = {
import: { label: '수입검사 성적서', hasTemplate: true, color: 'text-green-600' },
order: { label: '수주서', hasTemplate: true, color: 'text-blue-600' },
log: { label: '작업일지', hasTemplate: true, color: 'text-orange-500' },
report: { label: '중간검사 성적서', hasTemplate: true, color: 'text-blue-500' },
confirmation: { label: '납품확인서', hasTemplate: true, color: 'text-red-500' },
shipping: { label: '출고증', hasTemplate: true, color: 'text-gray-600' },
product: { label: '제품검사 성적서', hasTemplate: true, color: 'text-green-500' },
quality: { label: '품질관리서', hasTemplate: false, color: 'text-purple-600' },
};
// Placeholder 컴포넌트 (양식 대기 문서용)
const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: DocumentItem | null }) => {
const info = DOCUMENT_INFO[docType] || { label: '문서', hasTemplate: false, color: 'text-gray-600' };
return (
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 text-amber-500 mb-4 mx-auto">
<AlertCircle className="w-full h-full" />
</div>
<h2 className="text-xl font-bold text-gray-800 mb-2">{info.label}</h2>
<p className="text-gray-500 text-sm mb-2">{docItem?.title || '문서'}</p>
{docItem?.date && (
<p className="text-gray-400 text-xs mb-2">{docItem.date}</p>
)}
{docItem?.code && (
<p className="text-xs text-green-600 font-mono bg-green-50 px-2 py-1 rounded mb-4">
: {docItem.code}
</p>
)}
<div className="mt-4 p-4 bg-amber-50 rounded-lg border border-amber-200">
<p className="text-amber-700 text-sm font-medium"> </p>
<p className="text-amber-600 text-xs mt-1"> </p>
</div>
</div>
);
};
// QMS용 수주서 Mock 데이터
const QMS_MOCK_PRODUCTS: ProductInfo[] = [
{ productName: '방화 스크린 셔터 (표준형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 5, floor: '1F', code: 'FSS-01' },
{ productName: '방화 스크린 셔터 (방화형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 3, floor: '2F', code: 'FSS-02' },
];
const QMS_MOCK_ORDER_ITEMS: OrderItem[] = [
{ id: 'mt-1', itemCode: 'MT-001', itemName: '모터(380V 단상)', specification: '150K', type: '모터', quantity: 8, unit: 'EA', unitPrice: 120000, supplyAmount: 960000, taxAmount: 96000, totalAmount: 1056000, sortOrder: 1 },
{ id: 'br-1', itemCode: 'BR-001', itemName: '브라켓트', specification: '380X180 [2-4"]', type: '브라켓', quantity: 16, unit: 'EA', unitPrice: 15000, supplyAmount: 240000, taxAmount: 24000, totalAmount: 264000, sortOrder: 2 },
{ id: 'gr-1', itemCode: 'GR-001', itemName: '가이드레일 백면형 (120X70)', specification: 'EGI 1.5ST', type: '가이드레일', quantity: 16, unit: 'EA', unitPrice: 25000, supplyAmount: 400000, taxAmount: 40000, totalAmount: 440000, width: 120, height: 2500, sortOrder: 3 },
{ id: 'cs-1', itemCode: 'CS-001', itemName: '케이스(셔터박스)', specification: 'EGI 1.5ST 380X180', type: '케이스', quantity: 8, unit: 'EA', unitPrice: 35000, supplyAmount: 280000, taxAmount: 28000, totalAmount: 308000, width: 380, height: 180, sortOrder: 4 },
{ id: 'bf-1', itemCode: 'BF-001', itemName: '하단마감재', specification: 'EGI 1.5ST', type: '하단마감재', quantity: 8, unit: 'EA', unitPrice: 18000, supplyAmount: 144000, taxAmount: 14400, totalAmount: 158400, sortOrder: 5 },
];
// QMS용 제품검사 성적서 Mock 데이터
const QMS_MOCK_REPORT_DATA: InspectionReportDocumentType = {
documentNumber: 'RPT-KD-SS-2024-530',
createdDate: '2024-09-24',
approvalLine: [
{ role: '작성', name: '김검사', department: '품질관리부' },
{ role: '승인', name: '박승인', department: '품질관리부' },
],
productName: '방화스크린',
productLotNo: 'KD-SS-240924-19',
productCode: 'WY-SC780',
lotSize: '8',
client: '삼성물산(주)',
inspectionDate: '2024-09-26',
siteName: '강남 아파트 단지',
inspector: '김검사',
inspectionItems: mockReportInspectionItems,
specialNotes: '',
finalJudgment: '합격',
};
// QMS용 작업일지 Mock WorkOrder 생성
const createQmsMockWorkOrder = (subType?: string): WorkOrder => ({
id: 'qms-wo-1',
workOrderNo: 'KD-WO-240924-01',
lotNo: 'KD-SS-240924-19',
processId: 1,
processName: subType === 'slat' ? '슬랫' : subType === 'bending' ? '절곡' : '스크린',
processCode: subType || 'screen',
processType: (subType || 'screen') as 'screen' | 'slat' | 'bending',
status: 'in_progress',
client: '삼성물산(주)',
projectName: '강남 아파트 단지',
dueDate: '2024-10-05',
assignee: '김작업',
assignees: [
{ id: '1', name: '김작업', isPrimary: true },
{ id: '2', name: '이생산', isPrimary: false },
],
orderDate: '2024-09-20',
scheduledDate: '2024-09-24',
shipmentDate: '2024-10-04',
salesOrderDate: '2024-09-18',
isAssigned: true,
isStarted: true,
priority: 3,
priorityLabel: '긴급',
shutterCount: 5,
department: '생산부',
items: [
{ id: '1', no: 1, status: 'completed', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '3000×2500', quantity: 2, unit: 'EA', orderNodeId: null, orderNodeName: '' },
{ id: '2', no: 2, status: 'in_progress', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '3000×2500', quantity: 3, unit: 'EA', orderNodeId: null, orderNodeName: '' },
{ id: '3', no: 3, status: 'waiting', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12000×4500', quantity: 1, unit: 'EA', orderNodeId: null, orderNodeName: '' },
],
currentStep: 2,
issues: [],
note: '품질 검수 철저히 진행',
});
// 로딩 컴포넌트
const LoadingDocument = () => (
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
<p className="text-gray-600 text-sm"> 릿 ...</p>
</div>
);
// 에러 컴포넌트
const ErrorDocument = ({ message, onRetry }: { message: string; onRetry?: () => void }) => (
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<p className="text-gray-800 font-medium mb-2">릿 </p>
<p className="text-gray-500 text-sm mb-4">{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
>
</button>
)}
</div>
);
/**
* InspectionModal V2
* - DocumentViewer 시스템 사용
* - 수입검사: 모달 열릴 때 API로 템플릿 로드 (Lazy Loading)
*/
export const InspectionModalV2 = ({
isOpen,
onClose,
document: doc,
documentItem,
itemId,
itemName,
specification,
supplier,
inspector,
inspectorDept,
lotSize,
materialNo,
readOnly = false,
}: InspectionModalV2Props) => {
// 수입검사 템플릿 상태
const [importTemplate, setImportTemplate] = useState<ImportInspectionTemplate | null>(null);
const [importInitialValues, setImportInitialValues] = useState<InspectionItemValue[] | undefined>(undefined);
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false);
const [templateError, setTemplateError] = useState<string | null>(null);
// 수입검사 저장용 ref/상태
const importDocRef = useRef<ImportInspectionRef>(null);
const [isSaving, setIsSaving] = useState(false);
// 수입검사 템플릿 로드 (모달 열릴 때)
useEffect(() => {
// itemId가 있으면 실제 API로 조회, 없으면 itemName/specification으로 mock 조회
if (isOpen && doc?.type === 'import' && (itemId || (itemName && specification))) {
loadInspectionTemplate();
}
// 모달 닫힐 때 상태 초기화
if (!isOpen) {
setImportTemplate(null);
setImportInitialValues(undefined);
setTemplateError(null);
}
}, [isOpen, doc?.type, itemId, itemName, specification]);
const loadInspectionTemplate = async () => {
// itemId가 있으면 실제 API 호출, 없으면 itemName/specification 필요
if (!itemId && (!itemName || !specification)) return;
setIsLoadingTemplate(true);
setTemplateError(null);
try {
const result = await getInspectionTemplate({
itemId,
itemName,
specification,
lotNo: documentItem?.code,
supplier,
inspector,
lotSize,
materialNo,
});
if (result.success && result.data) {
const tmpl = result.data as ImportInspectionTemplate;
setImportTemplate(tmpl);
// 저장된 측정값을 initialValues로 변환
const docData = result.resolveData?.document?.data;
if (docData && docData.length > 0) {
const values = parseSavedDataToInitialValues(tmpl, docData.map((d: { field_key: string; field_value?: string | null }) => ({ field_key: d.field_key, field_value: d.field_value ?? null })));
setImportInitialValues(values);
} else {
setImportInitialValues(undefined);
}
} else {
setTemplateError(result.error || '템플릿을 불러올 수 없습니다.');
}
} catch (error) {
console.error('[InspectionModalV2] loadInspectionTemplate error:', error);
setTemplateError('템플릿 로드 중 오류가 발생했습니다.');
} finally {
setIsLoadingTemplate(false);
}
};
// 수입검사 저장 핸들러 (hooks는 early return 전에 호출해야 함)
const handleImportSave = useCallback(async () => {
if (!importDocRef.current) return;
const data = importDocRef.current.getInspectionData();
setIsSaving(true);
try {
// TODO: 실제 저장 API 연동
toast.success('검사 데이터가 저장되었습니다.');
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, []);
if (!doc) return null;
const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' };
const subtitle = documentItem
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` 로트: ${documentItem.code}` : ''}`
: docInfo.label;
// 품질관리서 PDF 업로드 핸들러
const handleQualityFileUpload = (file: File) => {
};
const handleQualityFileDelete = () => {
};
// 작업일지 공정별 렌더링
const renderWorkLogDocument = () => {
const subType = documentItem?.subType;
const mockOrder = createQmsMockWorkOrder(subType);
switch (subType) {
case 'screen':
return <ScreenWorkLogContent data={mockOrder} />;
case 'slat':
return <SlatWorkLogContent data={mockOrder} />;
case 'bending':
return <BendingWorkLogContent data={mockOrder} />;
default:
// subType 미지정 시 스크린 기본
return <ScreenWorkLogContent data={mockOrder} />;
}
};
// 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일)
const renderReportDocument = () => {
const subType = documentItem?.subType;
const mockOrder = createQmsMockWorkOrder(subType || 'screen');
switch (subType) {
case 'screen':
return <ScreenInspectionContent data={mockOrder} readOnly />;
case 'bending':
return <BendingInspectionContent data={mockOrder} readOnly />;
case 'slat':
return <SlatInspectionContent data={mockOrder} readOnly />;
case 'jointbar':
return <JointbarInspectionDocument />;
default:
return <ScreenInspectionContent data={mockOrder} readOnly />;
}
};
// 수입검사 문서 렌더링 (Lazy Loading)
const renderImportInspectionDocument = () => {
if (isLoadingTemplate) {
return <LoadingDocument />;
}
if (templateError) {
return <ErrorDocument message={templateError} onRetry={loadInspectionTemplate} />;
}
// 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용
return (
<ImportInspectionDocument
ref={importDocRef}
template={importTemplate || undefined}
initialValues={importInitialValues}
readOnly={readOnly}
inspectorDept={inspectorDept}
/>
);
};
// 문서 타입에 따른 컨텐츠 렌더링
const renderDocumentContent = () => {
switch (doc.type) {
case 'order':
return (
<SalesOrderDocument
orderNumber="KD-SS-240924-19"
documentNumber="KD-SS-240924-19"
certificationNumber="KD-SS-240924-19"
orderDate="2024-09-24"
client="삼성물산(주)"
siteName="강남 아파트 단지"
manager="김담당"
managerContact="010-1234-5678"
deliveryRequestDate="2024-10-05"
expectedShipDate="2024-10-04"
deliveryMethod="직접배차"
address="서울시 강남구 테헤란로 123"
recipientName="김인수"
recipientContact="010-9876-5432"
shutterCount={8}
products={QMS_MOCK_PRODUCTS}
items={QMS_MOCK_ORDER_ITEMS}
remarks="납기일 엄수 요청"
/>
);
case 'log':
return renderWorkLogDocument();
case 'confirmation':
return <DeliveryConfirmation data={MOCK_SHIPMENT_DETAIL} />;
case 'shipping':
return <ShippingSlip data={MOCK_SHIPMENT_DETAIL} />;
case 'import':
return renderImportInspectionDocument();
case 'product':
return <InspectionReportDocument data={QMS_MOCK_REPORT_DATA} />;
case 'report':
return renderReportDocument();
case 'quality':
return (
<QualityDocumentUploader
onFileUpload={handleQualityFileUpload}
onFileDelete={handleQualityFileDelete}
disabled={readOnly}
/>
);
default:
return <PlaceholderDocument docType={doc.type} docItem={documentItem} />;
}
};
// 다운로드 핸들러 (TODO: 실제 구현)
const handleDownload = () => {
};
// 수입검사 저장 버튼 (toolbarExtra) - readOnly일 때 숨김
const importToolbarExtra = doc.type === 'import' && !readOnly ? (
<Button onClick={handleImportSave} disabled={isSaving} size="sm">
{isSaving ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1.5" />
)}
{isSaving ? '저장 중...' : '저장'}
</Button>
) : undefined;
return (
<DocumentViewer
title={doc.title}
subtitle={subtitle}
preset="inspection"
open={isOpen}
onOpenChange={(open) => !open && onClose()}
onDownload={handleDownload}
toolbarExtra={importToolbarExtra}
>
{renderDocumentContent()}
</DocumentViewer>
);
};

View File

@@ -7,7 +7,7 @@ import { ReportList } from './components/ReportList';
import { RouteList } from './components/RouteList';
import { DocumentList } from './components/DocumentList';
// import { InspectionModal } from './components/InspectionModal';
import { InspectionModalV2 as InspectionModal } from './components/InspectionModalV2';
import { InspectionModal } from './components/InspectionModal';
import { DayTabs } from './components/DayTabs';
import { Day1ChecklistPanel } from './components/Day1ChecklistPanel';
import { Day1DocumentSection } from './components/Day1DocumentSection';

View File

@@ -259,6 +259,17 @@ export default function CustomerAccountManagementPage() {
setSelectedItems(new Set()); // 페이지 변경 시 선택 초기화
}, []);
// 테이블 컬럼 정의 (Hooks 순서 보장을 위해 조건부 return 전에 정의)
const tableColumns: TableColumn[] = useMemo(() => [
{ key: "rowNumber", label: "번호", className: "px-4" },
{ key: "code", label: "코드", className: "px-4" },
{ key: "clientType", label: "구분", className: "px-4" },
{ key: "name", label: "거래처명", className: "px-4" },
{ key: "representative", label: "대표자", className: "px-4" },
{ key: "manager", label: "담당자", className: "px-4" },
{ key: "phone", label: "전화번호", className: "px-4" },
], []);
// 핸들러 - 페이지 기반 네비게이션
const handleAddNew = () => {
router.push("/sales/client-management-sales-admin?mode=new");
@@ -434,17 +445,6 @@ export default function CustomerAccountManagementPage() {
}
};
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = useMemo(() => [
{ key: "rowNumber", label: "번호", className: "px-4" },
{ key: "code", label: "코드", className: "px-4" },
{ key: "clientType", label: "구분", className: "px-4" },
{ key: "name", label: "거래처명", className: "px-4" },
{ key: "representative", label: "대표자", className: "px-4" },
{ key: "manager", label: "담당자", className: "px-4" },
{ key: "phone", label: "전화번호", className: "px-4" },
], []);
// 테이블 행 렌더링
const renderTableRow = (
customer: Client,

View File

@@ -1,7 +1,7 @@
/**
* 견적 상세/수정 페이지 (V2 UI)
*
* IntegratedDetailTemplate + QuoteRegistrationV2
* IntegratedDetailTemplate + QuoteRegistration
* URL 패턴:
* - /quote-management/[id] → 상세 보기 (view)
* - /quote-management/[id]?mode=edit → 수정 모드 (edit)
@@ -11,8 +11,8 @@
import { useRouter, useParams, useSearchParams } from "next/navigation";
import { useState, useEffect, useMemo, useCallback } from "react";
import { QuoteRegistrationV2 } from "@/components/quotes/QuoteRegistrationV2";
import type { QuoteFormDataV2 } from "@/components/quotes/QuoteRegistrationV2";
import { QuoteRegistration } from "@/components/quotes/QuoteRegistration";
import type { QuoteFormDataV2 } from "@/components/quotes/QuoteRegistration";
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
import { quoteConfig } from "@/components/quotes/quoteConfig";
import { Badge } from "@/components/ui/badge";
@@ -196,7 +196,7 @@ export default function QuoteDetailPage() {
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(() => {
return (
<QuoteRegistrationV2
<QuoteRegistration
mode={isEditMode ? "edit" : "view"}
onBack={handleBack}
onSave={handleSave}

View File

@@ -1,688 +0,0 @@
/**
* 견적 상세/수정 페이지 (V2 통합)
* - 기본 정보 표시 (view mode)
* - 자동 견적 산출 정보
* - 견적서 / 산출내역서 / 발주서 모달
* - 수정 모드 (edit mode)
*
* URL 패턴:
* - /quote-management/[id] → 상세 보기 (view)
* - /quote-management/[id]?mode=edit → 수정 모드 (edit)
*/
"use client";
import { useRouter, useParams, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react";
import { QuoteRegistration, QuoteFormData } from "@/components/quotes/QuoteRegistration";
import { QuoteDocument } from "@/components/quotes/QuoteDocument";
import { QuoteCalculationReport } from "@/components/quotes/QuoteCalculationReport";
import { PurchaseOrderDocument } from "@/components/quotes/PurchaseOrderDocument";
import {
getQuoteById,
finalizeQuote,
convertQuoteToOrder,
sendQuoteEmail,
sendQuoteKakao,
transformQuoteToFormData,
updateQuote,
transformFormDataToApi,
} from "@/components/quotes";
import { getCompanyInfo } from "@/components/settings/CompanyInfoManagement/actions";
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
import { getItemTypeCodes, type CommonCode } from "@/lib/api/common-codes";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { DocumentViewer } from "@/components/document-system";
import {
FileText,
Edit,
List,
Printer,
FileOutput,
FileCheck,
Package,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { DetailPageSkeleton } from "@/components/ui/skeleton";
export default function QuoteDetailPage() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const quoteId = params.id as string;
// V2 패턴: mode 체크
const mode = searchParams.get("mode") || "view";
const isEditMode = mode === "edit";
const [quote, setQuote] = useState<QuoteFormData | null>(null);
const [companyInfo, setCompanyInfo] = useState<CompanyFormData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// 다이얼로그 상태
const [isQuoteDocumentOpen, setIsQuoteDocumentOpen] = useState(false);
const [isCalculationReportOpen, setIsCalculationReportOpen] = useState(false);
const [isPurchaseOrderOpen, setIsPurchaseOrderOpen] = useState(false);
// 산출내역서 표시 옵션
const [showDetailedBreakdown, setShowDetailedBreakdown] = useState(true);
const [showMaterialList, setShowMaterialList] = useState(true);
// BOM 자재 상세 펼침/접힘 상태
const [isBomExpanded, setIsBomExpanded] = useState(true);
// 공통 코드 (item_type)
const [itemTypeCodes, setItemTypeCodes] = useState<CommonCode[]>([]);
// 견적 데이터 조회
const fetchQuote = useCallback(async () => {
setIsLoading(true);
try {
const result = await getQuoteById(quoteId);
if (result.success && result.data) {
// 디버깅: Quote 변환 전 데이터
console.log('[QuoteDetail] Quote data:', {
clientId: result.data.clientId,
clientName: result.data.clientName,
calculationInputs: result.data.calculationInputs,
items: result.data.items?.map(item => ({
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
totalAmount: item.totalAmount,
})),
});
const formData = transformQuoteToFormData(result.data);
// 디버깅: QuoteFormData 변환 후 데이터
console.log('[QuoteDetail] FormData:', {
clientId: formData.clientId,
clientName: formData.clientName,
items: formData.items?.map(item => ({
productName: item.productName,
quantity: item.quantity,
inspectionFee: item.inspectionFee,
totalAmount: item.totalAmount,
})),
});
setQuote(formData);
} else {
toast.error(result.error || "견적 정보를 불러오는데 실패했습니다.");
router.push("/sales/quote-management");
}
} catch (error) {
toast.error("견적 정보를 불러오는데 실패했습니다.");
router.push("/sales/quote-management");
} finally {
setIsLoading(false);
}
}, [quoteId, router]);
// 회사 정보 조회
const fetchCompanyInfo = useCallback(async () => {
try {
const result = await getCompanyInfo();
if (result.success && result.data) {
setCompanyInfo(result.data);
}
} catch (error) {
console.error('[QuoteDetail] Failed to fetch company info:', error);
}
}, []);
// 공통 코드 조회
const fetchItemTypeCodes = useCallback(async () => {
const result = await getItemTypeCodes();
if (result.success && result.data) {
setItemTypeCodes(result.data);
}
}, []);
// item_type 코드 → 이름 변환 헬퍼
const getItemTypeLabel = useCallback((code: string | undefined | null): string => {
if (!code) return '-';
const found = itemTypeCodes.find(item => item.code === code);
return found?.name || code;
}, [itemTypeCodes]);
useEffect(() => {
fetchQuote();
fetchCompanyInfo();
fetchItemTypeCodes();
}, [fetchQuote, fetchCompanyInfo, fetchItemTypeCodes]);
const handleBack = () => {
router.push("/sales/quote-management");
};
const handleEdit = () => {
router.push(`/sales/quote-management/${quoteId}?mode=edit`);
};
// V2 패턴: 수정 저장 핸들러
const handleSave = async (formData: QuoteFormData): Promise<{ success: boolean; error?: string }> => {
if (isSaving) return { success: false, error: '저장 중입니다.' };
setIsSaving(true);
try {
const apiData = transformFormDataToApi(formData);
const result = await updateQuote(quoteId, apiData as any);
if (result.success) {
// toast는 IntegratedDetailTemplate에서 처리
router.push(`/sales/quote-management/${quoteId}`);
return { success: true };
} else {
return { success: false, error: result.error || "견적 수정에 실패했습니다." };
}
} catch (error) {
return { success: false, error: "견적 수정에 실패했습니다." };
} finally {
setIsSaving(false);
}
};
const handleFinalize = async () => {
if (isProcessing) return;
setIsProcessing(true);
try {
const result = await finalizeQuote(quoteId);
if (result.success) {
toast.success("견적이 최종 확정되었습니다.");
fetchQuote(); // 데이터 새로고침
} else {
toast.error(result.error || "견적 확정에 실패했습니다.");
}
} catch (error) {
toast.error("견적 확정에 실패했습니다.");
} finally {
setIsProcessing(false);
}
};
const handleConvertToOrder = async () => {
if (isProcessing) return;
setIsProcessing(true);
try {
const result = await convertQuoteToOrder(quoteId);
if (result.success) {
toast.success("수주로 전환되었습니다.");
if (result.orderId) {
router.push(`/sales/order-management/${result.orderId}`);
} else {
router.push("/sales/order-management");
}
} else {
toast.error(result.error || "수주 전환에 실패했습니다.");
}
} catch (error) {
toast.error("수주 전환에 실패했습니다.");
} finally {
setIsProcessing(false);
}
};
const handleSendEmail = async () => {
if (isProcessing) return;
// TODO: 이메일 입력 다이얼로그 추가
const email = prompt("발송할 이메일 주소를 입력하세요:");
if (!email) return;
setIsProcessing(true);
try {
const result = await sendQuoteEmail(quoteId, { email });
if (result.success) {
toast.success("이메일이 발송되었습니다.");
} else {
toast.error(result.error || "이메일 발송에 실패했습니다.");
}
} catch (error) {
toast.error("이메일 발송에 실패했습니다.");
} finally {
setIsProcessing(false);
}
};
const handleSendKakao = async () => {
if (isProcessing) return;
// TODO: 카카오 발송 다이얼로그 추가
const phone = prompt("발송할 전화번호를 입력하세요:");
if (!phone) return;
setIsProcessing(true);
try {
const result = await sendQuoteKakao(quoteId, { phone });
if (result.success) {
toast.success("카카오톡이 발송되었습니다.");
} else {
toast.error(result.error || "카카오톡 발송에 실패했습니다.");
}
} catch (error) {
toast.error("카카오톡 발송에 실패했습니다.");
} finally {
setIsProcessing(false);
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
return dateStr;
};
const formatAmount = (amount: number | undefined) => {
if (!amount) return "0";
return amount.toLocaleString("ko-KR");
};
// 총 금액 계산 (실제 금액 우선, 없으면 검사비 사용)
const totalAmount =
quote?.items?.reduce((sum, item) => {
// totalAmount가 있으면 사용, 없으면 unitPrice * quantity, 마지막으로 inspectionFee
const itemAmount = item.totalAmount || (item.unitPrice || 0) * (item.quantity || 1) || (item.inspectionFee || 0) * (item.quantity || 1);
return sum + itemAmount;
}, 0) || 0;
if (isLoading) {
return <DetailPageSkeleton sections={2} fieldsPerSection={6} />;
}
if (!quote) {
return (
<div className="p-6">
<p className="text-gray-500">견적 정보를 찾을 수 없습니다.</p>
<Button onClick={handleBack} className="mt-4">
목록으로 돌아가기
</Button>
</div>
);
}
// V2 패턴: Edit 모드일 때 QuoteRegistration 컴포넌트 렌더링
if (isEditMode) {
return (
<QuoteRegistration
onBack={handleBack}
onSave={handleSave}
editingQuote={quote}
/>
);
}
// View 모드: 상세 보기
return (
<div className="p-6 space-y-6">
{/* 헤더 */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<FileText className="w-6 h-6" />
견적 상세
</h1>
<p className="text-gray-500 mt-1">견적번호: {quote.id}</p>
</div>
<div className="flex flex-wrap gap-2">
{/* 문서 버튼들 */}
<Button
variant="outline"
onClick={() => setIsQuoteDocumentOpen(true)}
>
<Printer className="w-4 h-4 mr-2" />
견적서
</Button>
<Button
variant="outline"
onClick={() => setIsCalculationReportOpen(true)}
>
<FileText className="w-4 h-4 mr-2" />
산출내역서
</Button>
<Button
variant="outline"
onClick={() => setIsPurchaseOrderOpen(true)}
>
<FileOutput className="w-4 h-4 mr-2" />
발주서
</Button>
{/* 액션 버튼들 */}
<Button variant="outline" onClick={handleBack}>
<List className="w-4 h-4 mr-2" />
목록
</Button>
<Button variant="outline" onClick={handleEdit}>
<Edit className="w-4 h-4 mr-2" />
수정
</Button>
<Button
onClick={handleFinalize}
disabled={isProcessing}
className="bg-green-600 hover:bg-green-700 text-white"
>
<FileCheck className="w-4 h-4 mr-2" />
최종확정
</Button>
</div>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle>기본 정보</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div>
<Label>견적번호</Label>
<Input
value={quote.id || "-"}
disabled
className="bg-gray-50 text-black font-medium"
/>
</div>
<div>
<Label>작성자</Label>
<Input
value={quote.writer || "-"}
disabled
className="bg-gray-50 text-black font-medium"
/>
</div>
<div>
<Label>발주처</Label>
<Input
value={quote.clientName || "-"}
disabled
className="bg-gray-50 text-black font-medium"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div>
<Label>담당자</Label>
<Input
value={quote.manager || "-"}
disabled
className="bg-gray-50 text-black font-medium"
/>
</div>
<div>
<Label>연락처</Label>
<Input
value={quote.contact || "-"}
disabled
className="bg-gray-50 text-black font-medium"
/>
</div>
<div>
<Label>현장명</Label>
<Input
value={quote.siteName || "-"}
disabled
className="bg-gray-50 text-black font-medium"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div>
<Label>등록일</Label>
<Input
value={formatDate(quote.registrationDate || "")}
disabled
className="bg-gray-50 text-black font-medium"
/>
</div>
<div>
<Label>납기일</Label>
<Input
value={formatDate(quote.dueDate || "")}
disabled
className="bg-gray-50 text-black font-medium"
/>
</div>
</div>
{quote.remarks && (
<div>
<Label>비고</Label>
<Textarea
value={quote.remarks}
disabled
className="bg-gray-50 text-black font-medium min-h-[80px]"
/>
</div>
)}
</CardContent>
</Card>
{/* 자동 견적 산출 정보 */}
<Card>
<CardHeader>
<CardTitle>자동 견적 산출 정보</CardTitle>
</CardHeader>
<CardContent>
{quote.items && quote.items.length > 0 ? (
<div className="space-y-4">
{quote.items.map((item, index) => (
<div
key={item.id}
className="border rounded-lg p-4 bg-gray-50"
>
<div className="flex items-center justify-between mb-3">
<Badge variant="outline">항목 {index + 1}</Badge>
<Badge variant="secondary">{item.floor}</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">제품명</span>
<p className="font-medium">{item.productName}</p>
</div>
<div>
<span className="text-gray-500">오픈사이즈</span>
<p className="font-medium">
{item.openWidth} × {item.openHeight} mm
</p>
</div>
<div>
<span className="text-gray-500">수량</span>
<p className="font-medium">{item.quantity} SET</p>
</div>
<div>
<span className="text-gray-500">금액</span>
<p className="font-medium text-blue-600">
₩{formatAmount(item.totalAmount || (item.unitPrice || 0) * (item.quantity || 1) || (item.inspectionFee || 0) * (item.quantity || 1))}
</p>
</div>
</div>
</div>
))}
{/* 합계 */}
<div className="border-t pt-4 mt-4">
<div className="flex justify-between items-center text-lg font-bold">
<span>총 견적금액</span>
<span className="text-blue-600">
₩{formatAmount(totalAmount)}
</span>
</div>
</div>
</div>
) : (
<p className="text-gray-500 text-center py-8">
산출 항목이 없습니다.
</p>
)}
</CardContent>
</Card>
{/* BOM 자재 상세 */}
{quote.bomMaterials && quote.bomMaterials.length > 0 && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Package className="h-5 w-5" />
BOM 자재 상세
<Badge variant="secondary" className="ml-2">
{quote.bomMaterials.length}개 품목
</Badge>
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setIsBomExpanded(!isBomExpanded)}
>
{isBomExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</div>
</CardHeader>
{isBomExpanded && (
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left p-2 font-medium">No</th>
<th className="text-left p-2 font-medium">품목코드</th>
<th className="text-left p-2 font-medium">품목명</th>
<th className="text-left p-2 font-medium">유형</th>
<th className="text-left p-2 font-medium">규격</th>
<th className="text-center p-2 font-medium">단위</th>
<th className="text-right p-2 font-medium">수량</th>
<th className="text-right p-2 font-medium">단가</th>
<th className="text-right p-2 font-medium">금액</th>
</tr>
</thead>
<tbody>
{quote.bomMaterials.map((material, index) => (
<tr key={index} className="border-b hover:bg-muted/30">
<td className="p-2 text-muted-foreground">{index + 1}</td>
<td className="p-2 font-mono text-xs">{material.itemCode}</td>
<td className="p-2">{material.itemName}</td>
<td className="p-2">
<Badge variant="outline" className="text-xs">
{getItemTypeLabel(material.itemType)}
</Badge>
</td>
<td className="p-2 text-muted-foreground">{material.specification || '-'}</td>
<td className="p-2 text-center">{material.unit}</td>
<td className="p-2 text-right">{material.quantity.toLocaleString()}</td>
<td className="p-2 text-right">₩{material.unitPrice.toLocaleString()}</td>
<td className="p-2 text-right font-medium">₩{material.totalPrice.toLocaleString()}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 bg-muted/30">
<td colSpan={8} className="p-2 text-right font-medium">합계</td>
<td className="p-2 text-right font-bold text-blue-600">
₩{quote.bomMaterials.reduce((sum, m) => sum + m.totalPrice, 0).toLocaleString()}
</td>
</tr>
</tfoot>
</table>
</div>
</CardContent>
)}
</Card>
)}
{/* 견적서 다이얼로그 */}
<DocumentViewer
title="견적서"
preset="quote"
open={isQuoteDocumentOpen}
onOpenChange={setIsQuoteDocumentOpen}
onPdf={() => {
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
window.print();
}}
onEmail={handleSendEmail}
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
onKakao={handleSendKakao}
>
<QuoteDocument quote={quote} companyInfo={companyInfo} />
</DocumentViewer>
{/* 산출내역서 다이얼로그 */}
<DocumentViewer
title="산출내역서"
preset="quote"
open={isCalculationReportOpen}
onOpenChange={setIsCalculationReportOpen}
onPdf={() => {
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
window.print();
}}
onEmail={handleSendEmail}
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
onKakao={handleSendKakao}
toolbarExtra={
<>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showDetailedBreakdown}
onChange={(e) => setShowDetailedBreakdown(e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm">산출내역서</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showMaterialList}
onChange={(e) => setShowMaterialList(e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm">소요자재 내역</span>
</label>
</>
}
>
<QuoteCalculationReport
quote={quote}
companyInfo={companyInfo}
documentType="견적산출내역서"
showDetailedBreakdown={showDetailedBreakdown}
showMaterialList={showMaterialList}
/>
</DocumentViewer>
{/* 발주서 다이얼로그 */}
<DocumentViewer
title="발주서"
preset="quote"
open={isPurchaseOrderOpen}
onOpenChange={setIsPurchaseOrderOpen}
onPdf={() => {
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
window.print();
}}
onEmail={handleSendEmail}
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
onKakao={handleSendKakao}
>
<PurchaseOrderDocument quote={quote} companyInfo={companyInfo} />
</DocumentViewer>
</div>
);
}

View File

@@ -1,7 +1,7 @@
/**
* 견적 등록 페이지 (V2 UI)
*
* IntegratedDetailTemplate + QuoteRegistrationV2
* IntegratedDetailTemplate + QuoteRegistration
* URL: /sales/quote-management/new
*/
@@ -9,8 +9,8 @@
import { useRouter } from 'next/navigation';
import { useState, useCallback } from 'react';
import { QuoteRegistrationV2 } from '@/components/quotes/QuoteRegistrationV2';
import type { QuoteFormDataV2 } from '@/components/quotes/QuoteRegistrationV2';
import { QuoteRegistration } from '@/components/quotes/QuoteRegistration';
import type { QuoteFormDataV2 } from '@/components/quotes/QuoteRegistration';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { quoteCreateConfig } from '@/components/quotes/quoteConfig';
import { toast } from 'sonner';
@@ -58,7 +58,7 @@ export default function QuoteNewPage() {
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(() => {
return (
<QuoteRegistrationV2
<QuoteRegistration
mode="create"
onBack={handleBack}
onSave={handleSave}

View File

@@ -1,67 +0,0 @@
/**
* 견적 등록 페이지
*/
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { QuoteRegistration, QuoteFormData } from "@/components/quotes/QuoteRegistration";
import { createQuote, transformFormDataToApi } from "@/components/quotes";
import { toast } from "sonner";
export default function QuoteNewPage() {
const router = useRouter();
const [isSaving, setIsSaving] = useState(false);
const handleBack = () => {
router.push("/sales/quote-management");
};
const handleSave = async (formData: QuoteFormData): Promise<{ success: boolean; error?: string }> => {
if (isSaving) return { success: false, error: '저장 중입니다.' };
setIsSaving(true);
try {
// DEBUG: 원본 formData 확인
console.log('[QuoteNewPage] formData 원본:', {
writer: formData.writer,
manager: formData.manager,
contact: formData.contact,
remarks: formData.remarks,
});
// FormData를 API 요청 형식으로 변환
const apiData = transformFormDataToApi(formData);
// DEBUG: 변환된 apiData 확인
console.log('[QuoteNewPage] apiData 변환 후:', {
author: (apiData as any).author,
manager: (apiData as any).manager,
contact: (apiData as any).contact,
remarks: (apiData as any).remarks,
});
const result = await createQuote(apiData as any);
if (result.success && result.data) {
// toast는 IntegratedDetailTemplate에서 처리
router.push(`/sales/quote-management/${result.data.id}`);
return { success: true };
} else {
return { success: false, error: result.error || "견적 등록에 실패했습니다." };
}
} catch (error) {
return { success: false, error: "견적 등록에 실패했습니다." };
} finally {
setIsSaving(false);
}
};
return (
<QuoteRegistration
onBack={handleBack}
onSave={handleSave}
/>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -34,6 +34,9 @@ import {
} from './types';
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
// ===== 새 훅 import =====
import { useDetailData, useCRUDHandlers } from '@/hooks';
// ===== Props =====
interface BillDetailProps {
billId: string;
@@ -46,157 +49,199 @@ interface ClientOption {
name: string;
}
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
interface BillFormData {
billNumber: string;
billType: BillType;
vendorId: string;
amount: number;
issueDate: string;
maturityDate: string;
status: BillStatus;
note: string;
installments: InstallmentRecord[];
}
const INITIAL_FORM_DATA: BillFormData = {
billNumber: '',
billType: 'received',
vendorId: '',
amount: 0,
issueDate: '',
maturityDate: '',
status: 'stored',
note: '',
installments: [],
};
export function BillDetail({ billId, mode }: BillDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// ===== 로딩 상태 =====
const [isLoading, setIsLoading] = useState(false);
// ===== 거래처 목록 =====
const [clients, setClients] = useState<ClientOption[]>([]);
// ===== 폼 상태 =====
const [billNumber, setBillNumber] = useState('');
const [billType, setBillType] = useState<BillType>('received');
const [vendorId, setVendorId] = useState('');
const [amount, setAmount] = useState(0);
const [issueDate, setIssueDate] = useState('');
const [maturityDate, setMaturityDate] = useState('');
const [status, setStatus] = useState<BillStatus>('stored');
const [note, setNote] = useState('');
const [installments, setInstallments] = useState<InstallmentRecord[]>([]);
// ===== 폼 상태 (통합된 단일 state) =====
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
// ===== 초기 데이터 로드 (거래처 + 어음 상세 병렬) =====
// ===== 폼 필드 업데이트 헬퍼 =====
const updateField = useCallback(<K extends keyof BillFormData>(
field: K,
value: BillFormData[K]
) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// ===== 거래처 목록 로드 =====
useEffect(() => {
async function loadInitialData() {
const isEditMode = billId && billId !== 'new';
setIsLoading(!!isEditMode);
const [clientsResult, billResult] = await Promise.all([
getClients(),
isEditMode ? getBill(billId) : Promise.resolve(null),
]);
// 거래처 목록
if (clientsResult.success && clientsResult.data) {
setClients(clientsResult.data.map(c => ({ id: String(c.id), name: c.name })));
async function loadClients() {
const result = await getClients();
if (result.success && result.data) {
setClients(result.data.map(c => ({ id: String(c.id), name: c.name })));
}
}
loadClients();
}, []);
// 어음 상세
if (billResult) {
if (billResult.success && billResult.data) {
const data = billResult.data;
setBillNumber(data.billNumber);
setBillType(data.billType);
setVendorId(data.vendorId);
setAmount(data.amount);
setIssueDate(data.issueDate);
setMaturityDate(data.maturityDate);
setStatus(data.status);
setNote(data.note);
setInstallments(data.installments);
} else {
toast.error(billResult.error || '어음 정보를 불러올 수 없습니다.');
router.push('/ko/accounting/bills');
}
}
// ===== 새 훅: useDetailData로 데이터 로딩 =====
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
const fetchBillWrapper = useCallback(
(id: string | number) => getBill(String(id)),
[]
);
setIsLoading(false);
}
const {
data: billData,
isLoading,
error: loadError,
} = useDetailData<BillRecord>(
billId !== 'new' ? billId : null,
fetchBillWrapper,
{ skip: isNewMode }
);
loadInitialData();
}, [billId, router]);
// ===== 데이터 로드 시 폼에 반영 =====
useEffect(() => {
if (billData) {
setFormData({
billNumber: billData.billNumber,
billType: billData.billType,
vendorId: billData.vendorId,
amount: billData.amount,
issueDate: billData.issueDate,
maturityDate: billData.maturityDate,
status: billData.status,
note: billData.note,
installments: billData.installments,
});
}
}, [billData]);
// ===== 저장 핸들러 =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
// 유효성 검사
if (!billNumber.trim()) {
toast.error('어음번호를 입력해주세요.');
return { success: false, error: '어음번호를 입력해주세요.' };
// ===== 로드 에러 처리 =====
useEffect(() => {
if (loadError) {
toast.error(loadError);
router.push('/ko/accounting/bills');
}
if (!vendorId) {
toast.error('거래처를 선택해주세요.');
return { success: false, error: '거래처를 선택해주세요.' };
}, [loadError, router]);
// ===== 유효성 검사 함수 =====
const validateForm = useCallback((): { valid: boolean; error?: string } => {
if (!formData.billNumber.trim()) {
return { valid: false, error: '어음번호를 입력해주세요.' };
}
if (amount <= 0) {
toast.error('금액을 입력해주세요.');
return { success: false, error: '금액을 입력해주세요.' };
if (!formData.vendorId) {
return { valid: false, error: '거래처를 선택해주세요.' };
}
if (!issueDate) {
toast.error('발행일을 입력해주세요.');
return { success: false, error: '발행일을 입력해주세요.' };
if (formData.amount <= 0) {
return { valid: false, error: '금액을 입력해주세요.' };
}
if (!maturityDate) {
toast.error('만기일을 입력해주세요.');
return { success: false, error: '만기일을 입력해주세요.' };
if (!formData.issueDate) {
return { valid: false, error: '발행일을 입력해주세요.' };
}
if (!formData.maturityDate) {
return { valid: false, error: '만기일을 입력해주세요.' };
}
// 차수 유효성 검사
for (let i = 0; i < installments.length; i++) {
const inst = installments[i];
for (let i = 0; i < formData.installments.length; i++) {
const inst = formData.installments[i];
if (!inst.date) {
const errorMsg = `차수 ${i + 1}번의 일자를 입력해주세요.`;
toast.error(errorMsg);
return { success: false, error: errorMsg };
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
}
if (inst.amount <= 0) {
const errorMsg = `차수 ${i + 1}번의 금액을 입력해주세요.`;
toast.error(errorMsg);
return { success: false, error: errorMsg };
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
}
}
return { valid: true };
}, [formData]);
// ===== 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음 =====
const updateBillWrapper = useCallback(
(id: string | number, data: Partial<BillRecord>) => updateBill(String(id), data),
[]
);
const deleteBillWrapper = useCallback(
(id: string | number) => deleteBill(String(id)),
[]
);
// ===== 새 훅: useCRUDHandlers로 CRUD 처리 =====
const {
handleCreate,
handleUpdate,
handleDelete: crudDelete,
isSubmitting,
isDeleting,
} = useCRUDHandlers<Partial<BillRecord>, Partial<BillRecord>>({
onCreate: createBill,
onUpdate: updateBillWrapper,
onDelete: deleteBillWrapper,
successRedirect: '/ko/accounting/bills',
successMessages: {
create: '어음이 등록되었습니다.',
update: '어음이 수정되었습니다.',
delete: '어음이 삭제되었습니다.',
},
// 수정 성공 시 view 모드로 이동
disableRedirect: !isNewMode,
onSuccess: (action) => {
if (action === 'update') {
router.push(`/ko/accounting/bills/${billId}?mode=view`);
}
},
});
// ===== 저장 핸들러 (유효성 검사 + CRUD 훅 사용) =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
// 유효성 검사
const validation = validateForm();
if (!validation.valid) {
toast.error(validation.error!);
return { success: false, error: validation.error };
}
const billData: Partial<BillRecord> = {
billNumber,
billType,
vendorId,
vendorName: clients.find(c => c.id === vendorId)?.name || '',
amount,
issueDate,
maturityDate,
status,
note,
installments,
...formData,
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
};
let result;
if (isNewMode) {
result = await createBill(billData);
return handleCreate(billData);
} else {
result = await updateBill(billId, billData);
return handleUpdate(billId, billData);
}
if (result.success) {
toast.success(isNewMode ? '어음이 등록되었습니다.' : '어음이 수정되었습니다.');
if (isNewMode) {
router.push('/ko/accounting/bills');
} else {
router.push(`/ko/accounting/bills/${billId}?mode=view`);
}
return { success: true };
} else {
toast.error(result.error || '저장에 실패했습니다.');
return { success: false, error: result.error || '저장에 실패했습니다.' };
}
}, [billId, billNumber, billType, vendorId, amount, issueDate, maturityDate, status, note, installments, router, isNewMode, clients]);
}, [formData, clients, isNewMode, billId, handleCreate, handleUpdate, validateForm]);
// ===== 삭제 핸들러 =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
const result = await deleteBill(billId);
return crudDelete(billId);
}, [billId, crudDelete]);
if (result.success) {
toast.success('어음이 삭제되었습니다.');
router.push('/ko/accounting/bills');
return { success: true };
} else {
toast.error(result.error || '삭제에 실패했습니다.');
return { success: false, error: result.error || '삭제에 실패했습니다.' };
}
}, [billId, router]);
// ===== 차수 추가 =====
// ===== 차수 관리 핸들러 =====
const handleAddInstallment = useCallback(() => {
const newInstallment: InstallmentRecord = {
id: `inst-${Date.now()}`,
@@ -204,23 +249,37 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
amount: 0,
note: '',
};
setInstallments(prev => [...prev, newInstallment]);
setFormData(prev => ({
...prev,
installments: [...prev.installments, newInstallment],
}));
}, []);
// ===== 차수 삭제 =====
const handleRemoveInstallment = useCallback((id: string) => {
setInstallments(prev => prev.filter(inst => inst.id !== id));
setFormData(prev => ({
...prev,
installments: prev.installments.filter(inst => inst.id !== id),
}));
}, []);
// ===== 차수 업데이트 =====
const handleUpdateInstallment = useCallback((id: string, field: keyof InstallmentRecord, value: string | number) => {
setInstallments(prev => prev.map(inst =>
inst.id === id ? { ...inst, [field]: value } : inst
));
const handleUpdateInstallment = useCallback((
id: string,
field: keyof InstallmentRecord,
value: string | number
) => {
setFormData(prev => ({
...prev,
installments: prev.installments.map(inst =>
inst.id === id ? { ...inst, [field]: value } : inst
),
}));
}, []);
// ===== 상태 옵션 (구분에 따라 변경) =====
const statusOptions = getBillStatusOptions(billType);
const statusOptions = useMemo(
() => getBillStatusOptions(formData.billType),
[formData.billType]
);
// ===== 폼 콘텐츠 렌더링 =====
const renderFormContent = () => (
@@ -239,8 +298,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
</Label>
<Input
id="billNumber"
value={billNumber}
onChange={(e) => setBillNumber(e.target.value)}
value={formData.billNumber}
onChange={(e) => updateField('billNumber', e.target.value)}
placeholder="어음번호를 입력해주세요"
disabled={isViewMode}
/>
@@ -251,7 +310,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<Label htmlFor="billType">
<span className="text-red-500">*</span>
</Label>
<Select value={billType} onValueChange={(v) => setBillType(v as BillType)} disabled={isViewMode}>
<Select
value={formData.billType}
onValueChange={(v) => updateField('billType', v as BillType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
@@ -270,7 +333,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<Label htmlFor="vendorId">
<span className="text-red-500">*</span>
</Label>
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
<Select
value={formData.vendorId}
onValueChange={(v) => updateField('vendorId', v)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
@@ -291,8 +358,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
</Label>
<CurrencyInput
id="amount"
value={amount}
onChange={(value) => setAmount(value ?? 0)}
value={formData.amount}
onChange={(value) => updateField('amount', value ?? 0)}
placeholder="금액을 입력해주세요"
disabled={isViewMode}
/>
@@ -304,8 +371,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={issueDate}
onChange={setIssueDate}
value={formData.issueDate}
onChange={(date) => updateField('issueDate', date)}
disabled={isViewMode}
/>
</div>
@@ -316,8 +383,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={maturityDate}
onChange={setMaturityDate}
value={formData.maturityDate}
onChange={(date) => updateField('maturityDate', date)}
disabled={isViewMode}
/>
</div>
@@ -327,7 +394,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<Label htmlFor="status">
<span className="text-red-500">*</span>
</Label>
<Select value={status} onValueChange={(v) => setStatus(v as BillStatus)} disabled={isViewMode}>
<Select
value={formData.status}
onValueChange={(v) => updateField('status', v as BillStatus)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
@@ -346,8 +417,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<Label htmlFor="note"></Label>
<Input
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
value={formData.note}
onChange={(e) => updateField('note', e.target.value)}
placeholder="비고를 입력해주세요"
disabled={isViewMode}
/>
@@ -386,14 +457,14 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
</TableRow>
</TableHeader>
<TableBody>
{installments.length === 0 ? (
{formData.installments.length === 0 ? (
<TableRow>
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
</TableCell>
</TableRow>
) : (
installments.map((inst, index) => (
formData.installments.map((inst, index) => (
<TableRow key={inst.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
@@ -442,8 +513,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
);
// ===== 템플릿 모드 및 동적 설정 =====
// IntegratedDetailTemplate: create → "{title} 등록", view → "{title}", edit → "{title} 수정"
// view 모드에서 "어음 상세"로 표시하려면 직접 설정 필요
const templateMode = isNewMode ? 'create' : mode;
const dynamicConfig = {
...billConfig,
@@ -460,7 +529,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
mode={templateMode}
initialData={{}}
itemId={billId}
isLoading={isLoading}
isLoading={isLoading || isSubmitting || isDeleting}
onSubmit={handleSubmit}
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
renderView={() => renderFormContent()}

View File

@@ -1,539 +0,0 @@
'use client';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { billConfig } from './billConfig';
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
import {
BILL_TYPE_OPTIONS,
getBillStatusOptions,
} from './types';
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
// ===== 새 훅 import =====
import { useDetailData, useCRUDHandlers } from '@/hooks';
// ===== Props =====
interface BillDetailProps {
billId: string;
mode: 'view' | 'edit' | 'new';
}
// ===== 거래처 타입 =====
interface ClientOption {
id: string;
name: string;
}
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
interface BillFormData {
billNumber: string;
billType: BillType;
vendorId: string;
amount: number;
issueDate: string;
maturityDate: string;
status: BillStatus;
note: string;
installments: InstallmentRecord[];
}
const INITIAL_FORM_DATA: BillFormData = {
billNumber: '',
billType: 'received',
vendorId: '',
amount: 0,
issueDate: '',
maturityDate: '',
status: 'stored',
note: '',
installments: [],
};
export function BillDetailV2({ billId, mode }: BillDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// ===== 거래처 목록 =====
const [clients, setClients] = useState<ClientOption[]>([]);
// ===== 폼 상태 (통합된 단일 state) =====
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
// ===== 폼 필드 업데이트 헬퍼 =====
const updateField = useCallback(<K extends keyof BillFormData>(
field: K,
value: BillFormData[K]
) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// ===== 거래처 목록 로드 =====
useEffect(() => {
async function loadClients() {
const result = await getClients();
if (result.success && result.data) {
setClients(result.data.map(c => ({ id: String(c.id), name: c.name })));
}
}
loadClients();
}, []);
// ===== 새 훅: useDetailData로 데이터 로딩 =====
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
const fetchBillWrapper = useCallback(
(id: string | number) => getBill(String(id)),
[]
);
const {
data: billData,
isLoading,
error: loadError,
} = useDetailData<BillRecord>(
billId !== 'new' ? billId : null,
fetchBillWrapper,
{ skip: isNewMode }
);
// ===== 데이터 로드 시 폼에 반영 =====
useEffect(() => {
if (billData) {
setFormData({
billNumber: billData.billNumber,
billType: billData.billType,
vendorId: billData.vendorId,
amount: billData.amount,
issueDate: billData.issueDate,
maturityDate: billData.maturityDate,
status: billData.status,
note: billData.note,
installments: billData.installments,
});
}
}, [billData]);
// ===== 로드 에러 처리 =====
useEffect(() => {
if (loadError) {
toast.error(loadError);
router.push('/ko/accounting/bills');
}
}, [loadError, router]);
// ===== 유효성 검사 함수 =====
const validateForm = useCallback((): { valid: boolean; error?: string } => {
if (!formData.billNumber.trim()) {
return { valid: false, error: '어음번호를 입력해주세요.' };
}
if (!formData.vendorId) {
return { valid: false, error: '거래처를 선택해주세요.' };
}
if (formData.amount <= 0) {
return { valid: false, error: '금액을 입력해주세요.' };
}
if (!formData.issueDate) {
return { valid: false, error: '발행일을 입력해주세요.' };
}
if (!formData.maturityDate) {
return { valid: false, error: '만기일을 입력해주세요.' };
}
// 차수 유효성 검사
for (let i = 0; i < formData.installments.length; i++) {
const inst = formData.installments[i];
if (!inst.date) {
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
}
if (inst.amount <= 0) {
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
}
}
return { valid: true };
}, [formData]);
// ===== 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음 =====
const updateBillWrapper = useCallback(
(id: string | number, data: Partial<BillRecord>) => updateBill(String(id), data),
[]
);
const deleteBillWrapper = useCallback(
(id: string | number) => deleteBill(String(id)),
[]
);
// ===== 새 훅: useCRUDHandlers로 CRUD 처리 =====
const {
handleCreate,
handleUpdate,
handleDelete: crudDelete,
isSubmitting,
isDeleting,
} = useCRUDHandlers<Partial<BillRecord>, Partial<BillRecord>>({
onCreate: createBill,
onUpdate: updateBillWrapper,
onDelete: deleteBillWrapper,
successRedirect: '/ko/accounting/bills',
successMessages: {
create: '어음이 등록되었습니다.',
update: '어음이 수정되었습니다.',
delete: '어음이 삭제되었습니다.',
},
// 수정 성공 시 view 모드로 이동
disableRedirect: !isNewMode,
onSuccess: (action) => {
if (action === 'update') {
router.push(`/ko/accounting/bills/${billId}?mode=view`);
}
},
});
// ===== 저장 핸들러 (유효성 검사 + CRUD 훅 사용) =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
// 유효성 검사
const validation = validateForm();
if (!validation.valid) {
toast.error(validation.error!);
return { success: false, error: validation.error };
}
const billData: Partial<BillRecord> = {
...formData,
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
};
if (isNewMode) {
return handleCreate(billData);
} else {
return handleUpdate(billId, billData);
}
}, [formData, clients, isNewMode, billId, handleCreate, handleUpdate, validateForm]);
// ===== 삭제 핸들러 =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
return crudDelete(billId);
}, [billId, crudDelete]);
// ===== 차수 관리 핸들러 =====
const handleAddInstallment = useCallback(() => {
const newInstallment: InstallmentRecord = {
id: `inst-${Date.now()}`,
date: '',
amount: 0,
note: '',
};
setFormData(prev => ({
...prev,
installments: [...prev.installments, newInstallment],
}));
}, []);
const handleRemoveInstallment = useCallback((id: string) => {
setFormData(prev => ({
...prev,
installments: prev.installments.filter(inst => inst.id !== id),
}));
}, []);
const handleUpdateInstallment = useCallback((
id: string,
field: keyof InstallmentRecord,
value: string | number
) => {
setFormData(prev => ({
...prev,
installments: prev.installments.map(inst =>
inst.id === id ? { ...inst, [field]: value } : inst
),
}));
}, []);
// ===== 상태 옵션 (구분에 따라 변경) =====
const statusOptions = useMemo(
() => getBillStatusOptions(formData.billType),
[formData.billType]
);
// ===== 폼 콘텐츠 렌더링 =====
const renderFormContent = () => (
<>
{/* 기본 정보 섹션 */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 어음번호 */}
<div className="space-y-2">
<Label htmlFor="billNumber">
<span className="text-red-500">*</span>
</Label>
<Input
id="billNumber"
value={formData.billNumber}
onChange={(e) => updateField('billNumber', e.target.value)}
placeholder="어음번호를 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 구분 */}
<div className="space-y-2">
<Label htmlFor="billType">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.billType}
onValueChange={(v) => updateField('billType', v as BillType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{BILL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="vendorId">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.vendorId}
onValueChange={(v) => updateField('vendorId', v)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 금액 */}
<div className="space-y-2">
<Label htmlFor="amount">
<span className="text-red-500">*</span>
</Label>
<CurrencyInput
id="amount"
value={formData.amount}
onChange={(value) => updateField('amount', value ?? 0)}
placeholder="금액을 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 발행일 */}
<div className="space-y-2">
<Label htmlFor="issueDate">
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={formData.issueDate}
onChange={(date) => updateField('issueDate', date)}
disabled={isViewMode}
/>
</div>
{/* 만기일 */}
<div className="space-y-2">
<Label htmlFor="maturityDate">
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={formData.maturityDate}
onChange={(date) => updateField('maturityDate', date)}
disabled={isViewMode}
/>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label htmlFor="status">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.status}
onValueChange={(v) => updateField('status', v as BillStatus)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비고 */}
<div className="space-y-2">
<Label htmlFor="note"></Label>
<Input
id="note"
value={formData.note}
onChange={(e) => updateField('note', e.target.value)}
placeholder="비고를 입력해주세요"
disabled={isViewMode}
/>
</div>
</div>
</CardContent>
</Card>
{/* 차수 관리 섹션 */}
<Card className="mb-6">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<span className="text-red-500">*</span>
</CardTitle>
{!isViewMode && (
<Button
variant="outline"
size="sm"
onClick={handleAddInstallment}
className="text-orange-500 border-orange-300 hover:bg-orange-50"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">No</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
{!isViewMode && <TableHead className="w-[60px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{formData.installments.length === 0 ? (
<TableRow>
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
</TableCell>
</TableRow>
) : (
formData.installments.map((inst, index) => (
<TableRow key={inst.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<DatePicker
value={inst.date}
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
disabled={isViewMode}
/>
</TableCell>
<TableCell>
<CurrencyInput
value={inst.amount}
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
<TableCell>
<Input
value={inst.note}
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
{!isViewMode && (
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleRemoveInstallment(inst.id)}
>
<X className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</>
);
// ===== 템플릿 모드 및 동적 설정 =====
const templateMode = isNewMode ? 'create' : mode;
const dynamicConfig = {
...billConfig,
title: isViewMode ? '어음 상세' : '어음',
actions: {
...billConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',
},
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={{}}
itemId={billId}
isLoading={isLoading || isSubmitting || isDeleting}
onSubmit={handleSubmit}
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}

View File

@@ -34,7 +34,7 @@ import { CommentSection } from '../CommentSection';
import { deletePost } from '../actions';
import type { Post, Comment } from '../types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { sanitizeHTML } from '@/lib/sanitize';
interface BoardDetailProps {

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { CEODashboardSkeleton } from './skeletons';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
import {
TodayIssueSection,
EnhancedStatusBoardSection,
@@ -281,15 +282,18 @@ export function CEODashboard() {
description="전체 현황을 조회합니다."
icon={LayoutDashboard}
actions={
<Button
variant="outline"
size="sm"
onClick={handleSettingClick}
className="gap-2"
>
<Settings className="h-4 w-4" />
</Button>
<>
<Button
variant="outline"
size="sm"
onClick={handleSettingClick}
className="gap-2"
>
<Settings className="h-4 w-4" />
</Button>
<DashboardSwitcher />
</>
}
/>

View File

@@ -0,0 +1,73 @@
'use client';
import { useRouter, usePathname, useParams } from 'next/navigation';
import { ChevronDown, LayoutDashboard, LayoutGrid, Target, Activity, Columns3 } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
const dashboards = [
{ path: '/dashboard', label: '보고서형', icon: LayoutDashboard },
{ path: '/dashboard_type2', label: '탭형', icon: Columns3 },
{ path: '/dashboard_type3', label: '위젯형', icon: LayoutGrid },
{ path: '/dashboard_type4', label: '드릴다운형', icon: Target },
{ path: '/dashboard_type5', label: '피드형', icon: Activity },
] as const;
export function DashboardSwitcher() {
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const locale = (params.locale as string) || 'ko';
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// 현재 활성 대시보드 찾기
const current = dashboards.find((d) => pathname.endsWith(d.path)) ?? dashboards[0];
// 외부 클릭 시 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
if (open) document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-background text-sm font-medium hover:bg-muted transition-colors"
>
<current.icon className="w-4 h-4 text-primary" />
<span className="hidden sm:inline">{current.label}</span>
<ChevronDown className={`w-3.5 h-3.5 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-44 rounded-lg border bg-background shadow-lg z-50 py-1">
{dashboards.map((d) => {
const Icon = d.icon;
const isActive = d.path === current.path;
return (
<button
key={d.path}
onClick={() => {
router.push(`/${locale}${d.path}`);
setOpen(false);
}}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
isActive
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground hover:bg-muted'
}`}
>
<Icon className={`w-4 h-4 ${isActive ? 'text-primary' : 'text-muted-foreground'}`} />
{d.label}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -34,7 +34,7 @@ import {
import { updateContract, deleteContract, createContract } from './actions';
import { downloadFileById } from '@/lib/utils/fileDownload';
// import { ContractDocumentModal } from './modals/ContractDocumentModal';
import { ContractDocumentModalV2 as ContractDocumentModal } from './modals/ContractDocumentModalV2';
import { ContractDocumentModal } from './modals/ContractDocumentModal';
import {
ElectronicApprovalModal,
type ElectronicApproval,

View File

@@ -1,20 +1,7 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Edit,
X as XIcon,
Printer,
Send,
} from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import type { ContractDetail } from '../types';
interface ContractDocumentModalProps {
@@ -23,6 +10,13 @@ interface ContractDocumentModalProps {
contract: ContractDetail;
}
/**
* 계약서 문서 모달 V2
*
* DocumentViewer를 사용하여 통합 UI 제공
* - 줌/드래그 기능 추가
* - PDF iframe 지원
*/
export function ContractDocumentModal({
open,
onOpenChange,
@@ -38,67 +32,32 @@ export function ContractDocumentModal({
toast.info('전자결재 상신 기능은 준비 중입니다.');
};
// 인쇄
const handlePrint = () => {
printArea({ title: '계약서 인쇄' });
};
// PDF URL 확인
const pdfUrl = contract.contractFile?.fileUrl;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="default" size="sm" onClick={handleSubmit} className="bg-blue-600 hover:bg-blue-700">
<Send className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* PDF 뷰어 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
{pdfUrl ? (
<iframe
src={pdfUrl}
className="w-full h-full min-h-[297mm]"
title="계약서 PDF"
/>
) : (
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
<p> .</p>
</div>
)}
<DocumentViewer
title="계약서"
subtitle={`${contract.partnerName} - ${contract.projectName}`}
preset="construction"
open={open}
onOpenChange={onOpenChange}
onEdit={handleEdit}
onSubmit={handleSubmit}
>
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
{pdfUrl ? (
<iframe
src={pdfUrl}
className="w-full h-full min-h-[297mm]"
title="계약서 PDF"
/>
) : (
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
<p> .</p>
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
</DocumentViewer>
);
}
}

View File

@@ -1,63 +0,0 @@
'use client';
import { DocumentViewer } from '@/components/document-system';
import { toast } from 'sonner';
import type { ContractDetail } from '../types';
interface ContractDocumentModalV2Props {
open: boolean;
onOpenChange: (open: boolean) => void;
contract: ContractDetail;
}
/**
* 계약서 문서 모달 V2
*
* DocumentViewer를 사용하여 통합 UI 제공
* - 줌/드래그 기능 추가
* - PDF iframe 지원
*/
export function ContractDocumentModalV2({
open,
onOpenChange,
contract,
}: ContractDocumentModalV2Props) {
// 수정
const handleEdit = () => {
toast.info('수정 기능은 준비 중입니다.');
};
// 상신 (전자결재)
const handleSubmit = () => {
toast.info('전자결재 상신 기능은 준비 중입니다.');
};
// PDF URL 확인
const pdfUrl = contract.contractFile?.fileUrl;
return (
<DocumentViewer
title="계약서"
subtitle={`${contract.partnerName} - ${contract.projectName}`}
preset="construction"
open={open}
onOpenChange={onOpenChange}
onEdit={handleEdit}
onSubmit={handleSubmit}
>
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
{pdfUrl ? (
<iframe
src={pdfUrl}
className="w-full h-full min-h-[297mm]"
title="계약서 PDF"
/>
) : (
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
<p> .</p>
</div>
)}
</div>
</DocumentViewer>
);
}

View File

@@ -1,2 +1 @@
export { ContractDocumentModal } from './ContractDocumentModal';
export { ContractDocumentModalV2 } from './ContractDocumentModalV2';

View File

@@ -1,91 +1,46 @@
/**
* LaborDetailClient - IntegratedDetailTemplate 기반 노임 상세/등록/수정
*
* 기존 LaborDetailClient를 IntegratedDetailTemplate으로 마이그레이션
* - 6개 필드: 노임번호, 구분, 최소M, 최대M, 노임단가, 상태
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Hammer, ArrowLeft, Trash2, Edit, X, Save, Plus } from 'lucide-react';
import { useMenuStore } from '@/store/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type { Labor, LaborFormData, LaborCategory, LaborStatus } from './types';
import { CATEGORY_OPTIONS, STATUS_OPTIONS } from './constants';
import {
IntegratedDetailTemplate,
type DetailMode,
} from '@/components/templates/IntegratedDetailTemplate';
import { laborDetailConfig } from './laborDetailConfig';
import type { Labor, LaborFormData } from './types';
import { getLabor, createLabor, updateLabor, deleteLabor } from './actions';
interface LaborDetailClientProps {
laborId?: string;
isEditMode?: boolean;
isNewMode?: boolean;
initialMode?: DetailMode;
}
const initialFormData: LaborFormData = {
laborNumber: '',
category: '가로',
minM: 0,
maxM: 0,
laborPrice: null,
status: '사용',
};
export default function LaborDetailClient({
laborId,
isEditMode = false,
isNewMode = false,
initialMode = 'view',
}: LaborDetailClientProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const { canUpdate, canDelete } = usePermission();
// 모드 상태
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
isNewMode ? 'new' : isEditMode ? 'edit' : 'view'
);
// 폼 데이터
const [formData, setFormData] = useState<LaborFormData>(initialFormData);
const [originalData, setOriginalData] = useState<Labor | null>(null);
// 소수점 입력을 위한 문자열 상태 (입력 중인 값 유지)
const [minMInput, setMinMInput] = useState<string>('');
const [maxMInput, setMaxMInput] = useState<string>('');
// 상태
const [labor, setLabor] = useState<Labor | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [mode, setMode] = useState<DetailMode>(initialMode);
// 노임 데이터 로드
// 데이터 로드
useEffect(() => {
if (laborId && !isNewMode) {
const loadLabor = async () => {
if (laborId && initialMode !== 'create') {
const loadData = async () => {
setIsLoading(true);
try {
const result = await getLabor(laborId);
if (result.success && result.data) {
setOriginalData(result.data);
setFormData({
laborNumber: result.data.laborNumber,
category: result.data.category,
minM: result.data.minM,
maxM: result.data.maxM,
laborPrice: result.data.laborPrice,
status: result.data.status,
});
// 소수점 입력용 문자열 상태 초기화
setMinMInput(result.data.minM === 0 ? '' : result.data.minM.toString());
setMaxMInput(result.data.maxM === 0 ? '' : result.data.maxM.toString());
setLabor(result.data);
} else {
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/labor');
@@ -97,376 +52,69 @@ export default function LaborDetailClient({
setIsLoading(false);
}
};
loadLabor();
loadData();
}
}, [laborId, isNewMode, router]);
}, [laborId, initialMode, router]);
// 폼 필드 변경
const handleFieldChange = useCallback(
(field: keyof LaborFormData, value: string | number | null) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const submitData = laborDetailConfig.transformSubmitData!(formData) as unknown as LaborFormData;
// 최소 M / 최대 M 입력 핸들러 (소수점 둘째자리까지)
const handleMinMChange = useCallback(
(value: string) => {
// 빈 값 허용
if (value === '') {
setMinMInput('');
handleFieldChange('minM', 0);
return;
}
// 소수점 둘째자리까지 허용하는 정규식
const regex = /^\d*\.?\d{0,2}$/;
if (regex.test(value)) {
setMinMInput(value);
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
handleFieldChange('minM', numValue);
}
}
},
[handleFieldChange]
);
const handleMaxMChange = useCallback(
(value: string) => {
// 빈 값 허용
if (value === '') {
setMaxMInput('');
handleFieldChange('maxM', 0);
return;
}
// 소수점 둘째자리까지 허용하는 정규식
const regex = /^\d*\.?\d{0,2}$/;
if (regex.test(value)) {
setMaxMInput(value);
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
handleFieldChange('maxM', numValue);
}
}
},
[handleFieldChange]
);
// 노임단가 입력 핸들러 (정수만)
const handleLaborPriceChange = useCallback(
(value: string) => {
if (value === '') {
handleFieldChange('laborPrice', null);
return;
}
// 정수만 허용
const regex = /^\d*$/;
if (regex.test(value)) {
const numValue = parseInt(value, 10);
if (!isNaN(numValue)) {
handleFieldChange('laborPrice', numValue);
}
}
},
[handleFieldChange]
);
// 저장
const handleSave = useCallback(async () => {
// 유효성 검사
if (!formData.laborNumber.trim()) {
toast.error('노임번호를 입력해주세요.');
return;
}
setIsSaving(true);
try {
if (mode === 'new') {
const result = await createLabor(formData);
if (result.success && result.data) {
toast.success('노임이 등록되었습니다.');
router.push(`/ko/construction/order/base-info/labor/${result.data.id}?mode=view`);
} else {
toast.error(result.error || '노임 등록에 실패했습니다.');
}
} else if (mode === 'edit' && laborId) {
const result = await updateLabor(laborId, formData);
if (result.success) {
toast.success('노임이 수정되었습니다.');
setMode('view');
// 데이터 다시 로드
const reloadResult = await getLabor(laborId);
if (reloadResult.success && reloadResult.data) {
setOriginalData(reloadResult.data);
if (mode === 'create') {
const result = await createLabor(submitData);
if (result.success && result.data) {
return { success: true };
}
} else {
toast.error(result.error || '노임 수정에 실패했습니다.');
return { success: false, error: result.error || '노임 등록에 실패했습니다.' };
} else if (mode === 'edit' && laborId) {
const result = await updateLabor(laborId, submitData);
if (result.success) {
return { success: true };
}
return { success: false, error: result.error || '노임 수정에 실패했습니다.' };
}
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
} catch {
return { success: false, error: '저장 중 오류가 발생했습니다.' };
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [mode, formData, laborId, router]);
},
[mode, laborId]
);
// 삭제
const handleDelete = useCallback(async () => {
if (!laborId) return;
setIsLoading(true);
// 삭제 핸들러
const handleDelete = useCallback(async (id: string | number) => {
try {
const result = await deleteLabor(laborId);
const result = await deleteLabor(String(id));
if (result.success) {
toast.success('노임이 삭제되었습니다.');
router.push('/ko/construction/order/base-info/labor');
} else {
toast.error(result.error || '노임 삭제에 실패했습니다.');
return { success: true };
}
return { success: false, error: result.error || '노임 삭제에 실패했습니다.' };
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
}
}, [laborId, router]);
}, []);
// 수정 모드 전환
const handleEditMode = useCallback(() => {
setMode('edit');
router.replace(`/ko/construction/order/base-info/labor/${laborId}?mode=edit`);
}, [laborId, router]);
// 목록으로 이동
const handleBack = useCallback(() => {
router.push('/ko/construction/order/base-info/labor');
}, [router]);
// 취소
const handleCancel = useCallback(() => {
if (mode === 'new') {
router.push('/ko/construction/order/base-info/labor');
} else {
setMode('view');
// 원본 데이터로 복원
if (originalData) {
setFormData({
laborNumber: originalData.laborNumber,
category: originalData.category,
minM: originalData.minM,
maxM: originalData.maxM,
laborPrice: originalData.laborPrice,
status: originalData.status,
});
// 소수점 입력용 문자열 상태도 복원
setMinMInput(originalData.minM === 0 ? '' : originalData.minM.toString());
setMaxMInput(originalData.maxM === 0 ? '' : originalData.maxM.toString());
}
router.replace(`/ko/construction/order/base-info/labor/${laborId}`);
}
}, [mode, laborId, originalData, router]);
// 읽기 전용 여부
const isReadOnly = mode === 'view';
// 페이지 타이틀
const pageTitle = mode === 'new' ? '노임 등록' : '노임 상세';
if (isLoading && !isNewMode) {
return (
<PageLayout>
<PageHeader
title={pageTitle}
description="노임 정보를 등록하고 관리합니다."
icon={Hammer}
/>
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground"> ...</div>
</div>
</PageLayout>
);
}
// 모드 변경 핸들러
const handleModeChange = useCallback((newMode: DetailMode) => {
setMode(newMode);
}, []);
return (
<>
<PageLayout>
<PageHeader
title={pageTitle}
description="노임 정보를 등록하고 관리합니다."
icon={Hammer}
/>
<div className="space-y-6 pb-24">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base">
<span className="text-destructive">*</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Row 1: 노임번호, 구분 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="laborNumber"></Label>
<Input
id="laborNumber"
value={formData.laborNumber}
onChange={(e) => handleFieldChange('laborNumber', e.target.value)}
placeholder="노임번호를 입력하세요"
disabled={isReadOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="category"></Label>
<Select
value={formData.category}
onValueChange={(v) => handleFieldChange('category', v as LaborCategory)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="구분 선택" />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 2: 최소 M, 최대 M */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="minM"> M</Label>
<Input
id="minM"
type="text"
inputMode="decimal"
value={minMInput}
onChange={(e) => handleMinMChange(e.target.value)}
placeholder="0.00"
disabled={isReadOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxM"> M</Label>
<Input
id="maxM"
type="text"
inputMode="decimal"
value={maxMInput}
onChange={(e) => handleMaxMChange(e.target.value)}
placeholder="0.00"
disabled={isReadOnly}
/>
</div>
</div>
{/* Row 3: 노임단가, 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="laborPrice"></Label>
<Input
id="laborPrice"
type="text"
inputMode="numeric"
value={formData.laborPrice === null ? '' : formData.laborPrice.toString()}
onChange={(e) => handleLaborPriceChange(e.target.value)}
placeholder="0"
disabled={isReadOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(v) => handleFieldChange('status', v as LaborStatus)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 하단 액션 버튼 (sticky) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
<Button variant="outline" onClick={handleBack} size="sm" className="md:size-default">
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<div className="flex items-center gap-1 md:gap-2">
{mode === 'view' && (
<>
{canDelete && (
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(true)}
size="sm"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
>
<Trash2 className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
{canUpdate && (
<Button onClick={handleEditMode} size="sm" className="md:size-default">
<Edit className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
</>
)}
{mode === 'edit' && (
<>
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
<X className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button onClick={handleSave} disabled={isSaving} size="sm" className="md:size-default">
<Save className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">{isSaving ? '저장 중...' : '저장'}</span>
</Button>
</>
)}
{mode === 'new' && (
<>
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
<X className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button onClick={handleSave} disabled={isSaving} size="sm" className="md:size-default">
<Plus className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">{isSaving ? '등록 중...' : '등록'}</span>
</Button>
</>
)}
</div>
</div>
</PageLayout>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
description="이 노임을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={isLoading}
onConfirm={handleDelete}
/>
</>
<IntegratedDetailTemplate
config={laborDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
mode={mode}
initialData={labor as Record<string, unknown> | undefined}
itemId={laborId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onModeChange={handleModeChange}
/>
);
}
}
// Named export for backwards compatibility
export { LaborDetailClient };

View File

@@ -1,120 +0,0 @@
/**
* LaborDetailClientV2 - IntegratedDetailTemplate 기반 노임 상세/등록/수정
*
* 기존 LaborDetailClient를 IntegratedDetailTemplate으로 마이그레이션
* - 6개 필드: 노임번호, 구분, 최소M, 최대M, 노임단가, 상태
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
IntegratedDetailTemplate,
type DetailMode,
} from '@/components/templates/IntegratedDetailTemplate';
import { laborDetailConfig } from './laborDetailConfig';
import type { Labor, LaborFormData } from './types';
import { getLabor, createLabor, updateLabor, deleteLabor } from './actions';
interface LaborDetailClientV2Props {
laborId?: string;
initialMode?: DetailMode;
}
export default function LaborDetailClientV2({
laborId,
initialMode = 'view',
}: LaborDetailClientV2Props) {
const router = useRouter();
const [labor, setLabor] = useState<Labor | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [mode, setMode] = useState<DetailMode>(initialMode);
// 데이터 로드
useEffect(() => {
if (laborId && initialMode !== 'create') {
const loadData = async () => {
setIsLoading(true);
try {
const result = await getLabor(laborId);
if (result.success && result.data) {
setLabor(result.data);
} else {
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/labor');
}
} catch {
toast.error('노임 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/labor');
} finally {
setIsLoading(false);
}
};
loadData();
}
}, [laborId, initialMode, router]);
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const submitData = laborDetailConfig.transformSubmitData!(formData) as unknown as LaborFormData;
if (mode === 'create') {
const result = await createLabor(submitData);
if (result.success && result.data) {
return { success: true };
}
return { success: false, error: result.error || '노임 등록에 실패했습니다.' };
} else if (mode === 'edit' && laborId) {
const result = await updateLabor(laborId, submitData);
if (result.success) {
return { success: true };
}
return { success: false, error: result.error || '노임 수정에 실패했습니다.' };
}
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
} catch {
return { success: false, error: '저장 중 오류가 발생했습니다.' };
}
},
[mode, laborId]
);
// 삭제 핸들러
const handleDelete = useCallback(async (id: string | number) => {
try {
const result = await deleteLabor(String(id));
if (result.success) {
return { success: true };
}
return { success: false, error: result.error || '노임 삭제에 실패했습니다.' };
} catch {
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
}
}, []);
// 모드 변경 핸들러
const handleModeChange = useCallback((newMode: DetailMode) => {
setMode(newMode);
}, []);
return (
<IntegratedDetailTemplate
config={laborDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
mode={mode}
initialData={labor as Record<string, unknown> | undefined}
itemId={laborId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onModeChange={handleModeChange}
/>
);
}
// Named export for backwards compatibility
export { LaborDetailClientV2 };

View File

@@ -1,6 +1,5 @@
export { default as LaborManagementClient } from './LaborManagementClient';
export { default as LaborDetailClient } from './LaborDetailClient';
export { default as LaborDetailClientV2 } from './LaborDetailClientV2';
export { laborDetailConfig } from './laborDetailConfig';
export * from './types';
export * from './constants';

View File

@@ -1,464 +1,134 @@
/**
* PricingDetailClient - IntegratedDetailTemplate 기반 단가 상세/등록/수정
*
* 기존 PricingDetailClient를 IntegratedDetailTemplate으로 마이그레이션
* - 12개 필드: 단가번호, 품목유형, 카테고리명, 품목명, 규격, 무게, 단위, 구분, 거래처, 판매단가, 상태, 비고
* - 대부분 필드 readonly, 거래처/판매단가/상태/비고만 edit/create 모드에서 수정 가능
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { DollarSign, ArrowLeft, Trash2, Edit, X, Save, Plus, List } from 'lucide-react';
import { useMenuStore } from '@/store/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { CurrencyInput } from '@/components/ui/currency-input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Pricing, PricingStatus } from './types';
import { PRICING_STATUS_LABELS } from './types';
import {
getPricingDetail,
createPricing,
updatePricing,
deletePricing,
getVendorList,
} from './actions';
IntegratedDetailTemplate,
type DetailMode,
} from '@/components/templates/IntegratedDetailTemplate';
import { pricingDetailConfig } from './pricingDetailConfig';
import type { Pricing, PricingFormData } from './types';
import { getPricingDetail, createPricing, updatePricing, deletePricing } from './actions';
interface PricingDetailClientProps {
id?: string;
mode: 'view' | 'create' | 'edit';
pricingId?: string;
initialMode?: DetailMode;
}
interface FormData {
itemType: string;
category: string;
itemName: string;
spec: string;
unit: string;
division: string;
vendor: string;
purchasePrice: number;
marginRate: number;
sellingPrice: number;
status: PricingStatus;
note: string;
}
const initialFormData: FormData = {
itemType: '',
category: '',
itemName: '',
spec: '',
unit: '',
division: '',
vendor: '',
purchasePrice: 0,
marginRate: 0,
sellingPrice: 0,
status: 'in_use',
note: '',
};
export default function PricingDetailClient({ id, mode }: PricingDetailClientProps) {
export default function PricingDetailClient({
pricingId,
initialMode = 'view',
}: PricingDetailClientProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const { canUpdate, canDelete } = usePermission();
const [pricing, setPricing] = useState<Pricing | null>(null);
const [formData, setFormData] = useState<FormData>(initialFormData);
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
const [pricing, setPricing] = useState<Pricing | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const isViewMode = mode === 'view';
const isCreateMode = mode === 'create';
const isEditMode = mode === 'edit';
const [mode, setMode] = useState<DetailMode>(initialMode);
// 데이터 로드
useEffect(() => {
const loadData = async () => {
setIsLoading(true);
try {
// 거래처 목록 로드
const vendorResult = await getVendorList();
if (vendorResult.success && vendorResult.data) {
setVendors(vendorResult.data);
}
// 상세 데이터 로드 (수정/보기 모드)
if (id && (isViewMode || isEditMode)) {
const result = await getPricingDetail(id);
if (pricingId && initialMode !== 'create') {
const loadData = async () => {
setIsLoading(true);
try {
const result = await getPricingDetail(pricingId);
if (result.success && result.data) {
setPricing(result.data);
setFormData({
itemType: result.data.itemType,
category: result.data.category,
itemName: result.data.itemName,
spec: result.data.spec,
unit: result.data.unit,
division: result.data.division,
vendor: result.data.vendor,
purchasePrice: result.data.purchasePrice,
marginRate: result.data.marginRate,
sellingPrice: result.data.sellingPrice,
status: result.data.status,
note: '',
});
} else {
toast.error(result.error || '데이터를 불러올 수 없습니다.');
toast.error(result.error || '단가 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/pricing');
}
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
};
loadData();
}, [id, mode, isViewMode, isEditMode, router]);
// 판매단가 변경
const handleSellingPriceChange = useCallback((value: string) => {
const numValue = parseFloat(value) || 0;
setFormData((prev) => ({
...prev,
sellingPrice: numValue,
}));
}, []);
// 거래처 변경
const handleVendorChange = useCallback((value: string) => {
setFormData((prev) => ({ ...prev, vendor: value }));
}, []);
// 상태 변경
const handleStatusChange = useCallback((value: string) => {
setFormData((prev) => ({ ...prev, status: value as PricingStatus }));
}, []);
// 비고 변경
const handleNoteChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setFormData((prev) => ({ ...prev, note: e.target.value }));
}, []);
// 저장
const handleSave = useCallback(async () => {
setIsLoading(true);
try {
if (isCreateMode) {
const result = await createPricing({
itemType: formData.itemType,
category: formData.category,
itemName: formData.itemName,
spec: formData.spec,
orderItems: [],
unit: formData.unit,
division: formData.division,
vendor: formData.vendor,
purchasePrice: formData.purchasePrice,
marginRate: formData.marginRate,
sellingPrice: formData.sellingPrice,
status: formData.status,
});
if (result.success) {
toast.success('단가가 등록되었습니다.');
} catch {
toast.error('단가 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/pricing');
} else {
toast.error(result.error || '등록에 실패했습니다.');
} finally {
setIsLoading(false);
}
} else if (isEditMode && id) {
const result = await updatePricing(id, {
vendor: formData.vendor,
sellingPrice: formData.sellingPrice,
status: formData.status,
});
if (result.success) {
toast.success('단가가 수정되었습니다.');
router.push(`/ko/construction/order/base-info/pricing/${id}?mode=view`);
} else {
toast.error(result.error || '수정에 실패했습니다.');
}
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
};
loadData();
}
}, [isCreateMode, isEditMode, id, formData, router]);
}, [pricingId, initialMode, router]);
// 삭제
const handleDelete = useCallback(async () => {
if (!id) return;
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const submitData = pricingDetailConfig.transformSubmitData!(formData) as unknown as PricingFormData;
setIsLoading(true);
if (mode === 'create') {
const result = await createPricing(submitData);
if (result.success && result.data) {
return { success: true };
}
return { success: false, error: result.error || '단가 등록에 실패했습니다.' };
} else if (mode === 'edit' && pricingId) {
// edit 모드에서는 수정 가능한 필드만 전송
const result = await updatePricing(pricingId, {
vendor: submitData.vendor,
sellingPrice: submitData.sellingPrice,
status: submitData.status,
});
if (result.success) {
return { success: true };
}
return { success: false, error: result.error || '단가 수정에 실패했습니다.' };
}
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
} catch {
return { success: false, error: '저장 중 오류가 발생했습니다.' };
}
},
[mode, pricingId]
);
// 삭제 핸들러
const handleDelete = useCallback(async (id: string | number) => {
try {
const result = await deletePricing(id);
const result = await deletePricing(String(id));
if (result.success) {
toast.success('단가가 삭제되었습니다.');
router.push('/ko/construction/order/base-info/pricing');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
return { success: true };
}
return { success: false, error: result.error || '단가 삭제에 실패했습니다.' };
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
}
}, [id, router]);
}, []);
// 수정 페이지로 이동
const handleEdit = useCallback(() => {
if (id) {
router.push(`/ko/construction/order/base-info/pricing/${id}?mode=edit`);
}
}, [id, router]);
// 취소
const handleCancel = useCallback(() => {
if (isCreateMode) {
router.push('/ko/construction/order/base-info/pricing');
} else if (isEditMode && id) {
router.push(`/ko/construction/order/base-info/pricing/${id}?mode=view`);
}
}, [isCreateMode, isEditMode, id, router]);
// 목록으로 이동
const handleBack = useCallback(() => {
router.push('/ko/construction/order/base-info/pricing');
}, [router]);
// 숫자 포맷
const formatNumber = (num: number) => num.toLocaleString('ko-KR');
// 페이지 제목
const pageTitle = isCreateMode ? '단가 등록' : isEditMode ? '단가 수정' : '단가 상세';
const pageDescription = '단가 정보를 등록하고 관리합니다';
// 동적 항목 (무게, 두께 등) 가져오기
const dynamicOrderItems = pricing?.orderItems || [];
// 모드 변경 핸들러
const handleModeChange = useCallback(
(newMode: DetailMode) => {
if (newMode === 'edit' && pricingId) {
// edit 모드로 변경 시 별도 페이지로 이동 (기존 라우트 구조 유지)
router.push(`/ko/construction/order/base-info/pricing/${pricingId}?mode=edit`);
} else {
setMode(newMode);
}
},
[pricingId, router]
);
return (
<PageLayout>
<PageHeader
title={pageTitle}
description={pageDescription}
icon={DollarSign}
/>
<div className="space-y-6 pb-24">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 단가번호 / 품목유형 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
{isCreateMode ? (
<Input value="자동생성" disabled />
) : (
<Input value={pricing?.pricingNumber || ''} disabled />
)}
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.itemType} disabled />
</div>
</div>
{/* 카테고리명 / 품목명 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={formData.category} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.itemName} disabled />
</div>
</div>
{/* 규격 / 동적항목 (무게 등) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={formData.spec} disabled />
</div>
{dynamicOrderItems.length > 0 ? (
<div className="space-y-2">
<Label>{dynamicOrderItems[0]?.name || '무게'}</Label>
<Input value={dynamicOrderItems[0]?.value || '-'} disabled />
</div>
) : (
<div className="space-y-2">
<Label></Label>
<Input value="-" disabled />
</div>
)}
</div>
{/* 단위 / 구분 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={formData.unit} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.division} disabled />
</div>
</div>
{/* 거래처 / 판매단가 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={formData.vendor} disabled />
) : (
<Select value={formData.vendor} onValueChange={handleVendorChange}>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{vendors.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.name}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={formatNumber(formData.sellingPrice)} disabled />
) : (
<CurrencyInput
value={formData.sellingPrice}
onChange={(value) => handleSellingPriceChange(String(value ?? 0))}
placeholder="판매단가 입력"
/>
)}
</div>
</div>
{/* 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={PRICING_STATUS_LABELS[formData.status]} disabled />
) : (
<Select value={formData.status} onValueChange={handleStatusChange}>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="in_use"></SelectItem>
<SelectItem value="stopped"></SelectItem>
</SelectContent>
</Select>
)}
</div>
<div />
</div>
{/* 비고 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Textarea value={formData.note || '-'} disabled rows={3} />
) : (
<Textarea
value={formData.note}
onChange={handleNoteChange}
placeholder="비고 입력"
rows={3}
/>
)}
</div>
</CardContent>
</Card>
</div>
{/* 하단 액션 버튼 (sticky) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
<Button variant="outline" onClick={handleBack} size="sm" className="md:size-default">
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<div className="flex items-center gap-1 md:gap-2">
{isViewMode && (
<>
{canDelete && (
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(true)}
size="sm"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
>
<Trash2 className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
{canUpdate && (
<Button onClick={handleEdit} size="sm" className="md:size-default">
<Edit className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
</>
)}
{isEditMode && (
<>
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
<X className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button onClick={handleSave} disabled={isLoading} size="sm" className="md:size-default">
<Save className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">{isLoading ? '저장 중...' : '저장'}</span>
</Button>
</>
)}
{isCreateMode && (
<>
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
<X className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button onClick={handleSave} disabled={isLoading} size="sm" className="md:size-default">
<Plus className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">{isLoading ? '등록 중...' : '등록'}</span>
</Button>
</>
)}
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
description="이 단가를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
loading={isLoading}
onConfirm={handleDelete}
/>
</PageLayout>
<IntegratedDetailTemplate
config={pricingDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
mode={mode}
initialData={pricing as Record<string, unknown> | undefined}
itemId={pricingId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onModeChange={handleModeChange}
/>
);
}
// Named export for backwards compatibility
export { PricingDetailClient };

View File

@@ -1,134 +0,0 @@
/**
* PricingDetailClientV2 - IntegratedDetailTemplate 기반 단가 상세/등록/수정
*
* 기존 PricingDetailClient를 IntegratedDetailTemplate으로 마이그레이션
* - 12개 필드: 단가번호, 품목유형, 카테고리명, 품목명, 규격, 무게, 단위, 구분, 거래처, 판매단가, 상태, 비고
* - 대부분 필드 readonly, 거래처/판매단가/상태/비고만 edit/create 모드에서 수정 가능
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
IntegratedDetailTemplate,
type DetailMode,
} from '@/components/templates/IntegratedDetailTemplate';
import { pricingDetailConfig } from './pricingDetailConfig';
import type { Pricing, PricingFormData } from './types';
import { getPricingDetail, createPricing, updatePricing, deletePricing } from './actions';
interface PricingDetailClientV2Props {
pricingId?: string;
initialMode?: DetailMode;
}
export default function PricingDetailClientV2({
pricingId,
initialMode = 'view',
}: PricingDetailClientV2Props) {
const router = useRouter();
const [pricing, setPricing] = useState<Pricing | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [mode, setMode] = useState<DetailMode>(initialMode);
// 데이터 로드
useEffect(() => {
if (pricingId && initialMode !== 'create') {
const loadData = async () => {
setIsLoading(true);
try {
const result = await getPricingDetail(pricingId);
if (result.success && result.data) {
setPricing(result.data);
} else {
toast.error(result.error || '단가 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/pricing');
}
} catch {
toast.error('단가 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/pricing');
} finally {
setIsLoading(false);
}
};
loadData();
}
}, [pricingId, initialMode, router]);
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const submitData = pricingDetailConfig.transformSubmitData!(formData) as unknown as PricingFormData;
if (mode === 'create') {
const result = await createPricing(submitData);
if (result.success && result.data) {
return { success: true };
}
return { success: false, error: result.error || '단가 등록에 실패했습니다.' };
} else if (mode === 'edit' && pricingId) {
// edit 모드에서는 수정 가능한 필드만 전송
const result = await updatePricing(pricingId, {
vendor: submitData.vendor,
sellingPrice: submitData.sellingPrice,
status: submitData.status,
});
if (result.success) {
return { success: true };
}
return { success: false, error: result.error || '단가 수정에 실패했습니다.' };
}
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
} catch {
return { success: false, error: '저장 중 오류가 발생했습니다.' };
}
},
[mode, pricingId]
);
// 삭제 핸들러
const handleDelete = useCallback(async (id: string | number) => {
try {
const result = await deletePricing(String(id));
if (result.success) {
return { success: true };
}
return { success: false, error: result.error || '단가 삭제에 실패했습니다.' };
} catch {
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
}
}, []);
// 모드 변경 핸들러
const handleModeChange = useCallback(
(newMode: DetailMode) => {
if (newMode === 'edit' && pricingId) {
// edit 모드로 변경 시 별도 페이지로 이동 (기존 라우트 구조 유지)
router.push(`/ko/construction/order/base-info/pricing/${pricingId}?mode=edit`);
} else {
setMode(newMode);
}
},
[pricingId, router]
);
return (
<IntegratedDetailTemplate
config={pricingDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
mode={mode}
initialData={pricing as Record<string, unknown> | undefined}
itemId={pricingId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onModeChange={handleModeChange}
/>
);
}
// Named export for backwards compatibility
export { PricingDetailClientV2 };

View File

@@ -1,6 +1,5 @@
export { default as PricingListClient } from './PricingListClient';
export { default as PricingDetailClient } from './PricingDetailClient';
export { default as PricingDetailClientV2 } from './PricingDetailClientV2';
export { pricingDetailConfig } from './pricingDetailConfig';
export * from './types';
export * from './actions';

View File

@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';

View File

@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { toast } from 'sonner';
import {

View File

@@ -27,7 +27,7 @@ import {
import { Client } from "../../hooks/useClientList";
import { PageLayout } from "../organisms/PageLayout";
import { PageHeader } from "../organisms/PageHeader";
import { useMenuStore } from "@/store/menuStore";
import { useMenuStore } from "@/stores/menuStore";
interface ClientDetailProps {
client: Client;

View File

@@ -15,7 +15,7 @@ import {
randomRemark,
tempId,
} from './index';
import type { QuoteFormData, QuoteItem } from '@/components/quotes/QuoteRegistration';
import type { QuoteFormData, QuoteFormItem } from '@/components/quotes/types';
import type { Vendor } from '@/components/accounting/VendorManagement/types';
import type { FinishedGoods } from '@/components/quotes/actions';
@@ -40,11 +40,11 @@ const WRITERS = ['드미트리', '김철수', '이영희', '박지민', '최서
* @param products 제품 목록 (code, name, category 속성 필요)
* @param category 제품 카테고리 (지정하지 않으면 랜덤 선택)
*/
export function generateQuoteItem(
export function generateQuoteFormItem(
index: number,
products?: Array<{ code: string; name: string; category?: string }>,
category?: string
): QuoteItem {
): QuoteFormItem {
const selectedCategory = category || randomPick(PRODUCT_CATEGORIES);
// 카테고리에 맞는 제품 필터링
@@ -106,9 +106,9 @@ export function generateQuoteData(options: GenerateQuoteDataOptions = {}): Quote
// 품목 생성 (동일 카테고리 사용)
const selectedCategory = category || randomPick(PRODUCT_CATEGORIES);
const items: QuoteItem[] = [];
const items: QuoteFormItem[] = [];
for (let i = 0; i < count; i++) {
items.push(generateQuoteItem(i, products, selectedCategory));
items.push(generateQuoteFormItem(i, products, selectedCategory));
}
return {

View File

@@ -10,7 +10,7 @@ export { useDevFill } from './useDevFill';
export { DevToolbar } from './DevToolbar';
// Generators
export { generateQuoteData, generateQuoteItem } from './generators/quoteData';
export { generateQuoteData, generateQuoteFormItem } from './generators/quoteData';
export { generateOrderData, generateOrderDataFull } from './generators/orderData';
export { generateWorkOrderData } from './generators/workOrderData';
export { generateShipmentData } from './generators/shipmentData';

View File

@@ -9,7 +9,7 @@
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { Save, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';

View File

@@ -30,7 +30,7 @@ import {
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
import { downloadFileById } from '@/lib/utils/fileDownload';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
interface ItemDetailClientProps {

View File

@@ -2,7 +2,7 @@
import { useEffect, useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
import { useRouter } from 'next/navigation';
import { useMenuStore, type MenuItem } from '@/store/menuStore';
import { useMenuStore, type MenuItem } from '@/stores/menuStore';
import {
CommandDialog,
CommandInput,

View File

@@ -0,0 +1,209 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { MoreHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useFavoritesStore } from '@/stores/favoritesStore';
import { iconMap } from '@/lib/utils/menuTransform';
import type { FavoriteItem } from '@/stores/favoritesStore';
type DisplayMode = 'full' | 'icon-only' | 'overflow';
interface HeaderFavoritesBarProps {
isMobile: boolean;
}
export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) {
const router = useRouter();
const { favorites } = useFavoritesStore();
const containerRef = useRef<HTMLDivElement>(null);
const [displayMode, setDisplayMode] = useState<DisplayMode>('full');
// 반응형: ResizeObserver로 컨테이너 너비 감지
useEffect(() => {
if (isMobile) {
setDisplayMode('overflow');
return;
}
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
if (width < 300 || favorites.length > 4) {
setDisplayMode('overflow');
} else if (width < 600) {
setDisplayMode('icon-only');
} else {
setDisplayMode('full');
}
}
});
observer.observe(container);
return () => observer.disconnect();
}, [isMobile, favorites.length]);
const handleClick = useCallback(
(item: FavoriteItem) => {
router.push(item.path);
},
[router]
);
if (favorites.length === 0) return null;
const getIcon = (iconName: string) => {
const Icon = iconMap[iconName];
return Icon || null;
};
// 모바일: 최대 2개 아이콘 + 나머지 드롭다운
if (isMobile) {
const visible = favorites.slice(0, 2);
const overflow = favorites.slice(2);
return (
<div className="flex items-center space-x-0.5 sm:space-x-1">
{visible.map((item) => {
const Icon = getIcon(item.iconName);
if (!Icon) return null;
return (
<Button
key={item.id}
variant="default"
size="sm"
onClick={() => handleClick(item)}
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
title={item.label}
>
<Icon className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
</Button>
);
})}
{overflow.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-slate-600 hover:bg-slate-700 text-white flex items-center justify-center"
>
<MoreHorizontal className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{overflow.map((item) => {
const Icon = getIcon(item.iconName);
return (
<DropdownMenuItem
key={item.id}
onClick={() => handleClick(item)}
className="flex items-center gap-2 cursor-pointer"
>
{Icon && <Icon className="h-4 w-4" />}
<span>{item.label}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
// 데스크톱
const visibleCount = displayMode === 'overflow' ? 3 : favorites.length;
const visibleItems = favorites.slice(0, visibleCount);
const overflowItems = favorites.slice(visibleCount);
return (
<TooltipProvider delayDuration={300}>
<div ref={containerRef} className="flex items-center space-x-2">
{visibleItems.map((item) => {
const Icon = getIcon(item.iconName);
if (!Icon) return null;
if (displayMode === 'full') {
return (
<Button
key={item.id}
variant="default"
size="sm"
onClick={() => handleClick(item)}
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
>
<Icon className="h-4 w-4" />
<span className="hidden xl:inline">{item.label}</span>
</Button>
);
}
// icon-only 또는 overflow의 visible 부분
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
onClick={() => handleClick(item)}
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white p-2 flex items-center justify-center transition-all duration-200"
>
<Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
);
})}
{overflowItems.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="rounded-xl p-2 flex items-center justify-center"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{overflowItems.map((item) => {
const Icon = getIcon(item.iconName);
return (
<DropdownMenuItem
key={item.id}
onClick={() => handleClick(item)}
className="flex items-center gap-2 cursor-pointer"
>
{Icon && <Icon className="h-4 w-4" />}
<span>{item.label}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -1,6 +1,9 @@
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
import type { MenuItem } from '@/store/menuStore';
import { useEffect, useRef } from 'react';
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle, Star } from 'lucide-react';
import type { MenuItem } from '@/stores/menuStore';
import { useEffect, useRef, useCallback } from 'react';
import { useFavoritesStore } from '@/stores/favoritesStore';
import { getIconName } from '@/lib/utils/menuTransform';
import type { FavoriteItem } from '@/stores/favoritesStore';
interface SidebarProps {
menuItems: MenuItem[];
@@ -45,6 +48,24 @@ function MenuItemComponent({
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedMenus.includes(item.id);
const isActive = activeMenu === item.id;
const isLeaf = !hasChildren;
// 즐겨찾기 상태
const { toggleFavorite, isFavorite } = useFavoritesStore();
const isFav = isLeaf ? isFavorite(item.id) : false;
const handleStarClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const favItem: FavoriteItem = {
id: item.id,
label: item.label,
iconName: getIconName(item.icon),
path: item.path,
addedAt: Date.now(),
};
toggleFavorite(favItem);
}, [item, toggleFavorite]);
const handleClick = () => {
if (hasChildren) {
@@ -72,48 +93,63 @@ function MenuItemComponent({
className="relative"
ref={isActive ? activeMenuRef : null}
>
{/* 메인 메뉴 버튼 */}
<button
onClick={handleClick}
className={`w-full flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
sidebarCollapsed ? 'p-2.5 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
} ${
isActive
? "text-white clean-shadow scale-[0.98]"
: "text-foreground hover:bg-accent hover:scale-[1.02] active:scale-[0.98]"
}`}
style={isActive ? { backgroundColor: '#3B82F6' } : {}}
title={sidebarCollapsed ? item.label : undefined}
>
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon aspect-square ${
sidebarCollapsed ? 'w-7' : 'w-8'
} ${
isActive
? "bg-white/20"
: "bg-primary/10 group-hover:bg-primary/20"
}`}>
{IconComponent && <IconComponent className={`transition-all duration-200 ${
sidebarCollapsed ? 'h-4 w-4' : 'h-5 w-5'
{/* 메인 메뉴 버튼 + 별표 래퍼 */}
<div className="flex items-center group/row">
<button
onClick={handleClick}
className={`flex-1 min-w-0 flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
sidebarCollapsed ? 'p-2.5 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
} ${
isActive ? "text-white" : "text-primary"
}`} />}
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 font-medium transition-all duration-200 opacity-100 text-left text-sm">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-4 w-4" />
</div>
)}
</>
isActive
? "text-white clean-shadow scale-[0.98]"
: "text-foreground hover:bg-accent hover:scale-[1.02] active:scale-[0.98]"
}`}
style={isActive ? { backgroundColor: '#3B82F6' } : {}}
title={sidebarCollapsed ? item.label : undefined}
>
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon aspect-square ${
sidebarCollapsed ? 'w-7' : 'w-8'
} ${
isActive
? "bg-white/20"
: "bg-primary/10 group-hover:bg-primary/20"
}`}>
{IconComponent && <IconComponent className={`transition-all duration-200 ${
sidebarCollapsed ? 'h-4 w-4' : 'h-5 w-5'
} ${
isActive ? "text-white" : "text-primary"
}`} />}
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 font-medium transition-all duration-200 opacity-100 text-left text-sm">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-4 w-4" />
</div>
)}
</>
)}
{isActive && !sidebarCollapsed && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
)}
</button>
{isLeaf && !sidebarCollapsed && (
<button
onClick={handleStarClick}
className={`flex-shrink-0 p-1 rounded transition-all duration-200 ${
isFav
? 'opacity-100 text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Star className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
{isActive && !sidebarCollapsed && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
)}
</button>
</div>
{/* 자식 메뉴 (재귀) */}
{hasChildren && isExpanded && !sidebarCollapsed && (
@@ -143,24 +179,39 @@ function MenuItemComponent({
if (is2Depth) {
return (
<div ref={isActive ? activeMenuRef : null}>
<button
onClick={handleClick}
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2.5 space-x-2.5 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{IconComponent && <IconComponent className="h-4 w-4 flex-shrink-0" />}
<span className="flex-1 text-sm font-medium text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3.5 w-3.5" />
</div>
<div className="flex items-center group/row">
<button
onClick={handleClick}
className={`flex-1 min-w-0 flex items-center rounded-lg transition-all duration-200 p-2.5 space-x-2.5 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{IconComponent && <IconComponent className="h-4 w-4 flex-shrink-0" />}
<span className="flex-1 text-sm font-medium text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3.5 w-3.5" />
</div>
)}
</button>
{isLeaf && (
<button
onClick={handleStarClick}
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
isFav
? 'opacity-100 text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Star className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</button>
</div>
{/* 자식 메뉴 (3depth) */}
{hasChildren && isExpanded && (
@@ -190,26 +241,41 @@ function MenuItemComponent({
if (is3DepthOrMore) {
return (
<div ref={isActive ? activeMenuRef : null}>
<button
onClick={handleClick}
className={`w-full flex items-center rounded-md transition-all duration-200 p-2 space-x-2 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
<Circle className={`h-1.5 w-1.5 flex-shrink-0 ${
isActive ? 'fill-primary text-primary' : 'fill-muted-foreground/50 text-muted-foreground/50'
}`} />
<span className="flex-1 text-xs text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3 w-3" />
</div>
<div className="flex items-center group/row">
<button
onClick={handleClick}
className={`flex-1 min-w-0 flex items-center rounded-md transition-all duration-200 p-2 space-x-2 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
<Circle className={`h-1.5 w-1.5 flex-shrink-0 ${
isActive ? 'fill-primary text-primary' : 'fill-muted-foreground/50 text-muted-foreground/50'
}`} />
<span className="flex-1 text-xs text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3 w-3" />
</div>
)}
</button>
{isLeaf && (
<button
onClick={handleStarClick}
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
isFav
? 'opacity-100 text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Star className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</button>
</div>
{/* 자식 메뉴 (4depth 이상 - 재귀) */}
{hasChildren && isExpanded && (

View File

@@ -19,7 +19,7 @@ import { useRouter } from 'next/navigation';
import { Upload, FileText, Search, X, Plus, ClipboardCheck } from 'lucide-react';
import { FileDropzone } from '@/components/ui/file-dropzone';
import { ItemSearchModal } from '@/components/quotes/ItemSearchModal';
import { InspectionModalV2 } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModalV2';
import { InspectionModal } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModal';
import { ImportInspectionInputModal } from './ImportInspectionInputModal';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -874,7 +874,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
/>
{/* 수입검사 성적서 모달 (읽기 전용) */}
<InspectionModalV2
<InspectionModal
isOpen={isInspectionModalOpen}
onClose={() => setIsInspectionModalOpen(false)}
document={{

View File

@@ -12,7 +12,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft, FileText, CheckCircle2, Edit3, Save, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';

View File

@@ -25,7 +25,7 @@ import {
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { toast } from 'sonner';
import { createPricingTable, updatePricingTable, deletePricingTable } from './actions';

View File

@@ -4,16 +4,11 @@
* 중간검사 미리보기 모달
*
* 설정된 검사 항목들로 실제 성적서가 어떻게 보일지 미리보기
* DocumentViewer preset="readonly" 사용 → 줌/드래그/인쇄/PDF 자동 제공
*/
import { Fragment } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { DocumentViewer } from '@/components/document-system';
import { Badge } from '@/components/ui/badge';
import type { InspectionSetting } from '@/types/process';
@@ -30,21 +25,16 @@ export function InspectionPreviewModal({
}: InspectionPreviewModalProps) {
if (!inspectionSetting) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="py-12 text-center text-muted-foreground">
. .
</div>
<div className="flex justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
</div>
</DialogContent>
</Dialog>
<DocumentViewer
title="중간검사 미리보기"
preset="readonly"
open={open}
onOpenChange={onOpenChange}
>
<div className="py-12 text-center text-muted-foreground">
. .
</div>
</DocumentViewer>
);
}
@@ -69,214 +59,206 @@ export function InspectionPreviewModal({
const sampleRows = [1, 2, 3, 4, 5];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-[1400px] sm:max-w-[1400px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-4">
{/* 헤더 정보 */}
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">:</span>
<Badge variant="outline">{inspectionSetting.standardName || '미설정'}</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium"> :</span>
<Badge>{activeAppearanceItems.length + activeDimensionItems.length}</Badge>
</div>
<DocumentViewer
title="중간검사 미리보기"
preset="readonly"
open={open}
onOpenChange={onOpenChange}
>
<div className="space-y-6 p-6">
{/* 헤더 정보 */}
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">:</span>
<Badge variant="outline">{inspectionSetting.standardName || '미설정'}</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium"> :</span>
<Badge>{activeAppearanceItems.length + activeDimensionItems.length}</Badge>
</div>
</div>
{/* 중간검사 기준서 */}
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
{/* 중간검사 기준서 */}
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
</div>
<div className="p-4 grid grid-cols-2 gap-4">
{/* 도해 이미지 영역 */}
<div className="border rounded-lg p-4 min-h-[200px] flex items-center justify-center bg-muted/30">
{inspectionSetting.schematicImage ? (
<img
src={inspectionSetting.schematicImage}
alt="도해 이미지"
className="max-w-full max-h-[180px] object-contain"
/>
) : (
<span className="text-muted-foreground text-sm"> </span>
)}
</div>
<div className="p-4 grid grid-cols-2 gap-4">
{/* 도해 이미지 영역 */}
<div className="border rounded-lg p-4 min-h-[200px] flex items-center justify-center bg-muted/30">
{inspectionSetting.schematicImage ? (
<img
src={inspectionSetting.schematicImage}
alt="도해 이미지"
className="max-w-full max-h-[180px] object-contain"
/>
) : (
<span className="text-muted-foreground text-sm"> </span>
)}
</div>
{/* 검사기준 이미지 또는 검사 항목 테이블 */}
<div className="border rounded-lg overflow-hidden min-h-[200px] flex items-center justify-center bg-muted/30">
{inspectionSetting.inspectionStandardImage ? (
<img
src={inspectionSetting.inspectionStandardImage}
alt="검사기준 이미지"
className="max-w-full max-h-[180px] object-contain"
/>
) : (
<div className="w-full">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border-b px-3 py-2 text-left"></th>
<th className="border-b px-3 py-2 text-left"></th>
<th className="border-b px-3 py-2 text-left"></th>
{/* 검사기준 이미지 또는 검사 항목 테이블 */}
<div className="border rounded-lg overflow-hidden min-h-[200px] flex items-center justify-center bg-muted/30">
{inspectionSetting.inspectionStandardImage ? (
<img
src={inspectionSetting.inspectionStandardImage}
alt="검사기준 이미지"
className="max-w-full max-h-[180px] object-contain"
/>
) : (
<div className="w-full">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border-b px-3 py-2 text-left"></th>
<th className="border-b px-3 py-2 text-left"></th>
<th className="border-b px-3 py-2 text-left"></th>
</tr>
</thead>
<tbody>
{activeAppearanceItems.map((item) => (
<tr key={item.key} className="border-b last:border-b-0">
<td className="px-3 py-2">{item.label}</td>
<td className="px-3 py-2"></td>
<td className="px-3 py-2">-</td>
</tr>
</thead>
<tbody>
{activeAppearanceItems.map((item) => (
<tr key={item.key} className="border-b last:border-b-0">
<td className="px-3 py-2">{item.label}</td>
<td className="px-3 py-2"></td>
<td className="px-3 py-2">-</td>
</tr>
))}
{activeDimensionItems.map((item) => (
<tr key={item.key} className="border-b last:border-b-0">
<td className="px-3 py-2">{item.label}</td>
<td className="px-3 py-2">{item.method}</td>
<td className="px-3 py-2">{item.point}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
{activeDimensionItems.map((item) => (
<tr key={item.key} className="border-b last:border-b-0">
<td className="px-3 py-2">{item.label}</td>
<td className="px-3 py-2">{item.method}</td>
<td className="px-3 py-2">{item.point}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
{/* 중간검사 DATA */}
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
DATA
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border-b border-r px-3 py-2 text-center w-12">No.</th>
{/* 겉모양 항목들 */}
{/* 중간검사 DATA */}
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
DATA
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border-b border-r px-3 py-2 text-center w-12">No.</th>
{/* 겉모양 항목들 */}
{activeAppearanceItems.map((item) => (
<th
key={item.key}
className="border-b border-r px-3 py-2 text-center min-w-[80px]"
>
{item.label}
</th>
))}
{/* 치수 항목들 */}
{activeDimensionItems.map((item) => (
<th
key={item.key}
className="border-b border-r px-3 py-2 text-center"
colSpan={2}
>
{item.label} (mm)
</th>
))}
{/* 판정 */}
{inspectionSetting.judgment && (
<th className="border-b px-3 py-2 text-center w-20">
<br />
<span className="text-xs">(/)</span>
</th>
)}
</tr>
{/* 치수 서브헤더 */}
{activeDimensionItems.length > 0 && (
<tr className="bg-muted/30">
<th className="border-b border-r px-3 py-1"></th>
{activeAppearanceItems.map((item) => (
<th
key={item.key}
className="border-b border-r px-3 py-2 text-center min-w-[80px]"
>
{item.label}
<th key={item.key} className="border-b border-r px-3 py-1 text-xs">
/
</th>
))}
{/* 치수 항목들 */}
{activeDimensionItems.map((item) => (
<th
key={item.key}
className="border-b border-r px-3 py-2 text-center"
colSpan={2}
>
{item.label} (mm)
</th>
<Fragment key={`${item.key}-header`}>
<th className="border-b border-r px-3 py-1 text-xs">
</th>
<th className="border-b border-r px-3 py-1 text-xs">
</th>
</Fragment>
))}
{/* 판정 */}
{inspectionSetting.judgment && (
<th className="border-b px-3 py-2 text-center w-20">
<br />
<span className="text-xs">(/)</span>
</th>
<th className="border-b px-3 py-1"></th>
)}
</tr>
{/* 치수 서브헤더 */}
{activeDimensionItems.length > 0 && (
<tr className="bg-muted/30">
<th className="border-b border-r px-3 py-1"></th>
{activeAppearanceItems.map((item) => (
<th key={item.key} className="border-b border-r px-3 py-1 text-xs">
/
</th>
))}
{activeDimensionItems.map((item) => (
<Fragment key={`${item.key}-header`}>
<th className="border-b border-r px-3 py-1 text-xs">
</th>
<th className="border-b border-r px-3 py-1 text-xs">
</th>
</Fragment>
))}
{inspectionSetting.judgment && (
<th className="border-b px-3 py-1"></th>
)}
</tr>
)}
</thead>
<tbody>
{sampleRows.map((row) => (
<tr key={row} className="border-b last:border-b-0 hover:bg-muted/20">
<td className="border-r px-3 py-2 text-center">{row}</td>
{/* 겉모양 샘플 데이터 */}
{activeAppearanceItems.map((item) => (
<td key={item.key} className="border-r px-3 py-2 text-center">
<span className="text-muted-foreground"> </span>
<br />
<span className="text-muted-foreground"> </span>
)}
</thead>
<tbody>
{sampleRows.map((row) => (
<tr key={row} className="border-b last:border-b-0 hover:bg-muted/20">
<td className="border-r px-3 py-2 text-center">{row}</td>
{/* 겉모양 샘플 데이터 */}
{activeAppearanceItems.map((item) => (
<td key={item.key} className="border-r px-3 py-2 text-center">
<span className="text-muted-foreground"> </span>
<br />
<span className="text-muted-foreground"> </span>
</td>
))}
{/* 치수 샘플 데이터 */}
{activeDimensionItems.map((item) => (
<Fragment key={`${item.key}-data-${row}`}>
<td className="border-r px-3 py-2 text-center text-muted-foreground">
-
</td>
))}
{/* 치수 샘플 데이터 */}
{activeDimensionItems.map((item) => (
<Fragment key={`${item.key}-data-${row}`}>
<td className="border-r px-3 py-2 text-center text-muted-foreground">
-
</td>
<td className="border-r px-3 py-2 text-center text-muted-foreground">
-
</td>
</Fragment>
))}
{/* 판정 샘플 */}
{inspectionSetting.judgment && (
<td className="px-3 py-2 text-center">
<span className="text-muted-foreground">-</span>
<td className="border-r px-3 py-2 text-center text-muted-foreground">
-
</td>
)}
</tr>
))}
</tbody>
</table>
</Fragment>
))}
{/* 판정 샘플 */}
{inspectionSetting.judgment && (
<td className="px-3 py-2 text-center">
<span className="text-muted-foreground">-</span>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 부적합 내용 */}
{inspectionSetting.nonConformingContent && (
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-2">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-r">
</div>
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm">
</div>
</div>
<div className="grid grid-cols-2">
<div className="px-4 py-3 border-r min-h-[60px] text-muted-foreground text-sm">
( )
</div>
<div className="px-4 py-3 text-center text-muted-foreground text-sm">
/
</div>
</div>
</div>
{/* 부적합 내용 */}
{inspectionSetting.nonConformingContent && (
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-2">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-r">
</div>
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm">
</div>
</div>
<div className="grid grid-cols-2">
<div className="px-4 py-3 border-r min-h-[60px] text-muted-foreground text-sm">
( )
</div>
<div className="px-4 py-3 text-center text-muted-foreground text-sm">
/
</div>
</div>
</div>
)}
</div>
{/* 버튼 영역 */}
<div className="flex justify-end mt-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
</DocumentViewer>
);
}

View File

@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';

View File

@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { deleteProcessStep } from './actions';
import { toast } from 'sonner';

View File

@@ -41,10 +41,6 @@ export interface ScreenInspectionContentProps {
workItems?: WorkItemData[];
inspectionDataMap?: InspectionDataMap;
inspectionSetting?: InspectionSetting;
/** @deprecated inspectionSetting.schematicImage 사용 */
schematicImage?: string;
/** @deprecated inspectionSetting.inspectionStandardImage 사용 */
inspectionStandardImage?: string;
}
interface InspectionRow {
@@ -87,9 +83,8 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
workItems,
inspectionDataMap,
inspectionSetting,
schematicImage: schematicImageProp,
}, ref) {
const schematicImage = inspectionSetting?.schematicImage || schematicImageProp;
const schematicImage = inspectionSetting?.schematicImage;
const fullDate = getFullDate();
const today = getToday();
const { documentNo, primaryAssignee } = getOrderInfo(order);

View File

@@ -14,7 +14,7 @@
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp, List } from 'lucide-react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';

View File

@@ -35,7 +35,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { ItemSearchModal } from "./ItemSearchModal";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { LocationItem } from "./QuoteRegistration";
import type { FinishedGoods } from "./actions";
import type { BomCalculationResultItem } from "./types";

View File

@@ -27,7 +27,7 @@ import {
SelectValue,
} from "../ui/select";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { LocationItem } from "./QuoteRegistration";
// =============================================================================
// 상수

View File

@@ -33,7 +33,7 @@ import {
} from "../ui/table";
import { DeleteConfirmDialog } from "../ui/confirm-dialog";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { LocationItem } from "./QuoteRegistration";
import type { FinishedGoods } from "./actions";
// xlsx는 동적 로드 (번들 크기 최적화)

View File

@@ -5,7 +5,7 @@
* - DocumentHeader: quote 레이아웃 + LotApprovalTable
*/
import { QuoteFormData } from "./QuoteRegistration";
import { QuoteFormData } from "./types";
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
import { DocumentHeader, LotApprovalTable } from "@/components/document-system";

View File

@@ -4,7 +4,7 @@
* - documentType="견적산출내역서": 상세 산출내역서 + 소요자재 내역
*/
import { QuoteFormData } from "./QuoteRegistration";
import { QuoteFormData } from "./types";
import type { BomMaterial } from "./types";
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";

View File

@@ -6,7 +6,7 @@
* - SignatureSection: 서명/도장 영역
*/
import { QuoteFormData } from "./QuoteRegistration";
import { QuoteFormData } from "./types";
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
import { DocumentHeader, SignatureSection } from "@/components/document-system";

View File

@@ -9,7 +9,7 @@
*/
import React from 'react';
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
import type { QuoteFormDataV2 } from './QuoteRegistration';
import type { BomCalculationResultItem } from './types';
// 양식 타입

View File

@@ -18,7 +18,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { DocumentViewer } from '@/components/document-system';
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
import type { QuoteFormDataV2 } from './QuoteRegistration';
import { QuotePreviewContent } from './QuotePreviewContent';
// 양식 타입: 업체발송용 / 산출내역서

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ import { Coins } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { LocationItem } from "./QuoteRegistration";
// =============================================================================
// 목데이터 - 상세별 합계 (공정별 + 품목 상세)

View File

@@ -12,7 +12,7 @@
*/
import { DocumentViewer } from '@/components/document-system';
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
import type { QuoteFormDataV2 } from './QuoteRegistration';
interface QuoteTransactionModalProps {
open: boolean;

View File

@@ -549,22 +549,6 @@ export async function getQuoteReferenceData(): Promise<{
};
}
/** @deprecated getQuoteReferenceData 사용 */
export async function getSiteNames(): Promise<{
success: boolean;
data: string[];
error?: string;
__authError?: boolean;
}> {
const result = await getQuoteReferenceData();
return {
success: result.success,
data: result.data.siteNames,
error: result.error,
__authError: result.__authError,
};
}
// ===== 품목 카테고리 트리 조회 =====
export interface ItemCategoryNode {
id: number;

View File

@@ -5,9 +5,9 @@
// 클라이언트 컴포넌트
export { QuoteManagementClient } from './QuoteManagementClient';
// 기존 컴포넌트
// 컴포넌트
export { QuoteDocument } from './QuoteDocument';
export { QuoteRegistration, INITIAL_QUOTE_FORM } from './QuoteRegistration';
export { QuoteRegistration } from './QuoteRegistration';
export { QuoteCalculationReport } from './QuoteCalculationReport';
export { PurchaseOrderDocument } from './PurchaseOrderDocument';

View File

@@ -15,7 +15,7 @@ import {
Loader2,
Plus,
} from 'lucide-react';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { DetailPageSkeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

View File

@@ -16,7 +16,7 @@ import type { ReactNode } from 'react';
import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
export interface DetailActionsProps {
/** 현재 모드 */

View File

@@ -1,8 +1,8 @@
'use client';
import { useMenuStore } from '@/store/menuStore';
import type { SerializableMenuItem } from '@/store/menuStore';
import type { MenuItem } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import type { SerializableMenuItem } from '@/stores/menuStore';
import type { MenuItem } from '@/stores/menuStore';
import { useRouter, usePathname } from 'next/navigation';
import Image from 'next/image';
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
@@ -24,8 +24,6 @@ import {
ChevronLeft,
Home,
X,
BarChart3,
Award,
Bell,
Clock,
Minus,
@@ -42,6 +40,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import Sidebar from '@/components/layout/Sidebar';
import HeaderFavoritesBar from '@/components/layout/HeaderFavoritesBar';
import CommandMenuSearch, { type CommandMenuSearchRef } from '@/components/layout/CommandMenuSearch';
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/contexts/AuthContext';
@@ -434,6 +433,8 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
];
setMenuItems(defaultMenu);
}
// 즐겨찾기는 사용자가 사이드바에서 별표로 직접 추가
// useFavoritesStore.getState().initializeIfEmpty(DEFAULT_FAVORITES);
}
}, [_hasHydrated, setMenuItems]);
@@ -766,29 +767,10 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
)}
</div>
{/* 우측 영역: 종합분석, 품질인정심사, 유저 드롭다운, 메뉴 */}
{/* 우측 영역: 즐겨찾기, 유저 드롭다운, 메뉴 */}
<div className="flex items-center space-x-0.5 sm:space-x-1">
{/* 종합분석 바로가기 */}
<Button
variant="default"
size="sm"
onClick={() => router.push('/reports/comprehensive-analysis')}
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
title="종합분석"
>
<BarChart3 className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
</Button>
{/* 품질인정심사 바로가기 */}
<Button
variant="default"
size="sm"
onClick={() => router.push('/quality/qms')}
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white flex items-center justify-center"
title="품질인정심사"
>
<Award className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
</Button>
{/* 즐겨찾기 바로가기 */}
<HeaderFavoritesBar isMobile={true} />
{/* 알림 버튼 - 드롭다운 */}
<DropdownMenu>
@@ -1060,27 +1042,8 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
</div>
<div className="flex items-center space-x-3">
{/* 종합분석 바로가기 버튼 */}
<Button
variant="default"
size="sm"
onClick={() => router.push('/reports/comprehensive-analysis')}
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
>
<BarChart3 className="h-4 w-4" />
<span className="hidden xl:inline"></span>
</Button>
{/* 품질인정심사 바로가기 버튼 */}
<Button
variant="default"
size="sm"
onClick={() => router.push('/quality/qms')}
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
>
<Award className="h-4 w-4" />
<span className="hidden xl:inline"></span>
</Button>
{/* 즐겨찾기 바로가기 */}
<HeaderFavoritesBar isMobile={false} />
{/* 알림 버튼 - 드롭다운 */}
<DropdownMenu>

View File

@@ -536,59 +536,6 @@ export async function deleteItemFile(
return data.data;
}
// ===== 레거시 파일 업로드 (하위 호환성) =====
/**
* @deprecated uploadItemFile 사용 권장 (ID 기반)
* 파일 업로드 (시방서, 인정서, 전개도 등) - 품목 코드 기반
*/
export async function uploadFile(
itemCode: string,
file: File,
fileType: 'specification' | 'certification' | 'bending_diagram'
): Promise<{ url: string; filename: string }> {
const formData = new FormData();
formData.append('file', file);
formData.append('type', fileType);
const token = getAuthToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/files`,
{
method: 'POST',
headers,
body: formData,
credentials: 'include',
}
);
const data = await handleApiResponse<ApiResponse<{ url: string; filename: string }>>(response);
return data.data;
}
/**
* @deprecated deleteItemFile 사용 권장 (ID 기반)
* 파일 삭제 - 품목 코드 기반
*/
export async function deleteFile(
itemCode: string,
fileType: 'specification' | 'certification' | 'bending_diagram'
): Promise<void> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/files/${fileType}`,
createFetchOptions({
method: 'DELETE',
})
);
await handleApiResponse<ApiResponse<null>>(response);
}
// ===== 검색 및 필터 =====
/**

View File

@@ -7,8 +7,8 @@
*/
import { transformApiMenusToMenuItems, deserializeMenuItems } from './menuTransform';
import { useMenuStore } from '@/store/menuStore';
import type { SerializableMenuItem } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import type { SerializableMenuItem } from '@/stores/menuStore';
/**
* 메뉴 해시 생성 (변경 감지용)

View File

@@ -1,4 +1,4 @@
import type { MenuItem, SerializableMenuItem } from '@/store/menuStore';
import type { MenuItem, SerializableMenuItem } from '@/stores/menuStore';
import {
LayoutDashboard,
Folder,
@@ -180,6 +180,42 @@ export const iconMap: Record<string, LucideIcon> = {
server: Server,
};
// 역방향 아이콘 맵 (LucideIcon → string name)
const reverseIconMap = new Map<LucideIcon, string>();
for (const [name, icon] of Object.entries(iconMap)) {
// 첫 번째 매핑만 저장 (중복 아이콘 방지)
if (!reverseIconMap.has(icon)) {
reverseIconMap.set(icon, name);
}
}
/**
* LucideIcon 컴포넌트에서 문자열 이름을 조회
*/
export function getIconName(icon: LucideIcon): string {
return reverseIconMap.get(icon) || 'folder';
}
// 기본 즐겨찾기 항목 (최초 사용자용)
import type { FavoriteItem } from '@/stores/favoritesStore';
export const DEFAULT_FAVORITES: FavoriteItem[] = [
{
id: 'default-comprehensive-analysis',
label: '종합분석',
iconName: 'bar-chart-3',
path: '/reports/comprehensive-analysis',
addedAt: 0,
},
{
id: 'default-qms',
label: '품질인정심사',
iconName: 'award',
path: '/quality/qms',
addedAt: 1,
},
];
// API 메뉴 데이터 타입
interface ApiMenu {
id: number;

View File

@@ -0,0 +1,92 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { safeJsonParse } from '@/lib/utils';
export interface FavoriteItem {
id: string;
label: string;
iconName: string;
path: string;
addedAt: number;
}
const MAX_FAVORITES = 8;
function getUserId(): string {
if (typeof window === 'undefined') return 'default';
const userStr = localStorage.getItem('user');
if (!userStr) return 'default';
const user = safeJsonParse<Record<string, unknown> | null>(userStr, null);
return user?.id ? String(user.id) : 'default';
}
function getStorageKey(): string {
return `sam-favorites-${getUserId()}`;
}
interface FavoritesState {
favorites: FavoriteItem[];
toggleFavorite: (item: FavoriteItem) => void;
isFavorite: (id: string) => boolean;
setFavorites: (items: FavoriteItem[]) => void;
initializeIfEmpty: (defaults: FavoriteItem[]) => void;
}
export const useFavoritesStore = create<FavoritesState>()(
persist(
(set, get) => ({
favorites: [],
toggleFavorite: (item: FavoriteItem) => {
const { favorites } = get();
const exists = favorites.some((f) => f.id === item.id);
if (exists) {
set({ favorites: favorites.filter((f) => f.id !== item.id) });
} else {
if (favorites.length >= MAX_FAVORITES) return;
set({ favorites: [...favorites, { ...item, addedAt: Date.now() }] });
}
},
isFavorite: (id: string) => {
return get().favorites.some((f) => f.id === id);
},
setFavorites: (items: FavoriteItem[]) => {
set({ favorites: items.slice(0, MAX_FAVORITES) });
},
initializeIfEmpty: (defaults: FavoriteItem[]) => {
const { favorites } = get();
if (favorites.length === 0) {
set({ favorites: defaults.slice(0, MAX_FAVORITES) });
}
},
}),
{
name: 'sam-favorites',
// 사용자별 키를 위해 storage 커스텀
storage: {
getItem: (name) => {
const key = getStorageKey();
const str = localStorage.getItem(key);
if (!str) {
// fallback: 기본 키에서도 확인
const fallback = localStorage.getItem(name);
return fallback ? JSON.parse(fallback) : null;
}
return JSON.parse(str);
},
setItem: (name, value) => {
const key = getStorageKey();
localStorage.setItem(key, JSON.stringify(value));
},
removeItem: (name) => {
const key = getStorageKey();
localStorage.removeItem(key);
},
},
}
)
);