feat: [vehicle] 법인차량 관리 모듈 + MES 분석 보고서 + 프론트엔드 문서
- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력) - MES 데이터 정합성 분석 보고서 v1/v2 - sam-docs 프론트엔드 기술문서 v1 (9개 챕터) - claudedocs 가이드/테스트URL 업데이트
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
17
src/app/[locale]/(protected)/vehicle/vehicle-logs/page.tsx
Normal file
17
src/app/[locale]/(protected)/vehicle/vehicle-logs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
441
src/components/vehicle/CorporateVehicles/VehicleFormDialog.tsx
Normal file
441
src/components/vehicle/CorporateVehicles/VehicleFormDialog.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
src/components/vehicle/CorporateVehicles/actions.ts
Normal file
134
src/components/vehicle/CorporateVehicles/actions.ts
Normal 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: '차량 드롭다운 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
470
src/components/vehicle/CorporateVehicles/index.tsx
Normal file
470
src/components/vehicle/CorporateVehicles/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
408
src/components/vehicle/VehicleLogs/VehicleLogFormDialog.tsx
Normal file
408
src/components/vehicle/VehicleLogs/VehicleLogFormDialog.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
143
src/components/vehicle/VehicleLogs/actions.ts
Normal file
143
src/components/vehicle/VehicleLogs/actions.ts
Normal 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: '운행 통계 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
582
src/components/vehicle/VehicleLogs/index.tsx
Normal file
582
src/components/vehicle/VehicleLogs/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
91
src/components/vehicle/VehicleMaintenance/actions.ts
Normal file
91
src/components/vehicle/VehicleMaintenance/actions.ts
Normal 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: '정비이력 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
489
src/components/vehicle/VehicleMaintenance/index.tsx
Normal file
489
src/components/vehicle/VehicleMaintenance/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
442
src/components/vehicle/types.ts
Normal file
442
src/components/vehicle/types.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user