- 프론트엔드 개발자용 구현 가이드 (3개 메뉴) - 파일 구조, 타입 정의, API 호출 예시 포함 - 컴포넌트 활용 가이드, 배지 색상, 체크리스트 - vehicle-api.md 엔드포인트 수 17→20 업데이트
23 KiB
차량관리 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 호출
// 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 호출
// 목록 조회
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. 타입 정의
// 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 호출 예시
// 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/ |
검색형 선택 모달 |
사용 예시
// 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)로 분리한다.
핵심 로직
// 사진 업로드 (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(#1820)만 구현 완료 상태이며,
차량 CRUD(#16), 운행기록(#712), 정비이력(#1317) API는 이관 진행 예정이다.
API 완성 전에 타입 정의, 컴포넌트 구조, UI 레이아웃을 먼저 작업하고, API 연동은 엔드포인트 완성 후 순차적으로 연결하면 된다.
궁금한 사항은 docs/frontend/api-specs/vehicle-api.md 문서를 먼저 확인하고,
추가 질문은 Slack 또는 대면으로 요청한다.
최종 업데이트: 2026-03-12