Files
sam-react-prod/src/components/vehicle-management/VehicleList/index.tsx
유병철 e5f0f5da61 feat(WEB): 차량 관리 기능 추가 및 CEO 대시보드 Enhanced 섹션 적용
차량 관리 (신규):
- VehicleList/VehicleDetail: 차량 목록/상세
- ForkliftList/ForkliftDetail: 지게차 목록/상세
- VehicleLogList/VehicleLogDetail: 운행일지 목록/상세
- 관련 페이지 라우트 추가 (/vehicle-management/*)

CEO 대시보드:
- Enhanced 섹션 컴포넌트 적용 (아이콘 + 컬러 테마)
- EnhancedStatusBoardSection, EnhancedDailyReportSection, EnhancedMonthlyExpenseSection
- TodayIssueSection 개선

IntegratedDetailTemplate:
- FieldInput, FieldRenderer 기능 확장

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:53:20 +09:00

342 lines
12 KiB
TypeScript

'use client';
/**
* 차량 관리 리스트 - UniversalListPage 기반
* 레거시 5130 사이트 컬럼 구조 기반 (2025-01-28 스크린샷 검증)
*/
import { useState, useMemo, useCallback, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Car, Edit, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type ListParams,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import type { Vehicle } from '../types';
import { getVehicles, deleteVehicle, bulkDeleteVehicles } from './actions';
interface VehicleListProps {
initialData: Vehicle[];
}
export function VehicleList({ initialData }: VehicleListProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
const [allData, setAllData] = useState<Vehicle[]>(initialData);
const handleView = useCallback(
(vehicle: Vehicle) => {
router.push(`/vehicle-management/vehicle/${vehicle.id}`);
},
[router]
);
const handleEdit = useCallback(
(vehicle: Vehicle) => {
router.push(`/vehicle-management/vehicle/${vehicle.id}/edit`);
},
[router]
);
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setIsDeleteDialogOpen(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!deleteTargetId) return;
startTransition(async () => {
const result = await deleteVehicle(deleteTargetId);
if (result.success) {
const vehicle = allData.find((v) => v.id === deleteTargetId);
setAllData(allData.filter((v) => v.id !== deleteTargetId));
toast.success(`차량이 삭제되었습니다${vehicle ? `: ${vehicle.vehicleNumber}` : ''}`);
window.location.reload();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
setIsDeleteDialogOpen(false);
setDeleteTargetId(null);
});
}, [deleteTargetId, allData]);
const handleBulkDelete = useCallback((selectedIds: string[]) => {
if (selectedIds.length === 0) {
toast.error('삭제할 항목을 선택해주세요');
return;
}
setBulkDeleteIds(selectedIds);
setIsBulkDeleteDialogOpen(true);
}, []);
const handleConfirmBulkDelete = useCallback(async () => {
startTransition(async () => {
const result = await bulkDeleteVehicles(bulkDeleteIds);
if (result.success) {
setAllData(allData.filter((v) => !bulkDeleteIds.includes(v.id)));
toast.success(`${bulkDeleteIds.length}개의 차량이 삭제되었습니다`);
window.location.reload();
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
setIsBulkDeleteDialogOpen(false);
setBulkDeleteIds([]);
});
}, [bulkDeleteIds, allData]);
const config: UniversalListConfig<Vehicle> = useMemo(
() => ({
title: '차량 관리',
description: '차량 정보를 관리합니다',
icon: Car,
basePath: '/vehicle-management/vehicle',
idField: 'id',
getItemId: (item: Vehicle) => item.id,
actions: {
getList: async (params?: ListParams) => {
try {
const result = await getVehicles({
page: params?.page || 1,
perPage: params?.perPage || 20,
search: params?.search || undefined,
});
if (result.success) {
setAllData(result.data);
return {
success: true,
data: result.data,
totalCount: result.totalCount,
totalPages: result.totalPages,
};
}
return { success: false, error: result.error || '데이터 조회에 실패했습니다.' };
} catch {
return { success: false, error: '서버 오류가 발생했습니다.' };
}
},
deleteItem: async (id: string) => {
const result = await deleteVehicle(id);
return { success: result.success, error: result.error };
},
deleteBulk: async (ids: string[]) => {
const result = await bulkDeleteVehicles(ids);
return { success: result.success, error: result.error };
},
},
// 레거시 컬럼 구조 (5130 기준) - 12개 컬럼
columns: [
{ key: 'rowNumber', label: '번호', className: 'w-[50px] text-center' },
{ key: 'vehicleNumber', label: '차량번호', className: 'min-w-[120px]' },
{ key: 'managerMain', label: '담당자', className: 'w-[80px]' },
{ key: 'insuranceCompany', label: '보험사', className: 'w-[100px]' },
{ key: 'insuranceContact', label: '보험사 연락처', className: 'w-[110px]' },
{ key: 'firstRegistrationDate', label: '최초등록일', className: 'w-[90px]' },
{ key: 'purchaseDate', label: '구매일자', className: 'w-[90px]' },
{ key: 'purchaseType', label: '구매 유형', className: 'w-[80px]' },
{ key: 'oilChangeCycle', label: '엔진오일 교환 주기', className: 'w-[110px]' },
{ key: 'oilChangeRecords', label: '엔진오일교환일', className: 'min-w-[150px]' },
{ key: 'maintenanceRecords', label: '정비 정보', className: 'min-w-[180px]' },
{ key: 'remarks', label: '비고', className: 'min-w-[120px]' },
],
clientSideFiltering: true,
itemsPerPage: 20,
searchFilter: (item: Vehicle, searchValue: string) => {
const search = searchValue.toLowerCase();
return (
item.vehicleNumber.toLowerCase().includes(search) ||
item.vehicleType.toLowerCase().includes(search) ||
item.managerMain.toLowerCase().includes(search) ||
item.insuranceCompany.toLowerCase().includes(search)
);
},
tabs: [],
searchPlaceholder: '차량번호, 차종, 담당자, 보험사 검색...',
headerActions: () => (
<Button
className="ml-auto"
onClick={() => router.push('/vehicle-management/vehicle/new')}
>
<Car className="w-4 h-4 mr-2" />
</Button>
),
onBulkDelete: handleBulkDelete,
renderTableRow: (
vehicle: Vehicle,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Vehicle>
) => {
// 엔진오일 교환일 포맷 (5130 형식: "날짜, 주행거리 : XX km")
const oilChangeText = vehicle.oilChangeRecords && vehicle.oilChangeRecords.length > 0
? vehicle.oilChangeRecords.map(r =>
`${r.date}, 주행거리 : ${r.mileage || ''} km`
).join('\n')
: '정보 없음';
// 정비 정보 포맷 (5130 형식: "날짜: 내용")
const maintenanceText = vehicle.maintenanceRecords && vehicle.maintenanceRecords.length > 0
? vehicle.maintenanceRecords.map(r =>
`${r.date}: ${r.description}`
).join('\n')
: '정보 없음';
return (
<TableRow
key={vehicle.id}
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
onClick={() => handleView(vehicle)}
>
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
<Checkbox
checked={handlers.isSelected}
onCheckedChange={handlers.onToggle}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{vehicle.vehicleNumber}</TableCell>
<TableCell>{vehicle.managerMain || '-'}</TableCell>
<TableCell>{vehicle.insuranceCompany || '-'}</TableCell>
<TableCell>{vehicle.insuranceContact || '-'}</TableCell>
<TableCell>{vehicle.firstRegistrationDate || '-'}</TableCell>
<TableCell>{vehicle.purchaseDate || '-'}</TableCell>
<TableCell>{vehicle.purchaseType || '-'}</TableCell>
<TableCell>{vehicle.oilChangeCycle || '정보 없음'}</TableCell>
<TableCell className="whitespace-pre-line text-xs">{oilChangeText}</TableCell>
<TableCell className="whitespace-pre-line text-xs">{maintenanceText}</TableCell>
<TableCell className="whitespace-pre-line text-xs">{vehicle.remarks || '-'}</TableCell>
</TableRow>
);
},
renderMobileCard: (
vehicle: Vehicle,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Vehicle>
) => {
return (
<ListMobileCard
key={vehicle.id}
id={vehicle.id}
isSelected={handlers.isSelected}
onToggleSelection={handlers.onToggle}
onClick={() => handleView(vehicle)}
title={`${vehicle.vehicleNumber} ${vehicle.vehicleType}`}
subtitle={vehicle.purchaseType}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="담당자" value={vehicle.managerMain} />
<InfoField label="보험사" value={vehicle.insuranceCompany || '-'} />
<InfoField label="보험사 연락처" value={vehicle.insuranceContact || '-'} />
<InfoField label="구매 유형" value={vehicle.purchaseType || '-'} />
<InfoField label="최초등록일" value={vehicle.firstRegistrationDate || '-'} />
<InfoField label="구매일자" value={vehicle.purchaseDate || '-'} />
</div>
}
actions={
handlers.isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => {
e.stopPropagation();
handleEdit(vehicle);
}}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(vehicle.id);
}}
disabled={isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
},
}),
[router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending]
);
return (
<>
<UniversalListPage config={config} initialData={initialData} />
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
description={
<>
{deleteTargetId
? `차량번호: ${allData.find((v) => v.id === deleteTargetId)?.vehicleNumber || deleteTargetId}`
: ''}
<br />
? .
</>
}
loading={isPending}
onConfirm={handleConfirmDelete}
/>
<DeleteConfirmDialog
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
description={
<>
{bulkDeleteIds.length} ?
<br />
.
</>
}
loading={isPending}
onConfirm={handleConfirmBulkDelete}
/>
</>
);
}
export default VehicleList;