feat: [vehicle] 법인차량 관리 모듈 + MES 분석 보고서 + 프론트엔드 문서

- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력)
- MES 데이터 정합성 분석 보고서 v1/v2
- sam-docs 프론트엔드 기술문서 v1 (9개 챕터)
- claudedocs 가이드/테스트URL 업데이트
This commit is contained in:
유병철
2026-03-13 17:52:57 +09:00
parent 80164f722e
commit c309ac479f
27 changed files with 6383 additions and 31 deletions

View File

@@ -0,0 +1,17 @@
import { Suspense } from 'react';
import { CorporateVehicleList } from '@/components/vehicle/CorporateVehicles';
import { ListPageSkeleton } from '@/components/ui/skeleton';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '법인차량관리',
description: '법인/렌트/리스 차량을 관리합니다',
};
export default function CorporateVehiclesPage() {
return (
<Suspense fallback={<ListPageSkeleton showHeader={false} showStats={true} statsCount={4} />}>
<CorporateVehicleList />
</Suspense>
);
}

View File

@@ -0,0 +1,17 @@
import { Suspense } from 'react';
import { VehicleLogList } from '@/components/vehicle/VehicleLogs';
import { ListPageSkeleton } from '@/components/ui/skeleton';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '차량일지',
description: '차량 운행기록을 관리합니다',
};
export default function VehicleLogsPage() {
return (
<Suspense fallback={<ListPageSkeleton showHeader={false} showStats={true} statsCount={5} />}>
<VehicleLogList />
</Suspense>
);
}

View File

@@ -0,0 +1,17 @@
import { Suspense } from 'react';
import { VehicleMaintenanceList } from '@/components/vehicle/VehicleMaintenance';
import { ListPageSkeleton } from '@/components/ui/skeleton';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '정비이력',
description: '차량 정비 및 유지비 이력을 관리합니다',
};
export default function VehicleMaintenancePage() {
return (
<Suspense fallback={<ListPageSkeleton showHeader={false} showStats={true} statsCount={4} />}>
<VehicleMaintenanceList />
</Suspense>
);
}

View File

@@ -0,0 +1,441 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { Loader2 } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
type CorporateVehicle,
type VehicleFormData,
type OwnershipType,
EMPTY_VEHICLE_FORM,
VEHICLE_TYPES,
} from '../types';
import {
createCorporateVehicle,
updateCorporateVehicle,
deleteCorporateVehicle,
} from './actions';
interface VehicleFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: 'create' | 'edit';
vehicle?: CorporateVehicle | null;
onSuccess: () => void;
}
export function VehicleFormDialog({
open,
onOpenChange,
mode,
vehicle,
onSuccess,
}: VehicleFormDialogProps) {
const [form, setForm] = useState<VehicleFormData>(EMPTY_VEHICLE_FORM);
const [isSaving, setIsSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isEdit = mode === 'edit';
const isCorporate = form.ownershipType !== 'rent' && form.ownershipType !== 'lease';
useEffect(() => {
if (!open) return;
if (isEdit && vehicle) {
setForm({
plateNumber: vehicle.plateNumber,
vehicleType: vehicle.vehicleType || '',
ownershipType: vehicle.ownershipType || '',
model: vehicle.model,
year: vehicle.year ? String(vehicle.year) : '',
purchaseDate: vehicle.purchaseDate || '',
contractDate: vehicle.contractDate || '',
rentCompany: vehicle.rentCompany || '',
rentPeriod: vehicle.rentPeriod || '',
purchasePrice: vehicle.purchasePrice ? String(vehicle.purchasePrice) : '',
monthlyRent: vehicle.monthlyRent ? String(vehicle.monthlyRent) : '',
monthlyRentTax: vehicle.monthlyRentTax ? String(vehicle.monthlyRentTax) : '',
rentCompanyTel: vehicle.rentCompanyTel || '',
agreedMileage: vehicle.agreedMileage || '',
vehiclePrice: vehicle.vehiclePrice ? String(vehicle.vehiclePrice) : '',
residualValue: vehicle.residualValue ? String(vehicle.residualValue) : '',
deposit: vehicle.deposit ? String(vehicle.deposit) : '',
mileage: vehicle.mileage ? String(vehicle.mileage) : '',
insuranceCompany: vehicle.insuranceCompany || '',
insuranceCompanyTel: vehicle.insuranceCompanyTel || '',
driver: vehicle.driver || '',
status: vehicle.status || '',
memo: vehicle.memo || '',
});
} else {
setForm(EMPTY_VEHICLE_FORM);
}
}, [open, isEdit, vehicle]);
const updateField = useCallback(
<K extends keyof VehicleFormData>(key: K, value: VehicleFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
},
[]
);
const handleSubmit = async () => {
if (!form.plateNumber || !form.ownershipType || !form.model) {
toast.error('필수 항목을 입력해주세요.');
return;
}
setIsSaving(true);
try {
const result = isEdit && vehicle
? await updateCorporateVehicle(vehicle.id, form)
: await createCorporateVehicle(form);
if (result.success) {
toast.success(isEdit ? '차량이 수정되었습니다.' : '차량이 등록되었습니다.');
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!vehicle) return;
setIsDeleting(true);
try {
const result = await deleteCorporateVehicle(vehicle.id);
if (result.success) {
toast.success('차량이 삭제되었습니다.');
setDeleteOpen(false);
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? '차량 수정' : '차량 등록'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Row 1: 차량번호, 종류, 구분 */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Input
value={form.plateNumber}
onChange={(e) => updateField('plateNumber', e.target.value)}
placeholder="12가 3456"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Select
key={`vt-${form.vehicleType}`}
value={form.vehicleType}
onValueChange={(v) => updateField('vehicleType', v as VehicleFormData['vehicleType'])}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{VEHICLE_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Select
key={`ot-${form.ownershipType}`}
value={form.ownershipType}
onValueChange={(v) => updateField('ownershipType', v as OwnershipType)}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="corporate"></SelectItem>
<SelectItem value="rent"></SelectItem>
<SelectItem value="lease"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Row 2: 모델 */}
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Input
value={form.model}
onChange={(e) => updateField('model', e.target.value)}
placeholder="차량 모델명"
/>
</div>
{/* Row 3: 연식, 취득일/계약일자 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.year}
onChange={(e) => updateField('year', e.target.value)}
placeholder="2026"
/>
</div>
<div className="space-y-1.5">
<Label>{isCorporate ? '취득일' : '계약일자'}</Label>
{isCorporate ? (
<DatePicker
value={form.purchaseDate}
onChange={(v) => updateField('purchaseDate', v)}
placeholder="연도. 월. 일."
/>
) : (
<DatePicker
value={form.contractDate}
onChange={(v) => updateField('contractDate', v)}
placeholder="연도. 월. 일."
/>
)}
</div>
</div>
{/* Row 4: 구매처/렌트회사명, 계약기간/렌트기간 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{isCorporate ? '구매처' : '렌트회사명'}</Label>
<Input
value={form.rentCompany}
onChange={(e) => updateField('rentCompany', e.target.value)}
placeholder={isCorporate ? '회사명' : '렌트회사명'}
/>
</div>
<div className="space-y-1.5">
<Label>{isCorporate ? '계약기간' : '렌트기간'}</Label>
<Input
value={form.rentPeriod}
onChange={(e) => updateField('rentPeriod', e.target.value)}
placeholder="예: 36개월"
/>
</div>
</div>
{/* Row 5: 취득가(공급가)/월렌트료(공급가), 세액 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{isCorporate ? '취득가 (공급가)' : '월 렌트료 (공급가)'}</Label>
{isCorporate ? (
<Input
type="number"
value={form.purchasePrice}
onChange={(e) => updateField('purchasePrice', e.target.value)}
placeholder="0"
/>
) : (
<Input
type="number"
value={form.monthlyRent}
onChange={(e) => updateField('monthlyRent', e.target.value)}
placeholder="0"
/>
)}
</div>
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.monthlyRentTax}
onChange={(e) => updateField('monthlyRentTax', e.target.value)}
placeholder="0"
/>
</div>
</div>
{/* Row 6: 회사 연락처, 약정운행거리 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> </Label>
<Input
value={form.rentCompanyTel}
onChange={(e) => updateField('rentCompanyTel', e.target.value)}
placeholder="연락처"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.agreedMileage}
onChange={(e) => updateField('agreedMileage', e.target.value)}
placeholder="km"
/>
</div>
</div>
{/* Row 7: 차량가격, 추정잔존가액 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.vehiclePrice}
onChange={(e) => updateField('vehiclePrice', e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.residualValue}
onChange={(e) => updateField('residualValue', e.target.value)}
placeholder="0"
/>
</div>
</div>
{/* Row 8: 보증금, 최초 주행거리 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.deposit}
onChange={(e) => updateField('deposit', e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-1.5">
<Label> (km)</Label>
<Input
type="number"
value={form.mileage}
onChange={(e) => updateField('mileage', e.target.value)}
placeholder="0"
/>
</div>
</div>
{/* Row 9: 보험사명, 보험사 연락처 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.insuranceCompany}
onChange={(e) => updateField('insuranceCompany', e.target.value)}
placeholder="보험사명"
/>
</div>
<div className="space-y-1.5">
<Label> </Label>
<Input
value={form.insuranceCompanyTel}
onChange={(e) => updateField('insuranceCompanyTel', e.target.value)}
placeholder="연락처"
/>
</div>
</div>
{/* Row 10: 운전자, 상태 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.driver}
onChange={(e) => updateField('driver', e.target.value)}
placeholder="운전자명"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Select
key={`st-${form.status}`}
value={form.status}
onValueChange={(v) => updateField('status', v as VehicleFormData['status'])}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="active"></SelectItem>
<SelectItem value="maintenance"></SelectItem>
<SelectItem value="disposed"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Row 11: 메모 */}
<div className="space-y-1.5">
<Label></Label>
<Textarea
value={form.memo}
onChange={(e) => updateField('memo', e.target.value)}
placeholder="메모"
rows={2}
/>
</div>
</div>
{/* 버튼 */}
<div className="flex items-center gap-2 pt-2">
{isEdit && (
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteOpen(true)}
>
</Button>
)}
<div className="flex-1" />
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSubmit} disabled={isSaving}>
{isSaving && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
{isEdit ? '저장' : '등록'}
</Button>
</div>
</DialogContent>
</Dialog>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description="이 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
loading={isDeleting}
onConfirm={handleDelete}
/>
</>
);
}

View File

@@ -0,0 +1,134 @@
'use server';
import { buildApiUrl } from '@/lib/api/query-params';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import {
type CorporateVehicleApi,
type VehicleFormData,
transformVehicleApi,
transformVehicleDropdown,
} from '../types';
// ===== 차량 목록 (페이지네이션) =====
export async function getCorporateVehicles(params: {
page?: number;
perPage?: number;
search?: string;
ownershipType?: string;
status?: string;
}) {
return executePaginatedAction<CorporateVehicleApi, ReturnType<typeof transformVehicleApi>>({
url: buildApiUrl('/api/v1/corporate-vehicles', {
page: params.page,
per_page: params.perPage,
search: params.search,
ownership_type: params.ownershipType !== 'all' ? params.ownershipType : undefined,
status: params.status !== 'all' ? params.status : undefined,
}),
transform: transformVehicleApi,
errorMessage: '차량 목록 조회에 실패했습니다.',
});
}
// ===== 차량 단건 조회 =====
export async function getCorporateVehicleById(id: number) {
return executeServerAction<CorporateVehicleApi, ReturnType<typeof transformVehicleApi>>({
url: buildApiUrl(`/api/v1/corporate-vehicles/${id}`),
transform: transformVehicleApi,
errorMessage: '차량 정보 조회에 실패했습니다.',
});
}
// ===== 차량 등록 =====
export async function createCorporateVehicle(formData: VehicleFormData) {
const isCorporate = formData.ownershipType === 'corporate';
return executeServerAction({
url: buildApiUrl('/api/v1/corporate-vehicles'),
method: 'POST',
body: {
plate_number: formData.plateNumber,
model: formData.model,
vehicle_type: formData.vehicleType,
ownership_type: formData.ownershipType,
year: formData.year ? Number(formData.year) : null,
driver: formData.driver || null,
status: formData.status || 'active',
memo: formData.memo || null,
// 법인
purchase_date: isCorporate ? formData.purchaseDate || null : null,
purchase_price: isCorporate ? Number(formData.purchasePrice) || 0 : 0,
// 렌트/리스
contract_date: !isCorporate ? formData.contractDate || null : null,
rent_company: formData.rentCompany || null,
rent_company_tel: formData.rentCompanyTel || null,
rent_period: formData.rentPeriod || null,
agreed_mileage: formData.agreedMileage || null,
vehicle_price: Number(formData.vehiclePrice) || 0,
residual_value: Number(formData.residualValue) || 0,
deposit: Number(formData.deposit) || 0,
monthly_rent: !isCorporate ? Number(formData.monthlyRent) || 0 : 0,
monthly_rent_tax: Number(formData.monthlyRentTax) || 0,
mileage: Number(formData.mileage) || 0,
insurance_company: formData.insuranceCompany || null,
insurance_company_tel: formData.insuranceCompanyTel || null,
},
errorMessage: '차량 등록에 실패했습니다.',
});
}
// ===== 차량 수정 =====
export async function updateCorporateVehicle(id: number, formData: VehicleFormData) {
const isCorporate = formData.ownershipType === 'corporate';
return executeServerAction({
url: buildApiUrl(`/api/v1/corporate-vehicles/${id}`),
method: 'PUT',
body: {
plate_number: formData.plateNumber,
model: formData.model,
vehicle_type: formData.vehicleType,
ownership_type: formData.ownershipType,
year: formData.year ? Number(formData.year) : null,
driver: formData.driver || null,
status: formData.status || 'active',
memo: formData.memo || null,
purchase_date: isCorporate ? formData.purchaseDate || null : null,
purchase_price: isCorporate ? Number(formData.purchasePrice) || 0 : 0,
contract_date: !isCorporate ? formData.contractDate || null : null,
rent_company: formData.rentCompany || null,
rent_company_tel: formData.rentCompanyTel || null,
rent_period: formData.rentPeriod || null,
agreed_mileage: formData.agreedMileage || null,
vehicle_price: Number(formData.vehiclePrice) || 0,
residual_value: Number(formData.residualValue) || 0,
deposit: Number(formData.deposit) || 0,
monthly_rent: !isCorporate ? Number(formData.monthlyRent) || 0 : 0,
monthly_rent_tax: Number(formData.monthlyRentTax) || 0,
mileage: Number(formData.mileage) || 0,
insurance_company: formData.insuranceCompany || null,
insurance_company_tel: formData.insuranceCompanyTel || null,
},
errorMessage: '차량 수정에 실패했습니다.',
});
}
// ===== 차량 삭제 =====
export async function deleteCorporateVehicle(id: number) {
return executeServerAction({
url: buildApiUrl(`/api/v1/corporate-vehicles/${id}`),
method: 'DELETE',
errorMessage: '차량 삭제에 실패했습니다.',
});
}
// ===== 드롭다운 목록 =====
export async function getVehicleDropdown() {
return executeServerAction<
Array<{ id: number; plate_number: string; model: string }>,
ReturnType<typeof transformVehicleDropdown>[]
>({
url: buildApiUrl('/api/v1/corporate-vehicles/dropdown'),
transform: (data) => data.map(transformVehicleDropdown),
errorMessage: '차량 드롭다운 조회에 실패했습니다.',
});
}

View File

@@ -0,0 +1,470 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { Car, DollarSign, CreditCard, Gauge } from 'lucide-react';
import { TableRow, TableCell } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import {
IntegratedListTemplateV2,
type TableColumn,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
import { Trash2, Download } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { VehicleFormDialog } from './VehicleFormDialog';
import { getCorporateVehicles, deleteCorporateVehicle } from './actions';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import {
type CorporateVehicle,
type OwnershipType,
type VehicleStatus,
OWNERSHIP_LABELS,
OWNERSHIP_COLORS,
STATUS_LABELS,
STATUS_COLORS,
formatCurrency,
formatDistance,
} from '../types';
const PAGE_SIZE = 20;
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'vehicle', label: '차량', className: 'min-w-[200px]' },
{ key: 'plateNumber', label: '차량번호', className: 'w-[120px]', copyable: true },
{ key: 'ownershipType', label: '구분', className: 'text-center w-[100px]' },
{ key: 'driver', label: '운전자', className: 'w-[80px]' },
{ key: 'price', label: '취득가/월렌트료', className: 'text-right w-[140px]' },
{ key: 'status', label: '상태', className: 'text-center w-[80px]' },
];
export function CorporateVehicleList() {
const [data, setData] = useState<CorporateVehicle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [search, setSearch] = useState('');
const [filterOwnership, setFilterOwnership] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 모달 상태
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create');
const [selectedVehicle, setSelectedVehicle] = useState<CorporateVehicle | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CorporateVehicle | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// 컬럼 설정
const {
visibleColumns,
allColumnsWithVisibility,
columnWidths,
setColumnWidth,
toggleColumnVisibility,
resetSettings,
hasHiddenColumns,
} = useColumnSettings({
pageId: 'corporate-vehicles',
columns: TABLE_COLUMNS,
alwaysVisibleKeys: ['no', 'vehicle', 'plateNumber'],
});
// 데이터 조회
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getCorporateVehicles({
page: currentPage,
perPage: PAGE_SIZE,
search: search || undefined,
ownershipType: filterOwnership,
status: filterStatus,
});
if (result.success) {
setData(result.data);
setTotalPages(result.pagination.lastPage);
setTotalItems(result.pagination.total);
} else {
toast.error(result.error || '조회에 실패했습니다.');
setData([]);
}
} catch {
toast.error('조회 중 오류가 발생했습니다.');
setData([]);
} finally {
setIsLoading(false);
}
}, [currentPage, search, filterOwnership, filterStatus]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 선택
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedIds((prev) =>
prev.size === data.length
? new Set()
: new Set(data.map((item) => String(item.id)))
);
}, [data]);
// 모달 핸들러
const handleCreate = useCallback(() => {
setSelectedVehicle(null);
setDialogMode('create');
setDialogOpen(true);
}, []);
const handleEdit = useCallback((vehicle: CorporateVehicle) => {
setSelectedVehicle(vehicle);
setDialogMode('edit');
setDialogOpen(true);
}, []);
const handleDeleteClick = useCallback((vehicle: CorporateVehicle) => {
setDeleteTarget(vehicle);
setDeleteOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const result = await deleteCorporateVehicle(deleteTarget.id);
if (result.success) {
toast.success('차량이 삭제되었습니다.');
setDeleteOpen(false);
setDeleteTarget(null);
fetchData();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
}, [deleteTarget, fetchData]);
// 엑셀 다운로드
const excelColumns: ExcelColumn<CorporateVehicle>[] = useMemo(() => [
{ header: '차량번호', key: 'plateNumber', width: 15 },
{ header: '모델', key: 'model', width: 20 },
{ header: '종류', key: 'vehicleType', width: 10 },
{ header: '연식', key: 'year', width: 8 },
{ header: '구분', key: 'ownershipType', width: 10, transform: (val) => OWNERSHIP_LABELS[val as OwnershipType] || String(val) },
{ header: '운전자', key: 'driver', width: 10 },
{ header: '취득가', key: 'purchasePrice', width: 15, transform: (val) => Number(val) || 0 },
{ header: '월렌트료', key: 'monthlyRent', width: 15, transform: (val) => Number(val) || 0 },
{ header: '주행거리(km)', key: 'mileage', width: 15, transform: (val) => Number(val) || 0 },
{ header: '상태', key: 'status', width: 10, transform: (val) => STATUS_LABELS[val as VehicleStatus] || String(val) },
], []);
const handleExcelDownload = useCallback(async () => {
try {
toast.info('엑셀 파일 생성 중...');
const allData: CorporateVehicle[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getCorporateVehicles({
page,
perPage: 100,
search: search || undefined,
ownershipType: filterOwnership,
status: filterStatus,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination.lastPage;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({ data: allData as unknown as Record<string, unknown>[], columns: excelColumns as unknown as ExcelColumn[], filename: '법인차량목록', sheetName: '법인차량' });
toast.success(`${allData.length}건 다운로드 완료`);
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [search, filterOwnership, filterStatus, excelColumns]);
// 통계
const stats = useMemo(() => {
const corporatePrice = data
.filter((v) => v.ownershipType === 'corporate')
.reduce((sum, v) => sum + (v.purchasePrice || 0), 0);
const monthlyRent = data
.filter((v) => v.ownershipType === 'rent' || v.ownershipType === 'lease')
.reduce((sum, v) => sum + (v.monthlyRent || 0), 0);
const monthlyCount = data.filter(
(v) => v.ownershipType === 'rent' || v.ownershipType === 'lease'
).length;
const totalMileage = data.reduce((sum, v) => sum + (v.mileage || 0), 0);
const activeCount = data.filter((v) => v.status === 'active').length;
return [
{
label: '총 차량',
value: `${totalItems}`,
description: `운행중 ${activeCount}`,
icon: Car,
iconColor: 'text-gray-600' as const,
},
{
label: '법인차량 취득가',
value: formatCurrency(corporatePrice),
description: `${data.filter((v) => v.ownershipType === 'corporate').length}`,
icon: DollarSign,
iconColor: 'text-blue-600' as const,
},
{
label: '월 렌트/리스료',
value: formatCurrency(monthlyRent),
description: `${monthlyCount}`,
icon: CreditCard,
iconColor: 'text-blue-600' as const,
},
{
label: '총 주행거리',
value: formatDistance(totalMileage),
icon: Gauge,
iconColor: 'text-gray-600' as const,
},
];
}, [data, totalItems]);
// 통합 필터 (PC: 인라인, 모바일: 바텀시트 자동 분기)
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'ownership',
label: '구분',
type: 'single' as const,
options: [
{ value: 'corporate', label: '법인차량' },
{ value: 'rent', label: '렌트차량' },
{ value: 'lease', label: '리스차량' },
],
allOptionLabel: '전체 구분',
},
{
key: 'status',
label: '상태',
type: 'single' as const,
options: [
{ value: 'active', label: '운행중' },
{ value: 'maintenance', label: '정비중' },
{ value: 'disposed', label: '처분' },
],
allOptionLabel: '전체 상태',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
ownership: filterOwnership,
status: filterStatus,
}), [filterOwnership, filterStatus]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
if (key === 'ownership') { setFilterOwnership(value as string); setCurrentPage(1); }
if (key === 'status') { setFilterStatus(value as string); setCurrentPage(1); }
}, []);
const handleFilterReset = useCallback(() => {
setFilterOwnership('all');
setFilterStatus('all');
setCurrentPage(1);
}, []);
// 테이블 행 렌더
const renderTableRow = useCallback(
(item: CorporateVehicle, _index: number, globalIndex: number) => {
const isCorporate = item.ownershipType === 'corporate';
const priceText = isCorporate
? formatCurrency(item.purchasePrice || 0)
: `${formatCurrency(item.monthlyRent || 0)}/월`;
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleEdit(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(String(item.id))}
onCheckedChange={() => toggleSelection(String(item.id))}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>
<div>
<div className="font-medium truncate max-w-[200px]">{item.model}</div>
<div className="text-xs text-muted-foreground">
{item.vehicleType}{item.year ? ` · ${item.year}` : ''}
</div>
</div>
</TableCell>
<TableCell className="font-mono">{item.plateNumber}</TableCell>
<TableCell className="text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${OWNERSHIP_COLORS[item.ownershipType]}`}>
{OWNERSHIP_LABELS[item.ownershipType]}
</span>
</TableCell>
<TableCell>{item.driver || '-'}</TableCell>
<TableCell className="text-right">{priceText}</TableCell>
<TableCell className="text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[item.status]}`}>
{STATUS_LABELS[item.status]}
</span>
</TableCell>
</TableRow>
);
},
[handleEdit, handleDeleteClick]
);
// 모바일 카드 렌더
const renderMobileCard = useCallback(
(
item: CorporateVehicle,
_index: number,
_globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
const isCorporate = item.ownershipType === 'corporate';
const priceText = isCorporate
? formatCurrency(item.purchasePrice || 0)
: `${formatCurrency(item.monthlyRent || 0)}/월`;
return (
<MobileCard
key={item.id}
title={item.model}
subtitle={`${item.plateNumber} · ${item.vehicleType}${item.year ? ` · ${item.year}` : ''}`}
headerBadges={
<div className="flex gap-1">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${OWNERSHIP_COLORS[item.ownershipType]}`}>
{OWNERSHIP_LABELS[item.ownershipType]}
</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[item.status]}`}>
{STATUS_LABELS[item.status]}
</span>
</div>
}
infoGrid={[
<InfoField key="driver" label="운전자" value={item.driver || '-'} />,
<InfoField key="price" label={isCorporate ? '취득가' : '월렌트료'} value={priceText} />,
<InfoField key="mileage" label="주행거리" value={formatDistance(item.mileage)} />,
]}
isSelected={isSelected}
onToggleSelection={onToggle}
onClick={() => handleEdit(item)}
/>
);
},
[handleEdit]
);
return (
<>
<IntegratedListTemplateV2<CorporateVehicle>
title="법인차량관리"
description="Corporate Vehicles"
icon={Car}
// 검색
searchValue={search}
onSearchChange={(v) => { setSearch(v); setCurrentPage(1); }}
searchPlaceholder="차량번호, 모델, 운전자 검색..."
// 헤더 액션 (엑셀 다운로드)
headerActions={(
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-1 h-4 w-4" />
</Button>
)}
// 등록 버튼
createButton={{ label: '차량 등록', onClick: handleCreate }}
// 통계
stats={stats}
// 통합 필터 (PC: 인라인, 모바일: 바텀시트)
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="차량 필터"
// 컬럼
tableColumns={visibleColumns}
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
// 데이터
data={data}
selectedItems={selectedIds}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item) => String(item.id)}
// 렌더링
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
// 페이지네이션
pagination={{
currentPage,
totalPages,
totalItems,
itemsPerPage: PAGE_SIZE,
onPageChange: setCurrentPage,
}}
isLoading={isLoading}
/>
<VehicleFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode={dialogMode}
vehicle={selectedVehicle}
onSuccess={fetchData}
/>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description={`${deleteTarget?.model || ''} (${deleteTarget?.plateNumber || ''})을(를) 삭제하시겠습니까?`}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,408 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { ArrowUpDown, Loader2 } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
type VehicleLog,
type VehicleLogFormData,
type VehicleDropdownItem,
type TripType,
type LocationType,
EMPTY_LOG_FORM,
TRIP_TYPE_LABELS,
LOCATION_TYPE_LABELS,
NOTE_PRESETS,
} from '../types';
import {
createVehicleLog,
updateVehicleLog,
deleteVehicleLog,
} from './actions';
interface VehicleLogFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: 'create' | 'edit';
log?: VehicleLog | null;
vehicles: VehicleDropdownItem[];
onSuccess: () => void;
}
export function VehicleLogFormDialog({
open,
onOpenChange,
mode,
log,
vehicles,
onSuccess,
}: VehicleLogFormDialogProps) {
const [form, setForm] = useState<VehicleLogFormData>(EMPTY_LOG_FORM);
const [isSaving, setIsSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isEdit = mode === 'edit';
useEffect(() => {
if (!open) return;
if (isEdit && log) {
setForm({
vehicleId: String(log.vehicleId),
logDate: log.logDate,
department: log.department || '',
driverName: log.driverName,
tripType: log.tripType,
departureType: log.departureType,
departureName: log.departureName || '',
departureAddress: log.departureAddress || '',
arrivalType: log.arrivalType,
arrivalName: log.arrivalName || '',
arrivalAddress: log.arrivalAddress || '',
distanceKm: log.distanceKm ? String(log.distanceKm) : '',
note: log.note || '',
});
} else {
setForm({
...EMPTY_LOG_FORM,
logDate: new Date().toISOString().slice(0, 10),
});
}
}, [open, isEdit, log]);
const updateField = useCallback(
<K extends keyof VehicleLogFormData>(key: K, value: VehicleLogFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
},
[]
);
// 출발↔도착 교환
const handleSwapLocations = useCallback(() => {
setForm((prev) => {
// trip_type 자동 전환
let newTripType = prev.tripType;
if (prev.tripType === 'commute_to') newTripType = 'commute_from';
else if (prev.tripType === 'commute_from') newTripType = 'commute_to';
return {
...prev,
tripType: newTripType,
departureType: prev.arrivalType,
departureName: prev.arrivalName,
departureAddress: prev.arrivalAddress,
arrivalType: prev.departureType,
arrivalName: prev.departureName,
arrivalAddress: prev.departureAddress,
};
});
}, []);
// 비고 프리셋 삽입
const handleNotePreset = useCallback((preset: string) => {
setForm((prev) => ({
...prev,
note: prev.note ? `${prev.note} ${preset}` : preset,
}));
}, []);
const handleSubmit = async () => {
if (!form.vehicleId || !form.tripType || !form.driverName) {
toast.error('필수 항목을 입력해주세요.');
return;
}
setIsSaving(true);
try {
const result = isEdit && log
? await updateVehicleLog(log.id, form)
: await createVehicleLog(form);
if (result.success) {
toast.success(isEdit ? '운행기록이 수정되었습니다.' : '운행기록이 등록되었습니다.');
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!log) return;
setIsDeleting(true);
try {
const result = await deleteVehicleLog(log.id);
if (result.success) {
toast.success('운행기록이 삭제되었습니다.');
setDeleteOpen(false);
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2">
<DialogTitle>{isEdit ? '운행기록 수정' : '운행기록 등록'}</DialogTitle>
<Button
variant="outline"
size="sm"
onClick={handleSwapLocations}
className="text-xs"
>
<ArrowUpDown className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Row 1: 날짜, 차량, 구분 */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker
value={form.logDate}
onChange={(v) => updateField('logDate', v)}
placeholder="날짜 선택"
/>
</div>
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Select
key={`v-${form.vehicleId}`}
value={form.vehicleId}
onValueChange={(v) => updateField('vehicleId', v)}
>
<SelectTrigger><SelectValue placeholder="차량 선택" /></SelectTrigger>
<SelectContent>
{vehicles.map((v) => (
<SelectItem key={v.id} value={String(v.id)}>
{v.plateNumber} ({v.model})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Select
key={`tt-${form.tripType}`}
value={form.tripType}
onValueChange={(v) => updateField('tripType', v as TripType)}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(Object.entries(TRIP_TYPE_LABELS) as [TripType, string][]).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 2: 부서, 운전자, 주행거리 */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.department}
onChange={(e) => updateField('department', e.target.value)}
placeholder="부서"
/>
</div>
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Input
value={form.driverName}
onChange={(e) => updateField('driverName', e.target.value)}
placeholder="운전자명"
/>
</div>
<div className="space-y-1.5">
<Label> (km) <span className="text-red-500">*</span></Label>
<Input
type="number"
value={form.distanceKm}
onChange={(e) => updateField('distanceKm', e.target.value)}
placeholder="0"
/>
</div>
</div>
{/* 출발지 */}
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-green-500" />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Select
key={`dt-${form.departureType}`}
value={form.departureType as string}
onValueChange={(v) => updateField('departureType', v as LocationType)}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(Object.entries(LOCATION_TYPE_LABELS) as [LocationType, string][]).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={form.departureName}
onChange={(e) => updateField('departureName', e.target.value)}
placeholder="출발지명"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={form.departureAddress}
onChange={(e) => updateField('departureAddress', e.target.value)}
placeholder="주소"
/>
</div>
</div>
</div>
{/* 도착지 */}
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-red-500" />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Select
key={`at-${form.arrivalType}`}
value={form.arrivalType as string}
onValueChange={(v) => updateField('arrivalType', v as LocationType)}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(Object.entries(LOCATION_TYPE_LABELS) as [LocationType, string][]).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={form.arrivalName}
onChange={(e) => updateField('arrivalName', e.target.value)}
placeholder="도착지명"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={form.arrivalAddress}
onChange={(e) => updateField('arrivalAddress', e.target.value)}
placeholder="주소"
/>
</div>
</div>
</div>
{/* 비고 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-1.5 mb-2">
{NOTE_PRESETS.map((preset) => (
<Button
key={preset}
variant="outline"
size="sm"
className="text-xs h-7"
onClick={() => handleNotePreset(preset)}
>
{preset}
</Button>
))}
</div>
<Textarea
value={form.note}
onChange={(e) => updateField('note', e.target.value)}
placeholder="직접 입력"
rows={2}
/>
</div>
</div>
{/* 버튼 */}
<div className="flex items-center gap-2 pt-2">
{isEdit && (
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteOpen(true)}
>
</Button>
)}
<div className="flex-1" />
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button
onClick={handleSubmit}
disabled={isSaving}
className={isEdit ? '' : 'bg-green-600 hover:bg-green-700'}
>
{isSaving && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
{isEdit ? '저장' : '등록'}
</Button>
</div>
</DialogContent>
</Dialog>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description="이 운행기록을 삭제하시겠습니까?"
loading={isDeleting}
onConfirm={handleDelete}
/>
</>
);
}

View File

@@ -0,0 +1,143 @@
'use server';
import { buildApiUrl } from '@/lib/api/query-params';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import {
type VehicleLogApi,
type VehicleLogFormData,
type VehicleLogSummary,
transformVehicleLogApi,
} from '../types';
// ===== 운행기록 목록 (페이지네이션) =====
export async function getVehicleLogs(params: {
page?: number;
perPage?: number;
search?: string;
vehicleId?: string;
year?: number;
month?: number;
tripType?: string;
}) {
return executePaginatedAction<VehicleLogApi, ReturnType<typeof transformVehicleLogApi>>({
url: buildApiUrl('/api/v1/vehicle-logs', {
page: params.page,
per_page: params.perPage,
search: params.search,
vehicle_id: params.vehicleId !== 'all' ? params.vehicleId : undefined,
year: params.year,
month: params.month,
trip_type: params.tripType !== 'all' ? params.tripType : undefined,
}),
transform: transformVehicleLogApi,
errorMessage: '운행기록 목록 조회에 실패했습니다.',
});
}
// ===== 운행기록 단건 조회 =====
export async function getVehicleLogById(id: number) {
return executeServerAction<VehicleLogApi, ReturnType<typeof transformVehicleLogApi>>({
url: buildApiUrl(`/api/v1/vehicle-logs/${id}`),
transform: transformVehicleLogApi,
errorMessage: '운행기록 조회에 실패했습니다.',
});
}
// ===== 운행기록 등록 =====
export async function createVehicleLog(formData: VehicleLogFormData) {
return executeServerAction({
url: buildApiUrl('/api/v1/vehicle-logs'),
method: 'POST',
body: {
vehicle_id: Number(formData.vehicleId),
log_date: formData.logDate,
department: formData.department || null,
driver_name: formData.driverName,
trip_type: formData.tripType,
departure_type: formData.departureType || null,
departure_name: formData.departureName || null,
departure_address: formData.departureAddress || null,
arrival_type: formData.arrivalType || null,
arrival_name: formData.arrivalName || null,
arrival_address: formData.arrivalAddress || null,
distance_km: Number(formData.distanceKm) || 0,
note: formData.note || null,
},
errorMessage: '운행기록 등록에 실패했습니다.',
});
}
// ===== 운행기록 수정 =====
export async function updateVehicleLog(id: number, formData: VehicleLogFormData) {
return executeServerAction({
url: buildApiUrl(`/api/v1/vehicle-logs/${id}`),
method: 'PUT',
body: {
vehicle_id: Number(formData.vehicleId),
log_date: formData.logDate,
department: formData.department || null,
driver_name: formData.driverName,
trip_type: formData.tripType,
departure_type: formData.departureType || null,
departure_name: formData.departureName || null,
departure_address: formData.departureAddress || null,
arrival_type: formData.arrivalType || null,
arrival_name: formData.arrivalName || null,
arrival_address: formData.arrivalAddress || null,
distance_km: Number(formData.distanceKm) || 0,
note: formData.note || null,
},
errorMessage: '운행기록 수정에 실패했습니다.',
});
}
// ===== 운행기록 삭제 =====
export async function deleteVehicleLog(id: number) {
return executeServerAction({
url: buildApiUrl(`/api/v1/vehicle-logs/${id}`),
method: 'DELETE',
errorMessage: '운행기록 삭제에 실패했습니다.',
});
}
// ===== 월별 통계 =====
interface SummaryApi {
total_distance: number;
total_count: number;
commute_to_distance: number;
commute_to_count: number;
commute_from_distance: number;
commute_from_count: number;
business_distance: number;
business_count: number;
personal_distance: number;
personal_count: number;
}
export async function getVehicleLogSummary(params: {
vehicleId?: string;
year: number;
month: number;
}) {
return executeServerAction<SummaryApi, VehicleLogSummary>({
url: buildApiUrl('/api/v1/vehicle-logs/summary', {
vehicle_id: params.vehicleId !== 'all' ? params.vehicleId : undefined,
year: params.year,
month: params.month,
}),
transform: (api) => ({
totalDistance: api.total_distance,
totalCount: api.total_count,
commuteToDistance: api.commute_to_distance,
commuteToCount: api.commute_to_count,
commuteFromDistance: api.commute_from_distance,
commuteFromCount: api.commute_from_count,
businessDistance: api.business_distance,
businessCount: api.business_count,
personalDistance: api.personal_distance,
personalCount: api.personal_count,
}),
errorMessage: '운행 통계 조회에 실패했습니다.',
});
}

View File

@@ -0,0 +1,582 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { BookOpen, Route, Briefcase, User, MapPin, Copy, Edit, Trash2, Download } from 'lucide-react';
import { TableRow, TableCell } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
IntegratedListTemplateV2,
type TableColumn,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { VehicleLogFormDialog } from './VehicleLogFormDialog';
import { getVehicleLogs, getVehicleLogSummary, deleteVehicleLog } from './actions';
import { getVehicleDropdown } from '../CorporateVehicles/actions';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import {
type VehicleLog,
type VehicleDropdownItem,
type VehicleLogSummary,
type TripType,
TRIP_TYPE_LABELS,
TRIP_TYPE_COLORS,
LOCATION_TYPE_LABELS,
type LocationType,
formatDistance,
} from '../types';
const PAGE_SIZE = 20;
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'logDate', label: '날짜', className: 'w-[100px]' },
{ key: 'vehicle', label: '차량', className: 'w-[140px]' },
{ key: 'driver', label: '부서/성명', className: 'w-[120px]' },
{ key: 'tripType', label: '구분', className: 'text-center w-[90px]' },
{ key: 'departure', label: '출발지', className: 'min-w-[140px]' },
{ key: 'arrival', label: '도착지', className: 'min-w-[140px]' },
{ key: 'distanceKm', label: '주행(km)', className: 'text-right w-[90px]' },
{ key: 'note', label: '비고', className: 'min-w-[120px]' },
{ key: 'actions', label: '관리', className: 'text-center w-[100px]' },
];
const now = new Date();
const CURRENT_YEAR = now.getFullYear();
const CURRENT_MONTH = now.getMonth() + 1;
const YEAR_OPTIONS = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR - 2 + i);
const MONTH_OPTIONS = Array.from({ length: 12 }, (_, i) => i + 1);
export function VehicleLogList() {
const [data, setData] = useState<VehicleLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [search, setSearch] = useState('');
const [year, setYear] = useState(CURRENT_YEAR);
const [month, setMonth] = useState(CURRENT_MONTH);
const [filterVehicle, setFilterVehicle] = useState('all');
const [filterTripType, setFilterTripType] = useState('all');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 드롭다운 차량 목록
const [vehicles, setVehicles] = useState<VehicleDropdownItem[]>([]);
// 월별 통계
const [summary, setSummary] = useState<VehicleLogSummary | null>(null);
// 모달 상태
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create');
const [selectedLog, setSelectedLog] = useState<VehicleLog | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<VehicleLog | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// 컬럼 설정
const {
visibleColumns,
allColumnsWithVisibility,
columnWidths,
setColumnWidth,
toggleColumnVisibility,
resetSettings,
hasHiddenColumns,
} = useColumnSettings({
pageId: 'vehicle-logs',
columns: TABLE_COLUMNS,
alwaysVisibleKeys: ['no', 'logDate', 'vehicle', 'tripType', 'actions'],
});
// 차량 드롭다운 로드
useEffect(() => {
getVehicleDropdown().then((result) => {
if (result.success && result.data) {
setVehicles(Array.isArray(result.data) ? result.data : []);
}
});
}, []);
// 데이터 조회
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getVehicleLogs({
page: currentPage,
perPage: PAGE_SIZE,
search: search || undefined,
vehicleId: filterVehicle,
year,
month,
tripType: filterTripType,
});
if (result.success) {
setData(result.data);
setTotalPages(result.pagination.lastPage);
setTotalItems(result.pagination.total);
} else {
toast.error(result.error || '조회에 실패했습니다.');
setData([]);
}
} catch {
toast.error('조회 중 오류가 발생했습니다.');
setData([]);
} finally {
setIsLoading(false);
}
}, [currentPage, search, filterVehicle, year, month, filterTripType]);
// 통계 조회
const fetchSummary = useCallback(async () => {
try {
const result = await getVehicleLogSummary({
vehicleId: filterVehicle,
year,
month,
});
if (result.success && result.data) {
setSummary(result.data);
}
} catch {
// 통계 실패 시 무시
}
}, [filterVehicle, year, month]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
fetchSummary();
}, [fetchSummary]);
// 선택
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedIds((prev) =>
prev.size === data.length
? new Set()
: new Set(data.map((item) => String(item.id)))
);
}, [data]);
// 모달 핸들러
const handleCreate = useCallback(() => {
setSelectedLog(null);
setDialogMode('create');
setDialogOpen(true);
}, []);
const handleEdit = useCallback((log: VehicleLog) => {
setSelectedLog(log);
setDialogMode('edit');
setDialogOpen(true);
}, []);
// 복사 (기존 기록 기반, 날짜만 오늘)
const handleCopy = useCallback((log: VehicleLog) => {
setSelectedLog({ ...log, id: 0, logDate: new Date().toISOString().slice(0, 10) });
setDialogMode('create');
setDialogOpen(true);
}, []);
const handleDeleteClick = useCallback((log: VehicleLog) => {
setDeleteTarget(log);
setDeleteOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const result = await deleteVehicleLog(deleteTarget.id);
if (result.success) {
toast.success('운행기록이 삭제되었습니다.');
setDeleteOpen(false);
setDeleteTarget(null);
fetchData();
fetchSummary();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
}, [deleteTarget, fetchData, fetchSummary]);
const handleSuccess = useCallback(() => {
fetchData();
fetchSummary();
}, [fetchData, fetchSummary]);
// 엑셀 다운로드
const excelColumns: ExcelColumn<VehicleLog>[] = useMemo(() => [
{ header: '날짜', key: 'logDate', width: 12 },
{ header: '차량번호', key: 'vehicle', width: 15, transform: (_, row) => row.vehicle?.plateNumber || '-' },
{ header: '차량모델', key: 'vehicle', width: 15, transform: (_, row) => row.vehicle?.model || '-' },
{ header: '운전자', key: 'driverName', width: 10 },
{ header: '부서', key: 'department', width: 10 },
{ header: '구분', key: 'tripType', width: 12, transform: (val) => TRIP_TYPE_LABELS[val as TripType] || String(val) },
{ header: '출발지', key: 'departureName', width: 15 },
{ header: '도착지', key: 'arrivalName', width: 15 },
{ header: '주행거리(km)', key: 'distanceKm', width: 15, transform: (val) => Number(val) || 0 },
{ header: '비고', key: 'note', width: 20 },
], []);
const handleExcelDownload = useCallback(async () => {
try {
toast.info('엑셀 파일 생성 중...');
const allData: VehicleLog[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getVehicleLogs({
page,
perPage: 100,
search: search || undefined,
vehicleId: filterVehicle,
year,
month,
tripType: filterTripType,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination.lastPage;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({ data: allData as unknown as Record<string, unknown>[], columns: excelColumns as unknown as ExcelColumn[], filename: '차량일지', sheetName: '차량일지' });
toast.success(`${allData.length}건 다운로드 완료`);
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [search, filterVehicle, year, month, filterTripType, excelColumns]);
// 통계
const stats = useMemo(() => {
const s = summary;
return [
{
label: '전체',
value: formatDistance(s?.totalDistance ?? 0),
description: `${s?.totalCount ?? 0}`,
icon: Route,
iconColor: 'text-gray-600' as const,
},
{
label: '출근용',
value: formatDistance(s?.commuteToDistance ?? 0),
description: `${s?.commuteToCount ?? 0}`,
icon: MapPin,
iconColor: 'text-green-600' as const,
},
{
label: '퇴근용',
value: formatDistance(s?.commuteFromDistance ?? 0),
description: `${s?.commuteFromCount ?? 0}`,
icon: MapPin,
iconColor: 'text-green-600' as const,
},
{
label: '업무용',
value: formatDistance(s?.businessDistance ?? 0),
description: `${s?.businessCount ?? 0}`,
icon: Briefcase,
iconColor: 'text-blue-600' as const,
},
{
label: '비업무',
value: formatDistance(s?.personalDistance ?? 0),
description: `${s?.personalCount ?? 0}`,
icon: User,
iconColor: 'text-gray-600' as const,
},
];
}, [summary]);
// 통합 필터 (PC: 인라인, 모바일: 바텀시트 자동 분기)
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'vehicle',
label: '차량',
type: 'single' as const,
options: vehicles.map((v) => ({ value: String(v.id), label: `${v.plateNumber} (${v.model})` })),
allOptionLabel: '전체 차량',
},
{
key: 'tripType',
label: '구분',
type: 'single' as const,
options: (Object.entries(TRIP_TYPE_LABELS) as [TripType, string][]).map(([key, label]) => ({
value: key,
label,
})),
allOptionLabel: '전체 구분',
},
], [vehicles]);
const filterValues: FilterValues = useMemo(() => ({
vehicle: filterVehicle,
tripType: filterTripType,
}), [filterVehicle, filterTripType]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
if (key === 'vehicle') { setFilterVehicle(value as string); setCurrentPage(1); }
if (key === 'tripType') { setFilterTripType(value as string); setCurrentPage(1); }
}, []);
const handleFilterReset = useCallback(() => {
setFilterVehicle('all');
setFilterTripType('all');
setCurrentPage(1);
}, []);
// 위치 정보 표시 함수
const formatLocation = (type: LocationType | string, name: string | null) => {
const typeLabel = LOCATION_TYPE_LABELS[type as LocationType] || '';
return name ? `${typeLabel ? `[${typeLabel}] ` : ''}${name}` : typeLabel || '-';
};
// 테이블 행 렌더
const renderTableRow = useCallback(
(item: VehicleLog, _index: number, globalIndex: number) => {
const vehicleText = item.vehicle
? `${item.vehicle.plateNumber}`
: '-';
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleEdit(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(String(item.id))}
onCheckedChange={() => toggleSelection(String(item.id))}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-mono text-sm">{item.logDate}</TableCell>
<TableCell>
<div className="truncate max-w-[130px]">{vehicleText}</div>
{item.vehicle && (
<div className="text-xs text-muted-foreground truncate">{item.vehicle.model}</div>
)}
</TableCell>
<TableCell>
<div>{item.driverName}</div>
{item.department && (
<div className="text-xs text-muted-foreground">{item.department}</div>
)}
</TableCell>
<TableCell className="text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${TRIP_TYPE_COLORS[item.tripType]}`}>
{TRIP_TYPE_LABELS[item.tripType]}
</span>
</TableCell>
<TableCell className="text-sm">
{formatLocation(item.departureType, item.departureName)}
</TableCell>
<TableCell className="text-sm">
{formatLocation(item.arrivalType, item.arrivalName)}
</TableCell>
<TableCell className="text-right font-mono">
{item.distanceKm ? item.distanceKm.toLocaleString() : '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-[120px]">
{item.note || '-'}
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(item)}>
<Edit className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleCopy(item)}>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDeleteClick(item)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
},
[handleEdit, handleCopy, handleDeleteClick]
);
// 모바일 카드 렌더
const renderMobileCard = useCallback(
(
item: VehicleLog,
_index: number,
_globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
const vehicleText = item.vehicle
? `${item.vehicle.plateNumber} (${item.vehicle.model})`
: '-';
return (
<MobileCard
key={item.id}
title={`${item.logDate} · ${item.driverName}`}
subtitle={vehicleText}
headerBadges={
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${TRIP_TYPE_COLORS[item.tripType]}`}>
{TRIP_TYPE_LABELS[item.tripType]}
</span>
}
infoGrid={[
<InfoField key="departure" label="출발지" value={formatLocation(item.departureType, item.departureName)} />,
<InfoField key="arrival" label="도착지" value={formatLocation(item.arrivalType, item.arrivalName)} />,
<InfoField key="distance" label="주행거리" value={item.distanceKm ? formatDistance(item.distanceKm) : '-'} />,
]}
isSelected={isSelected}
onToggleSelection={onToggle}
onClick={() => handleEdit(item)}
/>
);
},
[handleEdit]
);
return (
<>
<IntegratedListTemplateV2<VehicleLog>
title="차량일지"
description="Vehicle Driving Logs"
icon={BookOpen}
// 검색
searchValue={search}
onSearchChange={(v) => { setSearch(v); setCurrentPage(1); }}
searchPlaceholder="운전자, 출발지, 도착지 검색..."
// 날짜 선택 (year/month)
dateRangeSelector={{
enabled: true,
hideDateInputs: true,
showPresets: false,
extraActions: (
<div className="flex items-center gap-2 flex-wrap">
<Select value={String(year)} onValueChange={(v) => { setYear(Number(v)); setCurrentPage(1); }}>
<SelectTrigger className="w-[100px] h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{YEAR_OPTIONS.map((y) => (
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={String(month)} onValueChange={(v) => { setMonth(Number(v)); setCurrentPage(1); }}>
<SelectTrigger className="w-[80px] h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTH_OPTIONS.map((m) => (
<SelectItem key={m} value={String(m)}>{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
),
}}
// 헤더 액션 (엑셀 다운로드)
headerActions={(
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-1 h-4 w-4" />
</Button>
)}
// 등록 버튼
createButton={{ label: '운행기록 등록', onClick: handleCreate }}
// 통계
stats={stats}
// 통합 필터 (PC: 인라인, 모바일: 바텀시트)
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="운행 필터"
// 컬럼
tableColumns={visibleColumns}
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
// 데이터
data={data}
selectedItems={selectedIds}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item) => String(item.id)}
// 렌더링
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
// 페이지네이션
pagination={{
currentPage,
totalPages,
totalItems,
itemsPerPage: PAGE_SIZE,
onPageChange: setCurrentPage,
}}
isLoading={isLoading}
/>
<VehicleLogFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode={dialogMode}
log={selectedLog}
vehicles={vehicles}
onSuccess={handleSuccess}
/>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description="이 운행기록을 삭제하시겠습니까?"
loading={isDeleting}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,278 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { Loader2 } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
type VehicleMaintenance,
type MaintenanceFormData,
type MaintenanceCategory,
type VehicleDropdownItem,
EMPTY_MAINTENANCE_FORM,
MAINTENANCE_CATEGORIES,
} from '../types';
import {
createVehicleMaintenance,
updateVehicleMaintenance,
deleteVehicleMaintenance,
} from './actions';
interface MaintenanceFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: 'create' | 'edit';
maintenance?: VehicleMaintenance | null;
vehicles: VehicleDropdownItem[];
onSuccess: () => void;
}
export function MaintenanceFormDialog({
open,
onOpenChange,
mode,
maintenance,
vehicles,
onSuccess,
}: MaintenanceFormDialogProps) {
const [form, setForm] = useState<MaintenanceFormData>(EMPTY_MAINTENANCE_FORM);
const [isSaving, setIsSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isEdit = mode === 'edit';
useEffect(() => {
if (!open) return;
if (isEdit && maintenance) {
setForm({
vehicleId: String(maintenance.vehicleId),
date: maintenance.date,
category: maintenance.category,
description: maintenance.description || '',
amount: maintenance.amount ? String(maintenance.amount) : '',
mileage: maintenance.mileage ? String(maintenance.mileage) : '',
vendor: maintenance.vendor || '',
memo: maintenance.memo || '',
});
} else {
setForm({
...EMPTY_MAINTENANCE_FORM,
date: new Date().toISOString().slice(0, 10),
});
}
}, [open, isEdit, maintenance]);
const updateField = useCallback(
<K extends keyof MaintenanceFormData>(key: K, value: MaintenanceFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
},
[]
);
const handleSubmit = async () => {
if (!form.vehicleId || !form.category || !form.description) {
toast.error('필수 항목을 입력해주세요.');
return;
}
setIsSaving(true);
try {
const result = isEdit && maintenance
? await updateVehicleMaintenance(maintenance.id, form)
: await createVehicleMaintenance(form);
if (result.success) {
toast.success(isEdit ? '비용이 수정되었습니다.' : '비용이 등록되었습니다.');
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!maintenance) return;
setIsDeleting(true);
try {
const result = await deleteVehicleMaintenance(maintenance.id);
if (result.success) {
toast.success('비용이 삭제되었습니다.');
setDeleteOpen(false);
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? '비용 수정' : '비용 등록'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Row 1: 날짜, 구분 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker
value={form.date}
onChange={(v) => updateField('date', v)}
placeholder="날짜 선택"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Select
key={`cat-${form.category}`}
value={form.category as string}
onValueChange={(v) => updateField('category', v as MaintenanceCategory)}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{MAINTENANCE_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 2: 차량 */}
<div className="space-y-1.5">
<Label></Label>
<Select
key={`v-${form.vehicleId}`}
value={form.vehicleId}
onValueChange={(v) => updateField('vehicleId', v)}
>
<SelectTrigger><SelectValue placeholder="차량 선택" /></SelectTrigger>
<SelectContent>
{vehicles.map((v) => (
<SelectItem key={v.id} value={String(v.id)}>
{v.plateNumber} ({v.model})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Row 3: 내용 */}
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Input
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="내용"
/>
</div>
{/* Row 4: 금액, 주행거리 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Input
type="number"
value={form.amount}
onChange={(e) => updateField('amount', e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-1.5">
<Label>(km)</Label>
<Input
type="number"
value={form.mileage}
onChange={(e) => updateField('mileage', e.target.value)}
placeholder="0"
/>
</div>
</div>
{/* Row 5: 업체, 메모 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.vendor}
onChange={(e) => updateField('vendor', e.target.value)}
placeholder="업체명"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.memo}
onChange={(e) => updateField('memo', e.target.value)}
placeholder="메모"
/>
</div>
</div>
</div>
{/* 버튼 */}
<div className="flex items-center gap-2 pt-2">
{isEdit && (
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteOpen(true)}
>
</Button>
)}
<div className="flex-1" />
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button
onClick={handleSubmit}
disabled={isSaving}
className={isEdit ? '' : 'bg-green-600 hover:bg-green-700'}
>
{isSaving && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
{isEdit ? '저장' : '등록'}
</Button>
</div>
</DialogContent>
</Dialog>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description="이 비용을 삭제하시겠습니까?"
loading={isDeleting}
onConfirm={handleDelete}
/>
</>
);
}

View File

@@ -0,0 +1,91 @@
'use server';
import { buildApiUrl } from '@/lib/api/query-params';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import {
type VehicleMaintenanceApi,
type MaintenanceFormData,
transformMaintenanceApi,
} from '../types';
// ===== 정비이력 목록 (페이지네이션) =====
export async function getVehicleMaintenances(params: {
page?: number;
perPage?: number;
search?: string;
vehicleId?: string;
category?: string;
startDate?: string;
endDate?: string;
}) {
return executePaginatedAction<VehicleMaintenanceApi, ReturnType<typeof transformMaintenanceApi>>({
url: buildApiUrl('/api/v1/vehicle-maintenances', {
page: params.page,
per_page: params.perPage,
search: params.search,
vehicle_id: params.vehicleId !== 'all' ? params.vehicleId : undefined,
category: params.category !== 'all' ? params.category : undefined,
start_date: params.startDate,
end_date: params.endDate,
}),
transform: transformMaintenanceApi,
errorMessage: '정비이력 목록 조회에 실패했습니다.',
});
}
// ===== 정비이력 단건 조회 =====
export async function getVehicleMaintenanceById(id: number) {
return executeServerAction<VehicleMaintenanceApi, ReturnType<typeof transformMaintenanceApi>>({
url: buildApiUrl(`/api/v1/vehicle-maintenances/${id}`),
transform: transformMaintenanceApi,
errorMessage: '정비이력 조회에 실패했습니다.',
});
}
// ===== 정비이력 등록 =====
export async function createVehicleMaintenance(formData: MaintenanceFormData) {
return executeServerAction({
url: buildApiUrl('/api/v1/vehicle-maintenances'),
method: 'POST',
body: {
vehicle_id: Number(formData.vehicleId),
date: formData.date,
category: formData.category,
description: formData.description,
amount: Number(formData.amount) || 0,
mileage: Number(formData.mileage) || 0,
vendor: formData.vendor || null,
memo: formData.memo || null,
},
errorMessage: '정비이력 등록에 실패했습니다.',
});
}
// ===== 정비이력 수정 =====
export async function updateVehicleMaintenance(id: number, formData: MaintenanceFormData) {
return executeServerAction({
url: buildApiUrl(`/api/v1/vehicle-maintenances/${id}`),
method: 'PUT',
body: {
vehicle_id: Number(formData.vehicleId),
date: formData.date,
category: formData.category,
description: formData.description,
amount: Number(formData.amount) || 0,
mileage: Number(formData.mileage) || 0,
vendor: formData.vendor || null,
memo: formData.memo || null,
},
errorMessage: '정비이력 수정에 실패했습니다.',
});
}
// ===== 정비이력 삭제 =====
export async function deleteVehicleMaintenance(id: number) {
return executeServerAction({
url: buildApiUrl(`/api/v1/vehicle-maintenances/${id}`),
method: 'DELETE',
errorMessage: '정비이력 삭제에 실패했습니다.',
});
}

View File

@@ -0,0 +1,489 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { Wrench, Fuel, DollarSign, Gauge, Edit, Trash2, Download } from 'lucide-react';
import { TableRow, TableCell } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import {
IntegratedListTemplateV2,
type TableColumn,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { MaintenanceFormDialog } from './MaintenanceFormDialog';
import { getVehicleMaintenances, deleteVehicleMaintenance } from './actions';
import { getVehicleDropdown } from '../CorporateVehicles/actions';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import {
type VehicleMaintenance,
type VehicleDropdownItem,
type MaintenanceCategory,
MAINTENANCE_CATEGORIES,
CATEGORY_COLORS,
formatCurrency,
formatDistance,
} from '../types';
const PAGE_SIZE = 20;
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'date', label: '날짜', className: 'w-[100px]' },
{ key: 'vehicle', label: '차량', className: 'w-[140px]' },
{ key: 'category', label: '분류', className: 'text-center w-[80px]' },
{ key: 'description', label: '내용', className: 'min-w-[180px]' },
{ key: 'amount', label: '금액', className: 'text-right w-[120px]' },
{ key: 'mileage', label: '주행(km)', className: 'text-right w-[90px]' },
{ key: 'vendor', label: '업체', className: 'w-[120px]' },
{ key: 'actions', label: '관리', className: 'text-center w-[80px]' },
];
export function VehicleMaintenanceList() {
const [data, setData] = useState<VehicleMaintenance[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [search, setSearch] = useState('');
const [filterVehicle, setFilterVehicle] = useState('all');
const [filterCategory, setFilterCategory] = useState('all');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 드롭다운 차량 목록
const [vehicles, setVehicles] = useState<VehicleDropdownItem[]>([]);
// 모달 상태
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create');
const [selectedItem, setSelectedItem] = useState<VehicleMaintenance | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<VehicleMaintenance | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// 컬럼 설정
const {
visibleColumns,
allColumnsWithVisibility,
columnWidths,
setColumnWidth,
toggleColumnVisibility,
resetSettings,
hasHiddenColumns,
} = useColumnSettings({
pageId: 'vehicle-maintenance',
columns: TABLE_COLUMNS,
alwaysVisibleKeys: ['no', 'date', 'vehicle', 'category', 'actions'],
});
// 차량 드롭다운 로드
useEffect(() => {
getVehicleDropdown().then((result) => {
if (result.success && result.data) {
setVehicles(Array.isArray(result.data) ? result.data : []);
}
});
}, []);
// 데이터 조회
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getVehicleMaintenances({
page: currentPage,
perPage: PAGE_SIZE,
search: search || undefined,
vehicleId: filterVehicle,
category: filterCategory,
startDate: startDate || undefined,
endDate: endDate || undefined,
});
if (result.success) {
setData(result.data);
setTotalPages(result.pagination.lastPage);
setTotalItems(result.pagination.total);
} else {
toast.error(result.error || '조회에 실패했습니다.');
setData([]);
}
} catch {
toast.error('조회 중 오류가 발생했습니다.');
setData([]);
} finally {
setIsLoading(false);
}
}, [currentPage, search, filterVehicle, filterCategory, startDate, endDate]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 선택
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedIds((prev) =>
prev.size === data.length
? new Set()
: new Set(data.map((item) => String(item.id)))
);
}, [data]);
// 모달 핸들러
const handleCreate = useCallback(() => {
setSelectedItem(null);
setDialogMode('create');
setDialogOpen(true);
}, []);
const handleEdit = useCallback((item: VehicleMaintenance) => {
setSelectedItem(item);
setDialogMode('edit');
setDialogOpen(true);
}, []);
const handleDeleteClick = useCallback((item: VehicleMaintenance) => {
setDeleteTarget(item);
setDeleteOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const result = await deleteVehicleMaintenance(deleteTarget.id);
if (result.success) {
toast.success('비용이 삭제되었습니다.');
setDeleteOpen(false);
setDeleteTarget(null);
fetchData();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
}, [deleteTarget, fetchData]);
// 엑셀 다운로드
const excelColumns: ExcelColumn<VehicleMaintenance>[] = useMemo(() => [
{ header: '날짜', key: 'date', width: 12 },
{ header: '차량번호', key: 'vehicle', width: 15, transform: (_, row) => row.vehicle?.plateNumber || '-' },
{ header: '차량모델', key: 'vehicle', width: 15, transform: (_, row) => row.vehicle?.model || '-' },
{ header: '분류', key: 'category', width: 10 },
{ header: '내용', key: 'description', width: 30 },
{ header: '금액', key: 'amount', width: 15, transform: (val) => Number(val) || 0 },
{ header: '주행거리(km)', key: 'mileage', width: 15, transform: (val) => Number(val) || 0 },
{ header: '업체', key: 'vendor', width: 15 },
{ header: '메모', key: 'memo', width: 20 },
], []);
const handleExcelDownload = useCallback(async () => {
try {
toast.info('엑셀 파일 생성 중...');
const allData: VehicleMaintenance[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getVehicleMaintenances({
page,
perPage: 100,
search: search || undefined,
vehicleId: filterVehicle,
category: filterCategory,
startDate: startDate || undefined,
endDate: endDate || undefined,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination.lastPage;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({ data: allData as unknown as Record<string, unknown>[], columns: excelColumns as unknown as ExcelColumn[], filename: '정비이력', sheetName: '정비이력' });
toast.success(`${allData.length}건 다운로드 완료`);
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [search, filterVehicle, filterCategory, startDate, endDate, excelColumns]);
// 프론트엔드 통계 (현재 페이지 데이터 기반)
const stats = useMemo(() => {
const totalAmount = data.reduce((sum, v) => sum + (v.amount || 0), 0);
const fuelAmount = data
.filter((v) => v.category === '주유')
.reduce((sum, v) => sum + (v.amount || 0), 0);
const repairAmount = data
.filter((v) => v.category === '정비')
.reduce((sum, v) => sum + (v.amount || 0), 0);
const otherAmount = totalAmount - fuelAmount - repairAmount;
return [
{
label: '총 비용',
value: formatCurrency(totalAmount),
description: `${totalItems}`,
icon: DollarSign,
iconColor: 'text-gray-600' as const,
},
{
label: '주유비',
value: formatCurrency(fuelAmount),
description: `${data.filter((v) => v.category === '주유').length}`,
icon: Fuel,
iconColor: 'text-amber-600' as const,
},
{
label: '정비비',
value: formatCurrency(repairAmount),
description: `${data.filter((v) => v.category === '정비').length}`,
icon: Wrench,
iconColor: 'text-blue-600' as const,
},
{
label: '기타 비용',
value: formatCurrency(otherAmount),
description: `${data.filter((v) => v.category !== '주유' && v.category !== '정비').length}`,
icon: Gauge,
iconColor: 'text-gray-600' as const,
},
];
}, [data, totalItems]);
// 통합 필터 (PC: 인라인, 모바일: 바텀시트 자동 분기)
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'vehicle',
label: '차량',
type: 'single' as const,
options: vehicles.map((v) => ({ value: String(v.id), label: `${v.plateNumber} (${v.model})` })),
allOptionLabel: '전체 차량',
},
{
key: 'category',
label: '분류',
type: 'single' as const,
options: MAINTENANCE_CATEGORIES.map((cat) => ({ value: cat, label: cat })),
allOptionLabel: '전체 분류',
},
], [vehicles]);
const filterValues: FilterValues = useMemo(() => ({
vehicle: filterVehicle,
category: filterCategory,
}), [filterVehicle, filterCategory]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
if (key === 'vehicle') { setFilterVehicle(value as string); setCurrentPage(1); }
if (key === 'category') { setFilterCategory(value as string); setCurrentPage(1); }
}, []);
const handleFilterReset = useCallback(() => {
setFilterVehicle('all');
setFilterCategory('all');
setCurrentPage(1);
}, []);
// 테이블 행 렌더
const renderTableRow = useCallback(
(item: VehicleMaintenance, _index: number, globalIndex: number) => {
const vehicleText = item.vehicle
? item.vehicle.plateNumber
: '-';
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleEdit(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(String(item.id))}
onCheckedChange={() => toggleSelection(String(item.id))}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-mono text-sm">{item.date}</TableCell>
<TableCell>
<div className="truncate max-w-[130px]">{vehicleText}</div>
{item.vehicle && (
<div className="text-xs text-muted-foreground truncate">{item.vehicle.model}</div>
)}
</TableCell>
<TableCell className="text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-gray-100 text-gray-700'}`}>
{item.category}
</span>
</TableCell>
<TableCell className="truncate max-w-[180px]">{item.description}</TableCell>
<TableCell className="text-right font-mono">{formatCurrency(item.amount)}</TableCell>
<TableCell className="text-right font-mono">
{item.mileage ? item.mileage.toLocaleString() : '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-[120px]">
{item.vendor || '-'}
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(item)}>
<Edit className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDeleteClick(item)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
},
[handleEdit, handleDeleteClick]
);
// 모바일 카드 렌더
const renderMobileCard = useCallback(
(
item: VehicleMaintenance,
_index: number,
_globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
const vehicleText = item.vehicle
? `${item.vehicle.plateNumber} (${item.vehicle.model})`
: '-';
return (
<MobileCard
key={item.id}
title={item.description}
subtitle={`${item.date} · ${vehicleText}`}
headerBadges={
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-gray-100 text-gray-700'}`}>
{item.category}
</span>
}
infoGrid={[
<InfoField key="amount" label="금액" value={formatCurrency(item.amount)} />,
<InfoField key="mileage" label="주행거리" value={item.mileage ? formatDistance(item.mileage) : '-'} />,
<InfoField key="vendor" label="업체" value={item.vendor || '-'} />,
]}
isSelected={isSelected}
onToggleSelection={onToggle}
onClick={() => handleEdit(item)}
/>
);
},
[handleEdit]
);
return (
<>
<IntegratedListTemplateV2<VehicleMaintenance>
title="정비이력"
description="Vehicle Maintenance History"
icon={Wrench}
// 검색
searchValue={search}
onSearchChange={(v) => { setSearch(v); setCurrentPage(1); }}
searchPlaceholder="내용, 업체 검색..."
// 날짜 범위
dateRangeSelector={{
enabled: true,
showPresets: true,
presets: ['thisYear', 'lastMonth', 'thisMonth'],
startDate,
endDate,
onStartDateChange: (v) => { setStartDate(v); setCurrentPage(1); },
onEndDateChange: (v) => { setEndDate(v); setCurrentPage(1); },
}}
// 헤더 액션 (엑셀 다운로드)
headerActions={(
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-1 h-4 w-4" />
</Button>
)}
// 등록 버튼
createButton={{ label: '비용 등록', onClick: handleCreate }}
// 통계
stats={stats}
// 통합 필터 (PC: 인라인, 모바일: 바텀시트)
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="비용 필터"
// 컬럼
tableColumns={visibleColumns}
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
// 데이터
data={data}
selectedItems={selectedIds}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item) => String(item.id)}
// 렌더링
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
// 페이지네이션
pagination={{
currentPage,
totalPages,
totalItems,
itemsPerPage: PAGE_SIZE,
onPageChange: setCurrentPage,
}}
isLoading={isLoading}
/>
<MaintenanceFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode={dialogMode}
maintenance={selectedItem}
vehicles={vehicles}
onSuccess={fetchData}
/>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description="이 비용을 삭제하시겠습니까?"
loading={isDeleting}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,442 @@
/**
* 차량관리 공통 타입 정의
* DB 마이그레이션 스키마 기반 (corporate_vehicles, vehicle_logs, vehicle_maintenances)
*/
// ===== 차량 목록 (Corporate Vehicles) =====
export type OwnershipType = 'corporate' | 'rent' | 'lease';
export type VehicleStatus = 'active' | 'maintenance' | 'disposed';
export type VehicleType = '승용차' | '승합차' | '화물차' | 'SUV';
export interface CorporateVehicle {
id: number;
plateNumber: string;
model: string;
vehicleType: VehicleType;
ownershipType: OwnershipType;
year: number | null;
driver: string | null;
status: VehicleStatus;
mileage: number;
memo: string | null;
// 법인 전용
purchaseDate: string | null;
purchasePrice: number;
// 렌트/리스 전용
contractDate: string | null;
rentCompany: string | null;
rentCompanyTel: string | null;
rentPeriod: string | null;
agreedMileage: string | null;
vehiclePrice: number;
residualValue: number;
deposit: number;
monthlyRent: number;
monthlyRentTax: number;
insuranceCompany: string | null;
insuranceCompanyTel: string | null;
createdAt: string;
updatedAt: string;
}
// API 응답 (snake_case)
export interface CorporateVehicleApi {
id: number;
plate_number: string;
model: string;
vehicle_type: string;
ownership_type: string;
year: number | null;
driver: string | null;
status: string;
mileage: number;
memo: string | null;
purchase_date: string | null;
purchase_price: number;
contract_date: string | null;
rent_company: string | null;
rent_company_tel: string | null;
rent_period: string | null;
agreed_mileage: string | null;
vehicle_price: number;
residual_value: number;
deposit: number;
monthly_rent: number;
monthly_rent_tax: number;
insurance_company: string | null;
insurance_company_tel: string | null;
created_at: string;
updated_at: string;
}
export function transformVehicleApi(api: CorporateVehicleApi): CorporateVehicle {
return {
id: api.id,
plateNumber: api.plate_number,
model: api.model,
vehicleType: api.vehicle_type as VehicleType,
ownershipType: api.ownership_type as OwnershipType,
year: api.year,
driver: api.driver,
status: api.status as VehicleStatus,
mileage: api.mileage,
memo: api.memo,
purchaseDate: api.purchase_date,
purchasePrice: api.purchase_price,
contractDate: api.contract_date,
rentCompany: api.rent_company,
rentCompanyTel: api.rent_company_tel,
rentPeriod: api.rent_period,
agreedMileage: api.agreed_mileage,
vehiclePrice: api.vehicle_price,
residualValue: api.residual_value,
deposit: api.deposit,
monthlyRent: api.monthly_rent,
monthlyRentTax: api.monthly_rent_tax,
insuranceCompany: api.insurance_company,
insuranceCompanyTel: api.insurance_company_tel,
createdAt: api.created_at,
updatedAt: api.updated_at,
};
}
export interface VehicleFormData {
plateNumber: string;
vehicleType: VehicleType | '';
ownershipType: OwnershipType | '';
model: string;
year: string;
// 법인: 취득일, 렌트/리스: 계약일자
purchaseDate: string;
contractDate: string;
// 법인: 구매처, 렌트/리스: 렌트회사명
rentCompany: string;
// 법인: 계약기간, 렌트/리스: 렌트기간
rentPeriod: string;
// 법인: 취득가(공급가), 렌트/리스: 월 렌트료(공급가)
purchasePrice: string;
monthlyRent: string;
monthlyRentTax: string;
rentCompanyTel: string;
agreedMileage: string;
vehiclePrice: string;
residualValue: string;
deposit: string;
mileage: string;
insuranceCompany: string;
insuranceCompanyTel: string;
driver: string;
status: VehicleStatus | '';
memo: string;
}
export const EMPTY_VEHICLE_FORM: VehicleFormData = {
plateNumber: '',
vehicleType: '',
ownershipType: '',
model: '',
year: '',
purchaseDate: '',
contractDate: '',
rentCompany: '',
rentPeriod: '',
purchasePrice: '',
monthlyRent: '',
monthlyRentTax: '',
rentCompanyTel: '',
agreedMileage: '',
vehiclePrice: '',
residualValue: '',
deposit: '',
mileage: '',
insuranceCompany: '',
insuranceCompanyTel: '',
driver: '',
status: '',
memo: '',
};
// 드롭다운용 차량 목록
export interface VehicleDropdownItem {
id: number;
plateNumber: string;
model: string;
}
interface VehicleDropdownApi {
id: number;
plate_number: string;
model: string;
}
export function transformVehicleDropdown(api: VehicleDropdownApi): VehicleDropdownItem {
return {
id: api.id,
plateNumber: api.plate_number,
model: api.model,
};
}
// ===== 차량일지 (Vehicle Logs) =====
export type TripType =
| 'commute_to'
| 'commute_from'
| 'business'
| 'personal'
| 'commute_round'
| 'business_round'
| 'personal_round';
export type LocationType = 'home' | 'office' | 'client' | 'other';
export const TRIP_TYPE_LABELS: Record<TripType, string> = {
commute_to: '출근',
commute_from: '퇴근',
business: '업무용',
personal: '비업무',
commute_round: '출퇴근(왕복)',
business_round: '업무(왕복)',
personal_round: '비업무(왕복)',
};
export const TRIP_TYPE_COLORS: Record<TripType, string> = {
commute_to: 'bg-green-100 text-green-700',
commute_from: 'bg-green-100 text-green-700',
business: 'bg-blue-100 text-blue-700',
personal: 'bg-gray-100 text-gray-700',
commute_round: 'bg-green-100 text-green-700',
business_round: 'bg-blue-100 text-blue-700',
personal_round: 'bg-gray-100 text-gray-700',
};
export const LOCATION_TYPE_LABELS: Record<LocationType, string> = {
home: '자택',
office: '회사',
client: '거래처',
other: '기타',
};
export interface VehicleLog {
id: number;
vehicleId: number;
logDate: string;
department: string | null;
driverName: string;
tripType: TripType;
departureType: LocationType;
departureName: string | null;
departureAddress: string | null;
arrivalType: LocationType;
arrivalName: string | null;
arrivalAddress: string | null;
distanceKm: number;
note: string | null;
// joined
vehicle?: VehicleDropdownItem;
}
export interface VehicleLogApi {
id: number;
vehicle_id: number;
log_date: string;
department: string | null;
driver_name: string;
trip_type: string;
departure_type: string;
departure_name: string | null;
departure_address: string | null;
arrival_type: string;
arrival_name: string | null;
arrival_address: string | null;
distance_km: number;
note: string | null;
vehicle?: VehicleDropdownApi;
}
export function transformVehicleLogApi(api: VehicleLogApi): VehicleLog {
return {
id: api.id,
vehicleId: api.vehicle_id,
logDate: api.log_date,
department: api.department,
driverName: api.driver_name,
tripType: api.trip_type as TripType,
departureType: api.departure_type as LocationType,
departureName: api.departure_name,
departureAddress: api.departure_address,
arrivalType: api.arrival_type as LocationType,
arrivalName: api.arrival_name,
arrivalAddress: api.arrival_address,
distanceKm: api.distance_km,
note: api.note,
vehicle: api.vehicle ? transformVehicleDropdown(api.vehicle) : undefined,
};
}
export interface VehicleLogFormData {
vehicleId: string;
logDate: string;
department: string;
driverName: string;
tripType: TripType | '';
departureType: LocationType | '';
departureName: string;
departureAddress: string;
arrivalType: LocationType | '';
arrivalName: string;
arrivalAddress: string;
distanceKm: string;
note: string;
}
export const EMPTY_LOG_FORM: VehicleLogFormData = {
vehicleId: '',
logDate: new Date().toISOString().slice(0, 10),
department: '',
driverName: '',
tripType: '',
departureType: '',
departureName: '',
departureAddress: '',
arrivalType: '',
arrivalName: '',
arrivalAddress: '',
distanceKm: '',
note: '',
};
export const NOTE_PRESETS = ['거래처방문', '제조시설등', '회의참석', '판촉활동', '교육등'];
export interface VehicleLogSummary {
totalDistance: number;
totalCount: number;
commuteToDistance: number;
commuteToCount: number;
commuteFromDistance: number;
commuteFromCount: number;
businessDistance: number;
businessCount: number;
personalDistance: number;
personalCount: number;
}
// ===== 정비이력 (Vehicle Maintenance) =====
export type MaintenanceCategory = '주유' | '정비' | '보험' | '세차' | '주차' | '통행료' | '검사' | '기타';
export const MAINTENANCE_CATEGORIES: MaintenanceCategory[] = [
'주유', '정비', '보험', '세차', '주차', '통행료', '검사', '기타',
];
export const CATEGORY_COLORS: Record<MaintenanceCategory, string> = {
'주유': 'bg-amber-100 text-amber-700',
'정비': 'bg-blue-100 text-blue-700',
'보험': 'bg-emerald-100 text-emerald-700',
'세차': 'bg-cyan-100 text-cyan-700',
'주차': 'bg-purple-100 text-purple-700',
'통행료': 'bg-orange-100 text-orange-700',
'검사': 'bg-indigo-100 text-indigo-700',
'기타': 'bg-gray-100 text-gray-700',
};
export interface VehicleMaintenance {
id: number;
vehicleId: number;
date: string;
category: MaintenanceCategory;
description: string;
amount: number;
mileage: number;
vendor: string | null;
memo: string | null;
// joined
vehicle?: VehicleDropdownItem;
}
export interface VehicleMaintenanceApi {
id: number;
vehicle_id: number;
date: string;
category: string;
description: string;
amount: number;
mileage: number;
vendor: string | null;
memo: string | null;
vehicle?: { id: number; plate_number: string; model: string };
}
export function transformMaintenanceApi(api: VehicleMaintenanceApi): VehicleMaintenance {
return {
id: api.id,
vehicleId: api.vehicle_id,
date: api.date,
category: api.category as MaintenanceCategory,
description: api.description,
amount: api.amount,
mileage: api.mileage,
vendor: api.vendor,
memo: api.memo,
vehicle: api.vehicle ? transformVehicleDropdown(api.vehicle) : undefined,
};
}
export interface MaintenanceFormData {
vehicleId: string;
date: string;
category: MaintenanceCategory | '';
description: string;
amount: string;
mileage: string;
vendor: string;
memo: string;
}
export const EMPTY_MAINTENANCE_FORM: MaintenanceFormData = {
vehicleId: '',
date: new Date().toISOString().slice(0, 10),
category: '',
description: '',
amount: '',
mileage: '',
vendor: '',
memo: '',
};
// ===== 공통 유틸 =====
export const OWNERSHIP_LABELS: Record<OwnershipType, string> = {
corporate: '법인차량',
rent: '렌트차량',
lease: '리스차량',
};
export const OWNERSHIP_COLORS: Record<OwnershipType, string> = {
corporate: 'bg-purple-100 text-purple-700',
rent: 'bg-blue-100 text-blue-700',
lease: 'bg-green-100 text-green-700',
};
export const STATUS_LABELS: Record<VehicleStatus, string> = {
active: '운행중',
maintenance: '정비중',
disposed: '처분',
};
export const STATUS_COLORS: Record<VehicleStatus, string> = {
active: 'bg-green-100 text-green-700',
maintenance: 'bg-yellow-100 text-yellow-700',
disposed: 'bg-red-100 text-red-700',
};
export const VEHICLE_TYPES: VehicleType[] = ['승용차', '승합차', '화물차', 'SUV'];
export function formatCurrency(value: number): string {
return value.toLocaleString('ko-KR') + '원';
}
export function formatDistance(value: number): string {
return value.toLocaleString('ko-KR') + 'km';
}