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:
유병철
2026-01-28 14:53:20 +09:00
parent 805063c686
commit e5f0f5da61
43 changed files with 5165 additions and 135 deletions

View File

@@ -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 (차량/지게차 메뉴 추가)

View File

@@ -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 | ✅ |

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,11 @@
'use client';
/**
* 지게차 등록 페이지
*/
import { ForkliftDetail } from '@/components/vehicle-management/ForkliftDetail';
export default function ForkliftNewPage() {
return <ForkliftDetail mode="create" />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,11 @@
'use client';
/**
* 차량일지 등록 페이지
*/
import { VehicleLogDetail } from '@/components/vehicle-management/VehicleLogDetail';
export default function VehicleLogNewPage() {
return <VehicleLogDetail mode="create" />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,11 @@
'use client';
/**
* 차량 등록 페이지
*/
import { VehicleDetail } from '@/components/vehicle-management/VehicleDetail';
export default function VehicleNewPage() {
return <VehicleDetail mode="create" />;
}

View File

@@ -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} />;
}

View File

@@ -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}
/>

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -63,7 +63,6 @@ export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionPr
subLabel={item.subLabel}
isHighlighted={item.isHighlighted}
onClick={() => handleItemClick(item.path)}
icon={item.icon}
/>
))}
</div>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -56,15 +56,15 @@ export interface TodayIssueItem {
icon?: React.ComponentType<{ className?: string }>; // 카드 아이콘
}
// 오늘의 이슈 뱃지 타입 (최대 4자, 띄어쓰기 없음 - API TodayIssue 모델과 동기화)
// 오늘의 이슈 뱃지 타입 (API TodayIssue 모델과 동기화)
export type TodayIssueListBadgeType =
| '수주등록'
| '수주 성공'
| '추심 이슈'
| '안전재고'
| '지출승인'
| '적정 재고'
| '지출예상내역서'
| '세금 신고'
| '결재 요청'
| '신규업체'
| '신규거래처'
| '입금'
| '출금'
| '기타';

View File

@@ -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}

View File

@@ -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);

View File

@@ -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);

View 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: '닫기',
},
};

View 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;

View 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: '지게차 일괄 삭제 중 오류가 발생했습니다.',
};
}
}

View 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;

View 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: '닫기',
},
};

View 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;

View 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: '차량 일괄 삭제 중 오류가 발생했습니다.',
};
}
}

View 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;

View 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: '닫기',
},
};

View 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;

View 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: '차량일지 일괄 삭제 중 오류가 발생했습니다.',
};
}
}

View 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;

View 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;
}

View File

@@ -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>