Files
sam-docs/plans/vehicle-react-implementation.md
김보곤 93ddc74352 docs: [vehicle] 차량관리 React 구현 요청서 작성
- 프론트엔드 개발자용 구현 가이드 (3개 메뉴)
- 파일 구조, 타입 정의, API 호출 예시 포함
- 컴포넌트 활용 가이드, 배지 색상, 체크리스트
- vehicle-api.md 엔드포인트 수 17→20 업데이트
2026-03-12 22:38:13 +09:00

728 lines
23 KiB
Markdown

# 차량관리 React 구현 요청서
> **작성일**: 2026-03-12
> **요청자**: R&D 실장
> **대상**: 프론트엔드 개발자
> **우선순위**: 🟡 중요
> **API 상태**: 이관 진행중 (사진 API 구현 완료, CRUD API 구현 예정)
---
## 1. 개요
MNG에서 운영중인 차량관리 3개 메뉴를 React(Next.js)로 구현한다.
멀티테넌트 지원을 위한 이관 작업이며, API는 순차적으로 제공된다.
### 1.1 구현 대상
| 메뉴 | 설명 | 난이도 |
|------|------|:------:|
| **차량목록** | 법인/렌트/리스 차량 등록 관리 + 사진 | 중 |
| **차량일지** | 운행기록 CRUD + 월별 통계 | 중 |
| **정비이력** | 정비/주유/보험 비용 관리 + 카테고리별 집계 | 중 |
### 1.2 참고 문서
| 문서 | 경로 | 용도 |
|------|------|------|
| **API 명세** (필독) | `docs/frontend/api-specs/vehicle-api.md` | 전체 엔드포인트, 데이터 모델, UI 가이드 |
| 차량목록 기능 상세 | `docs/features/card-vehicle/corporate-vehicles.md` | MNG 기존 동작 |
| 차량일지 기능 상세 | `docs/features/card-vehicle/vehicle-logs.md` | MNG 기존 동작 |
| 정비이력 기능 상세 | `docs/features/card-vehicle/vehicle-maintenance.md` | MNG 기존 동작 |
---
## 2. 파일 구조 (제안)
```
src/
├── app/[locale]/(protected)/
│ ├── vehicles/ # 차량목록
│ │ ├── page.tsx # 목록 페이지
│ │ ├── create/page.tsx # 등록 페이지
│ │ └── [id]/
│ │ ├── page.tsx # 상세 페이지
│ │ └── edit/page.tsx # 수정 페이지
│ │
│ ├── vehicle-logs/ # 차량일지
│ │ └── page.tsx # 목록 + 등록/수정 모달
│ │
│ └── vehicle-maintenance/ # 정비이력
│ └── page.tsx # 목록 + 등록/수정 모달
├── components/vehicles/ # 차량 관련 컴포넌트
│ ├── VehicleListClient.tsx # 차량목록 클라이언트
│ ├── VehicleForm.tsx # 차량 등록/수정 폼
│ ├── VehiclePhotoSection.tsx # 사진 업로드/관리 영역
│ ├── VehicleDetailView.tsx # 차량 상세 뷰
│ ├── VehicleLogListClient.tsx # 차량일지 클라이언트
│ ├── VehicleLogModal.tsx # 차량일지 등록/수정 모달
│ ├── VehicleMaintenanceListClient.tsx # 정비이력 클라이언트
│ └── VehicleMaintenanceModal.tsx # 정비이력 등록/수정 모달
├── lib/api/
│ └── vehicle.ts # 차량 관련 Server Action
├── hooks/
│ ├── useVehicleList.ts # 차량목록 훅
│ ├── useVehicleLogs.ts # 차량일지 훅
│ └── useVehicleMaintenance.ts # 정비이력 훅
└── types/
└── vehicle.ts # 차량 관련 타입 정의
```
---
## 3. 메뉴별 구현 상세
### 3.1 차량목록
#### 화면 구성
```
┌─ PageHeader ──────────────────────────
│ 제목: "차량목록"
│ [검색] [Excel 다운로드] [+ 차량 등록]
├─ StatCards (4열) ─────────────────────
│ 총 차량 | 법인 취득가 | 월 렌트/리스비 | 총 주행거리
├─ 필터 바 ─────────────────────────────
│ 소유형태: [전체] [법인] [렌트] [리스] ← 탭 or 버튼 그룹
│ 상태: [전체] [운행중] [정비중] [처분]
├─ DataTable ───────────────────────────
│ 차량번호 | 차종/모델 | 소유형태(배지) | 운전자 | 주행거리 | 상태(배지)
│ └─ 행 클릭 → /vehicles/{id} 상세 페이지
└─ 빈 상태: EmptyState "등록된 차량이 없습니다."
```
#### API 호출
```typescript
// lib/api/vehicle.ts
export async function getVehicles(filters) {
return executePaginatedAction({
url: buildApiUrl('/api/v1/corporate-vehicles', filters),
transform: mapVehicleResponse,
errorMessage: '차량 목록 조회 실패',
});
}
```
#### 요약 카드 계산 (프론트엔드에서 처리)
| 카드 | 계산식 |
|------|--------|
| 총 차량 | 목록 전체 건수 |
| 법인 취득가 | `ownership_type === 'corporate'`인 차량의 `purchase_price` 합계 |
| 월 렌트/리스비 | `ownership_type in ('rent','lease')`인 차량의 `monthly_rent + monthly_rent_tax` 합계 |
| 총 주행거리 | 전체 차량의 `total_mileage` 합계 |
#### 등록/수정 폼 — 조건부 필드
`ownership_type` 값에 따라 폼 하단 필드가 변경된다:
| 소유형태 | 표시 필드 |
|---------|----------|
| `corporate` (법인) | 취득일자, 취득가액 |
| `rent` / `lease` | 계약일자, 렌트/리스사, 연락처, 계약기간, 약정주행거리, 차량가액, 잔존가치, 보증금, 월렌트료(공급가), 월렌트료(부가세), 보험사, 보험사 연락처 |
#### 사진 기능 (차량 상세/수정 페이지)
```
┌─ 사진 영역 ──────────────────────────
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ │ 📷 │ │ 📷 │ │ 📷 │ │ + │
│ │ 사진1 │ │ 사진2 │ │ 사진3 │ │ 추가 │
│ └──────┘ └──────┘ └──────┘ └──────┘
│ 3 / 10장
└──────────────────────────────────────
```
| 기능 | 설명 |
|------|------|
| 업로드 | `POST /{id}/photos`, `multipart/form-data`, `files[]` 다중 선택 |
| 삭제 | `DELETE /{id}/photos/{fileId}`, 각 사진에 X 버튼 |
| 제한 | 최대 10장, 10MB/파일, jpg/jpeg/png/gif/bmp/webp |
| 미리보기 | 업로드 전 FileReader API로 미리보기 |
| 확대 보기 | 사진 클릭 시 모달 확대 (선택 구현) |
| 카운터 | `{현재 수} / 10장` 표시, 10장 도달 시 추가 버튼 비활성화 |
#### 배지 색상
**소유형태 (`ownership_type`)**:
| 값 | 라벨 | 색상 |
|----|------|------|
| `corporate` | 법인 | purple |
| `rent` | 렌트 | blue |
| `lease` | 리스 | green |
**상태 (`status`)**:
| 값 | 라벨 | 색상 |
|----|------|------|
| `active` | 운행중 | green |
| `maintenance` | 정비중 | yellow |
| `disposed` | 처분 | red |
---
### 3.2 차량일지
#### 화면 구성
```
┌─ PageHeader ──────────────────────────
│ 차량: [드롭다운 ▼] (현재 주행거리: 17,350 km)
│ [2026년 ▼] [3월 ▼] [CSV 다운로드] [+ 기록 추가]
├─ StatCards (4열) ─────────────────────
│ 출근 (5건 / 125km) | 퇴근 (5건 / 130km) | 업무 (10건 / 280km) | 비업무 (2건 / 45km)
├─ DataTable ───────────────────────────
│ 날짜 | 차량 | 부서/이름 | 용도(배지) | 출발지 | 도착지 | 거리(km) | 비고 | 작업
│ └─ 작업: [복사] [수정] [삭제]
└─ 등록/수정 모달 (StandardDialog)
```
#### API 호출
```typescript
// 목록 조회
getVehicleLogs({ vehicle_id, year, month, trip_type, search })
// 통계 조회 (별도 API)
getVehicleLogSummary({ vehicle_id, year, month })
// 차량 드롭다운
getVehicleDropdown() // GET /api/v1/corporate-vehicles/dropdown
```
#### 통계 카드 (summary API 응답 사용)
API 응답 `data.byType`의 각 항목에서 `count``distance`를 표시한다.
`data.total`로 전체 합계도 표시 가능하다.
#### 등록/수정 모달
```
┌─ 모달 ────────────────────────────────
│ 차량: [드롭다운 ▼]
│ 날짜: [2026-03-12] 부서: [영업부]
│ 운전자: [홍길동] 용도: [업무용 ▼]
│ ── 출발지 ──
│ 유형: [회사 ▼] 이름: [본사] 주소: [서울시 강남구...]
│ ── 도착지 ── [↕ 출발↔도착 교환]
│ 유형: [거래처 ▼] 이름: [거래처A] 주소: [경기도 성남시...]
│ 운행거리: [45] km
│ 비고: [거래처방문 ]
│ [거래처방문] [제조시설등] [회의참석] [판촉활동] [교육등] ← 프리셋 버튼
│ [삭제] [취소] [저장]
└───────────────────────────────────────
```
#### 특수 기능 (프론트엔드 로직)
| 기능 | 동작 |
|------|------|
| **기록 복사** | 기존 행의 데이터를 모달에 채워서 열기 (날짜만 오늘로 변경). API는 일반 POST |
| **출발↔도착 교환** | 출발지/도착지 필드 swap + `tripType` 자동 전환 (`commute_to``commute_from`) |
| **비고 프리셋** | 5개 버튼 클릭 시 비고 input에 텍스트 삽입 |
| **왕복 용도** | `commute_round`, `business_round`, `personal_round` 선택 시 편도의 2배로 `distance_km` 자동 제안 (선택) |
#### 용도 배지 색상
| 값 | 라벨 | 색상 |
|----|------|------|
| `commute_to` | 출근용 | green |
| `commute_from` | 퇴근용 | blue |
| `business` | 업무용 | purple |
| `personal` | 비업무 | gray |
| `commute_round` | 출퇴근 왕복 | green |
| `business_round` | 업무용 왕복 | purple |
| `personal_round` | 비업무 왕복 | gray |
#### 위치 유형 드롭다운
| 값 | 라벨 |
|----|------|
| `home` | 자택 |
| `office` | 회사 |
| `client` | 거래처 |
| `other` | 기타 |
---
### 3.3 정비이력
#### 화면 구성
```
┌─ PageHeader ──────────────────────────
│ 제목: "정비이력"
│ [새로고침] [CSV 다운로드] [+ 정비 등록]
├─ StatCards (4열) ─────────────────────
│ 총 정비비용 | 주유비 | 정비비 | 기타비용
├─ 필터 영역 ───────────────────────────
│ 기간: [시작일] ~ [종료일] (기본: 최근 3개월)
│ 카테고리: [전체] [주유] [정비] [보험] [세차] [주차] [통행료] [검사] [기타]
│ 차량: [드롭다운 ▼]
│ 검색: [설명, 업체명 검색...]
├─ DataTable ───────────────────────────
│ 날짜 | 차량 | 카테고리(배지) | 설명 | 금액 | 주행거리 | 작업
│ └─ 작업: [수정] [삭제]
└─ 등록/수정 모달 (StandardDialog)
```
#### 요약 카드 계산 (조회된 목록 데이터 기준)
| 카드 | 계산식 |
|------|--------|
| 총 정비비용 | 전체 `amount` 합계 |
| 주유비 | `category === '주유'``amount` 합계 |
| 정비비 | `category === '정비'``amount` 합계 |
| 기타비용 | 총 정비비용 - 주유비 - 정비비 |
#### 등록/수정 모달
```
┌─ 모달 ────────────────────────────────
│ 차량: [드롭다운 ▼]
│ 날짜: [2026-03-10]
│ 카테고리: [주유 ▼]
│ 설명: [LPG 충전]
│ 금액: [85,000] 원 ← currency 포맷
│ 주행거리: [17,350] km
│ 업체명: [SK에너지 강남점]
│ 메모: [ ]
│ [삭제] [취소] [저장]
└───────────────────────────────────────
```
#### 카테고리 배지 색상
| 카테고리 | 색상 |
|---------|------|
| 주유 | amber |
| 정비 | blue |
| 보험 | emerald |
| 세차 | cyan |
| 주차 | purple |
| 통행료 | orange |
| 검사 | indigo |
| 기타 | gray |
#### 부수 효과 안내
정비 등록/수정 시 `mileage` 값이 있으면 해당 차량의 기준 주행거리가 자동 갱신된다.
프론트엔드에서는 별도 처리 불필요 (API가 자동 처리).
---
## 4. 타입 정의
```typescript
// types/vehicle.ts
// ─── 차량 ───
export interface CorporateVehicle {
id: number;
plateNumber: string;
model: string;
vehicleType: string;
ownershipType: 'corporate' | 'rent' | 'lease';
year?: number;
driver?: string;
status: 'active' | 'maintenance' | 'disposed';
mileage: number;
memo?: string;
// 법인 전용
purchaseDate?: string;
purchasePrice?: number;
// 렌트/리스 전용
contractDate?: string;
rentCompany?: string;
rentCompanyTel?: string;
rentPeriod?: string;
agreedMileage?: string;
vehiclePrice?: number;
residualValue?: number;
deposit?: number;
monthlyRent?: number;
monthlyRentTax?: number;
insuranceCompany?: string;
insuranceCompanyTel?: string;
// 계산 필드
logDistance: number;
totalMileage: number;
}
export interface VehicleDropdownItem {
id: number;
plateNumber: string;
model: string;
}
// ─── 차량 사진 ───
export interface VehiclePhoto {
id: number;
fileName: string;
filePath: string;
fileUrl: string;
fileSize: number;
mimeType: string;
createdAt: string;
}
// ─── 운행기록 ───
export interface VehicleLog {
id: number;
logDate: string;
vehicleId: number;
plateNumber: string;
model: string;
department?: string;
driverName: string;
tripType: TripType;
departureType?: LocationType;
departureName?: string;
departureAddress?: string;
arrivalType?: LocationType;
arrivalName?: string;
arrivalAddress?: string;
distanceKm: number;
note?: string;
}
export type TripType =
| 'commute_to' | 'commute_from'
| 'business' | 'personal'
| 'commute_round' | 'business_round' | 'personal_round';
export type LocationType = 'home' | 'office' | 'client' | 'other';
export interface VehicleLogSummary {
byType: Record<string, { label: string; count: number; distance: number }>;
total: { count: number; distance: number };
}
// ─── 정비이력 ───
export interface VehicleMaintenance {
id: number;
date: string;
vehicleId: number;
plateNumber: string;
model: string;
category: MaintenanceCategory;
description?: string;
amount: number;
mileage?: number;
vendor?: string;
memo?: string;
}
export type MaintenanceCategory =
| '주유' | '정비' | '보험' | '세차'
| '주차' | '통행료' | '검사' | '기타';
```
---
## 5. API 호출 예시
```typescript
// lib/api/vehicle.ts
'use server';
import { buildApiUrl } from '@/lib/api/query-params';
import { executePaginatedAction, executeServerAction } from '@/lib/api/execute-server-action';
// ─── 차량 목록 ───
export async function getVehicles(params: {
ownership_type?: string;
vehicle_type?: string;
status?: string;
search?: string;
}) {
return executePaginatedAction({
url: buildApiUrl('/api/v1/corporate-vehicles', params),
transform: mapVehicleResponse,
errorMessage: '차량 목록 조회 실패',
});
}
// ─── 차량 드롭다운 ───
export async function getVehicleDropdown() {
return executeServerAction({
url: buildApiUrl('/api/v1/corporate-vehicles/dropdown'),
errorMessage: '차량 목록 조회 실패',
});
}
// ─── 차량 등록 ───
export async function createVehicle(data: Record<string, unknown>) {
return executeServerAction({
url: buildApiUrl('/api/v1/corporate-vehicles'),
options: { method: 'POST', body: JSON.stringify(data) },
errorMessage: '차량 등록 실패',
});
}
// ─── 차량 사진 ───
export async function getVehiclePhotos(vehicleId: number) {
return executeServerAction({
url: buildApiUrl(`/api/v1/corporate-vehicles/${vehicleId}/photos`),
errorMessage: '사진 조회 실패',
});
}
// ─── 운행기록 목록 ───
export async function getVehicleLogs(params: {
vehicle_id?: number;
year?: number;
month?: number;
trip_type?: string;
search?: string;
}) {
return executePaginatedAction({
url: buildApiUrl('/api/v1/vehicle-logs', params),
transform: (item) => item, // camelCase 그대로
errorMessage: '운행기록 조회 실패',
});
}
// ─── 운행기록 통계 ───
export async function getVehicleLogSummary(params: {
vehicle_id?: number;
year?: number;
month?: number;
}) {
return executeServerAction({
url: buildApiUrl('/api/v1/vehicle-logs/summary', params),
errorMessage: '운행 통계 조회 실패',
});
}
// ─── 정비이력 목록 ───
export async function getMaintenances(params: {
vehicle_id?: number;
category?: string;
start_date?: string;
end_date?: string;
search?: string;
}) {
return executePaginatedAction({
url: buildApiUrl('/api/v1/vehicle-maintenances', params),
transform: (item) => item,
errorMessage: '정비이력 조회 실패',
});
}
```
---
## 6. 공통 컴포넌트 활용 가이드
기존 프로젝트에 구현된 공통 컴포넌트를 **반드시 재사용**한다.
| 컴포넌트 | 위치 | 용도 |
|---------|------|------|
| `PageLayout` | `components/organisms/` | 페이지 기본 레이아웃 |
| `PageHeader` | `components/organisms/` | 페이지 헤더 (제목 + 액션 버튼) |
| `StatCards` | `components/organisms/` | 요약 통계 카드 |
| `DataTable` | `components/organisms/` | 데이터 테이블 |
| `EmptyState` | `components/organisms/` | 빈 상태 표시 |
| `StandardDialog` | `components/molecules/` | 모달 다이얼로그 |
| `FormField` | `components/molecules/` | 통합 폼 필드 (text, number, date, select, currency 등) |
| `StatusBadge` | `components/molecules/` | 상태 배지 |
| `DateRangeSelector` | `components/molecules/` | 기간 선택 |
| `SearchableSelectionModal` | `components/organisms/` | 검색형 선택 모달 |
### 사용 예시
```tsx
// FormField 사용 (신규 폼 필수)
<FormField
label="차량번호"
type="text"
value={plateNumber}
onChange={setPlateNumber}
required
error={errors.plateNumber}
/>
<FormField
label="취득가액"
type="currency"
value={purchasePrice}
onChange={setPurchasePrice}
unit="원"
/>
// StatusBadge 사용
<StatusBadge status={vehicle.status} config={vehicleStatusConfig} />
```
---
## 7. 숫자/금액 포맷
| 항목 | 포맷 | 예시 |
|------|------|------|
| 금액 | 천단위 콤마 + "원" | `85,000원` |
| 거리 | 천단위 콤마 + "km" | `17,350 km` |
| 취득가 | 천단위 콤마 + "원" | `85,000,000원` |
| 월렌트료 | 천단위 콤마 + "원" | `1,200,000원` |
기존 `lib/utils/amount.ts`의 포맷 함수를 사용한다.
---
## 8. 사진 업로드 컴포넌트 구현 가이드
사진 업로드는 별도 컴포넌트(`VehiclePhotoSection`)로 분리한다.
### 핵심 로직
```typescript
// 사진 업로드 (multipart/form-data)
async function uploadPhotos(vehicleId: number, files: File[]) {
const formData = new FormData();
files.forEach(file => formData.append('files[]', file));
// Next.js 프록시를 통해 전송
const res = await fetch(`/api/proxy/corporate-vehicles/${vehicleId}/photos`, {
method: 'POST',
body: formData,
// Content-Type 헤더를 직접 설정하지 않음 (브라우저가 boundary 자동 설정)
});
return res.json();
}
```
### 주의사항
```
❌ Content-Type: multipart/form-data 직접 설정 금지 (boundary 누락됨)
❌ JSON.stringify(formData) 금지
✅ FormData 객체를 body에 그대로 전달
✅ 파일 선택 시 남은 수량(10 - 현재) 체크 후 초과분 차단
```
---
## 9. 라우팅 및 메뉴 등록
### 페이지 라우트
| 경로 | 페이지 |
|------|--------|
| `/vehicles` | 차량 목록 |
| `/vehicles/create` | 차량 등록 |
| `/vehicles/{id}` | 차량 상세 |
| `/vehicles/{id}/edit` | 차량 수정 |
| `/vehicle-logs` | 차량일지 (모달 CRUD) |
| `/vehicle-maintenance` | 정비이력 (모달 CRUD) |
### 메뉴 등록
메뉴 DB 등록은 별도 요청 예정. 개발 단계에서는 URL 직접 접근으로 테스트한다.
---
## 10. 데이터 관계 요약
```
corporate_vehicles (차량 마스터)
├── vehicle_logs (1:N)
│ 운행기록 → distance_km 합산 → 차량 total_mileage 계산
├── vehicle_maintenances (1:N)
│ 정비기록 → mileage 입력 시 차량 기준 주행거리 자동 갱신
└── files (1:N, polymorphic)
사진 → 최대 10장, R2 스토리지
총 주행거리 = mileage (정비 갱신 기준값) + SUM(vehicle_logs.distance_km)
```
---
## 11. 작업 순서 (권장)
| 순서 | 작업 | 의존성 |
|:----:|------|--------|
| 1 | `types/vehicle.ts` 타입 정의 | 없음 |
| 2 | `lib/api/vehicle.ts` Server Action | 타입 정의 |
| 3 | **차량목록** 페이지 (가장 기본) | Server Action |
| 4 | 차량 사진 컴포넌트 | 차량목록 |
| 5 | **차량일지** 페이지 (드롭다운이 차량목록 API 사용) | 차량목록 API |
| 6 | **정비이력** 페이지 (드롭다운이 차량목록 API 사용) | 차량목록 API |
---
## 12. 체크리스트
### 공통
- [ ] 모든 페이지 `'use client'` 선언
- [ ] `FormField` 컴포넌트 사용 (Label+Input 수동 조합 금지)
- [ ] `buildApiUrl` 사용 (new URLSearchParams 금지)
- [ ] 금액은 `currency` 포맷, 거리는 천단위 콤마 + km
- [ ] 빈 상태 표시 (`EmptyState` 컴포넌트)
- [ ] 에러 처리 (토스트 알림)
### 차량목록
- [ ] 소유형태별 조건부 폼 필드 (법인 vs 렌트/리스)
- [ ] 요약 카드 4개 (총 차량, 법인 취득가, 월 렌트/리스비, 총 주행거리)
- [ ] 소유형태/상태 배지 색상
- [ ] 사진 업로드/삭제 (최대 10장, 카운터 표시)
### 차량일지
- [ ] 차량 드롭다운 (dropdown API)
- [ ] 연/월 선택 필터
- [ ] 용도별 통계 카드 (summary API)
- [ ] 기록 복사 기능 (날짜만 오늘로)
- [ ] 출발↔도착 교환 기능 (tripType 자동 전환)
- [ ] 비고 프리셋 버튼 5개
### 정비이력
- [ ] 기간 필터 (기본: 최근 3개월)
- [ ] 카테고리 필터 (8종)
- [ ] 요약 카드 4개 (총 정비비용, 주유비, 정비비, 기타비용)
- [ ] 카테고리별 배지 색상 (8종)
- [ ] 금액 currency 포맷
---
## 13. 질문/논의 사항
API 구현은 순차적으로 진행된다.
현재 **차량 사진 API**(#18~20)만 구현 완료 상태이며,
차량 CRUD(#1~6), 운행기록(#7~12), 정비이력(#13~17) API는 이관 진행 예정이다.
API 완성 전에 타입 정의, 컴포넌트 구조, UI 레이아웃을 먼저 작업하고,
API 연동은 엔드포인트 완성 후 순차적으로 연결하면 된다.
궁금한 사항은 `docs/frontend/api-specs/vehicle-api.md` 문서를 먼저 확인하고,
추가 질문은 Slack 또는 대면으로 요청한다.
---
**최종 업데이트**: 2026-03-12