feat(WEB): 차량 관리 기능 추가 및 CEO 대시보드 Enhanced 섹션 적용
차량 관리 (신규): - VehicleList/VehicleDetail: 차량 목록/상세 - ForkliftList/ForkliftDetail: 지게차 목록/상세 - VehicleLogList/VehicleLogDetail: 운행일지 목록/상세 - 관련 페이지 라우트 추가 (/vehicle-management/*) CEO 대시보드: - Enhanced 섹션 컴포넌트 적용 (아이콘 + 컬러 테마) - EnhancedStatusBoardSection, EnhancedDailyReportSection, EnhancedMonthlyExpenseSection - TodayIssueSection 개선 IntegratedDetailTemplate: - FieldInput, FieldRenderer 기능 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 전체 페이지 테스트 URL 목록
|
||||
|
||||
> 백엔드 메뉴 연동 전 테스트용 직접 접근 URL (Last Updated: 2026-01-21)
|
||||
> 백엔드 메뉴 연동 전 테스트용 직접 접근 URL (Last Updated: 2026-01-28)
|
||||
|
||||
## 🚀 클릭 가능한 웹 페이지
|
||||
|
||||
@@ -136,6 +136,24 @@ http://localhost:3000/ko/quality/inspections # 🆕 검사관리
|
||||
|
||||
---
|
||||
|
||||
## 🚗 차량/지게차 (Vehicle Management)
|
||||
|
||||
| 페이지 | URL | 상태 |
|
||||
|--------|-----|------|
|
||||
| **차량관리** | `/ko/vehicle-management/vehicle` | 🆕 NEW |
|
||||
| **차량일지/월간사진기록** | `/ko/vehicle-management/vehicle-log` | 🆕 NEW |
|
||||
| **지게차 관리** | `/ko/vehicle-management/forklift` | 🆕 NEW |
|
||||
|
||||
```
|
||||
http://localhost:3000/ko/vehicle-management/vehicle # 🆕 차량관리
|
||||
http://localhost:3000/ko/vehicle-management/vehicle-log # 🆕 차량일지/월간사진기록
|
||||
http://localhost:3000/ko/vehicle-management/forklift # 🆕 지게차 관리
|
||||
```
|
||||
|
||||
> ℹ️ **참고**: 각 페이지에서 등록/상세/수정 페이지로 이동 가능 (별도 URL 등록 불필요)
|
||||
|
||||
---
|
||||
|
||||
## 📤 출고관리 (Outbound)
|
||||
|
||||
| 페이지 | URL | 상태 |
|
||||
@@ -357,6 +375,13 @@ http://localhost:3000/ko/quality/inspections # 🆕 검사관리
|
||||
http://localhost:3000/ko/outbound/shipments # 🆕 출하관리
|
||||
```
|
||||
|
||||
### Vehicle Management (차량/지게차)
|
||||
```
|
||||
http://localhost:3000/ko/vehicle-management/vehicle # 🆕 차량관리
|
||||
http://localhost:3000/ko/vehicle-management/vehicle-log # 🆕 차량일지/월간사진기록
|
||||
http://localhost:3000/ko/vehicle-management/forklift # 🆕 지게차 관리
|
||||
```
|
||||
|
||||
### Settings
|
||||
```
|
||||
http://localhost:3000/ko/settings/leave-policy
|
||||
@@ -464,6 +489,11 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
||||
// Outbound (출고관리)
|
||||
'/outbound/shipments' // 출하관리 (🆕 NEW)
|
||||
|
||||
// Vehicle Management (차량/지게차)
|
||||
'/vehicle-management/vehicle' // 차량관리 (🆕 NEW)
|
||||
'/vehicle-management/vehicle-log' // 차량일지/월간사진기록 (🆕 NEW)
|
||||
'/vehicle-management/forklift' // 지게차 관리 (🆕 NEW)
|
||||
|
||||
// Settings
|
||||
'/settings/leave-policy'
|
||||
'/settings/permissions'
|
||||
@@ -521,4 +551,4 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
||||
## 작성일
|
||||
|
||||
- 최초 작성: 2025-12-06
|
||||
- 최종 업데이트: 2026-01-21 (account-info 경로 수정)
|
||||
- 최종 업데이트: 2026-01-28 (차량/지게차 메뉴 추가)
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
# 차량/지게차 메뉴 구현 계획
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 목적
|
||||
5130 사이트(레거시)의 차량/지게차 메뉴를 SAM 프로젝트로 마이그레이션
|
||||
|
||||
### 1.2 메뉴 구조
|
||||
|
||||
```
|
||||
📁 차량/지게차
|
||||
├── 📄 차량관리 (vehicle)
|
||||
├── 📄 차량일지/월간사진기록 (vehicle-log)
|
||||
└── 📄 지게차 관리 (forklift)
|
||||
```
|
||||
|
||||
### 1.3 페이지 구성 방식
|
||||
|
||||
| 페이지 | 리스트 | 등록 | 상세 | 수정 |
|
||||
|--------|--------|------|------|------|
|
||||
| 차량관리 | ✅ 페이지 | ✅ 페이지 | ✅ 페이지 | ✅ 페이지 |
|
||||
| 차량일지 | ✅ 페이지 | ✅ 페이지 | ✅ 페이지 | ✅ 페이지 |
|
||||
| 지게차 관리 | ✅ 페이지 | ✅ 페이지 | ✅ 페이지 | ✅ 페이지 |
|
||||
|
||||
### 1.4 사용할 공통 컴포넌트
|
||||
|
||||
| 페이지 타입 | 사용 템플릿 | 비고 |
|
||||
|------------|------------|------|
|
||||
| 리스트 | **UniversalListPage** | config 기반 |
|
||||
| 등록/상세/수정 | **IntegratedDetailTemplate** | config 기반 |
|
||||
|
||||
**구현 패턴**:
|
||||
```
|
||||
config.ts → 컬럼/필드 정의
|
||||
actions.ts → API 호출 (Server Actions)
|
||||
index.tsx → 템플릿에 config 전달
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 페이지별 상세 분석 (5130 사이트 기준)
|
||||
|
||||
### 2.1 차량관리 (vehicle)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **페이지 타입** | 표준 CRUD (리스트 + 등록/상세/수정 페이지) |
|
||||
| **레거시 URL** | `/car/list.php` |
|
||||
| **전체 건수** | 6건 (예시) |
|
||||
|
||||
**리스트 테이블 컬럼** (12개):
|
||||
| 순서 | 컬럼명 | 타입 | 설명 |
|
||||
|------|--------|------|------|
|
||||
| 1 | 번호 | number | 행 번호 (1부터) |
|
||||
| 2 | 차량번호 | string | 차량 식별번호 |
|
||||
| 3 | 담당자 | string | 담당자명 |
|
||||
| 4 | 보험사 | string | 보험 회사 |
|
||||
| 5 | 보험만료일 | date | 보험 만료일 |
|
||||
| 6 | 차량명 | string | 차량 이름 |
|
||||
| 7 | 구입금액 | number | 구입 금액 |
|
||||
| 8 | 구입일 | date | 구입 날짜 |
|
||||
| 9 | 작성자 | string | 작성자명 |
|
||||
| 10 | 정비정보 | string | 정비 관련 정보 |
|
||||
| 11 | 등록일 | date | 등록 날짜 |
|
||||
| 12 | 관리 | action | 상세/수정/삭제 버튼 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 차량일지/월간사진기록 (vehicle-log)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **페이지 타입** | 표준 CRUD (리스트 + 등록/상세/수정 페이지) |
|
||||
| **레거시 URL** | `/carrecord/list.php` |
|
||||
| **전체 건수** | 41건 (예시) |
|
||||
|
||||
**리스트 테이블 컬럼** (5개):
|
||||
| 순서 | 컬럼명 | 타입 | 설명 |
|
||||
|------|--------|------|------|
|
||||
| 1 | 번호 | number | 행 번호 (1부터) |
|
||||
| 2 | 작성일 | date | 작성 날짜 |
|
||||
| 3 | 차량종류 | string | 차량 종류/타입 |
|
||||
| 4 | 작성자 | string | 작성자명 |
|
||||
| 5 | 글제목 | string | 일지 제목 |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 지게차 관리 (forklift)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **페이지 타입** | 표준 CRUD (리스트 + 등록/상세/수정 페이지) |
|
||||
| **레거시 URL** | `/lift/list.php` |
|
||||
| **전체 건수** | 7건 (예시) |
|
||||
|
||||
**리스트 테이블 컬럼** (13개, 차량관리 + 용량):
|
||||
| 순서 | 컬럼명 | 타입 | 설명 |
|
||||
|------|--------|------|------|
|
||||
| 1 | 번호 | number | 행 번호 (1부터) |
|
||||
| 2 | 차량번호 | string | 지게차 식별번호 |
|
||||
| 3 | 담당자 | string | 담당자명 |
|
||||
| 4 | 보험사 | string | 보험 회사 |
|
||||
| 5 | 보험만료일 | date | 보험 만료일 |
|
||||
| 6 | 차량명 | string | 지게차 이름 |
|
||||
| 7 | 구입금액 | number | 구입 금액 |
|
||||
| 8 | 구입일 | date | 구입 날짜 |
|
||||
| 9 | 작성자 | string | 작성자명 |
|
||||
| 10 | 정비정보 | string | 정비 관련 정보 |
|
||||
| 11 | 등록일 | date | 등록 날짜 |
|
||||
| 12 | 용량 | string | 지게차 용량 ⭐ |
|
||||
| 13 | 관리 | action | 상세/수정/삭제 버튼 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 파일 구조
|
||||
|
||||
### 3.1 페이지 구조
|
||||
|
||||
```
|
||||
src/app/[locale]/(protected)/
|
||||
└── vehicle-management/ # 차량/지게차
|
||||
├── vehicle/ # 차량관리
|
||||
│ ├── page.tsx # 리스트
|
||||
│ ├── new/
|
||||
│ │ └── page.tsx # 등록
|
||||
│ └── [id]/
|
||||
│ ├── page.tsx # 상세
|
||||
│ └── edit/
|
||||
│ └── page.tsx # 수정
|
||||
│
|
||||
├── vehicle-log/ # 차량일지/월간사진기록
|
||||
│ ├── page.tsx # 리스트
|
||||
│ ├── new/
|
||||
│ │ └── page.tsx # 등록
|
||||
│ └── [id]/
|
||||
│ ├── page.tsx # 상세
|
||||
│ └── edit/
|
||||
│ └── page.tsx # 수정
|
||||
│
|
||||
└── forklift/ # 지게차 관리
|
||||
├── page.tsx # 리스트
|
||||
├── new/
|
||||
│ └── page.tsx # 등록
|
||||
└── [id]/
|
||||
├── page.tsx # 상세
|
||||
└── edit/
|
||||
└── page.tsx # 수정
|
||||
```
|
||||
|
||||
### 3.2 컴포넌트 구조
|
||||
|
||||
```
|
||||
src/components/vehicle-management/
|
||||
├── VehicleList/
|
||||
│ ├── index.tsx # UniversalListPage 사용
|
||||
│ ├── actions.ts # Server Actions
|
||||
│ └── config.ts # 컬럼/필터 설정
|
||||
├── VehicleDetail/
|
||||
│ ├── index.tsx # IntegratedDetailTemplate 사용
|
||||
│ └── config.ts # 필드 설정
|
||||
│
|
||||
├── VehicleLogList/
|
||||
│ ├── index.tsx
|
||||
│ ├── actions.ts
|
||||
│ └── config.ts
|
||||
├── VehicleLogDetail/
|
||||
│ ├── index.tsx
|
||||
│ └── config.ts
|
||||
│
|
||||
├── ForkliftList/
|
||||
│ ├── index.tsx
|
||||
│ ├── actions.ts
|
||||
│ └── config.ts
|
||||
└── ForkliftDetail/
|
||||
├── index.tsx
|
||||
└── config.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 엔드포인트 (예상)
|
||||
|
||||
| 기능 | Method | Endpoint |
|
||||
|------|--------|----------|
|
||||
| 차량 목록 | GET | `/api/vehicle/list` |
|
||||
| 차량 상세 | GET | `/api/vehicle/{id}` |
|
||||
| 차량 등록 | POST | `/api/vehicle` |
|
||||
| 차량 수정 | PUT | `/api/vehicle/{id}` |
|
||||
| 차량 삭제 | DELETE | `/api/vehicle/{id}` |
|
||||
| 차량일지 목록 | GET | `/api/vehicle-log/list` |
|
||||
| 차량일지 상세 | GET | `/api/vehicle-log/{id}` |
|
||||
| 차량일지 등록 | POST | `/api/vehicle-log` |
|
||||
| 차량일지 수정 | PUT | `/api/vehicle-log/{id}` |
|
||||
| 차량일지 삭제 | DELETE | `/api/vehicle-log/{id}` |
|
||||
| 지게차 목록 | GET | `/api/forklift/list` |
|
||||
| 지게차 상세 | GET | `/api/forklift/{id}` |
|
||||
| 지게차 등록 | POST | `/api/forklift` |
|
||||
| 지게차 수정 | PUT | `/api/forklift/{id}` |
|
||||
| 지게차 삭제 | DELETE | `/api/forklift/{id}` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 체크리스트
|
||||
|
||||
### Phase 1: 리스트 페이지 & 컴포넌트
|
||||
|
||||
**차량관리**
|
||||
- [x] VehicleList/actions.ts ✅
|
||||
- [x] VehicleList/index.tsx ✅
|
||||
- [x] vehicle/page.tsx ✅
|
||||
|
||||
**차량일지**
|
||||
- [x] VehicleLogList/actions.ts ✅
|
||||
- [x] VehicleLogList/index.tsx ✅
|
||||
- [x] vehicle-log/page.tsx ✅
|
||||
|
||||
**지게차 관리**
|
||||
- [x] ForkliftList/actions.ts ✅
|
||||
- [x] ForkliftList/index.tsx ✅
|
||||
- [x] forklift/page.tsx ✅
|
||||
|
||||
### Phase 2: 상세/등록/수정 페이지 & 컴포넌트
|
||||
|
||||
**차량관리**
|
||||
- [x] VehicleDetail/config.ts ✅
|
||||
- [x] VehicleDetail/index.tsx ✅
|
||||
- [x] vehicle/new/page.tsx (등록) ✅
|
||||
- [x] vehicle/[id]/page.tsx (상세) ✅
|
||||
- [x] vehicle/[id]/edit/page.tsx (수정) ✅
|
||||
|
||||
**차량일지**
|
||||
- [x] VehicleLogDetail/config.ts ✅
|
||||
- [x] VehicleLogDetail/index.tsx ✅
|
||||
- [x] vehicle-log/new/page.tsx (등록) ✅
|
||||
- [x] vehicle-log/[id]/page.tsx (상세) ✅
|
||||
- [x] vehicle-log/[id]/edit/page.tsx (수정) ✅
|
||||
|
||||
**지게차 관리**
|
||||
- [x] ForkliftDetail/config.ts ✅
|
||||
- [x] ForkliftDetail/index.tsx ✅
|
||||
- [x] forklift/new/page.tsx (등록) ✅
|
||||
- [x] forklift/[id]/page.tsx (상세) ✅
|
||||
- [x] forklift/[id]/edit/page.tsx (수정) ✅
|
||||
|
||||
### Phase 3: 메뉴 및 테스트 URL 등록
|
||||
|
||||
- [ ] 사이드바 메뉴 추가 (차량/지게차) - 백엔드 메뉴 연동 필요
|
||||
- [x] /dev/test-urls에 등록 ✅
|
||||
|
||||
### 공통 파일
|
||||
- [x] types.ts (Vehicle, VehicleLog, Forklift 타입) ✅
|
||||
|
||||
---
|
||||
|
||||
## 6. 참고 스크린샷 (Desktop)
|
||||
|
||||
| 파일명 | 내용 |
|
||||
|--------|------|
|
||||
| 5130_car_list.png | 차량관리 목록 |
|
||||
| 5130_carrecord_list.png | 차량일지 목록 |
|
||||
| 5130_lift_list.png | 지게차 관리 목록 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 특이사항
|
||||
|
||||
1. **차량 vs 지게차 핵심 차이점** (2025-01-28 확인):
|
||||
- **차량**: 보험사, 보험사 연락처, 엔진오일 교환 주기/일 사용
|
||||
- **지게차**: 구입업체, 구입업체 연락처, 부속품 교환 주기/일 사용
|
||||
- 차종 필드에 용량 포함 (예: "3톤 디젤")
|
||||
|
||||
2. **API 미확정**: 백엔드 API가 아직 없을 수 있으므로 Mock 데이터로 우선 구현
|
||||
|
||||
3. **번호 컬럼**: 모든 테이블에서 1번부터 시작 (globalIndex 사용)
|
||||
|
||||
4. **정비정보/월간사진**: 상세 페이지에서 이미지 업로드 기능 필요할 수 있음
|
||||
|
||||
---
|
||||
|
||||
## 8. 컬럼 검증 결과 (2025-01-28)
|
||||
|
||||
### 8.1 차량관리 (vehicle) - ✅ 검증 완료
|
||||
| 순서 | 레거시 컬럼명 | SAM 구현 | 상태 |
|
||||
|------|-------------|----------|------|
|
||||
| 1 | 번호 | globalIndex | ✅ |
|
||||
| 2 | 차량번호 | vehicleNumber | ✅ |
|
||||
| 3 | 담당자 | manager | ✅ |
|
||||
| 4 | 보험사 | insuranceCompany | ✅ |
|
||||
| 5 | 보험사 연락처 | insuranceContact | ✅ |
|
||||
| 6 | 최초등록일 | firstRegistrationDate | ✅ |
|
||||
| 7 | 구매일자 | purchaseDate | ✅ |
|
||||
| 8 | 구매 유형 | purchaseType | ✅ |
|
||||
| 9 | 엔진오일 교환 주기 | oilChangeCycle | ✅ |
|
||||
| 10 | 엔진오일교환일 | oilChangeDate | ✅ |
|
||||
| 11 | 정비 정보 | maintenanceInfo | ✅ |
|
||||
| 12 | 비고 | remarks | ✅ |
|
||||
|
||||
### 8.2 차량일지 (vehicle-log) - ✅ 검증 완료
|
||||
| 순서 | 레거시 컬럼명 | SAM 구현 | 상태 |
|
||||
|------|-------------|----------|------|
|
||||
| 1 | 번호 | globalIndex | ✅ |
|
||||
| 2 | 작성일 | writeDate | ✅ |
|
||||
| 3 | 차량종류 | vehicleType | ✅ |
|
||||
| 4 | 작성자 | writer | ✅ |
|
||||
| 5 | 글제목 | title | ✅ |
|
||||
|
||||
### 8.3 지게차 관리 (forklift) - ✅ 검증 완료
|
||||
| 순서 | 레거시 컬럼명 | SAM 구현 | 상태 |
|
||||
|------|-------------|----------|------|
|
||||
| 1 | 번호 | globalIndex | ✅ |
|
||||
| 2 | 차량번호 | vehicleNumber | ✅ |
|
||||
| 3 | 차종 | vehicleType | ✅ |
|
||||
| 4 | 담당자 | manager | ✅ |
|
||||
| 5 | 구입업체 | purchaseCompany | ✅ |
|
||||
| 6 | 구입업체 연락처 | purchaseCompanyContact | ✅ |
|
||||
| 7 | 최초등록일 | firstRegistrationDate | ✅ |
|
||||
| 8 | 구매일자 | purchaseDate | ✅ |
|
||||
| 9 | 구매 유형 | purchaseType | ✅ |
|
||||
| 10 | 부속품 교환 주기 | partsChangeCycle | ✅ |
|
||||
| 11 | 부속품 교환일 | partsChangeDate | ✅ |
|
||||
| 12 | 정비 정보 | maintenanceInfo | ✅ |
|
||||
| 13 | 비고 | remarks | ✅ |
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지게차 수정 페이지
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ForkliftDetail } from '@/components/vehicle-management/ForkliftDetail';
|
||||
import { getForkliftById } from '@/components/vehicle-management/ForkliftList/actions';
|
||||
import type { Forklift } from '@/components/vehicle-management/types';
|
||||
|
||||
export default function ForkliftEditPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [data, setData] = useState<Forklift | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
getForkliftById(id)
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '데이터를 불러올 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error || '지게차를 찾을 수 없습니다.'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ForkliftDetail mode="edit" initialData={data} id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지게차 상세 페이지
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ForkliftDetail } from '@/components/vehicle-management/ForkliftDetail';
|
||||
import { getForkliftById } from '@/components/vehicle-management/ForkliftList/actions';
|
||||
import type { Forklift } from '@/components/vehicle-management/types';
|
||||
|
||||
export default function ForkliftDetailPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [data, setData] = useState<Forklift | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
getForkliftById(id)
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '데이터를 불러올 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error || '지게차를 찾을 수 없습니다.'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ForkliftDetail mode="view" initialData={data} id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지게차 등록 페이지
|
||||
*/
|
||||
|
||||
import { ForkliftDetail } from '@/components/vehicle-management/ForkliftDetail';
|
||||
|
||||
export default function ForkliftNewPage() {
|
||||
return <ForkliftDetail mode="create" />;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지게차 관리 리스트 페이지
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ForkliftList } from '@/components/vehicle-management/ForkliftList';
|
||||
import { getForklifts } from '@/components/vehicle-management/ForkliftList/actions';
|
||||
import type { Forklift } from '@/components/vehicle-management/types';
|
||||
|
||||
export default function ForkliftPage() {
|
||||
const [data, setData] = useState<Forklift[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getForklifts()
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ForkliftList initialData={data} />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량일지 수정 페이지
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { VehicleLogDetail } from '@/components/vehicle-management/VehicleLogDetail';
|
||||
import { getVehicleLogById } from '@/components/vehicle-management/VehicleLogList/actions';
|
||||
import type { VehicleLog } from '@/components/vehicle-management/types';
|
||||
|
||||
export default function VehicleLogEditPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [data, setData] = useState<VehicleLog | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
getVehicleLogById(id)
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '데이터를 불러올 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error || '차량일지를 찾을 수 없습니다.'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <VehicleLogDetail mode="edit" initialData={data} id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량일지 상세 페이지
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { VehicleLogDetail } from '@/components/vehicle-management/VehicleLogDetail';
|
||||
import { getVehicleLogById } from '@/components/vehicle-management/VehicleLogList/actions';
|
||||
import type { VehicleLog } from '@/components/vehicle-management/types';
|
||||
|
||||
export default function VehicleLogDetailPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [data, setData] = useState<VehicleLog | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
getVehicleLogById(id)
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '데이터를 불러올 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error || '차량일지를 찾을 수 없습니다.'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <VehicleLogDetail mode="view" initialData={data} id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량일지 등록 페이지
|
||||
*/
|
||||
|
||||
import { VehicleLogDetail } from '@/components/vehicle-management/VehicleLogDetail';
|
||||
|
||||
export default function VehicleLogNewPage() {
|
||||
return <VehicleLogDetail mode="create" />;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량일지/월간사진기록 리스트 페이지
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { VehicleLogList } from '@/components/vehicle-management/VehicleLogList';
|
||||
import { getVehicleLogs } from '@/components/vehicle-management/VehicleLogList/actions';
|
||||
import type { VehicleLog } from '@/components/vehicle-management/types';
|
||||
|
||||
export default function VehicleLogPage() {
|
||||
const [data, setData] = useState<VehicleLog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getVehicleLogs()
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <VehicleLogList initialData={data} />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량 수정 페이지
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { VehicleDetail } from '@/components/vehicle-management/VehicleDetail';
|
||||
import { getVehicleById } from '@/components/vehicle-management/VehicleList/actions';
|
||||
import type { Vehicle } from '@/components/vehicle-management/types';
|
||||
|
||||
export default function VehicleEditPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [data, setData] = useState<Vehicle | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
getVehicleById(id)
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '데이터를 불러올 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error || '차량을 찾을 수 없습니다.'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <VehicleDetail mode="edit" initialData={data} id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량 상세 페이지
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { VehicleDetail } from '@/components/vehicle-management/VehicleDetail';
|
||||
import { getVehicleById } from '@/components/vehicle-management/VehicleList/actions';
|
||||
import type { Vehicle } from '@/components/vehicle-management/types';
|
||||
|
||||
export default function VehicleDetailPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [data, setData] = useState<Vehicle | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
getVehicleById(id)
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '데이터를 불러올 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error || '차량을 찾을 수 없습니다.'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <VehicleDetail mode="view" initialData={data} id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량 등록 페이지
|
||||
*/
|
||||
|
||||
import { VehicleDetail } from '@/components/vehicle-management/VehicleDetail';
|
||||
|
||||
export default function VehicleNewPage() {
|
||||
return <VehicleDetail mode="create" />;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량 관리 리스트 페이지
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { VehicleList } from '@/components/vehicle-management/VehicleList';
|
||||
import { getVehicles } from '@/components/vehicle-management/VehicleList/actions';
|
||||
import type { Vehicle } from '@/components/vehicle-management/types';
|
||||
|
||||
export default function VehiclePage() {
|
||||
const [data, setData] = useState<Vehicle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getVehicles()
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <VehicleList initialData={data} />;
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import {
|
||||
TodayIssueSection,
|
||||
StatusBoardSection,
|
||||
DailyReportSection,
|
||||
MonthlyExpenseSection,
|
||||
EnhancedStatusBoardSection,
|
||||
EnhancedDailyReportSection,
|
||||
EnhancedMonthlyExpenseSection,
|
||||
CardManagementSection,
|
||||
EntertainmentSection,
|
||||
WelfareSection,
|
||||
@@ -302,25 +302,25 @@ export function CEODashboard() {
|
||||
<TodayIssueSection items={data.todayIssueList} />
|
||||
)}
|
||||
|
||||
{/* 일일 일보 */}
|
||||
{/* 일일 일보 (Enhanced) */}
|
||||
{dashboardSettings.dailyReport && (
|
||||
<DailyReportSection
|
||||
<EnhancedDailyReportSection
|
||||
data={data.dailyReport}
|
||||
onClick={handleDailyReportClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 현황판 (구 오늘의 이슈 - 카드 형태) */}
|
||||
{/* 현황판 (Enhanced - 아이콘 + 컬러 테마) */}
|
||||
{(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled) && (
|
||||
<StatusBoardSection
|
||||
<EnhancedStatusBoardSection
|
||||
items={data.todayIssue}
|
||||
itemSettings={dashboardSettings.statusBoard?.items ?? dashboardSettings.todayIssue.items}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 당월 예상 지출 내역 */}
|
||||
{/* 당월 예상 지출 내역 (Enhanced) */}
|
||||
{dashboardSettings.monthlyExpense && (
|
||||
<MonthlyExpenseSection
|
||||
<EnhancedMonthlyExpenseSection
|
||||
data={data.monthlyExpense}
|
||||
onCardClick={handleMonthlyExpenseCardClick}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { Check, AlertTriangle, Info, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CheckPoint, CheckPointType, AmountCard, HighlightColor } from './types';
|
||||
|
||||
// 섹션별 컬러 테마 타입
|
||||
export type SectionColorTheme = 'blue' | 'purple' | 'orange' | 'green' | 'red' | 'amber' | 'cyan' | 'pink' | 'emerald' | 'indigo';
|
||||
|
||||
// 컬러 테마별 스타일
|
||||
export const SECTION_THEME_STYLES: Record<SectionColorTheme, { bg: string; border: string; iconBg: string; labelColor: string; accentColor: string }> = {
|
||||
blue: { bg: '#eff6ff', border: '#bfdbfe', iconBg: '#3b82f6', labelColor: '#1d4ed8', accentColor: '#3b82f6' },
|
||||
purple: { bg: '#faf5ff', border: '#e9d5ff', iconBg: '#a855f7', labelColor: '#7c3aed', accentColor: '#a855f7' },
|
||||
orange: { bg: '#fff7ed', border: '#fed7aa', iconBg: '#f97316', labelColor: '#ea580c', accentColor: '#f97316' },
|
||||
green: { bg: '#f0fdf4', border: '#bbf7d0', iconBg: '#22c55e', labelColor: '#16a34a', accentColor: '#22c55e' },
|
||||
red: { bg: '#fef2f2', border: '#fecaca', iconBg: '#ef4444', labelColor: '#dc2626', accentColor: '#ef4444' },
|
||||
amber: { bg: '#fffbeb', border: '#fde68a', iconBg: '#f59e0b', labelColor: '#d97706', accentColor: '#f59e0b' },
|
||||
cyan: { bg: '#ecfeff', border: '#a5f3fc', iconBg: '#06b6d4', labelColor: '#0891b2', accentColor: '#06b6d4' },
|
||||
pink: { bg: '#fdf2f8', border: '#fbcfe8', iconBg: '#ec4899', labelColor: '#db2777', accentColor: '#ec4899' },
|
||||
emerald: { bg: '#ecfdf5', border: '#a7f3d0', iconBg: '#10b981', labelColor: '#059669', accentColor: '#10b981' },
|
||||
indigo: { bg: '#eef2ff', border: '#c7d2fe', iconBg: '#6366f1', labelColor: '#4f46e5', accentColor: '#6366f1' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 금액 포맷 함수
|
||||
*/
|
||||
@@ -133,27 +158,42 @@ export const CheckPointItem = ({ checkpoint }: { checkpoint: CheckPoint }) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 섹션 타이틀 컴포넌트
|
||||
* 섹션 타이틀 컴포넌트 (아이콘 지원 버전)
|
||||
*/
|
||||
export const SectionTitle = ({
|
||||
title,
|
||||
badge,
|
||||
actionButton,
|
||||
icon: Icon,
|
||||
colorTheme,
|
||||
}: {
|
||||
title: string;
|
||||
badge?: 'warning' | 'success' | 'info' | 'error';
|
||||
actionButton?: { label: string; onClick: () => void };
|
||||
icon?: LucideIcon;
|
||||
colorTheme?: SectionColorTheme;
|
||||
}) => {
|
||||
const themeStyle = colorTheme ? SECTION_THEME_STYLES[colorTheme] : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && themeStyle ? (
|
||||
<div
|
||||
className="p-1.5 rounded-lg"
|
||||
style={{ backgroundColor: themeStyle.iconBg }}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
"w-1.5 h-6 rounded-full",
|
||||
badge === 'warning' ? 'bg-amber-500' :
|
||||
badge === 'success' ? 'bg-green-500' :
|
||||
badge === 'info' ? 'bg-blue-500' : 'bg-red-500'
|
||||
)} />
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
{actionButton && (
|
||||
<Button
|
||||
@@ -170,17 +210,31 @@ export const SectionTitle = ({
|
||||
};
|
||||
|
||||
/**
|
||||
* 금액 카드 컴포넌트
|
||||
* 금액 카드 컴포넌트 (아이콘/컬러 테마 지원)
|
||||
*/
|
||||
export const AmountCardItem = ({
|
||||
card,
|
||||
onClick,
|
||||
className,
|
||||
icon: Icon,
|
||||
colorTheme,
|
||||
showTrend,
|
||||
trendValue,
|
||||
trendDirection,
|
||||
showCountBadge,
|
||||
}: {
|
||||
card: AmountCard;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
icon?: LucideIcon;
|
||||
colorTheme?: SectionColorTheme;
|
||||
showTrend?: boolean;
|
||||
trendValue?: string;
|
||||
trendDirection?: 'up' | 'down';
|
||||
showCountBadge?: boolean;
|
||||
}) => {
|
||||
const themeStyle = colorTheme ? SECTION_THEME_STYLES[colorTheme] : null;
|
||||
|
||||
// 금액 포맷 함수 (통화에 따라 분기)
|
||||
const formatCardAmount = (amount: number): string => {
|
||||
if (card.unit === '건') {
|
||||
@@ -192,47 +246,113 @@ export const AmountCardItem = ({
|
||||
return formatBillion(amount);
|
||||
};
|
||||
|
||||
// 테마 적용 시 스타일
|
||||
const cardStyle = themeStyle && !card.isHighlighted ? {
|
||||
backgroundColor: themeStyle.bg,
|
||||
borderColor: themeStyle.border,
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
onClick && 'cursor-pointer hover:shadow-md transition-shadow',
|
||||
<Card
|
||||
className={cn(
|
||||
'relative',
|
||||
onClick && 'cursor-pointer hover:shadow-lg transition-all hover:scale-[1.02]',
|
||||
card.isHighlighted && 'border-red-300 bg-red-50',
|
||||
!themeStyle && !card.isHighlighted && 'border',
|
||||
className
|
||||
)}>
|
||||
)}
|
||||
style={cardStyle}
|
||||
>
|
||||
{/* 건수 뱃지 (오른쪽 상단) */}
|
||||
{showCountBadge && card.subLabel && (
|
||||
<div
|
||||
className="absolute top-3 right-3 px-2 py-0.5 rounded-full text-xs font-bold"
|
||||
style={{
|
||||
backgroundColor: themeStyle ? themeStyle.iconBg : '#ef4444',
|
||||
color: '#ffffff'
|
||||
}}
|
||||
>
|
||||
{card.subLabel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardContent
|
||||
className="p-4 md:p-6"
|
||||
className={cn(
|
||||
"p-4 flex flex-col",
|
||||
card.subItems && card.subItems.length > 0 ? "min-h-[140px]" : showTrend && trendValue ? "min-h-[130px]" : "min-h-[110px]"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<p className={cn(
|
||||
"text-sm font-medium mb-2",
|
||||
{/* 아이콘 + 라벨 */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{Icon && themeStyle && !card.isHighlighted && (
|
||||
<div
|
||||
className="p-1.5 rounded-lg shrink-0"
|
||||
style={{ backgroundColor: themeStyle.iconBg }}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
card.isHighlighted ? 'text-red-600' : 'text-muted-foreground'
|
||||
)}>
|
||||
)}
|
||||
style={themeStyle && !card.isHighlighted ? { color: themeStyle.labelColor } : undefined}
|
||||
>
|
||||
{card.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 금액 */}
|
||||
<p className={cn(
|
||||
"text-lg xs:text-xl md:text-2xl lg:text-3xl font-bold",
|
||||
"text-2xl font-bold",
|
||||
card.isHighlighted && 'text-red-600'
|
||||
)}>
|
||||
{formatCardAmount(card.amount)}
|
||||
</p>
|
||||
|
||||
{/* 트렌드 표시 (pill 형태, 금액 아래에 배치) */}
|
||||
{showTrend && trendValue && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit"
|
||||
style={{
|
||||
backgroundColor: trendDirection === 'up' ? '#dcfce7' : '#fee2e2',
|
||||
color: trendDirection === 'up' ? '#16a34a' : '#dc2626'
|
||||
}}
|
||||
>
|
||||
{trendDirection === 'up' ? (
|
||||
<TrendingUp className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span>{trendValue}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* subItems 배열이 있는 경우 (매출, 입금 등 다중 서브 정보) */}
|
||||
{card.subItems && card.subItems.length > 0 && (
|
||||
<div className="mt-2 space-y-0.5 text-xs text-muted-foreground">
|
||||
{!showTrend && card.subItems && card.subItems.length > 0 && (
|
||||
<div className="mt-auto space-y-0.5 text-xs text-muted-foreground">
|
||||
{card.subItems.map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between">
|
||||
<span>{item.label}</span>
|
||||
<span>{typeof item.value === 'number' ? formatAmount(item.value, false) : item.value}</span>
|
||||
<div key={idx} className="flex justify-between gap-2">
|
||||
<span className="shrink-0">{item.label}</span>
|
||||
<span className="text-right">{typeof item.value === 'number' ? formatAmount(item.value, false) : item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기존 단일 서브 정보 */}
|
||||
{!card.subItems && (card.subAmount !== undefined || card.previousAmount !== undefined || card.subLabel || card.previousLabel) && (
|
||||
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
|
||||
{!showTrend && !card.subItems && (card.subAmount !== undefined || card.previousAmount !== undefined || card.subLabel || card.previousLabel) && (
|
||||
<div className="flex gap-4 mt-auto text-xs text-muted-foreground">
|
||||
{card.subAmount !== undefined && card.subLabel && (
|
||||
<span>{card.subLabel}: {card.unit === '건' ? `${card.subAmount}건` : formatAmount(card.subAmount)}</span>
|
||||
)}
|
||||
{card.previousLabel && (
|
||||
<span>{card.previousLabel}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3 text-green-600" />
|
||||
{card.previousLabel}
|
||||
</span>
|
||||
)}
|
||||
{card.subLabel && card.subAmount === undefined && !card.previousLabel && (
|
||||
<span>{card.subLabel}</span>
|
||||
@@ -253,14 +373,12 @@ export const IssueCardItem = ({
|
||||
subLabel,
|
||||
isHighlighted,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
count: number | string;
|
||||
subLabel?: string;
|
||||
isHighlighted?: boolean;
|
||||
onClick?: () => void;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
@@ -271,8 +389,6 @@ export const IssueCardItem = ({
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={cn(
|
||||
"text-sm font-medium",
|
||||
isHighlighted ? 'text-white' : 'text-muted-foreground'
|
||||
@@ -296,16 +412,6 @@ export const IssueCardItem = ({
|
||||
{subLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn(
|
||||
"w-8 h-8 xs:w-10 xs:h-10 md:w-12 md:h-12 opacity-15",
|
||||
isHighlighted ? 'text-white' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { CreditCard, Wallet, Receipt, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem, type SectionColorTheme } from '../components';
|
||||
import type { CardManagementData } from '../types';
|
||||
|
||||
// 카드별 아이콘 매핑
|
||||
const CARD_ICONS = [CreditCard, Wallet, Receipt, AlertTriangle];
|
||||
const CARD_THEMES: SectionColorTheme[] = ['blue', 'indigo', 'purple', 'orange'];
|
||||
|
||||
interface CardManagementSectionProps {
|
||||
data: CardManagementData;
|
||||
onCardClick?: (cardId: string) => void;
|
||||
@@ -24,20 +29,31 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="카드/가지급금 관리" badge="warning" />
|
||||
<SectionTitle
|
||||
title="카드/가지급금 관리"
|
||||
badge="warning"
|
||||
icon={CreditCard}
|
||||
colorTheme="blue"
|
||||
/>
|
||||
|
||||
{data.warningBanner && (
|
||||
<div className="bg-red-500 text-white text-sm font-medium px-4 py-2 rounded-lg mb-4">
|
||||
<div className="bg-red-500 text-white text-sm font-medium px-4 py-2 rounded-lg mb-4 flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{data.warningBanner}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
{data.cards.map((card, idx) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={() => handleClick(card.id)}
|
||||
icon={CARD_ICONS[idx] || CreditCard}
|
||||
colorTheme={CARD_THEMES[idx] || 'blue'}
|
||||
showTrend={!!card.previousLabel}
|
||||
trendValue={card.previousLabel}
|
||||
trendDirection="up"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Gavel, FileWarning, AlertCircle, Scale } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem, type SectionColorTheme } from '../components';
|
||||
import type { DebtCollectionData } from '../types';
|
||||
|
||||
// 카드별 아이콘 매핑
|
||||
const CARD_ICONS = [Scale, FileWarning, AlertCircle, Gavel];
|
||||
const CARD_THEMES: SectionColorTheme[] = ['red', 'orange', 'amber', 'purple'];
|
||||
|
||||
interface DebtCollectionSectionProps {
|
||||
data: DebtCollectionData;
|
||||
}
|
||||
@@ -21,14 +26,25 @@ export function DebtCollectionSection({ data }: DebtCollectionSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="채권추심 현황" badge="info" />
|
||||
<SectionTitle
|
||||
title="채권추심 현황"
|
||||
badge="error"
|
||||
icon={Gavel}
|
||||
colorTheme="red"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
{data.cards.map((card, idx) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={data.detailButtonPath ? handleClick : undefined}
|
||||
icon={CARD_ICONS[idx] || Gavel}
|
||||
colorTheme={CARD_THEMES[idx] || 'red'}
|
||||
showTrend={!!card.previousLabel}
|
||||
trendValue={card.previousLabel}
|
||||
trendDirection="down"
|
||||
showCountBadge={!!card.subLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Wallet,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Users,
|
||||
FileText,
|
||||
ShoppingCart,
|
||||
Building2,
|
||||
Calendar,
|
||||
CreditCard,
|
||||
Receipt,
|
||||
Briefcase,
|
||||
AlertCircle,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Banknote,
|
||||
CircleDollarSign,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { DailyReportData, MonthlyExpenseData, TodayIssueItem, TodayIssueSettings } from '../types';
|
||||
|
||||
// ============================================================
|
||||
// 유틸리티 함수
|
||||
// ============================================================
|
||||
|
||||
const formatBillion = (amount: number): string => {
|
||||
const billion = amount / 100000000;
|
||||
if (billion >= 1) {
|
||||
return billion.toFixed(1) + '억원';
|
||||
}
|
||||
const man = amount / 10000;
|
||||
if (man >= 1) {
|
||||
return Math.floor(man).toLocaleString() + '만원';
|
||||
}
|
||||
return amount.toLocaleString() + '원';
|
||||
};
|
||||
|
||||
const formatUSD = (amount: number): string => {
|
||||
return '$ ' + new Intl.NumberFormat('en-US').format(amount);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 강화된 일일 일보 섹션
|
||||
// ============================================================
|
||||
|
||||
interface EnhancedDailyReportSectionProps {
|
||||
data: DailyReportData;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyReportSectionProps) {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
{/* 다크 헤더 - 인라인 스타일로 확실하게 적용 */}
|
||||
<div
|
||||
style={{ backgroundColor: '#1e293b' }}
|
||||
className="px-6 py-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.1)' }}
|
||||
className="p-2 rounded-lg"
|
||||
>
|
||||
<FileText style={{ color: '#ffffff' }} className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold">일일 일보</h3>
|
||||
<p style={{ color: '#cbd5e1' }} className="text-sm">{data.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
style={{ backgroundColor: '#10b981', color: '#ffffff', border: 'none' }}
|
||||
className="hover:opacity-90"
|
||||
>
|
||||
실시간
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 내용 - 흰색 배경 */}
|
||||
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
||||
{/* 카드 그리드 */}
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{/* 카드 1: 현금성 자산 */}
|
||||
<div
|
||||
style={{ backgroundColor: '#ecfdf5', borderColor: '#a7f3d0' }}
|
||||
className="rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border h-[110px] flex flex-col"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div style={{ backgroundColor: '#10b981' }} className="p-1.5 rounded-lg">
|
||||
<Wallet style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||||
</div>
|
||||
<span style={{ color: '#047857' }} className="text-sm font-medium">
|
||||
{data.cards[0]?.label || '현금성 자산 합계'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
||||
{formatBillion(data.cards[0]?.amount || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 2: 외국환 */}
|
||||
<div
|
||||
style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}
|
||||
className="rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border h-[110px] flex flex-col"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div style={{ backgroundColor: '#3b82f6' }} className="p-1.5 rounded-lg">
|
||||
<DollarSign style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||||
</div>
|
||||
<span style={{ color: '#1d4ed8' }} className="text-sm font-medium">
|
||||
{data.cards[1]?.label || '외국환(USD) 합계'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
||||
{data.cards[1]?.currency === 'USD'
|
||||
? formatUSD(data.cards[1]?.amount || 0)
|
||||
: formatBillion(data.cards[1]?.amount || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 3: 입금 */}
|
||||
<div
|
||||
style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}
|
||||
className="rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border h-[110px] flex flex-col"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div style={{ backgroundColor: '#22c55e' }} className="p-1.5 rounded-lg">
|
||||
<TrendingUp style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||||
</div>
|
||||
<span style={{ color: '#15803d' }} className="text-sm font-medium">
|
||||
{data.cards[2]?.label || '입금 합계'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
||||
{formatBillion(data.cards[2]?.amount || 0)}
|
||||
</span>
|
||||
<span style={{ color: '#16a34a' }} className="flex items-center text-xs font-medium mb-1">
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
+12%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 4: 출금 */}
|
||||
<div
|
||||
style={{ backgroundColor: '#fff1f2', borderColor: '#fecdd3' }}
|
||||
className="rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border h-[110px] flex flex-col"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div style={{ backgroundColor: '#f43f5e' }} className="p-1.5 rounded-lg">
|
||||
<TrendingDown style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||||
</div>
|
||||
<span style={{ color: '#be123c' }} className="text-sm font-medium">
|
||||
{data.cards[3]?.label || '출금 합계'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
||||
{formatBillion(data.cards[3]?.amount || 0)}
|
||||
</span>
|
||||
<span style={{ color: '#e11d48' }} className="flex items-center text-xs font-medium mb-1">
|
||||
<ArrowDownRight className="h-3 w-3" />
|
||||
-8%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 체크포인트 */}
|
||||
{data.checkPoints.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p style={{ color: '#64748b' }} className="text-xs font-medium uppercase tracking-wider mb-3">주요 알림</p>
|
||||
{data.checkPoints.map((cp, idx) => (
|
||||
<div
|
||||
key={cp.id}
|
||||
style={{
|
||||
backgroundColor: idx === 0 ? '#fffbeb' : '#f8fafc',
|
||||
borderColor: idx === 0 ? '#fde68a' : '#e2e8f0'
|
||||
}}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border"
|
||||
>
|
||||
<div
|
||||
style={{ backgroundColor: idx === 0 ? '#fef3c7' : '#f1f5f9' }}
|
||||
className="p-1 rounded-full shrink-0"
|
||||
>
|
||||
{idx === 0 ? (
|
||||
<AlertTriangle style={{ color: '#d97706' }} className="h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 style={{ color: '#16a34a' }} className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<p style={{ color: '#475569' }} className="text-sm flex-1">{cp.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 강화된 현황판 섹션
|
||||
// ============================================================
|
||||
|
||||
// 라벨 → 설정키 매핑
|
||||
const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
|
||||
'수주': 'orders',
|
||||
'채권 추심': 'debtCollection',
|
||||
'안전 재고': 'safetyStock',
|
||||
'세금 신고': 'taxReport',
|
||||
'신규 업체 등록': 'newVendor',
|
||||
'연차': 'annualLeave',
|
||||
'지각': 'lateness',
|
||||
'결근': 'absence',
|
||||
'발주': 'purchase',
|
||||
'결재 요청': 'approvalRequest',
|
||||
};
|
||||
|
||||
// 라벨별 스타일 매핑 (인라인 스타일용)
|
||||
const ITEM_STYLES: Record<string, { bg: string; border: string; iconBg: string; labelColor: string; Icon: React.ComponentType<{ style?: React.CSSProperties; className?: string }> }> = {
|
||||
'수주': { bg: '#eff6ff', border: '#bfdbfe', iconBg: '#3b82f6', labelColor: '#1d4ed8', Icon: ShoppingCart },
|
||||
'채권 추심': { bg: '#fef2f2', border: '#fecaca', iconBg: '#ef4444', labelColor: '#dc2626', Icon: AlertCircle },
|
||||
'안전 재고': { bg: '#fff7ed', border: '#fed7aa', iconBg: '#f97316', labelColor: '#ea580c', Icon: Receipt },
|
||||
'세금 신고': { bg: '#faf5ff', border: '#e9d5ff', iconBg: '#a855f7', labelColor: '#9333ea', Icon: FileText },
|
||||
'신규 업체 등록': { bg: '#ecfdf5', border: '#a7f3d0', iconBg: '#10b981', labelColor: '#059669', Icon: Building2 },
|
||||
'연차': { bg: '#ecfeff', border: '#a5f3fc', iconBg: '#06b6d4', labelColor: '#0891b2', Icon: Calendar },
|
||||
'지각': { bg: '#fffbeb', border: '#fde68a', iconBg: '#f59e0b', labelColor: '#d97706', Icon: Clock },
|
||||
'결근': { bg: '#fff1f2', border: '#fecdd3', iconBg: '#f43f5e', labelColor: '#e11d48', Icon: Users },
|
||||
'발주': { bg: '#eef2ff', border: '#c7d2fe', iconBg: '#6366f1', labelColor: '#4f46e5', Icon: Briefcase },
|
||||
'결재 요청': { bg: '#fdf2f8', border: '#fbcfe8', iconBg: '#ec4899', labelColor: '#db2777', Icon: CheckCircle2 },
|
||||
};
|
||||
|
||||
const DEFAULT_STYLE = { bg: '#f8fafc', border: '#e2e8f0', iconBg: '#64748b', labelColor: '#475569', Icon: FileText };
|
||||
|
||||
interface EnhancedStatusBoardSectionProps {
|
||||
items: TodayIssueItem[];
|
||||
itemSettings?: TodayIssueSettings;
|
||||
}
|
||||
|
||||
export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStatusBoardSectionProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleItemClick = (path: string) => {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
// 설정에 따라 항목 필터링
|
||||
const filteredItems = itemSettings
|
||||
? items.filter((item) => {
|
||||
const settingKey = LABEL_TO_SETTING_KEY[item.label];
|
||||
return settingKey ? itemSettings[settingKey] : true;
|
||||
})
|
||||
: items;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div style={{ backgroundColor: '#f59e0b' }} className="w-1.5 h-6 rounded-full" />
|
||||
<h3 style={{ color: '#0f172a' }} className="text-lg font-semibold">현황판</h3>
|
||||
<Badge
|
||||
style={{ backgroundColor: '#fef3c7', color: '#b45309', borderColor: '#fde68a' }}
|
||||
>
|
||||
{filteredItems.length}개 항목
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 카드 그리드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{filteredItems.map((item) => {
|
||||
const isHighlighted = item.isHighlighted;
|
||||
const style = ITEM_STYLES[item.label] || DEFAULT_STYLE;
|
||||
const Icon = style.Icon;
|
||||
|
||||
// 긴급 항목은 빨간 배경
|
||||
const bgColor = isHighlighted ? '#ef4444' : style.bg;
|
||||
const borderColor = isHighlighted ? '#ef4444' : style.border;
|
||||
const iconBgColor = isHighlighted ? 'rgba(255,255,255,0.2)' : style.iconBg;
|
||||
const labelColor = isHighlighted ? '#ffffff' : style.labelColor;
|
||||
const countColor = isHighlighted ? '#ffffff' : '#0f172a';
|
||||
const subColor = isHighlighted ? '#fecaca' : '#64748b';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{ backgroundColor: bgColor, borderColor: borderColor }}
|
||||
className="relative p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md h-[110px] flex flex-col"
|
||||
onClick={() => handleItemClick(item.path)}
|
||||
>
|
||||
{/* 아이콘 + 라벨 */}
|
||||
<div className="flex items-center gap-2 mb-3 min-w-0">
|
||||
<div style={{ backgroundColor: iconBgColor }} className="p-1.5 rounded-lg shrink-0">
|
||||
<Icon style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||||
</div>
|
||||
<span style={{ color: labelColor }} className="text-sm font-medium truncate flex-1 min-w-0">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 숫자 */}
|
||||
<div style={{ color: countColor }} className="text-2xl font-bold">
|
||||
{typeof item.count === 'number' ? `${item.count}건` : item.count}
|
||||
</div>
|
||||
|
||||
{/* 부가 정보 */}
|
||||
{item.subLabel && (
|
||||
<span style={{ color: subColor }} className="text-xs mt-auto">
|
||||
{item.subLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 강화된 당월 예상 지출 섹션
|
||||
// ============================================================
|
||||
|
||||
interface EnhancedMonthlyExpenseSectionProps {
|
||||
data: MonthlyExpenseData;
|
||||
onCardClick?: (cardId: string) => void;
|
||||
}
|
||||
|
||||
export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMonthlyExpenseSectionProps) {
|
||||
// 총 예상 지출 계산 (API에서 문자열로 올 수 있으므로 Number로 변환)
|
||||
const totalAmount = data.cards.reduce((sum, card) => sum + (Number(card?.amount) || 0), 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div style={{ backgroundColor: '#f97316' }} className="w-1.5 h-6 rounded-full" />
|
||||
<h3 style={{ color: '#0f172a' }} className="text-lg font-semibold">당월 예상 지출 내역</h3>
|
||||
<Badge
|
||||
style={{ backgroundColor: '#ffedd5', color: '#c2410c', borderColor: '#fed7aa' }}
|
||||
>
|
||||
전월 대비 +15%
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 카드 그리드 */}
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{/* 카드 1: 매입 */}
|
||||
<div
|
||||
style={{ backgroundColor: '#f5f3ff', borderColor: '#ddd6fe' }}
|
||||
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border h-[130px] flex flex-col"
|
||||
onClick={() => onCardClick?.(data.cards[0]?.id || 'me1')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div style={{ backgroundColor: '#8b5cf6' }} className="p-1.5 rounded-lg">
|
||||
<Receipt style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||||
</div>
|
||||
<span style={{ color: '#6d28d9' }} className="text-sm font-medium">
|
||||
{data.cards[0]?.label || '매입'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
||||
{formatBillion(data.cards[0]?.amount || 0)}
|
||||
</div>
|
||||
{data.cards[0]?.previousLabel && (
|
||||
<div style={{ backgroundColor: '#dcfce7', color: '#16a34a' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{data.cards[0].previousLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 카드 2: 카드 */}
|
||||
<div
|
||||
style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}
|
||||
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border h-[130px] flex flex-col"
|
||||
onClick={() => onCardClick?.(data.cards[1]?.id || 'me2')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div style={{ backgroundColor: '#3b82f6' }} className="p-1.5 rounded-lg">
|
||||
<CreditCard style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||||
</div>
|
||||
<span style={{ color: '#1d4ed8' }} className="text-sm font-medium">
|
||||
{data.cards[1]?.label || '카드'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
||||
{formatBillion(data.cards[1]?.amount || 0)}
|
||||
</div>
|
||||
{data.cards[1]?.previousLabel && (
|
||||
<div style={{ backgroundColor: '#dcfce7', color: '#16a34a' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{data.cards[1].previousLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 카드 3: 발행어음 */}
|
||||
<div
|
||||
style={{ backgroundColor: '#fffbeb', borderColor: '#fde68a' }}
|
||||
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border h-[130px] flex flex-col"
|
||||
onClick={() => onCardClick?.(data.cards[2]?.id || 'me3')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div style={{ backgroundColor: '#f59e0b' }} className="p-1.5 rounded-lg">
|
||||
<Banknote style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||||
</div>
|
||||
<span style={{ color: '#b45309' }} className="text-sm font-medium">
|
||||
{data.cards[2]?.label || '발행어음'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
||||
{formatBillion(data.cards[2]?.amount || 0)}
|
||||
</div>
|
||||
{data.cards[2]?.previousLabel && (
|
||||
<div style={{ backgroundColor: '#dcfce7', color: '#16a34a' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{data.cards[2].previousLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 카드 4: 총 예상 지출 합계 (강조 - 인라인 스타일) */}
|
||||
<div
|
||||
style={{ backgroundColor: '#f43f5e', borderColor: '#f43f5e' }}
|
||||
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border h-[130px] flex flex-col"
|
||||
onClick={() => onCardClick?.(data.cards[3]?.id || 'me4')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div style={{ backgroundColor: 'rgba(255,255,255,0.2)' }} className="p-1.5 rounded-lg">
|
||||
<CircleDollarSign style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||||
</div>
|
||||
<span style={{ color: '#ffe4e6' }} className="text-sm font-medium">
|
||||
총 예상 지출 합계
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: '#ffffff' }} className="text-2xl font-bold">
|
||||
{formatBillion(totalAmount)}
|
||||
</div>
|
||||
<div style={{ backgroundColor: 'rgba(255,255,255,0.2)', color: '#ffffff' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
전월 대비 +10.5%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 체크포인트 */}
|
||||
{data.checkPoints.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{data.checkPoints.map((cp, idx) => {
|
||||
const colors = [
|
||||
{ bg: '#fef2f2', border: '#fecaca', iconColor: '#ef4444' },
|
||||
{ bg: '#fffbeb', border: '#fde68a', iconColor: '#f59e0b' },
|
||||
{ bg: '#f0fdf4', border: '#bbf7d0', iconColor: '#22c55e' },
|
||||
];
|
||||
const color = colors[idx] || colors[2];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cp.id}
|
||||
style={{ backgroundColor: color.bg, borderColor: color.border }}
|
||||
className="p-3 rounded-lg border flex items-start gap-2"
|
||||
>
|
||||
{idx === 0 ? (
|
||||
<AlertTriangle style={{ color: color.iconColor }} className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
) : idx === 1 ? (
|
||||
<AlertCircle style={{ color: color.iconColor }} className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 style={{ color: color.iconColor }} className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
)}
|
||||
<p style={{ color: '#475569' }} className="text-xs line-clamp-2">{cp.message}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Wine, Utensils, Users, CreditCard } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem, type SectionColorTheme } from '../components';
|
||||
import type { EntertainmentData } from '../types';
|
||||
|
||||
// 카드별 아이콘 매핑
|
||||
const CARD_ICONS = [Wine, Utensils, Users, CreditCard];
|
||||
const CARD_THEMES: SectionColorTheme[] = ['pink', 'purple', 'indigo', 'red'];
|
||||
|
||||
interface EntertainmentSectionProps {
|
||||
data: EntertainmentData;
|
||||
onCardClick?: (cardId: string) => void;
|
||||
@@ -13,14 +18,24 @@ export function EntertainmentSection({ data, onCardClick }: EntertainmentSection
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="접대비 현황" badge="warning" />
|
||||
<SectionTitle
|
||||
title="접대비 현황"
|
||||
badge="warning"
|
||||
icon={Wine}
|
||||
colorTheme="pink"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
{data.cards.map((card, idx) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={() => onCardClick?.(card.id)}
|
||||
icon={CARD_ICONS[idx] || Wine}
|
||||
colorTheme={CARD_THEMES[idx] || 'pink'}
|
||||
showTrend={!!card.previousLabel}
|
||||
trendValue={card.previousLabel}
|
||||
trendDirection="up"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Banknote, Clock, AlertTriangle, CircleDollarSign } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem, type SectionColorTheme } from '../components';
|
||||
import type { ReceivableData } from '../types';
|
||||
|
||||
// 카드별 아이콘 매핑 (미수금 합계, 30일 이내, 30~90일, 90일 초과)
|
||||
const CARD_ICONS = [CircleDollarSign, Banknote, Clock, AlertTriangle];
|
||||
const CARD_THEMES: SectionColorTheme[] = ['amber', 'green', 'orange', 'red'];
|
||||
|
||||
interface ReceivableSectionProps {
|
||||
data: ReceivableData;
|
||||
}
|
||||
@@ -24,6 +29,8 @@ export function ReceivableSection({ data }: ReceivableSectionProps) {
|
||||
<SectionTitle
|
||||
title="미수금 현황"
|
||||
badge="warning"
|
||||
icon={Banknote}
|
||||
colorTheme="amber"
|
||||
actionButton={
|
||||
data.detailButtonLabel
|
||||
? {
|
||||
@@ -35,11 +42,16 @@ export function ReceivableSection({ data }: ReceivableSectionProps) {
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
{data.cards.map((card, idx) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={handleDetailClick}
|
||||
icon={CARD_ICONS[idx] || Banknote}
|
||||
colorTheme={CARD_THEMES[idx] || 'amber'}
|
||||
showTrend={!!card.previousLabel}
|
||||
trendValue={card.previousLabel}
|
||||
trendDirection={idx === 3 ? 'down' : 'up'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,6 @@ export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionPr
|
||||
subLabel={item.subLabel}
|
||||
isHighlighted={item.isHighlighted}
|
||||
onClick={() => handleItemClick(item.path)}
|
||||
icon={item.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,17 +13,51 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ShoppingCart,
|
||||
AlertCircle,
|
||||
Package,
|
||||
Receipt,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
Building2,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Info,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { TodayIssueListItem, TodayIssueListBadgeType } from '../types';
|
||||
|
||||
// 뱃지 색상 매핑 (API TodayIssue 모델과 동기화)
|
||||
// 뱃지 스타일 매핑 (아이콘 + 색상)
|
||||
interface BadgeStyle {
|
||||
bg: string;
|
||||
text: string;
|
||||
iconBg: string;
|
||||
Icon: LucideIcon;
|
||||
}
|
||||
|
||||
const BADGE_STYLES: Record<TodayIssueListBadgeType, BadgeStyle> = {
|
||||
'수주 성공': { bg: 'bg-blue-50', text: 'text-blue-700', iconBg: 'bg-blue-500', Icon: ShoppingCart },
|
||||
'추심 이슈': { bg: 'bg-purple-50', text: 'text-purple-700', iconBg: 'bg-purple-500', Icon: AlertCircle },
|
||||
'적정 재고': { bg: 'bg-orange-50', text: 'text-orange-700', iconBg: 'bg-orange-500', Icon: Package },
|
||||
'지출예상내역서': { bg: 'bg-green-50', text: 'text-green-700', iconBg: 'bg-green-500', Icon: Receipt },
|
||||
'세금 신고': { bg: 'bg-red-50', text: 'text-red-700', iconBg: 'bg-red-500', Icon: FileText },
|
||||
'결재 요청': { bg: 'bg-amber-50', text: 'text-amber-700', iconBg: 'bg-amber-500', Icon: CheckCircle2 },
|
||||
'신규거래처': { bg: 'bg-emerald-50', text: 'text-emerald-700', iconBg: 'bg-emerald-500', Icon: Building2 },
|
||||
'입금': { bg: 'bg-cyan-50', text: 'text-cyan-700', iconBg: 'bg-cyan-500', Icon: TrendingUp },
|
||||
'출금': { bg: 'bg-pink-50', text: 'text-pink-700', iconBg: 'bg-pink-500', Icon: TrendingDown },
|
||||
'기타': { bg: 'bg-gray-50', text: 'text-gray-700', iconBg: 'bg-gray-500', Icon: Info },
|
||||
};
|
||||
|
||||
// 기존 호환용 뱃지 색상 (legacy)
|
||||
const BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
|
||||
'수주등록': 'bg-blue-100 text-blue-700 hover:bg-blue-100',
|
||||
'수주 성공': 'bg-blue-100 text-blue-700 hover:bg-blue-100',
|
||||
'추심 이슈': 'bg-purple-100 text-purple-700 hover:bg-purple-100',
|
||||
'안전재고': 'bg-orange-100 text-orange-700 hover:bg-orange-100',
|
||||
'지출승인': 'bg-green-100 text-green-700 hover:bg-green-100',
|
||||
'적정 재고': 'bg-orange-100 text-orange-700 hover:bg-orange-100',
|
||||
'지출예상내역서': 'bg-green-100 text-green-700 hover:bg-green-100',
|
||||
'세금 신고': 'bg-red-100 text-red-700 hover:bg-red-100',
|
||||
'결재 요청': 'bg-yellow-100 text-yellow-700 hover:bg-yellow-100',
|
||||
'신규업체': 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100',
|
||||
'신규거래처': 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100',
|
||||
'입금': 'bg-cyan-100 text-cyan-700 hover:bg-cyan-100',
|
||||
'출금': 'bg-pink-100 text-pink-700 hover:bg-pink-100',
|
||||
'기타': 'bg-gray-100 text-gray-700 hover:bg-gray-100',
|
||||
@@ -47,13 +81,13 @@ const getRandomCreditRating = (): CreditRating => {
|
||||
// 필터 옵션 키 (API TodayIssue 모델과 동기화)
|
||||
const FILTER_KEYS = [
|
||||
'all',
|
||||
'수주등록',
|
||||
'수주 성공',
|
||||
'추심 이슈',
|
||||
'안전재고',
|
||||
'지출승인',
|
||||
'적정 재고',
|
||||
'지출예상내역서',
|
||||
'세금 신고',
|
||||
'결재 요청',
|
||||
'신규업체',
|
||||
'신규거래처',
|
||||
'입금',
|
||||
'출금',
|
||||
'기타',
|
||||
@@ -61,7 +95,7 @@ const FILTER_KEYS = [
|
||||
|
||||
// badge를 필터 키로 변환 (정의되지 않은 타입은 기타로)
|
||||
const getFilterKey = (badge: string): string => {
|
||||
const knownBadges = ['수주등록', '추심이슈', '안전재고', '지출승인', '세금신고', '결재요청', '신규업체', '입금', '출금'];
|
||||
const knownBadges = ['수주 성공', '추심 이슈', '적정 재고', '지출예상내역서', '세금 신고', '결재 요청', '신규거래처', '입금', '출금'];
|
||||
return knownBadges.includes(badge) ? badge : '기타';
|
||||
};
|
||||
|
||||
@@ -81,7 +115,7 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
|
||||
const creditRatings = useMemo(() => {
|
||||
const ratings: Record<string, CreditRating> = {};
|
||||
items.forEach((item) => {
|
||||
if (item.badge === '신규업체') {
|
||||
if (item.badge === '신규거래처') {
|
||||
ratings[item.id] = getRandomCreditRating();
|
||||
}
|
||||
});
|
||||
@@ -170,19 +204,25 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
|
||||
표시할 이슈가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item) => (
|
||||
filteredItems.map((item) => {
|
||||
const badgeStyle = BADGE_STYLES[item.badge as TodayIssueListBadgeType] || BADGE_STYLES['기타'];
|
||||
const BadgeIcon = badgeStyle.Icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
className={`flex items-center gap-2 p-3 border rounded-lg hover:shadow-md transition-all cursor-pointer ${badgeStyle.bg} border-transparent hover:border-gray-200`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
{/* 뱃지 (입금/출금 등은 기타 색상 사용) */}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`shrink-0 text-xs ${BADGE_COLORS[item.badge as TodayIssueListBadgeType] || BADGE_COLORS['기타']}`}
|
||||
>
|
||||
{/* 아이콘 + 뱃지 */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<div className={`p-1 rounded ${badgeStyle.iconBg}`}>
|
||||
<BadgeIcon className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<span className={`text-xs font-semibold ${badgeStyle.text}`}>
|
||||
{item.badge}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<span className="text-sm text-gray-800 truncate flex-1 min-w-0">
|
||||
@@ -246,7 +286,8 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Heart, Gift, Coffee, Smile } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem, type SectionColorTheme } from '../components';
|
||||
import type { WelfareData } from '../types';
|
||||
|
||||
// 카드별 아이콘 매핑
|
||||
const CARD_ICONS = [Heart, Gift, Coffee, Smile];
|
||||
const CARD_THEMES: SectionColorTheme[] = ['emerald', 'green', 'cyan', 'blue'];
|
||||
|
||||
interface WelfareSectionProps {
|
||||
data: WelfareData;
|
||||
onCardClick?: (cardId: string) => void;
|
||||
@@ -13,14 +18,24 @@ export function WelfareSection({ data, onCardClick }: WelfareSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="복리후생비 현황" badge="info" />
|
||||
<SectionTitle
|
||||
title="복리후생비 현황"
|
||||
badge="info"
|
||||
icon={Heart}
|
||||
colorTheme="emerald"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
{data.cards.map((card, idx) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={onCardClick ? () => onCardClick(card.id) : undefined}
|
||||
icon={CARD_ICONS[idx] || Heart}
|
||||
colorTheme={CARD_THEMES[idx] || 'emerald'}
|
||||
showTrend={!!card.previousLabel}
|
||||
trendValue={card.previousLabel}
|
||||
trendDirection="up"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,3 +9,10 @@ export { ReceivableSection } from './ReceivableSection';
|
||||
export { DebtCollectionSection } from './DebtCollectionSection';
|
||||
export { VatSection } from './VatSection';
|
||||
export { CalendarSection } from './CalendarSection';
|
||||
|
||||
// Enhanced Sections (디자인 강화 버전)
|
||||
export {
|
||||
EnhancedDailyReportSection,
|
||||
EnhancedStatusBoardSection,
|
||||
EnhancedMonthlyExpenseSection,
|
||||
} from './EnhancedSections';
|
||||
@@ -56,15 +56,15 @@ export interface TodayIssueItem {
|
||||
icon?: React.ComponentType<{ className?: string }>; // 카드 아이콘
|
||||
}
|
||||
|
||||
// 오늘의 이슈 뱃지 타입 (최대 4자, 띄어쓰기 없음 - API TodayIssue 모델과 동기화)
|
||||
// 오늘의 이슈 뱃지 타입 (API TodayIssue 모델과 동기화)
|
||||
export type TodayIssueListBadgeType =
|
||||
| '수주등록'
|
||||
| '수주 성공'
|
||||
| '추심 이슈'
|
||||
| '안전재고'
|
||||
| '지출승인'
|
||||
| '적정 재고'
|
||||
| '지출예상내역서'
|
||||
| '세금 신고'
|
||||
| '결재 요청'
|
||||
| '신규업체'
|
||||
| '신규거래처'
|
||||
| '입금'
|
||||
| '출금'
|
||||
| '기타';
|
||||
|
||||
@@ -20,7 +20,7 @@ export function PageLayout({ children, maxWidth = "full", versionInfo }: PageLay
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-3 md:p-6 pb-0 space-y-3 md:space-y-6 flex flex-col ${maxWidthClasses[maxWidth]} mx-auto w-full relative`}>
|
||||
<div className={`p-0 space-y-3 md:space-y-6 flex flex-col ${maxWidthClasses[maxWidth]} mx-auto w-full relative`}>
|
||||
{versionInfo && (
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
{versionInfo}
|
||||
|
||||
@@ -78,6 +78,16 @@ function renderViewValue(
|
||||
value: unknown,
|
||||
options: FieldOption[]
|
||||
): ReactNode {
|
||||
// custom 타입이면서 renderField가 있으면 view 모드로 렌더링
|
||||
if (field.type === 'custom' && field.renderField) {
|
||||
return field.renderField({
|
||||
value,
|
||||
onChange: () => {}, // view 모드에서는 사용 안됨
|
||||
mode: 'view',
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 커스텀 포맷터가 있으면 사용
|
||||
if (field.formatValue) {
|
||||
return field.formatValue(value);
|
||||
|
||||
@@ -91,6 +91,16 @@ function renderViewValue(
|
||||
value: unknown,
|
||||
options: FieldOption[]
|
||||
): ReactNode {
|
||||
// custom 타입이면서 renderField가 있으면 view 모드로 렌더링
|
||||
if (field.type === 'custom' && field.renderField) {
|
||||
return field.renderField({
|
||||
value,
|
||||
onChange: () => {}, // view 모드에서는 사용 안됨
|
||||
mode: 'view',
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 커스텀 포맷터가 있으면 사용
|
||||
if (field.formatValue) {
|
||||
return field.formatValue(value);
|
||||
|
||||
430
src/components/vehicle-management/ForkliftDetail/config.tsx
Normal file
430
src/components/vehicle-management/ForkliftDetail/config.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지게차 관리 상세 페이지 설정
|
||||
* 레거시 5130 사이트 등록 폼 기준 (2025-01-28 스크린샷 검증)
|
||||
*/
|
||||
|
||||
import { Truck } from 'lucide-react';
|
||||
import type {
|
||||
DetailConfig,
|
||||
FieldDefinition,
|
||||
SectionDefinition,
|
||||
} from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { Forklift, ForkliftFormData, PartsChangeRecord, ForkliftMaintenanceRecord } from '../types';
|
||||
import { EditableTable, EditableColumn } from '@/components/common/EditableTable';
|
||||
|
||||
// ===== 부속품 교환 기록 테이블 컬럼 =====
|
||||
const partsChangeColumns: EditableColumn<PartsChangeRecord>[] = [
|
||||
{
|
||||
key: 'date',
|
||||
header: '교환일',
|
||||
type: 'text',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
width: '140px',
|
||||
},
|
||||
{
|
||||
key: 'mileage',
|
||||
header: '주행거리',
|
||||
type: 'text',
|
||||
placeholder: '예: 5000',
|
||||
width: '120px',
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
header: '비용',
|
||||
type: 'text',
|
||||
placeholder: '예: 50,000',
|
||||
width: '120px',
|
||||
align: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 정비내역 테이블 컬럼 =====
|
||||
const maintenanceColumns: EditableColumn<ForkliftMaintenanceRecord>[] = [
|
||||
{
|
||||
key: 'date',
|
||||
header: '정비일자',
|
||||
type: 'text',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
width: '140px',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: '정비내역 기록',
|
||||
type: 'text',
|
||||
placeholder: '정비 내용 입력',
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
header: '비용',
|
||||
type: 'text',
|
||||
placeholder: '예: 150,000',
|
||||
width: '120px',
|
||||
align: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 새 행 생성 함수 =====
|
||||
let partsRecordIdCounter = 0;
|
||||
const createNewPartsRecord = (): PartsChangeRecord => ({
|
||||
id: `parts-${Date.now()}-${++partsRecordIdCounter}`,
|
||||
date: '',
|
||||
mileage: '',
|
||||
cost: '',
|
||||
});
|
||||
|
||||
let maintenanceRecordIdCounter = 0;
|
||||
const createNewMaintenanceRecord = (): ForkliftMaintenanceRecord => ({
|
||||
id: `maint-${Date.now()}-${++maintenanceRecordIdCounter}`,
|
||||
date: '',
|
||||
description: '',
|
||||
cost: '',
|
||||
});
|
||||
|
||||
// ===== 필드 정의 (5130 스크린샷 순서대로) =====
|
||||
export const forkliftFields: FieldDefinition[] = [
|
||||
// Row 1: 지게차번호, 차종, 구매유형
|
||||
{
|
||||
key: 'vehicleNumber',
|
||||
label: '지게차번호',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '지게차번호 입력',
|
||||
validation: [
|
||||
{ type: 'required', message: '지게차번호를 입력해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'vehicleType',
|
||||
label: '차종',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '예: 3톤 디젤',
|
||||
validation: [
|
||||
{ type: 'required', message: '차종을 입력해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'purchaseType',
|
||||
label: '구매유형',
|
||||
type: 'select',
|
||||
placeholder: '구매유형 선택',
|
||||
options: [
|
||||
{ label: '리스', value: '리스' },
|
||||
{ label: '렌트', value: '렌트' },
|
||||
{ label: '회사 소유', value: '회사 소유' },
|
||||
],
|
||||
},
|
||||
|
||||
// Row 2: 담당자(정), 담당자(부)
|
||||
{
|
||||
key: 'managerMain',
|
||||
label: '담당자(정)',
|
||||
type: 'text',
|
||||
placeholder: '담당자(정) 입력',
|
||||
},
|
||||
{
|
||||
key: 'managerSub',
|
||||
label: '담당자(부)',
|
||||
type: 'text',
|
||||
placeholder: '담당자(부) 입력',
|
||||
},
|
||||
|
||||
// Row 3: 구입업체, 구입업체 연락처
|
||||
{
|
||||
key: 'purchaseCompany',
|
||||
label: '구입업체',
|
||||
type: 'text',
|
||||
placeholder: '구입업체명 입력',
|
||||
},
|
||||
{
|
||||
key: 'purchaseCompanyContact',
|
||||
label: '구입업체 연락처',
|
||||
type: 'text',
|
||||
placeholder: '예: 031-123-4567',
|
||||
},
|
||||
|
||||
// Row 4: 총 주행거리, 기록일
|
||||
{
|
||||
key: 'totalMileage',
|
||||
label: '총 주행거리 (km)',
|
||||
type: 'text',
|
||||
placeholder: 'km',
|
||||
},
|
||||
{
|
||||
key: 'mileageRecordDate',
|
||||
label: '기록일',
|
||||
type: 'date',
|
||||
placeholder: '연도.월.일.',
|
||||
},
|
||||
|
||||
// Row 5: 최초등록일, 구매일자
|
||||
{
|
||||
key: 'firstRegistrationDate',
|
||||
label: '최초등록일',
|
||||
type: 'date',
|
||||
placeholder: '연도.월.일.',
|
||||
},
|
||||
{
|
||||
key: 'purchaseDate',
|
||||
label: '구매일자',
|
||||
type: 'date',
|
||||
placeholder: '연도.월.일.',
|
||||
},
|
||||
|
||||
// 부속품 교환주기, 부속품 교환 기록 테이블
|
||||
{
|
||||
key: 'partsChangeCycle',
|
||||
label: '부속품 교환주기(Km)',
|
||||
type: 'text',
|
||||
placeholder: 'Km',
|
||||
},
|
||||
{
|
||||
key: 'partsChangeRecords',
|
||||
label: '부속품 교환일',
|
||||
type: 'custom',
|
||||
gridSpan: 2,
|
||||
formatValue: (value: unknown) => {
|
||||
const records = (value as PartsChangeRecord[]) || [];
|
||||
if (records.length === 0) return '기록 없음';
|
||||
return records.map((record, index) =>
|
||||
`${index + 1}. ${record.date} / 주행거리: ${record.mileage}km / 비용: ${record.cost}원`
|
||||
).join('\n');
|
||||
},
|
||||
renderField: ({ value, onChange, mode }) => {
|
||||
const records = (value as PartsChangeRecord[]) || [];
|
||||
const isViewMode = mode === 'view';
|
||||
|
||||
if (isViewMode) {
|
||||
if (records.length === 0) {
|
||||
return <div className="text-muted-foreground">기록 없음</div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{records.map((record, index) => (
|
||||
<div key={record.id} className="text-sm">
|
||||
{index + 1}. {record.date} / 주행거리: {record.mileage}km / 비용: {record.cost}원
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditableTable
|
||||
columns={partsChangeColumns}
|
||||
data={records}
|
||||
onChange={(newData) => onChange(newData)}
|
||||
createNewRow={createNewPartsRecord}
|
||||
addButtonLabel="부속품 교환일 추가"
|
||||
emptyMessage="교환 기록이 없습니다. 기록을 추가해주세요."
|
||||
showRowNumber={true}
|
||||
compact={true}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// 정비내역 테이블
|
||||
{
|
||||
key: 'maintenanceRecords',
|
||||
label: '정비내역',
|
||||
type: 'custom',
|
||||
gridSpan: 2,
|
||||
formatValue: (value: unknown) => {
|
||||
const records = (value as ForkliftMaintenanceRecord[]) || [];
|
||||
if (records.length === 0) return '기록 없음';
|
||||
return records.map((record, index) =>
|
||||
`${index + 1}. ${record.date}: ${record.description} (비용: ${record.cost}원)`
|
||||
).join('\n');
|
||||
},
|
||||
renderField: ({ value, onChange, mode }) => {
|
||||
const records = (value as ForkliftMaintenanceRecord[]) || [];
|
||||
const isViewMode = mode === 'view';
|
||||
|
||||
if (isViewMode) {
|
||||
if (records.length === 0) {
|
||||
return <div className="text-muted-foreground">기록 없음</div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{records.map((record, index) => (
|
||||
<div key={record.id} className="text-sm">
|
||||
{index + 1}. {record.date}: {record.description} (비용: {record.cost}원)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditableTable
|
||||
columns={maintenanceColumns}
|
||||
data={records}
|
||||
onChange={(newData) => onChange(newData)}
|
||||
createNewRow={createNewMaintenanceRecord}
|
||||
addButtonLabel="정비내역 추가"
|
||||
emptyMessage="정비 기록이 없습니다. 기록을 추가해주세요."
|
||||
showRowNumber={true}
|
||||
compact={true}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// 비고
|
||||
{
|
||||
key: 'remarks',
|
||||
label: '비고',
|
||||
type: 'textarea',
|
||||
placeholder: '비고 입력',
|
||||
gridSpan: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 섹션 정의 (5130 스크린샷 순서대로) =====
|
||||
export const forkliftSections: SectionDefinition[] = [
|
||||
{
|
||||
id: 'basicInfo',
|
||||
title: '기본 정보',
|
||||
description: '지게차의 기본 정보를 입력하세요',
|
||||
fields: ['vehicleNumber', 'vehicleType', 'purchaseType'],
|
||||
},
|
||||
{
|
||||
id: 'managerInfo',
|
||||
title: '담당자 정보',
|
||||
description: '담당자 정보를 입력하세요',
|
||||
fields: ['managerMain', 'managerSub'],
|
||||
},
|
||||
{
|
||||
id: 'purchaseCompanyInfo',
|
||||
title: '구입업체 정보',
|
||||
description: '구입업체 관련 정보를 입력하세요',
|
||||
fields: ['purchaseCompany', 'purchaseCompanyContact'],
|
||||
},
|
||||
{
|
||||
id: 'mileageInfo',
|
||||
title: '주행거리 정보',
|
||||
description: '주행거리 관련 정보를 입력하세요',
|
||||
fields: ['totalMileage', 'mileageRecordDate'],
|
||||
},
|
||||
{
|
||||
id: 'purchaseInfo',
|
||||
title: '등록/구매 정보',
|
||||
description: '등록 및 구매 관련 정보를 입력하세요',
|
||||
fields: ['firstRegistrationDate', 'purchaseDate'],
|
||||
},
|
||||
{
|
||||
id: 'partsInfo',
|
||||
title: '부속품 교환',
|
||||
description: '부속품 교환 관련 정보를 입력하세요',
|
||||
fields: ['partsChangeCycle', 'partsChangeRecords'],
|
||||
},
|
||||
{
|
||||
id: 'maintenanceSection',
|
||||
title: '정비내역',
|
||||
description: '정비 관련 정보를 입력하세요',
|
||||
fields: ['maintenanceRecords'],
|
||||
},
|
||||
{
|
||||
id: 'remarksSection',
|
||||
title: '비고',
|
||||
description: '기타 정보를 입력하세요',
|
||||
fields: ['remarks'],
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 설정 =====
|
||||
export const forkliftDetailConfig: DetailConfig<Forklift> = {
|
||||
title: '지게차',
|
||||
description: '지게차 정보를 관리합니다',
|
||||
icon: Truck,
|
||||
basePath: '/ko/vehicle-management/forklift',
|
||||
fields: forkliftFields,
|
||||
sections: forkliftSections,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
submitLabel: '저장',
|
||||
cancelLabel: '취소',
|
||||
showDelete: true,
|
||||
deleteLabel: '삭제',
|
||||
showEdit: true,
|
||||
editLabel: '수정',
|
||||
showBack: true,
|
||||
backLabel: '목록',
|
||||
deleteConfirmMessage: {
|
||||
title: '지게차 삭제',
|
||||
description: '이 지게차를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
transformInitialData: (data: Forklift) => ({
|
||||
vehicleNumber: data.vehicleNumber || '',
|
||||
vehicleType: data.vehicleType || '',
|
||||
purchaseType: data.purchaseType || '',
|
||||
managerMain: data.managerMain || '',
|
||||
managerSub: data.managerSub || '',
|
||||
purchaseCompany: data.purchaseCompany || '',
|
||||
purchaseCompanyContact: data.purchaseCompanyContact || '',
|
||||
totalMileage: data.totalMileage || '',
|
||||
mileageRecordDate: data.mileageRecordDate || '',
|
||||
firstRegistrationDate: data.firstRegistrationDate || '',
|
||||
purchaseDate: data.purchaseDate || '',
|
||||
partsChangeCycle: data.partsChangeCycle || '',
|
||||
partsChangeRecords: data.partsChangeRecords || [],
|
||||
maintenanceRecords: data.maintenanceRecords || [],
|
||||
remarks: data.remarks || '',
|
||||
}),
|
||||
transformSubmitData: (formData): Partial<ForkliftFormData> => ({
|
||||
vehicleNumber: formData.vehicleNumber as string,
|
||||
vehicleType: formData.vehicleType as string,
|
||||
purchaseType: formData.purchaseType as string,
|
||||
managerMain: formData.managerMain as string,
|
||||
managerSub: formData.managerSub as string,
|
||||
purchaseCompany: formData.purchaseCompany as string,
|
||||
purchaseCompanyContact: formData.purchaseCompanyContact as string,
|
||||
totalMileage: formData.totalMileage as string,
|
||||
mileageRecordDate: formData.mileageRecordDate as string,
|
||||
firstRegistrationDate: formData.firstRegistrationDate as string,
|
||||
purchaseDate: formData.purchaseDate as string,
|
||||
partsChangeCycle: formData.partsChangeCycle as string,
|
||||
partsChangeRecords: formData.partsChangeRecords as PartsChangeRecord[],
|
||||
maintenanceRecords: formData.maintenanceRecords as ForkliftMaintenanceRecord[],
|
||||
remarks: formData.remarks as string,
|
||||
}),
|
||||
};
|
||||
|
||||
// ===== 등록 페이지 Config =====
|
||||
export const forkliftCreateConfig: DetailConfig = {
|
||||
title: '지게차',
|
||||
description: '지게차 정보를 입력하세요',
|
||||
icon: Truck,
|
||||
basePath: '/vehicle-management/forklift',
|
||||
fields: forkliftFields,
|
||||
sections: forkliftSections,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '닫기',
|
||||
},
|
||||
};
|
||||
|
||||
// ===== 수정 페이지 Config =====
|
||||
export const forkliftEditConfig: DetailConfig = {
|
||||
title: '지게차',
|
||||
description: '지게차 정보를 수정합니다',
|
||||
icon: Truck,
|
||||
basePath: '/vehicle-management/forklift',
|
||||
fields: forkliftFields,
|
||||
sections: forkliftSections,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '닫기',
|
||||
},
|
||||
};
|
||||
116
src/components/vehicle-management/ForkliftDetail/index.tsx
Normal file
116
src/components/vehicle-management/ForkliftDetail/index.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지게차 관리 상세/등록/수정 컴포넌트
|
||||
*/
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import type { Forklift, ForkliftFormData } from '../types';
|
||||
import { forkliftDetailConfig, forkliftCreateConfig, forkliftEditConfig } from './config';
|
||||
import {
|
||||
createForklift,
|
||||
updateForklift,
|
||||
deleteForklift,
|
||||
} from '../ForkliftList/actions';
|
||||
|
||||
interface ForkliftDetailProps {
|
||||
mode: 'create' | 'view' | 'edit';
|
||||
initialData?: Forklift;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function ForkliftDetail({ mode, initialData, id }: ForkliftDetailProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const getConfig = () => {
|
||||
switch (mode) {
|
||||
case 'create':
|
||||
return forkliftCreateConfig;
|
||||
case 'edit':
|
||||
return forkliftEditConfig;
|
||||
default:
|
||||
return forkliftDetailConfig;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (formData: Record<string, unknown>) => {
|
||||
const forkliftData = forkliftDetailConfig.transformSubmitData?.(formData) as Partial<ForkliftFormData>;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
const result = await createForklift(forkliftData);
|
||||
if (result.success) {
|
||||
toast.success('지게차가 등록되었습니다.');
|
||||
router.push('/vehicle-management/forklift');
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
}
|
||||
} else if (mode === 'edit' && id) {
|
||||
const result = await updateForklift(id, forkliftData);
|
||||
if (result.success) {
|
||||
toast.success('지게차 정보가 수정되었습니다.');
|
||||
router.push(`/vehicle-management/forklift/${id}`);
|
||||
} else {
|
||||
toast.error(result.error || '수정에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteForklift(id);
|
||||
if (result.success) {
|
||||
toast.success('지게차가 삭제되었습니다.');
|
||||
router.push('/vehicle-management/forklift');
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (id) {
|
||||
router.push(`/vehicle-management/forklift/${id}/edit`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.push('/vehicle-management/forklift');
|
||||
};
|
||||
|
||||
const transformedData = initialData && forkliftDetailConfig.transformInitialData
|
||||
? forkliftDetailConfig.transformInitialData(initialData)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={getConfig()}
|
||||
mode={mode}
|
||||
initialData={transformedData}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onBack={handleBack}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForkliftDetail;
|
||||
234
src/components/vehicle-management/ForkliftList/actions.ts
Normal file
234
src/components/vehicle-management/ForkliftList/actions.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* 지게차 관리 서버 액션
|
||||
* 레거시 5130 사이트 등록 폼 기준 (2025-01-28 스크린샷 검증)
|
||||
*/
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { Forklift, ForkliftFormData, ActionResponse, ListResponse } from '../types';
|
||||
|
||||
// ===== Mock 데이터 (5130 레거시 기준) =====
|
||||
const mockForklifts: Forklift[] = [
|
||||
{
|
||||
id: '1',
|
||||
vehicleNumber: '경기85사1234',
|
||||
vehicleType: '3톤 디젤',
|
||||
purchaseType: '회사 소유',
|
||||
managerMain: '김지게',
|
||||
managerSub: '이지게',
|
||||
purchaseCompany: '두산산업차량',
|
||||
purchaseCompanyContact: '031-123-4567',
|
||||
totalMileage: '15000',
|
||||
mileageRecordDate: '2025-01-15',
|
||||
firstRegistrationDate: '2020-06-15',
|
||||
purchaseDate: '2020-06-15',
|
||||
partsChangeCycle: '500',
|
||||
partsChangeRecords: [
|
||||
{ id: 'p1', date: '2024-12-01', mileage: '14500', cost: '150000' },
|
||||
{ id: 'p2', date: '2024-06-15', mileage: '12000', cost: '120000' },
|
||||
],
|
||||
maintenanceRecords: [
|
||||
{ id: 'm1', date: '2024-12-15', description: '정기점검', cost: '50000' },
|
||||
{ id: 'm2', date: '2024-10-01', description: '유압오일 교환', cost: '80000' },
|
||||
],
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
vehicleNumber: '경기85사5678',
|
||||
vehicleType: '2.5톤 전동',
|
||||
purchaseType: '렌트',
|
||||
managerMain: '박지게',
|
||||
managerSub: '',
|
||||
purchaseCompany: '현대건설기계',
|
||||
purchaseCompanyContact: '032-456-7890',
|
||||
totalMileage: '8000',
|
||||
mileageRecordDate: '2025-01-10',
|
||||
firstRegistrationDate: '2021-03-20',
|
||||
purchaseDate: '2023-03-20',
|
||||
partsChangeCycle: '400',
|
||||
partsChangeRecords: [
|
||||
{ id: 'p3', date: '2025-01-15', mileage: '8000', cost: '200000' },
|
||||
],
|
||||
maintenanceRecords: [
|
||||
{ id: 'm3', date: '2025-01-10', description: '배터리 점검', cost: '30000' },
|
||||
],
|
||||
remarks: '2025년 6월 렌트 만료',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
vehicleNumber: '인천12가3456',
|
||||
vehicleType: '1.5톤 LPG',
|
||||
purchaseType: '리스',
|
||||
managerMain: '최지게',
|
||||
managerSub: '강지게',
|
||||
purchaseCompany: '클라크코리아',
|
||||
purchaseCompanyContact: '02-789-0123',
|
||||
totalMileage: '3000',
|
||||
mileageRecordDate: '2025-01-20',
|
||||
firstRegistrationDate: '2024-01-10',
|
||||
purchaseDate: '2024-01-10',
|
||||
partsChangeCycle: '500',
|
||||
partsChangeRecords: [],
|
||||
maintenanceRecords: [],
|
||||
remarks: '신규 도입',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 지게차 목록 조회 =====
|
||||
export async function getForklifts(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
}): Promise<ListResponse<Forklift>> {
|
||||
try {
|
||||
let filteredData = [...mockForklifts];
|
||||
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filteredData = filteredData.filter(
|
||||
(f) =>
|
||||
f.vehicleNumber.toLowerCase().includes(search) ||
|
||||
f.vehicleType.toLowerCase().includes(search) ||
|
||||
f.managerMain.toLowerCase().includes(search) ||
|
||||
f.managerSub.toLowerCase().includes(search) ||
|
||||
f.purchaseCompany.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: filteredData,
|
||||
totalCount: filteredData.length,
|
||||
totalPages: 1,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ForkliftActions] getForklifts error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
totalPages: 0,
|
||||
error: '지게차 목록 조회 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 지게차 단건 조회 =====
|
||||
export async function getForkliftById(id: string): Promise<ActionResponse<Forklift>> {
|
||||
try {
|
||||
const forklift = mockForklifts.find((f) => f.id === id);
|
||||
if (!forklift) {
|
||||
return { success: false, error: '지게차를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true, data: forklift };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ForkliftActions] getForkliftById error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '지게차 조회 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 지게차 생성 =====
|
||||
export async function createForklift(
|
||||
formData: Partial<ForkliftFormData>
|
||||
): Promise<ActionResponse<Forklift>> {
|
||||
try {
|
||||
const newForklift: Forklift = {
|
||||
id: String(Date.now()),
|
||||
vehicleNumber: formData.vehicleNumber || '',
|
||||
vehicleType: formData.vehicleType || '',
|
||||
purchaseType: formData.purchaseType || '',
|
||||
managerMain: formData.managerMain || '',
|
||||
managerSub: formData.managerSub || '',
|
||||
purchaseCompany: formData.purchaseCompany || '',
|
||||
purchaseCompanyContact: formData.purchaseCompanyContact || '',
|
||||
totalMileage: formData.totalMileage || '',
|
||||
mileageRecordDate: formData.mileageRecordDate || '',
|
||||
firstRegistrationDate: formData.firstRegistrationDate || '',
|
||||
purchaseDate: formData.purchaseDate || '',
|
||||
partsChangeCycle: formData.partsChangeCycle || '',
|
||||
partsChangeRecords: formData.partsChangeRecords || [],
|
||||
maintenanceRecords: formData.maintenanceRecords || [],
|
||||
remarks: formData.remarks || '',
|
||||
};
|
||||
mockForklifts.push(newForklift);
|
||||
return { success: true, data: newForklift };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ForkliftActions] createForklift error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '지게차 등록 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 지게차 수정 =====
|
||||
export async function updateForklift(
|
||||
id: string,
|
||||
formData: Partial<ForkliftFormData>
|
||||
): Promise<ActionResponse<Forklift>> {
|
||||
try {
|
||||
const index = mockForklifts.findIndex((f) => f.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '지게차를 찾을 수 없습니다.' };
|
||||
}
|
||||
mockForklifts[index] = {
|
||||
...mockForklifts[index],
|
||||
...formData,
|
||||
};
|
||||
return { success: true, data: mockForklifts[index] };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ForkliftActions] updateForklift error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '지게차 수정 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 지게차 삭제 =====
|
||||
export async function deleteForklift(id: string): Promise<ActionResponse> {
|
||||
try {
|
||||
const index = mockForklifts.findIndex((f) => f.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '지게차를 찾을 수 없습니다.' };
|
||||
}
|
||||
mockForklifts.splice(index, 1);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ForkliftActions] deleteForklift error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '지게차 삭제 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 지게차 일괄 삭제 =====
|
||||
export async function bulkDeleteForklifts(ids: string[]): Promise<ActionResponse> {
|
||||
try {
|
||||
ids.forEach((id) => {
|
||||
const index = mockForklifts.findIndex((f) => f.id === id);
|
||||
if (index !== -1) {
|
||||
mockForklifts.splice(index, 1);
|
||||
}
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ForkliftActions] bulkDeleteForklifts error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '지게차 일괄 삭제 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
369
src/components/vehicle-management/ForkliftList/index.tsx
Normal file
369
src/components/vehicle-management/ForkliftList/index.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지게차 관리 리스트 - UniversalListPage 기반
|
||||
* 레거시 5130 사이트 컬럼 구조 기반
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Truck, Edit, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type ListParams,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type { Forklift } from '../types';
|
||||
import { getForklifts, deleteForklift, bulkDeleteForklifts } from './actions';
|
||||
|
||||
interface ForkliftListProps {
|
||||
initialData: Forklift[];
|
||||
}
|
||||
|
||||
export function ForkliftList({ initialData }: ForkliftListProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
|
||||
const [allData, setAllData] = useState<Forklift[]>(initialData);
|
||||
|
||||
const handleView = useCallback(
|
||||
(forklift: Forklift) => {
|
||||
router.push(`/vehicle-management/forklift/${forklift.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(forklift: Forklift) => {
|
||||
router.push(`/vehicle-management/forklift/${forklift.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((id: string) => {
|
||||
setDeleteTargetId(id);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await deleteForklift(deleteTargetId);
|
||||
|
||||
if (result.success) {
|
||||
const forklift = allData.find((f) => f.id === deleteTargetId);
|
||||
setAllData(allData.filter((f) => f.id !== deleteTargetId));
|
||||
toast.success(`지게차가 삭제되었습니다${forklift ? `: ${forklift.vehicleNumber}` : ''}`);
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
});
|
||||
}, [deleteTargetId, allData]);
|
||||
|
||||
const handleBulkDelete = useCallback((selectedIds: string[]) => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error('삭제할 항목을 선택해주세요');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteIds(selectedIds);
|
||||
setIsBulkDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmBulkDelete = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
const result = await bulkDeleteForklifts(bulkDeleteIds);
|
||||
|
||||
if (result.success) {
|
||||
setAllData(allData.filter((f) => !bulkDeleteIds.includes(f.id)));
|
||||
toast.success(`${bulkDeleteIds.length}개의 지게차가 삭제되었습니다`);
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
setBulkDeleteIds([]);
|
||||
});
|
||||
}, [bulkDeleteIds, allData]);
|
||||
|
||||
const config: UniversalListConfig<Forklift> = useMemo(
|
||||
() => ({
|
||||
title: '지게차 관리',
|
||||
description: '지게차 정보를 관리합니다',
|
||||
icon: Truck,
|
||||
basePath: '/vehicle-management/forklift',
|
||||
|
||||
idField: 'id',
|
||||
getItemId: (item: Forklift) => item.id,
|
||||
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
const result = await getForklifts({
|
||||
page: params?.page || 1,
|
||||
perPage: params?.perPage || 20,
|
||||
search: params?.search || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setAllData(result.data);
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.totalCount,
|
||||
totalPages: result.totalPages,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error || '데이터 조회에 실패했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteForklift(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await bulkDeleteForklifts(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 레거시 컬럼 구조 (5130 기준) - 13개 컬럼
|
||||
columns: [
|
||||
{ key: 'rowNumber', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'vehicleNumber', label: '차량번호', className: 'min-w-[100px]' },
|
||||
{ key: 'vehicleType', label: '차종', className: 'w-[90px]' },
|
||||
{ key: 'manager', label: '담당자', className: 'w-[80px]' },
|
||||
{ key: 'purchaseCompany', label: '구입업체', className: 'w-[90px]' },
|
||||
{ key: 'purchaseCompanyContact', label: '구입업체 연락처', className: 'w-[110px]' },
|
||||
{ key: 'firstRegistrationDate', label: '최초등록일', className: 'w-[90px]' },
|
||||
{ key: 'purchaseDate', label: '구매일자', className: 'w-[90px]' },
|
||||
{ key: 'purchaseType', label: '구매 유형', className: 'w-[80px]' },
|
||||
{ key: 'partsChangeCycle', label: '부속품 교환 주기', className: 'w-[100px]' },
|
||||
{ key: 'partsChangeRecords', label: '부속품 교환일', className: 'min-w-[150px]' },
|
||||
{ key: 'maintenanceRecords', label: '정비 정보', className: 'min-w-[150px]' },
|
||||
{ key: 'remarks', label: '비고', className: 'min-w-[150px]' },
|
||||
],
|
||||
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
searchFilter: (item: Forklift, searchValue: string) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.vehicleNumber.toLowerCase().includes(search) ||
|
||||
item.vehicleType.toLowerCase().includes(search) ||
|
||||
item.managerMain.toLowerCase().includes(search) ||
|
||||
item.managerSub.toLowerCase().includes(search) ||
|
||||
item.purchaseCompany.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
tabs: [],
|
||||
searchPlaceholder: '지게차번호, 차종, 담당자, 구입업체 검색...',
|
||||
|
||||
headerActions: () => (
|
||||
<Button
|
||||
className="ml-auto"
|
||||
onClick={() => router.push('/vehicle-management/forklift/new')}
|
||||
>
|
||||
<Truck className="w-4 h-4 mr-2" />
|
||||
지게차 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
onBulkDelete: handleBulkDelete,
|
||||
|
||||
renderTableRow: (
|
||||
forklift: Forklift,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Forklift>
|
||||
) => {
|
||||
// 부속품 교환일 포맷
|
||||
const partsChangeText = forklift.partsChangeRecords?.length > 0
|
||||
? forklift.partsChangeRecords.map(r =>
|
||||
`${r.date}, 주행거리 : ${r.mileage || ''} km`
|
||||
).join('\n')
|
||||
: '정보 없음';
|
||||
|
||||
// 정비내역 포맷
|
||||
const maintenanceText = forklift.maintenanceRecords?.length > 0
|
||||
? forklift.maintenanceRecords.map(r =>
|
||||
`${r.date}: ${r.description}`
|
||||
).join('\n')
|
||||
: '정보 없음';
|
||||
|
||||
// 담당자 (정/부 합침)
|
||||
const managerText = [forklift.managerMain, forklift.managerSub]
|
||||
.filter(Boolean)
|
||||
.join(' / ') || '-';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={forklift.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(forklift)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={handlers.onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{forklift.vehicleNumber}</TableCell>
|
||||
<TableCell>{forklift.vehicleType}</TableCell>
|
||||
<TableCell>{managerText}</TableCell>
|
||||
<TableCell>{forklift.purchaseCompany || '-'}</TableCell>
|
||||
<TableCell>{forklift.purchaseCompanyContact || '-'}</TableCell>
|
||||
<TableCell>{forklift.firstRegistrationDate || '-'}</TableCell>
|
||||
<TableCell>{forklift.purchaseDate || '-'}</TableCell>
|
||||
<TableCell>{forklift.purchaseType || '-'}</TableCell>
|
||||
<TableCell>{forklift.partsChangeCycle || '정보 없음'}</TableCell>
|
||||
<TableCell className="whitespace-pre-line text-xs">{partsChangeText}</TableCell>
|
||||
<TableCell className="whitespace-pre-line text-xs">{maintenanceText}</TableCell>
|
||||
<TableCell className="whitespace-pre-line text-xs">{forklift.remarks || '-'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
renderMobileCard: (
|
||||
forklift: Forklift,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Forklift>
|
||||
) => {
|
||||
// 담당자 (정/부 합침)
|
||||
const managerText = [forklift.managerMain, forklift.managerSub]
|
||||
.filter(Boolean)
|
||||
.join(' / ') || '-';
|
||||
|
||||
// 부속품 교환일 포맷
|
||||
const partsChangeText = forklift.partsChangeRecords?.length > 0
|
||||
? forklift.partsChangeRecords.map(r => `${r.date}`).join(', ')
|
||||
: '정보 없음';
|
||||
|
||||
// 정비내역 포맷
|
||||
const maintenanceText = forklift.maintenanceRecords?.length > 0
|
||||
? forklift.maintenanceRecords.map(r => `${r.date}: ${r.description}`).join('\n')
|
||||
: '정보 없음';
|
||||
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={forklift.id}
|
||||
id={forklift.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
onClick={() => handleView(forklift)}
|
||||
title={forklift.vehicleNumber}
|
||||
subtitle={`${forklift.vehicleType} (${forklift.purchaseType || '-'})`}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="차종" value={forklift.vehicleType} />
|
||||
<InfoField label="담당자" value={managerText} />
|
||||
<InfoField label="구입업체" value={forklift.purchaseCompany || '-'} />
|
||||
<InfoField label="구입업체 연락처" value={forklift.purchaseCompanyContact || '-'} />
|
||||
<InfoField label="최초등록일" value={forklift.firstRegistrationDate || '-'} />
|
||||
<InfoField label="구매일자" value={forklift.purchaseDate || '-'} />
|
||||
<InfoField label="구매 유형" value={forklift.purchaseType || '-'} />
|
||||
<InfoField label="부속품 교환 주기" value={forklift.partsChangeCycle || '정보 없음'} />
|
||||
<InfoField label="부속품 교환일" value={partsChangeText} className="col-span-2" />
|
||||
<InfoField label="정비 정보" value={maintenanceText} className="col-span-2 whitespace-pre-line" />
|
||||
{forklift.remarks && (
|
||||
<InfoField label="비고" value={forklift.remarks} className="col-span-2" />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
handlers.isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(forklift);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick(forklift.id);
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={initialData} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
description={
|
||||
<>
|
||||
{deleteTargetId
|
||||
? `차량번호: ${allData.find((f) => f.id === deleteTargetId)?.vehicleNumber || deleteTargetId}`
|
||||
: ''}
|
||||
<br />
|
||||
이 지게차를 삭제하시겠습니까?
|
||||
</>
|
||||
}
|
||||
loading={isPending}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={isBulkDeleteDialogOpen}
|
||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
||||
description={
|
||||
<>
|
||||
선택한 {bulkDeleteIds.length}개의 지게차를 삭제하시겠습니까?
|
||||
</>
|
||||
}
|
||||
loading={isPending}
|
||||
onConfirm={handleConfirmBulkDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForkliftList;
|
||||
478
src/components/vehicle-management/VehicleDetail/config.tsx
Normal file
478
src/components/vehicle-management/VehicleDetail/config.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량 관리 상세 페이지 설정
|
||||
* 레거시 5130 사이트 등록 폼 기준 (2025-01-28 스크린샷 검증)
|
||||
*/
|
||||
|
||||
import { Car } from 'lucide-react';
|
||||
import type {
|
||||
DetailConfig,
|
||||
FieldDefinition,
|
||||
SectionDefinition,
|
||||
} from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { Vehicle, VehicleFormData, OilChangeRecord, MaintenanceRecord } from '../types';
|
||||
import { EditableTable, EditableColumn } from '@/components/common/EditableTable';
|
||||
|
||||
// ===== 엔진오일 교환 기록 테이블 컬럼 =====
|
||||
const oilChangeColumns: EditableColumn<OilChangeRecord>[] = [
|
||||
{
|
||||
key: 'date',
|
||||
header: '교환일',
|
||||
type: 'text',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
width: '140px',
|
||||
},
|
||||
{
|
||||
key: 'mileage',
|
||||
header: '주행거리(km)',
|
||||
type: 'text',
|
||||
placeholder: '예: 92000',
|
||||
width: '140px',
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
header: '비용',
|
||||
type: 'text',
|
||||
placeholder: '예: 50,000',
|
||||
width: '120px',
|
||||
align: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 정비내역 테이블 컬럼 =====
|
||||
const maintenanceColumns: EditableColumn<MaintenanceRecord>[] = [
|
||||
{
|
||||
key: 'date',
|
||||
header: '정비일',
|
||||
type: 'text',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
width: '140px',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: '정비내역',
|
||||
type: 'text',
|
||||
placeholder: '정비 내용 입력',
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
header: '비용',
|
||||
type: 'text',
|
||||
placeholder: '예: 150,000',
|
||||
width: '120px',
|
||||
align: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 새 행 생성 함수 =====
|
||||
let oilRecordIdCounter = 0;
|
||||
const createNewOilRecord = (): OilChangeRecord => ({
|
||||
id: `oil-${Date.now()}-${++oilRecordIdCounter}`,
|
||||
date: '',
|
||||
mileage: '',
|
||||
cost: '',
|
||||
});
|
||||
|
||||
let maintenanceRecordIdCounter = 0;
|
||||
const createNewMaintenanceRecord = (): MaintenanceRecord => ({
|
||||
id: `maint-${Date.now()}-${++maintenanceRecordIdCounter}`,
|
||||
date: '',
|
||||
description: '',
|
||||
cost: '',
|
||||
});
|
||||
|
||||
// ===== 필드 정의 (스크린샷 순서대로) =====
|
||||
export const vehicleFields: FieldDefinition[] = [
|
||||
// ===== Row 1: 차량번호, 차종, 구매유형, 최초등록/계약일 =====
|
||||
{
|
||||
key: 'vehicleNumber',
|
||||
label: '차량번호',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '차량번호 입력',
|
||||
validation: [
|
||||
{ type: 'required', message: '차량번호를 입력해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'vehicleType',
|
||||
label: '차종',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '차종 입력',
|
||||
validation: [
|
||||
{ type: 'required', message: '차종을 입력해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'purchaseType',
|
||||
label: '구매유형',
|
||||
type: 'select',
|
||||
placeholder: '구매유형 선택',
|
||||
options: [
|
||||
{ label: '회사 소유', value: '회사 소유' },
|
||||
{ label: '렌트', value: '렌트' },
|
||||
{ label: '리스', value: '리스' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'firstRegistrationDate',
|
||||
label: '최초등록/계약일',
|
||||
type: 'date',
|
||||
placeholder: '연도.월.일.',
|
||||
},
|
||||
|
||||
// ===== Row 2: 담당자(정), 담당자(부), 구매일자, 리스/렌트 종료일 =====
|
||||
{
|
||||
key: 'managerMain',
|
||||
label: '담당자(정)',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '담당자(정) 입력',
|
||||
validation: [
|
||||
{ type: 'required', message: '담당자(정)를 입력해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'managerSub',
|
||||
label: '담당자(부)',
|
||||
type: 'text',
|
||||
placeholder: '담당자(부) 입력',
|
||||
},
|
||||
{
|
||||
key: 'purchaseDate',
|
||||
label: '구매일자',
|
||||
type: 'date',
|
||||
placeholder: '연도.월.일.',
|
||||
},
|
||||
{
|
||||
key: 'leaseEndDate',
|
||||
label: '리스/렌트 종료일',
|
||||
type: 'date',
|
||||
placeholder: '연도.월.일.',
|
||||
},
|
||||
|
||||
// ===== Row 3: 총 주행거리, 기록일, 검사주기 =====
|
||||
{
|
||||
key: 'totalMileage',
|
||||
label: '총 주행거리 (km)',
|
||||
type: 'text',
|
||||
placeholder: 'km',
|
||||
},
|
||||
{
|
||||
key: 'mileageRecordDate',
|
||||
label: '기록일',
|
||||
type: 'date',
|
||||
placeholder: '연도.월.일.',
|
||||
},
|
||||
{
|
||||
key: 'inspectionStartDate',
|
||||
label: '검사주기 시작',
|
||||
type: 'date',
|
||||
placeholder: '연도.월.일.',
|
||||
},
|
||||
{
|
||||
key: 'inspectionEndDate',
|
||||
label: '검사주기 종료',
|
||||
type: 'date',
|
||||
placeholder: '연도.월.일.',
|
||||
},
|
||||
|
||||
// ===== 보험사 가입정보 =====
|
||||
{
|
||||
key: 'insuranceJoinDate',
|
||||
label: '가입일',
|
||||
type: 'date',
|
||||
placeholder: '연도.월.일.',
|
||||
},
|
||||
{
|
||||
key: 'insuranceCompany',
|
||||
label: '보험회사, 증서번호',
|
||||
type: 'text',
|
||||
placeholder: '보험사 및 증권번호',
|
||||
},
|
||||
{
|
||||
key: 'insuranceContact',
|
||||
label: '연락처',
|
||||
type: 'text',
|
||||
placeholder: '연락처 입력',
|
||||
},
|
||||
{
|
||||
key: 'insuranceFee',
|
||||
label: '보험료',
|
||||
type: 'text',
|
||||
placeholder: '금액',
|
||||
},
|
||||
{
|
||||
key: 'insuranceAmount',
|
||||
label: '가입금액',
|
||||
type: 'text',
|
||||
placeholder: '금액',
|
||||
},
|
||||
|
||||
// ===== 엔진오일 교환 =====
|
||||
{
|
||||
key: 'oilChangeCycle',
|
||||
label: '엔진오일 교환주기(Km)',
|
||||
type: 'text',
|
||||
placeholder: 'Km',
|
||||
},
|
||||
{
|
||||
key: 'oilChangeRecords',
|
||||
label: '엔진오일 교환 기록',
|
||||
type: 'custom',
|
||||
gridSpan: 2,
|
||||
formatValue: (value: unknown) => {
|
||||
const records = (value as OilChangeRecord[]) || [];
|
||||
if (records.length === 0) return '기록 없음';
|
||||
return records.map((record, index) =>
|
||||
`${index + 1}. ${record.date} / 주행거리: ${record.mileage}km / 비용: ${record.cost}원`
|
||||
).join('\n');
|
||||
},
|
||||
renderField: ({ value, onChange, mode }) => {
|
||||
const records = (value as OilChangeRecord[]) || [];
|
||||
const isViewMode = mode === 'view';
|
||||
|
||||
// view 모드에서는 formatValue가 처리
|
||||
if (isViewMode) {
|
||||
if (records.length === 0) {
|
||||
return <div className="text-muted-foreground">기록 없음</div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{records.map((record, index) => (
|
||||
<div key={record.id} className="text-sm">
|
||||
{index + 1}. {record.date} / 주행거리: {record.mileage}km / 비용: {record.cost}원
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// create/edit 모드에서는 EditableTable
|
||||
return (
|
||||
<EditableTable
|
||||
columns={oilChangeColumns}
|
||||
data={records}
|
||||
onChange={(newData) => onChange(newData)}
|
||||
createNewRow={createNewOilRecord}
|
||||
addButtonLabel="교환 기록 추가"
|
||||
emptyMessage="교환 기록이 없습니다. 기록을 추가해주세요."
|
||||
showRowNumber={true}
|
||||
compact={true}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 정비내역 =====
|
||||
{
|
||||
key: 'maintenanceRecords',
|
||||
label: '정비 기록',
|
||||
type: 'custom',
|
||||
gridSpan: 2,
|
||||
formatValue: (value: unknown) => {
|
||||
const records = (value as MaintenanceRecord[]) || [];
|
||||
if (records.length === 0) return '기록 없음';
|
||||
return records.map((record, index) =>
|
||||
`${index + 1}. ${record.date}: ${record.description} (비용: ${record.cost}원)`
|
||||
).join('\n');
|
||||
},
|
||||
renderField: ({ value, onChange, mode }) => {
|
||||
const records = (value as MaintenanceRecord[]) || [];
|
||||
const isViewMode = mode === 'view';
|
||||
|
||||
// view 모드에서는 formatValue가 처리
|
||||
if (isViewMode) {
|
||||
if (records.length === 0) {
|
||||
return <div className="text-muted-foreground">기록 없음</div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{records.map((record, index) => (
|
||||
<div key={record.id} className="text-sm">
|
||||
{index + 1}. {record.date}: {record.description} (비용: {record.cost}원)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// create/edit 모드에서는 EditableTable
|
||||
return (
|
||||
<EditableTable
|
||||
columns={maintenanceColumns}
|
||||
data={records}
|
||||
onChange={(newData) => onChange(newData)}
|
||||
createNewRow={createNewMaintenanceRecord}
|
||||
addButtonLabel="정비 기록 추가"
|
||||
emptyMessage="정비 기록이 없습니다. 기록을 추가해주세요."
|
||||
showRowNumber={true}
|
||||
compact={true}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 비고 =====
|
||||
{
|
||||
key: 'remarks',
|
||||
label: '비고',
|
||||
type: 'textarea',
|
||||
placeholder: '비고 입력',
|
||||
gridSpan: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 섹션 정의 (스크린샷 순서대로) =====
|
||||
export const vehicleSections: SectionDefinition[] = [
|
||||
{
|
||||
id: 'basicInfo',
|
||||
title: '기본 정보',
|
||||
description: '차량의 기본 정보를 입력하세요',
|
||||
fields: ['vehicleNumber', 'vehicleType', 'purchaseType', 'firstRegistrationDate'],
|
||||
},
|
||||
{
|
||||
id: 'managerInfo',
|
||||
title: '담당자 및 구매 정보',
|
||||
description: '담당자 및 구매 관련 정보를 입력하세요',
|
||||
fields: ['managerMain', 'managerSub', 'purchaseDate', 'leaseEndDate'],
|
||||
},
|
||||
{
|
||||
id: 'mileageInfo',
|
||||
title: '주행거리 및 검사주기',
|
||||
description: '주행거리 및 검사 관련 정보를 입력하세요',
|
||||
fields: ['totalMileage', 'mileageRecordDate', 'inspectionStartDate', 'inspectionEndDate'],
|
||||
},
|
||||
{
|
||||
id: 'insuranceInfo',
|
||||
title: '보험사 가입정보',
|
||||
description: '보험 관련 정보를 입력하세요',
|
||||
fields: ['insuranceJoinDate', 'insuranceCompany', 'insuranceContact', 'insuranceFee', 'insuranceAmount'],
|
||||
},
|
||||
{
|
||||
id: 'oilChangeInfo',
|
||||
title: '엔진오일 교환',
|
||||
description: '엔진오일 교환 관련 정보를 입력하세요',
|
||||
fields: ['oilChangeCycle', 'oilChangeRecords'],
|
||||
},
|
||||
{
|
||||
id: 'maintenanceSection',
|
||||
title: '정비내역',
|
||||
description: '정비 관련 정보를 입력하세요',
|
||||
fields: ['maintenanceRecords'],
|
||||
},
|
||||
{
|
||||
id: 'remarksSection',
|
||||
title: '비고',
|
||||
description: '기타 정보를 입력하세요',
|
||||
fields: ['remarks'],
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 설정 =====
|
||||
export const vehicleDetailConfig: DetailConfig<Vehicle> = {
|
||||
title: '차량',
|
||||
description: '차량 정보를 관리합니다',
|
||||
icon: Car,
|
||||
basePath: '/ko/vehicle-management/vehicle',
|
||||
fields: vehicleFields,
|
||||
sections: vehicleSections,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
submitLabel: '저장',
|
||||
cancelLabel: '취소',
|
||||
showDelete: true,
|
||||
deleteLabel: '삭제',
|
||||
showEdit: true,
|
||||
editLabel: '수정',
|
||||
showBack: true,
|
||||
backLabel: '목록',
|
||||
deleteConfirmMessage: {
|
||||
title: '차량 삭제',
|
||||
description: '이 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
transformInitialData: (data: Vehicle) => ({
|
||||
vehicleNumber: data.vehicleNumber || '',
|
||||
vehicleType: data.vehicleType || '',
|
||||
purchaseType: data.purchaseType || '',
|
||||
firstRegistrationDate: data.firstRegistrationDate || '',
|
||||
managerMain: data.managerMain || '',
|
||||
managerSub: data.managerSub || '',
|
||||
purchaseDate: data.purchaseDate || '',
|
||||
leaseEndDate: data.leaseEndDate || '',
|
||||
totalMileage: data.totalMileage || '',
|
||||
mileageRecordDate: data.mileageRecordDate || '',
|
||||
inspectionStartDate: data.inspectionStartDate || '',
|
||||
inspectionEndDate: data.inspectionEndDate || '',
|
||||
insuranceJoinDate: data.insuranceJoinDate || '',
|
||||
insuranceCompany: data.insuranceCompany || '',
|
||||
insuranceContact: data.insuranceContact || '',
|
||||
insuranceFee: data.insuranceFee || '',
|
||||
insuranceAmount: data.insuranceAmount || '',
|
||||
oilChangeCycle: data.oilChangeCycle || '',
|
||||
oilChangeRecords: data.oilChangeRecords || [],
|
||||
maintenanceRecords: data.maintenanceRecords || [],
|
||||
remarks: data.remarks || '',
|
||||
}),
|
||||
transformSubmitData: (formData): Partial<VehicleFormData> => ({
|
||||
vehicleNumber: formData.vehicleNumber as string,
|
||||
vehicleType: formData.vehicleType as string,
|
||||
purchaseType: formData.purchaseType as string,
|
||||
firstRegistrationDate: formData.firstRegistrationDate as string,
|
||||
managerMain: formData.managerMain as string,
|
||||
managerSub: formData.managerSub as string,
|
||||
purchaseDate: formData.purchaseDate as string,
|
||||
leaseEndDate: formData.leaseEndDate as string,
|
||||
totalMileage: formData.totalMileage as string,
|
||||
mileageRecordDate: formData.mileageRecordDate as string,
|
||||
inspectionStartDate: formData.inspectionStartDate as string,
|
||||
inspectionEndDate: formData.inspectionEndDate as string,
|
||||
insuranceJoinDate: formData.insuranceJoinDate as string,
|
||||
insuranceCompany: formData.insuranceCompany as string,
|
||||
insuranceContact: formData.insuranceContact as string,
|
||||
insuranceFee: formData.insuranceFee as string,
|
||||
insuranceAmount: formData.insuranceAmount as string,
|
||||
oilChangeCycle: formData.oilChangeCycle as string,
|
||||
oilChangeRecords: formData.oilChangeRecords as OilChangeRecord[],
|
||||
maintenanceRecords: formData.maintenanceRecords as MaintenanceRecord[],
|
||||
remarks: formData.remarks as string,
|
||||
}),
|
||||
};
|
||||
|
||||
// ===== 등록 페이지 Config =====
|
||||
export const vehicleCreateConfig: DetailConfig = {
|
||||
title: '차량',
|
||||
description: '차량 정보를 입력하세요',
|
||||
icon: Car,
|
||||
basePath: '/vehicle-management/vehicle',
|
||||
fields: vehicleFields,
|
||||
sections: vehicleSections,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '닫기',
|
||||
},
|
||||
};
|
||||
|
||||
// ===== 수정 페이지 Config =====
|
||||
export const vehicleEditConfig: DetailConfig = {
|
||||
title: '차량',
|
||||
description: '차량 정보를 수정합니다',
|
||||
icon: Car,
|
||||
basePath: '/vehicle-management/vehicle',
|
||||
fields: vehicleFields,
|
||||
sections: vehicleSections,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '닫기',
|
||||
},
|
||||
};
|
||||
125
src/components/vehicle-management/VehicleDetail/index.tsx
Normal file
125
src/components/vehicle-management/VehicleDetail/index.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량 관리 상세/등록/수정 컴포넌트
|
||||
* IntegratedDetailTemplate 기반
|
||||
*/
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import type { Vehicle, VehicleFormData } from '../types';
|
||||
import { vehicleDetailConfig, vehicleCreateConfig, vehicleEditConfig } from './config';
|
||||
import {
|
||||
getVehicleById,
|
||||
createVehicle,
|
||||
updateVehicle,
|
||||
deleteVehicle,
|
||||
} from '../VehicleList/actions';
|
||||
|
||||
// ===== Props 타입 =====
|
||||
interface VehicleDetailProps {
|
||||
mode: 'create' | 'view' | 'edit';
|
||||
initialData?: Vehicle;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function VehicleDetail({ mode, initialData, id }: VehicleDetailProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// ===== 설정 선택 =====
|
||||
const getConfig = () => {
|
||||
switch (mode) {
|
||||
case 'create':
|
||||
return vehicleCreateConfig;
|
||||
case 'edit':
|
||||
return vehicleEditConfig;
|
||||
default:
|
||||
return vehicleDetailConfig;
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSave = async (formData: Record<string, unknown>) => {
|
||||
const vehicleData = vehicleDetailConfig.transformSubmitData?.(formData) as Partial<VehicleFormData>;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
const result = await createVehicle(vehicleData);
|
||||
if (result.success) {
|
||||
toast.success('차량이 등록되었습니다.');
|
||||
router.push('/vehicle-management/vehicle');
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
}
|
||||
} else if (mode === 'edit' && id) {
|
||||
const result = await updateVehicle(id, vehicleData);
|
||||
if (result.success) {
|
||||
toast.success('차량 정보가 수정되었습니다.');
|
||||
router.push(`/vehicle-management/vehicle/${id}`);
|
||||
} else {
|
||||
toast.error(result.error || '수정에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ===== 삭제 핸들러 =====
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteVehicle(id);
|
||||
if (result.success) {
|
||||
toast.success('차량이 삭제되었습니다.');
|
||||
router.push('/vehicle-management/vehicle');
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ===== 수정 모드 전환 =====
|
||||
const handleEdit = () => {
|
||||
if (id) {
|
||||
router.push(`/vehicle-management/vehicle/${id}/edit`);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 목록으로 이동 =====
|
||||
const handleBack = () => {
|
||||
router.push('/vehicle-management/vehicle');
|
||||
};
|
||||
|
||||
// ===== 초기 데이터 변환 =====
|
||||
const transformedData = initialData && vehicleDetailConfig.transformInitialData
|
||||
? vehicleDetailConfig.transformInitialData(initialData)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={getConfig()}
|
||||
mode={mode}
|
||||
initialData={transformedData}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onBack={handleBack}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default VehicleDetail;
|
||||
315
src/components/vehicle-management/VehicleList/actions.ts
Normal file
315
src/components/vehicle-management/VehicleList/actions.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* 차량 관리 서버 액션
|
||||
* 레거시 5130 사이트 컬럼 구조 기반 (2025-01-28 스크린샷 검증)
|
||||
*/
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { Vehicle, VehicleFormData, ActionResponse, ListResponse, OilChangeRecord, MaintenanceRecord } from '../types';
|
||||
|
||||
// ===== Mock 데이터 (API 연동 전까지 사용) =====
|
||||
const mockVehicles: Vehicle[] = [
|
||||
{
|
||||
id: '1',
|
||||
vehicleNumber: '83버5277',
|
||||
vehicleType: '포터',
|
||||
purchaseType: '회사 소유',
|
||||
firstRegistrationDate: '2022-01-15',
|
||||
managerMain: '이재만',
|
||||
managerSub: '',
|
||||
purchaseDate: '2023-01-15',
|
||||
leaseEndDate: '',
|
||||
totalMileage: '92000',
|
||||
mileageRecordDate: '2025-01-15',
|
||||
inspectionStartDate: '2025-01-01',
|
||||
inspectionEndDate: '2025-12-31',
|
||||
insuranceJoinDate: '2023-01-01',
|
||||
insuranceCompany: '한화손해보험, 12345678',
|
||||
insuranceContact: '1566-8000',
|
||||
insuranceFee: '500000',
|
||||
insuranceAmount: '50000000',
|
||||
oilChangeCycle: '10000',
|
||||
oilChangeRecords: [
|
||||
{ id: 'oil-1-1', date: '2025-01-15', mileage: '92000', cost: '50000' },
|
||||
],
|
||||
maintenanceRecords: [
|
||||
{ id: 'maint-1-1', date: '2025-10-01', description: '오일교환, 브레이크라이닝2개교체', cost: '150000' },
|
||||
],
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
vehicleNumber: '81러8178',
|
||||
vehicleType: '봉고III 1.2톤',
|
||||
purchaseType: '회사 소유',
|
||||
firstRegistrationDate: '2016-06-16',
|
||||
managerMain: '김진호선임',
|
||||
managerSub: '',
|
||||
purchaseDate: '2016-06-16',
|
||||
leaseEndDate: '',
|
||||
totalMileage: '92000',
|
||||
mileageRecordDate: '2024-12-12',
|
||||
inspectionStartDate: '2025-06-01',
|
||||
inspectionEndDate: '2025-06-16',
|
||||
insuranceJoinDate: '2016-06-16',
|
||||
insuranceCompany: '한화손해보험',
|
||||
insuranceContact: '1566-8000',
|
||||
insuranceFee: '',
|
||||
insuranceAmount: '',
|
||||
oilChangeCycle: '10000',
|
||||
oilChangeRecords: [
|
||||
{ id: 'oil-2-1', date: '2024-12-12', mileage: '92000', cost: '' },
|
||||
],
|
||||
maintenanceRecords: [
|
||||
{ id: 'maint-2-1', date: '2024-12-19', description: '차량검사 완료', cost: '' },
|
||||
{ id: 'maint-2-2', date: '2025-05-22', description: '디스크 삼발이 교체', cost: '' },
|
||||
{ id: 'maint-2-3', date: '2025-06-17', description: '후방 소음기 파손 교체', cost: '' },
|
||||
],
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
vehicleNumber: '81러8197',
|
||||
vehicleType: '그랜드 스타렉스',
|
||||
purchaseType: '회사 소유',
|
||||
firstRegistrationDate: '2016-06-22',
|
||||
managerMain: '황규선',
|
||||
managerSub: '',
|
||||
purchaseDate: '2016-06-22',
|
||||
leaseEndDate: '',
|
||||
totalMileage: '',
|
||||
mileageRecordDate: '',
|
||||
inspectionStartDate: '',
|
||||
inspectionEndDate: '',
|
||||
insuranceJoinDate: '2016-06-22',
|
||||
insuranceCompany: '한화손해보험',
|
||||
insuranceContact: '1566-8000',
|
||||
insuranceFee: '',
|
||||
insuranceAmount: '',
|
||||
oilChangeCycle: '',
|
||||
oilChangeRecords: [],
|
||||
maintenanceRecords: [
|
||||
{ id: 'maint-3-1', date: '2025-08-18', description: '앞바퀴 타이어 교체', cost: '' },
|
||||
{ id: 'maint-3-2', date: '2025-10-01', description: '오일교환 및 에어컨필터교환', cost: '' },
|
||||
{ id: 'maint-3-3', date: '2025-12-04', description: '오일교환', cost: '' },
|
||||
],
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
vehicleNumber: '80소0595',
|
||||
vehicleType: '코란도스포츠',
|
||||
purchaseType: '회사 소유',
|
||||
firstRegistrationDate: '2017-08-14',
|
||||
managerMain: '이세희차장',
|
||||
managerSub: '',
|
||||
purchaseDate: '2017-08-14',
|
||||
leaseEndDate: '',
|
||||
totalMileage: '16400',
|
||||
mileageRecordDate: '2025-01-15',
|
||||
inspectionStartDate: '',
|
||||
inspectionEndDate: '',
|
||||
insuranceJoinDate: '2017-08-14',
|
||||
insuranceCompany: '한화손해보험',
|
||||
insuranceContact: '1566-8000',
|
||||
insuranceFee: '',
|
||||
insuranceAmount: '',
|
||||
oilChangeCycle: '',
|
||||
oilChangeRecords: [
|
||||
{ id: 'oil-4-1', date: '2025-01-15', mileage: '16400', cost: '' },
|
||||
],
|
||||
maintenanceRecords: [
|
||||
{ id: 'maint-4-1', date: '2025-01-15', description: '베터리 교체(솔라이트90r)', cost: '' },
|
||||
],
|
||||
remarks: '2025.8.8차량매각(주식회사 차들)',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
vehicleNumber: '224호9739',
|
||||
vehicleType: '레이',
|
||||
purchaseType: '렌트',
|
||||
firstRegistrationDate: '2024-10-25',
|
||||
managerMain: '공장',
|
||||
managerSub: '',
|
||||
purchaseDate: '2024-10-25',
|
||||
leaseEndDate: '',
|
||||
totalMileage: '',
|
||||
mileageRecordDate: '',
|
||||
inspectionStartDate: '',
|
||||
inspectionEndDate: '',
|
||||
insuranceJoinDate: '2024-10-25',
|
||||
insuranceCompany: '메리츠캐피탈',
|
||||
insuranceContact: '1588-9666',
|
||||
insuranceFee: '',
|
||||
insuranceAmount: '',
|
||||
oilChangeCycle: '7000',
|
||||
oilChangeRecords: [],
|
||||
maintenanceRecords: [
|
||||
{ id: 'maint-5-1', date: '2025-06-25', description: '배터리 및 메인휴즈교체', cost: '' },
|
||||
],
|
||||
remarks: '긴급출동 연락처:1577-0565',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 차량 목록 조회 =====
|
||||
export async function getVehicles(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
}): Promise<ListResponse<Vehicle>> {
|
||||
try {
|
||||
let filteredData = [...mockVehicles];
|
||||
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filteredData = filteredData.filter(
|
||||
(v) =>
|
||||
v.vehicleNumber.toLowerCase().includes(search) ||
|
||||
v.vehicleType.toLowerCase().includes(search) ||
|
||||
v.managerMain.toLowerCase().includes(search) ||
|
||||
v.insuranceCompany.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: filteredData,
|
||||
totalCount: filteredData.length,
|
||||
totalPages: 1,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleActions] getVehicles error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
totalPages: 0,
|
||||
error: '차량 목록 조회 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 차량 단건 조회 =====
|
||||
export async function getVehicleById(id: string): Promise<ActionResponse<Vehicle>> {
|
||||
try {
|
||||
const vehicle = mockVehicles.find((v) => v.id === id);
|
||||
if (!vehicle) {
|
||||
return { success: false, error: '차량을 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true, data: vehicle };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleActions] getVehicleById error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '차량 조회 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 차량 생성 =====
|
||||
export async function createVehicle(
|
||||
formData: Partial<VehicleFormData>
|
||||
): Promise<ActionResponse<Vehicle>> {
|
||||
try {
|
||||
const newVehicle: Vehicle = {
|
||||
id: String(Date.now()),
|
||||
vehicleNumber: formData.vehicleNumber || '',
|
||||
vehicleType: formData.vehicleType || '',
|
||||
purchaseType: formData.purchaseType || '',
|
||||
firstRegistrationDate: formData.firstRegistrationDate || '',
|
||||
managerMain: formData.managerMain || '',
|
||||
managerSub: formData.managerSub || '',
|
||||
purchaseDate: formData.purchaseDate || '',
|
||||
leaseEndDate: formData.leaseEndDate || '',
|
||||
totalMileage: formData.totalMileage || '',
|
||||
mileageRecordDate: formData.mileageRecordDate || '',
|
||||
inspectionStartDate: formData.inspectionStartDate || '',
|
||||
inspectionEndDate: formData.inspectionEndDate || '',
|
||||
insuranceJoinDate: formData.insuranceJoinDate || '',
|
||||
insuranceCompany: formData.insuranceCompany || '',
|
||||
insuranceContact: formData.insuranceContact || '',
|
||||
insuranceFee: formData.insuranceFee || '',
|
||||
insuranceAmount: formData.insuranceAmount || '',
|
||||
oilChangeCycle: formData.oilChangeCycle || '',
|
||||
oilChangeRecords: formData.oilChangeRecords || [],
|
||||
maintenanceRecords: formData.maintenanceRecords || [],
|
||||
remarks: formData.remarks || '',
|
||||
};
|
||||
mockVehicles.push(newVehicle);
|
||||
return { success: true, data: newVehicle };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleActions] createVehicle error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '차량 등록 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 차량 수정 =====
|
||||
export async function updateVehicle(
|
||||
id: string,
|
||||
formData: Partial<VehicleFormData>
|
||||
): Promise<ActionResponse<Vehicle>> {
|
||||
try {
|
||||
const index = mockVehicles.findIndex((v) => v.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '차량을 찾을 수 없습니다.' };
|
||||
}
|
||||
mockVehicles[index] = {
|
||||
...mockVehicles[index],
|
||||
...formData,
|
||||
};
|
||||
return { success: true, data: mockVehicles[index] };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleActions] updateVehicle error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '차량 수정 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 차량 삭제 =====
|
||||
export async function deleteVehicle(id: string): Promise<ActionResponse> {
|
||||
try {
|
||||
const index = mockVehicles.findIndex((v) => v.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '차량을 찾을 수 없습니다.' };
|
||||
}
|
||||
mockVehicles.splice(index, 1);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleActions] deleteVehicle error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '차량 삭제 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 차량 일괄 삭제 =====
|
||||
export async function bulkDeleteVehicles(ids: string[]): Promise<ActionResponse> {
|
||||
try {
|
||||
ids.forEach((id) => {
|
||||
const index = mockVehicles.findIndex((v) => v.id === id);
|
||||
if (index !== -1) {
|
||||
mockVehicles.splice(index, 1);
|
||||
}
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleActions] bulkDeleteVehicles error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '차량 일괄 삭제 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
341
src/components/vehicle-management/VehicleList/index.tsx
Normal file
341
src/components/vehicle-management/VehicleList/index.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량 관리 리스트 - UniversalListPage 기반
|
||||
* 레거시 5130 사이트 컬럼 구조 기반 (2025-01-28 스크린샷 검증)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Car, Edit, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type ListParams,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type { Vehicle } from '../types';
|
||||
import { getVehicles, deleteVehicle, bulkDeleteVehicles } from './actions';
|
||||
|
||||
interface VehicleListProps {
|
||||
initialData: Vehicle[];
|
||||
}
|
||||
|
||||
export function VehicleList({ initialData }: VehicleListProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
|
||||
const [allData, setAllData] = useState<Vehicle[]>(initialData);
|
||||
|
||||
const handleView = useCallback(
|
||||
(vehicle: Vehicle) => {
|
||||
router.push(`/vehicle-management/vehicle/${vehicle.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(vehicle: Vehicle) => {
|
||||
router.push(`/vehicle-management/vehicle/${vehicle.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((id: string) => {
|
||||
setDeleteTargetId(id);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await deleteVehicle(deleteTargetId);
|
||||
|
||||
if (result.success) {
|
||||
const vehicle = allData.find((v) => v.id === deleteTargetId);
|
||||
setAllData(allData.filter((v) => v.id !== deleteTargetId));
|
||||
toast.success(`차량이 삭제되었습니다${vehicle ? `: ${vehicle.vehicleNumber}` : ''}`);
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
});
|
||||
}, [deleteTargetId, allData]);
|
||||
|
||||
const handleBulkDelete = useCallback((selectedIds: string[]) => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error('삭제할 항목을 선택해주세요');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteIds(selectedIds);
|
||||
setIsBulkDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmBulkDelete = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
const result = await bulkDeleteVehicles(bulkDeleteIds);
|
||||
|
||||
if (result.success) {
|
||||
setAllData(allData.filter((v) => !bulkDeleteIds.includes(v.id)));
|
||||
toast.success(`${bulkDeleteIds.length}개의 차량이 삭제되었습니다`);
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
setBulkDeleteIds([]);
|
||||
});
|
||||
}, [bulkDeleteIds, allData]);
|
||||
|
||||
const config: UniversalListConfig<Vehicle> = useMemo(
|
||||
() => ({
|
||||
title: '차량 관리',
|
||||
description: '차량 정보를 관리합니다',
|
||||
icon: Car,
|
||||
basePath: '/vehicle-management/vehicle',
|
||||
|
||||
idField: 'id',
|
||||
getItemId: (item: Vehicle) => item.id,
|
||||
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
const result = await getVehicles({
|
||||
page: params?.page || 1,
|
||||
perPage: params?.perPage || 20,
|
||||
search: params?.search || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setAllData(result.data);
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.totalCount,
|
||||
totalPages: result.totalPages,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error || '데이터 조회에 실패했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteVehicle(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await bulkDeleteVehicles(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 레거시 컬럼 구조 (5130 기준) - 12개 컬럼
|
||||
columns: [
|
||||
{ key: 'rowNumber', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'vehicleNumber', label: '차량번호', className: 'min-w-[120px]' },
|
||||
{ key: 'managerMain', label: '담당자', className: 'w-[80px]' },
|
||||
{ key: 'insuranceCompany', label: '보험사', className: 'w-[100px]' },
|
||||
{ key: 'insuranceContact', label: '보험사 연락처', className: 'w-[110px]' },
|
||||
{ key: 'firstRegistrationDate', label: '최초등록일', className: 'w-[90px]' },
|
||||
{ key: 'purchaseDate', label: '구매일자', className: 'w-[90px]' },
|
||||
{ key: 'purchaseType', label: '구매 유형', className: 'w-[80px]' },
|
||||
{ key: 'oilChangeCycle', label: '엔진오일 교환 주기', className: 'w-[110px]' },
|
||||
{ key: 'oilChangeRecords', label: '엔진오일교환일', className: 'min-w-[150px]' },
|
||||
{ key: 'maintenanceRecords', label: '정비 정보', className: 'min-w-[180px]' },
|
||||
{ key: 'remarks', label: '비고', className: 'min-w-[120px]' },
|
||||
],
|
||||
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
searchFilter: (item: Vehicle, searchValue: string) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.vehicleNumber.toLowerCase().includes(search) ||
|
||||
item.vehicleType.toLowerCase().includes(search) ||
|
||||
item.managerMain.toLowerCase().includes(search) ||
|
||||
item.insuranceCompany.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
tabs: [],
|
||||
searchPlaceholder: '차량번호, 차종, 담당자, 보험사 검색...',
|
||||
|
||||
headerActions: () => (
|
||||
<Button
|
||||
className="ml-auto"
|
||||
onClick={() => router.push('/vehicle-management/vehicle/new')}
|
||||
>
|
||||
<Car className="w-4 h-4 mr-2" />
|
||||
차량 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
onBulkDelete: handleBulkDelete,
|
||||
|
||||
renderTableRow: (
|
||||
vehicle: Vehicle,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Vehicle>
|
||||
) => {
|
||||
// 엔진오일 교환일 포맷 (5130 형식: "날짜, 주행거리 : XX km")
|
||||
const oilChangeText = vehicle.oilChangeRecords && vehicle.oilChangeRecords.length > 0
|
||||
? vehicle.oilChangeRecords.map(r =>
|
||||
`${r.date}, 주행거리 : ${r.mileage || ''} km`
|
||||
).join('\n')
|
||||
: '정보 없음';
|
||||
|
||||
// 정비 정보 포맷 (5130 형식: "날짜: 내용")
|
||||
const maintenanceText = vehicle.maintenanceRecords && vehicle.maintenanceRecords.length > 0
|
||||
? vehicle.maintenanceRecords.map(r =>
|
||||
`${r.date}: ${r.description}`
|
||||
).join('\n')
|
||||
: '정보 없음';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={vehicle.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(vehicle)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={handlers.onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{vehicle.vehicleNumber}</TableCell>
|
||||
<TableCell>{vehicle.managerMain || '-'}</TableCell>
|
||||
<TableCell>{vehicle.insuranceCompany || '-'}</TableCell>
|
||||
<TableCell>{vehicle.insuranceContact || '-'}</TableCell>
|
||||
<TableCell>{vehicle.firstRegistrationDate || '-'}</TableCell>
|
||||
<TableCell>{vehicle.purchaseDate || '-'}</TableCell>
|
||||
<TableCell>{vehicle.purchaseType || '-'}</TableCell>
|
||||
<TableCell>{vehicle.oilChangeCycle || '정보 없음'}</TableCell>
|
||||
<TableCell className="whitespace-pre-line text-xs">{oilChangeText}</TableCell>
|
||||
<TableCell className="whitespace-pre-line text-xs">{maintenanceText}</TableCell>
|
||||
<TableCell className="whitespace-pre-line text-xs">{vehicle.remarks || '-'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
renderMobileCard: (
|
||||
vehicle: Vehicle,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Vehicle>
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={vehicle.id}
|
||||
id={vehicle.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
onClick={() => handleView(vehicle)}
|
||||
title={`${vehicle.vehicleNumber} ${vehicle.vehicleType}`}
|
||||
subtitle={vehicle.purchaseType}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="담당자" value={vehicle.managerMain} />
|
||||
<InfoField label="보험사" value={vehicle.insuranceCompany || '-'} />
|
||||
<InfoField label="보험사 연락처" value={vehicle.insuranceContact || '-'} />
|
||||
<InfoField label="구매 유형" value={vehicle.purchaseType || '-'} />
|
||||
<InfoField label="최초등록일" value={vehicle.firstRegistrationDate || '-'} />
|
||||
<InfoField label="구매일자" value={vehicle.purchaseDate || '-'} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
handlers.isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(vehicle);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick(vehicle.id);
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={initialData} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
description={
|
||||
<>
|
||||
{deleteTargetId
|
||||
? `차량번호: ${allData.find((v) => v.id === deleteTargetId)?.vehicleNumber || deleteTargetId}`
|
||||
: ''}
|
||||
<br />
|
||||
이 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.
|
||||
</>
|
||||
}
|
||||
loading={isPending}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={isBulkDeleteDialogOpen}
|
||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
||||
description={
|
||||
<>
|
||||
선택한 {bulkDeleteIds.length}개의 차량을 삭제하시겠습니까?
|
||||
<br />
|
||||
삭제된 데이터는 복구할 수 없습니다.
|
||||
</>
|
||||
}
|
||||
loading={isPending}
|
||||
onConfirm={handleConfirmBulkDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VehicleList;
|
||||
265
src/components/vehicle-management/VehicleLogDetail/config.tsx
Normal file
265
src/components/vehicle-management/VehicleLogDetail/config.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량일지/월간사진기록 상세 페이지 설정
|
||||
* 레거시 5130 사이트 등록 폼 기준 (2025-01-28 스크린샷 검증)
|
||||
*/
|
||||
|
||||
import { FileText, Upload, X, Image as ImageIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type {
|
||||
DetailConfig,
|
||||
FieldDefinition,
|
||||
SectionDefinition,
|
||||
} from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { VehicleLog, VehicleLogFormData } from '../types';
|
||||
|
||||
// ===== 차량 정보 옵션 (5130 레거시와 동일) =====
|
||||
const vehicleOptions = [
|
||||
{ label: '그랜드 스타렉스 - 81러8197', value: '그랜드 스타렉스 - 81러8197' },
|
||||
{ label: '레이 - 224호9739', value: '레이 - 224호9739' },
|
||||
{ label: '봉고III - 81러8178', value: '봉고III - 81러8178' },
|
||||
{ label: '봉고III1.2톤 - 81러8178', value: '봉고III1.2톤 - 81러8178' },
|
||||
{ label: '코란도스포츠 - 80소0595', value: '코란도스포츠 - 80소0595' },
|
||||
{ label: '포터 - 83버5277', value: '포터 - 83버5277' },
|
||||
];
|
||||
|
||||
// ===== 필드 정의 (5130 등록 폼 순서대로) =====
|
||||
export const vehicleLogFields: FieldDefinition[] = [
|
||||
// Row 1: 작성일, 차량정보
|
||||
{
|
||||
key: 'writeDate',
|
||||
label: '작성일',
|
||||
type: 'date',
|
||||
required: true,
|
||||
validation: [
|
||||
{ type: 'required', message: '작성일을 선택해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'vehicleInfo',
|
||||
label: '차량정보',
|
||||
type: 'select',
|
||||
required: true,
|
||||
placeholder: '(차량 정보 선택)',
|
||||
options: vehicleOptions,
|
||||
validation: [
|
||||
{ type: 'required', message: '차량정보를 선택해주세요.' },
|
||||
],
|
||||
},
|
||||
// Row 2: 작성자, 제목
|
||||
{
|
||||
key: 'writer',
|
||||
label: '작성자',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '작성자 입력',
|
||||
validation: [
|
||||
{ type: 'required', message: '작성자를 입력해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: '제목',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '월별 차량사진 대지',
|
||||
validation: [
|
||||
{ type: 'required', message: '제목을 입력해주세요.' },
|
||||
],
|
||||
},
|
||||
// 사진 첨부 (custom)
|
||||
{
|
||||
key: 'images',
|
||||
label: '사진 첨부',
|
||||
type: 'custom',
|
||||
gridSpan: 2,
|
||||
formatValue: (value: unknown) => {
|
||||
const images = (value as string[]) || [];
|
||||
if (images.length === 0) return '첨부된 사진 없음';
|
||||
return `${images.length}개 사진 첨부됨`;
|
||||
},
|
||||
renderField: ({ value, onChange, mode }) => {
|
||||
const images = (value as string[]) || [];
|
||||
const isViewMode = mode === 'view';
|
||||
|
||||
// view 모드
|
||||
if (isViewMode) {
|
||||
if (images.length === 0) {
|
||||
return <div className="text-muted-foreground">첨부된 사진 없음</div>;
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{images.map((img, index) => (
|
||||
<div key={index} className="relative aspect-square bg-gray-100 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={img}
|
||||
alt={`첨부 사진 ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// create/edit 모드
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
// 파일을 URL로 변환 (실제 구현에서는 서버 업로드 필요)
|
||||
const newImages: string[] = [...images];
|
||||
Array.from(files).forEach((file) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
newImages.push(url);
|
||||
});
|
||||
onChange(newImages);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const newImages = images.filter((_, i) => i !== index);
|
||||
onChange(newImages);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 업로드 영역 */}
|
||||
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<Upload className="w-8 h-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600">클릭하여 사진 업로드</p>
|
||||
<p className="text-xs text-gray-500">PNG, JPG (최대 10MB)</p>
|
||||
</div>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 업로드된 이미지 미리보기 */}
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{images.map((img, index) => (
|
||||
<div key={index} className="relative aspect-square bg-gray-100 rounded-lg overflow-hidden group">
|
||||
<img
|
||||
src={img}
|
||||
alt={`첨부 사진 ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 w-6 h-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
<ImageIcon className="inline w-4 h-4 mr-1" />
|
||||
아직 첨부된 사진이 없습니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 섹션 정의 =====
|
||||
export const vehicleLogSections: SectionDefinition[] = [
|
||||
{
|
||||
id: 'basicInfo',
|
||||
title: '기본 정보',
|
||||
description: '차량일지 기본 정보를 입력하세요',
|
||||
fields: ['writeDate', 'vehicleInfo', 'writer', 'title'],
|
||||
},
|
||||
{
|
||||
id: 'imageSection',
|
||||
title: '사진 첨부',
|
||||
description: '차량 사진을 첨부하세요',
|
||||
fields: ['images'],
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 설정 =====
|
||||
export const vehicleLogDetailConfig: DetailConfig<VehicleLog> = {
|
||||
title: '차량일지',
|
||||
description: '차량일지 정보를 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/ko/vehicle-management/vehicle-log',
|
||||
fields: vehicleLogFields,
|
||||
sections: vehicleLogSections,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
submitLabel: '저장',
|
||||
cancelLabel: '취소',
|
||||
showDelete: true,
|
||||
deleteLabel: '삭제',
|
||||
showEdit: true,
|
||||
editLabel: '수정',
|
||||
showBack: true,
|
||||
backLabel: '목록',
|
||||
deleteConfirmMessage: {
|
||||
title: '차량일지 삭제',
|
||||
description: '이 차량일지를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
transformInitialData: (data: VehicleLog) => ({
|
||||
writeDate: data.writeDate || '',
|
||||
vehicleInfo: data.vehicleInfo || '',
|
||||
writer: data.writer || '',
|
||||
title: data.title || '',
|
||||
images: data.images || [],
|
||||
}),
|
||||
transformSubmitData: (formData): Partial<VehicleLogFormData> => ({
|
||||
writeDate: formData.writeDate as string,
|
||||
vehicleInfo: formData.vehicleInfo as string,
|
||||
writer: formData.writer as string,
|
||||
title: formData.title as string,
|
||||
images: formData.images as string[],
|
||||
}),
|
||||
};
|
||||
|
||||
export const vehicleLogCreateConfig: DetailConfig = {
|
||||
title: '차량일지',
|
||||
description: '차량일지를 등록합니다',
|
||||
icon: FileText,
|
||||
basePath: '/vehicle-management/vehicle-log',
|
||||
fields: vehicleLogFields,
|
||||
sections: vehicleLogSections,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '닫기',
|
||||
},
|
||||
};
|
||||
|
||||
export const vehicleLogEditConfig: DetailConfig = {
|
||||
title: '차량일지',
|
||||
description: '차량일지를 수정합니다',
|
||||
icon: FileText,
|
||||
basePath: '/vehicle-management/vehicle-log',
|
||||
fields: vehicleLogFields,
|
||||
sections: vehicleLogSections,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '닫기',
|
||||
},
|
||||
};
|
||||
117
src/components/vehicle-management/VehicleLogDetail/index.tsx
Normal file
117
src/components/vehicle-management/VehicleLogDetail/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량일지/월간사진기록 상세/등록/수정 컴포넌트
|
||||
*/
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import type { VehicleLog, VehicleLogFormData } from '../types';
|
||||
import { vehicleLogDetailConfig, vehicleLogCreateConfig, vehicleLogEditConfig } from './config';
|
||||
import {
|
||||
getVehicleLogById,
|
||||
createVehicleLog,
|
||||
updateVehicleLog,
|
||||
deleteVehicleLog,
|
||||
} from '../VehicleLogList/actions';
|
||||
|
||||
interface VehicleLogDetailProps {
|
||||
mode: 'create' | 'view' | 'edit';
|
||||
initialData?: VehicleLog;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function VehicleLogDetail({ mode, initialData, id }: VehicleLogDetailProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const getConfig = () => {
|
||||
switch (mode) {
|
||||
case 'create':
|
||||
return vehicleLogCreateConfig;
|
||||
case 'edit':
|
||||
return vehicleLogEditConfig;
|
||||
default:
|
||||
return vehicleLogDetailConfig;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (formData: Record<string, unknown>) => {
|
||||
const logData = vehicleLogDetailConfig.transformSubmitData?.(formData) as Partial<VehicleLogFormData>;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
const result = await createVehicleLog(logData);
|
||||
if (result.success) {
|
||||
toast.success('차량일지가 등록되었습니다.');
|
||||
router.push('/vehicle-management/vehicle-log');
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
}
|
||||
} else if (mode === 'edit' && id) {
|
||||
const result = await updateVehicleLog(id, logData);
|
||||
if (result.success) {
|
||||
toast.success('차량일지가 수정되었습니다.');
|
||||
router.push(`/vehicle-management/vehicle-log/${id}`);
|
||||
} else {
|
||||
toast.error(result.error || '수정에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteVehicleLog(id);
|
||||
if (result.success) {
|
||||
toast.success('차량일지가 삭제되었습니다.');
|
||||
router.push('/vehicle-management/vehicle-log');
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (id) {
|
||||
router.push(`/vehicle-management/vehicle-log/${id}/edit`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.push('/vehicle-management/vehicle-log');
|
||||
};
|
||||
|
||||
const transformedData = initialData && vehicleLogDetailConfig.transformInitialData
|
||||
? vehicleLogDetailConfig.transformInitialData(initialData)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={getConfig()}
|
||||
mode={mode}
|
||||
initialData={transformedData}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onBack={handleBack}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default VehicleLogDetail;
|
||||
207
src/components/vehicle-management/VehicleLogList/actions.ts
Normal file
207
src/components/vehicle-management/VehicleLogList/actions.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* 차량일지/월간사진기록 서버 액션
|
||||
*/
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { VehicleLog, VehicleLogFormData, ActionResponse, ListResponse } from '../types';
|
||||
|
||||
// ===== Mock 데이터 (5130 레거시 기준) =====
|
||||
const mockVehicleLogs: VehicleLog[] = [
|
||||
{
|
||||
id: '1',
|
||||
writeDate: '2025-12-01',
|
||||
vehicleInfo: '레이 - 224호9739',
|
||||
writer: '이세희',
|
||||
title: '25년11월 차량사진 대지',
|
||||
images: [
|
||||
'https://picsum.photos/400/300?random=1',
|
||||
'https://picsum.photos/400/300?random=2',
|
||||
'https://picsum.photos/400/300?random=3',
|
||||
],
|
||||
createdAt: '2025-12-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
writeDate: '2025-12-01',
|
||||
vehicleInfo: '봉고III1.2톤 - 81러8178',
|
||||
writer: '김진호',
|
||||
title: '25년11월 차량사진 대지',
|
||||
images: [],
|
||||
createdAt: '2025-12-01',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
writeDate: '2025-12-01',
|
||||
vehicleInfo: '그랜드 스타렉스 - 81러8197',
|
||||
writer: '황규선',
|
||||
title: '25년11월 차량사진 대지',
|
||||
images: [],
|
||||
createdAt: '2025-12-01',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
writeDate: '2025-12-01',
|
||||
vehicleInfo: '포터 - 83버5277',
|
||||
writer: '이재만',
|
||||
title: '25년11월 차량사진 대지',
|
||||
images: [],
|
||||
createdAt: '2025-12-01',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
writeDate: '2025-11-05',
|
||||
vehicleInfo: '레이 - 224호9739',
|
||||
writer: '이세희',
|
||||
title: '25년 10월 차량사진 대지',
|
||||
images: [],
|
||||
createdAt: '2025-11-05',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 차량일지 목록 조회 =====
|
||||
export async function getVehicleLogs(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
}): Promise<ListResponse<VehicleLog>> {
|
||||
try {
|
||||
let filteredData = [...mockVehicleLogs];
|
||||
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filteredData = filteredData.filter(
|
||||
(v) =>
|
||||
v.title.toLowerCase().includes(search) ||
|
||||
v.vehicleInfo.toLowerCase().includes(search) ||
|
||||
v.writer.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: filteredData,
|
||||
totalCount: filteredData.length,
|
||||
totalPages: 1,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleLogActions] getVehicleLogs error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
totalPages: 0,
|
||||
error: '차량일지 목록 조회 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 차량일지 단건 조회 =====
|
||||
export async function getVehicleLogById(id: string): Promise<ActionResponse<VehicleLog>> {
|
||||
try {
|
||||
const log = mockVehicleLogs.find((v) => v.id === id);
|
||||
if (!log) {
|
||||
return { success: false, error: '차량일지를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true, data: log };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleLogActions] getVehicleLogById error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '차량일지 조회 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 차량일지 생성 =====
|
||||
export async function createVehicleLog(
|
||||
formData: Partial<VehicleLogFormData>
|
||||
): Promise<ActionResponse<VehicleLog>> {
|
||||
try {
|
||||
const newLog: VehicleLog = {
|
||||
id: String(Date.now()),
|
||||
writeDate: formData.writeDate || new Date().toISOString().split('T')[0],
|
||||
vehicleInfo: formData.vehicleInfo || '',
|
||||
writer: formData.writer || '',
|
||||
title: formData.title || '',
|
||||
images: formData.images || [],
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
mockVehicleLogs.push(newLog);
|
||||
return { success: true, data: newLog };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleLogActions] createVehicleLog error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '차량일지 등록 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 차량일지 수정 =====
|
||||
export async function updateVehicleLog(
|
||||
id: string,
|
||||
formData: Partial<VehicleLogFormData>
|
||||
): Promise<ActionResponse<VehicleLog>> {
|
||||
try {
|
||||
const index = mockVehicleLogs.findIndex((v) => v.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '차량일지를 찾을 수 없습니다.' };
|
||||
}
|
||||
mockVehicleLogs[index] = {
|
||||
...mockVehicleLogs[index],
|
||||
...formData,
|
||||
};
|
||||
return { success: true, data: mockVehicleLogs[index] };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleLogActions] updateVehicleLog error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '차량일지 수정 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 차량일지 삭제 =====
|
||||
export async function deleteVehicleLog(id: string): Promise<ActionResponse> {
|
||||
try {
|
||||
const index = mockVehicleLogs.findIndex((v) => v.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '차량일지를 찾을 수 없습니다.' };
|
||||
}
|
||||
mockVehicleLogs.splice(index, 1);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleLogActions] deleteVehicleLog error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '차량일지 삭제 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 차량일지 일괄 삭제 =====
|
||||
export async function bulkDeleteVehicleLogs(ids: string[]): Promise<ActionResponse> {
|
||||
try {
|
||||
ids.forEach((id) => {
|
||||
const index = mockVehicleLogs.findIndex((v) => v.id === id);
|
||||
if (index !== -1) {
|
||||
mockVehicleLogs.splice(index, 1);
|
||||
}
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VehicleLogActions] bulkDeleteVehicleLogs error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '차량일지 일괄 삭제 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
312
src/components/vehicle-management/VehicleLogList/index.tsx
Normal file
312
src/components/vehicle-management/VehicleLogList/index.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 차량일지/월간사진기록 리스트 - UniversalListPage 기반
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Edit, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type ListParams,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type { VehicleLog } from '../types';
|
||||
import { getVehicleLogs, deleteVehicleLog, bulkDeleteVehicleLogs } from './actions';
|
||||
|
||||
// ===== Props 타입 =====
|
||||
interface VehicleLogListProps {
|
||||
initialData: VehicleLog[];
|
||||
}
|
||||
|
||||
export function VehicleLogList({ initialData }: VehicleLogListProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// ===== 삭제 다이얼로그 상태 =====
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
|
||||
|
||||
// ===== 전체 데이터 상태 =====
|
||||
const [allData, setAllData] = useState<VehicleLog[]>(initialData);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleView = useCallback(
|
||||
(log: VehicleLog) => {
|
||||
router.push(`/vehicle-management/vehicle-log/${log.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(log: VehicleLog) => {
|
||||
router.push(`/vehicle-management/vehicle-log/${log.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((id: string) => {
|
||||
setDeleteTargetId(id);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await deleteVehicleLog(deleteTargetId);
|
||||
|
||||
if (result.success) {
|
||||
const log = allData.find((v) => v.id === deleteTargetId);
|
||||
setAllData(allData.filter((v) => v.id !== deleteTargetId));
|
||||
toast.success(`차량일지가 삭제되었습니다${log ? `: ${log.title}` : ''}`);
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
});
|
||||
}, [deleteTargetId, allData]);
|
||||
|
||||
const handleBulkDelete = useCallback((selectedIds: string[]) => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error('삭제할 항목을 선택해주세요');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteIds(selectedIds);
|
||||
setIsBulkDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmBulkDelete = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
const result = await bulkDeleteVehicleLogs(bulkDeleteIds);
|
||||
|
||||
if (result.success) {
|
||||
setAllData(allData.filter((v) => !bulkDeleteIds.includes(v.id)));
|
||||
toast.success(`${bulkDeleteIds.length}개의 차량일지가 삭제되었습니다`);
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
setBulkDeleteIds([]);
|
||||
});
|
||||
}, [bulkDeleteIds, allData]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<VehicleLog> = useMemo(
|
||||
() => ({
|
||||
title: '차량일지/월간사진기록',
|
||||
description: '차량 운행 및 정비 기록을 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/vehicle-management/vehicle-log',
|
||||
|
||||
idField: 'id',
|
||||
getItemId: (item: VehicleLog) => item.id,
|
||||
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
const result = await getVehicleLogs({
|
||||
page: params?.page || 1,
|
||||
perPage: params?.perPage || 20,
|
||||
search: params?.search || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setAllData(result.data);
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.totalCount,
|
||||
totalPages: result.totalPages,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error || '데이터 조회에 실패했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteVehicleLog(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await bulkDeleteVehicleLogs(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 레거시 컬럼 구조 (5130 기준) - 5개 컬럼
|
||||
columns: [
|
||||
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'writeDate', label: '작성일', className: 'w-[100px]' },
|
||||
{ key: 'vehicleInfo', label: '차량종류', className: 'min-w-[180px]' },
|
||||
{ key: 'writer', label: '작성자', className: 'w-[80px]' },
|
||||
{ key: 'title', label: '글제목', className: 'min-w-[250px]' },
|
||||
],
|
||||
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
searchFilter: (item: VehicleLog, searchValue: string) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(search) ||
|
||||
item.vehicleInfo.toLowerCase().includes(search) ||
|
||||
item.writer.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
tabs: [],
|
||||
|
||||
searchPlaceholder: '제목, 차량종류, 작성자 검색...',
|
||||
|
||||
headerActions: () => (
|
||||
<Button
|
||||
className="ml-auto"
|
||||
onClick={() => router.push('/vehicle-management/vehicle-log/new')}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
일지 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
onBulkDelete: handleBulkDelete,
|
||||
|
||||
renderTableRow: (
|
||||
log: VehicleLog,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<VehicleLog>
|
||||
) => {
|
||||
return (
|
||||
<TableRow
|
||||
key={log.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(log)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={handlers.onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{log.writeDate}</TableCell>
|
||||
<TableCell>{log.vehicleInfo}</TableCell>
|
||||
<TableCell>{log.writer}</TableCell>
|
||||
<TableCell className="font-medium">{log.title}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
renderMobileCard: (
|
||||
log: VehicleLog,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<VehicleLog>
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={log.id}
|
||||
id={log.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
onClick={() => handleView(log)}
|
||||
title={log.title}
|
||||
subtitle={log.vehicleInfo}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="작성일" value={log.writeDate} />
|
||||
<InfoField label="작성자" value={log.writer} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
handlers.isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(log);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick(log.id);
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={initialData} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
description={
|
||||
<>
|
||||
{deleteTargetId
|
||||
? `제목: ${allData.find((v) => v.id === deleteTargetId)?.title || deleteTargetId}`
|
||||
: ''}
|
||||
<br />
|
||||
이 차량일지를 삭제하시겠습니까?
|
||||
</>
|
||||
}
|
||||
loading={isPending}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={isBulkDeleteDialogOpen}
|
||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
||||
description={
|
||||
<>
|
||||
선택한 {bulkDeleteIds.length}개의 차량일지를 삭제하시겠습니까?
|
||||
</>
|
||||
}
|
||||
loading={isPending}
|
||||
onConfirm={handleConfirmBulkDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VehicleLogList;
|
||||
174
src/components/vehicle-management/types.ts
Normal file
174
src/components/vehicle-management/types.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 차량/지게차 관리 타입 정의
|
||||
* 레거시 5130 사이트 컬럼 구조 기반 (2025-01-28 스크린샷 검증)
|
||||
*/
|
||||
|
||||
// ===== 엔진오일 교환 기록 =====
|
||||
export interface OilChangeRecord {
|
||||
id: string;
|
||||
date: string; // 교환일
|
||||
mileage: string; // 주행거리
|
||||
cost: string; // 비용
|
||||
}
|
||||
|
||||
// ===== 정비 기록 =====
|
||||
export interface MaintenanceRecord {
|
||||
id: string;
|
||||
date: string; // 정비일
|
||||
description: string; // 정비내역
|
||||
cost: string; // 비용
|
||||
}
|
||||
|
||||
// ===== 차량 관리 =====
|
||||
export interface Vehicle {
|
||||
id: string;
|
||||
// 기본 정보
|
||||
vehicleNumber: string; // 차량번호
|
||||
vehicleType: string; // 차종
|
||||
purchaseType: string; // 구매유형 (회사 소유, 렌트, 리스)
|
||||
firstRegistrationDate: string; // 최초등록/계약일
|
||||
|
||||
// 담당자 정보
|
||||
managerMain: string; // 담당자(정)
|
||||
managerSub: string; // 담당자(부)
|
||||
|
||||
// 구매/리스 정보
|
||||
purchaseDate: string; // 구매일자
|
||||
leaseEndDate: string; // 리스/렌트 종료일
|
||||
|
||||
// 주행/검사 정보
|
||||
totalMileage: string; // 총 주행거리 (km)
|
||||
mileageRecordDate: string; // 기록일
|
||||
inspectionStartDate: string; // 검사주기 시작일
|
||||
inspectionEndDate: string; // 검사주기 종료일
|
||||
|
||||
// 보험사 가입정보
|
||||
insuranceJoinDate: string; // 가입일
|
||||
insuranceCompany: string; // 보험회사, 증서번호
|
||||
insuranceContact: string; // 연락처
|
||||
insuranceFee: string; // 보험료
|
||||
insuranceAmount: string; // 가입금액
|
||||
|
||||
// 엔진오일 교환
|
||||
oilChangeCycle: string; // 엔진오일 교환주기 (Km)
|
||||
oilChangeRecords: OilChangeRecord[]; // 엔진오일 교환 기록 (테이블)
|
||||
|
||||
// 정비내역
|
||||
maintenanceRecords: MaintenanceRecord[]; // 정비 기록 (테이블)
|
||||
|
||||
// 비고
|
||||
remarks: string; // 비고
|
||||
}
|
||||
|
||||
export interface VehicleFormData {
|
||||
vehicleNumber: string;
|
||||
vehicleType: string;
|
||||
purchaseType: string;
|
||||
firstRegistrationDate: string;
|
||||
managerMain: string;
|
||||
managerSub: string;
|
||||
purchaseDate: string;
|
||||
leaseEndDate: string;
|
||||
totalMileage: string;
|
||||
mileageRecordDate: string;
|
||||
inspectionStartDate: string;
|
||||
inspectionEndDate: string;
|
||||
insuranceJoinDate: string;
|
||||
insuranceCompany: string;
|
||||
insuranceContact: string;
|
||||
insuranceFee: string;
|
||||
insuranceAmount: string;
|
||||
oilChangeCycle: string;
|
||||
oilChangeRecords: OilChangeRecord[];
|
||||
maintenanceRecords: MaintenanceRecord[];
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
// ===== 차량일지/월간사진기록 =====
|
||||
export interface VehicleLog {
|
||||
id: string;
|
||||
writeDate: string; // 작성일
|
||||
vehicleInfo: string; // 차량정보 (차종 - 차량번호)
|
||||
writer: string; // 작성자
|
||||
title: string; // 제목
|
||||
images?: string[]; // 첨부 이미지
|
||||
createdAt: string; // 등록일
|
||||
}
|
||||
|
||||
export interface VehicleLogFormData {
|
||||
writeDate: string;
|
||||
vehicleInfo: string; // 차량정보 (차종 - 차량번호)
|
||||
writer: string; // 작성자
|
||||
title: string; // 제목
|
||||
images?: string[]; // 첨부 이미지
|
||||
}
|
||||
|
||||
// ===== 지게차 부속품 교환 기록 =====
|
||||
export interface PartsChangeRecord {
|
||||
id: string;
|
||||
date: string; // 교환일
|
||||
mileage: string; // 주행거리
|
||||
cost: string; // 비용
|
||||
}
|
||||
|
||||
// ===== 지게차 정비 기록 =====
|
||||
export interface ForkliftMaintenanceRecord {
|
||||
id: string;
|
||||
date: string; // 정비일자
|
||||
description: string; // 정비내역 기록
|
||||
cost: string; // 비용
|
||||
}
|
||||
|
||||
// ===== 지게차 관리 =====
|
||||
export interface Forklift {
|
||||
id: string;
|
||||
vehicleNumber: string; // 지게차번호
|
||||
vehicleType: string; // 차종
|
||||
purchaseType: string; // 구매유형
|
||||
managerMain: string; // 담당자(정)
|
||||
managerSub: string; // 담당자(부)
|
||||
purchaseCompany: string; // 구입업체
|
||||
purchaseCompanyContact: string; // 구입업체 연락처
|
||||
totalMileage: string; // 총 주행거리 (km)
|
||||
mileageRecordDate: string; // 기록일
|
||||
firstRegistrationDate: string; // 최초등록일
|
||||
purchaseDate: string; // 구매일자
|
||||
partsChangeCycle: string; // 부속품 교환주기(Km)
|
||||
partsChangeRecords: PartsChangeRecord[]; // 부속품 교환 기록 (테이블)
|
||||
maintenanceRecords: ForkliftMaintenanceRecord[]; // 정비내역 (테이블)
|
||||
remarks: string; // 비고
|
||||
}
|
||||
|
||||
export interface ForkliftFormData {
|
||||
vehicleNumber: string;
|
||||
vehicleType: string;
|
||||
purchaseType: string;
|
||||
managerMain: string;
|
||||
managerSub: string;
|
||||
purchaseCompany: string;
|
||||
purchaseCompanyContact: string;
|
||||
totalMileage: string;
|
||||
mileageRecordDate: string;
|
||||
firstRegistrationDate: string;
|
||||
purchaseDate: string;
|
||||
partsChangeCycle: string;
|
||||
partsChangeRecords: PartsChangeRecord[];
|
||||
maintenanceRecords: ForkliftMaintenanceRecord[];
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
export interface ActionResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
success: boolean;
|
||||
data: T[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
error?: string;
|
||||
}
|
||||
@@ -903,7 +903,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col w-full">
|
||||
{/* 헤더 - 전체 너비 상단 고정 */}
|
||||
<header className="clean-glass px-8 py-5 mx-3 mt-3 mb-0 rounded-2xl clean-shadow relative overflow-hidden flex-shrink-0 sticky top-3 z-50 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<header className="clean-glass px-8 py-3 mx-3 mt-3 mb-0 rounded-2xl clean-shadow relative overflow-hidden flex-shrink-0 sticky top-3 z-50 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<div className="flex items-center space-x-6">
|
||||
{/* SAM 로고 섹션 - 클릭 시 대시보드로 이동 */}
|
||||
@@ -1161,7 +1161,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<main className="flex-1 overflow-auto p-3 md:p-6 pb-0">
|
||||
<main className="flex-1 overflow-auto pl-3 md:pl-6 pr-3 pt-3 md:pt-6 pb-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user