# 차량관리 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
