- 24개 Blade 파일의 수동 SVG 생성 코드를 lucide.createElement(_def)로 통일 - 불필요한 quote-stripping regex(/^"|"$/g) 제거 - Lucide 공식 API 사용으로 SVG viewBox/path 속성 에러 해결
780 lines
40 KiB
PHP
780 lines
40 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '차량일지')
|
|
|
|
@push('styles')
|
|
<style>
|
|
@media print { .no-print { display: none !important; } }
|
|
</style>
|
|
@endpush
|
|
|
|
@section('content')
|
|
<div id="vehicle-logs-root"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
@include('partials.react-cdn')
|
|
<script src="https://unpkg.com/lucide@0.469.0?v={{ time() }}"></script>
|
|
@verbatim
|
|
<script type="text/babel">
|
|
const { useState, useRef, useEffect } = React;
|
|
|
|
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
|
|
const ref = useRef(null);
|
|
useEffect(() => {
|
|
const _def=((n)=>{const a={'check-circle':'CircleCheck','alert-circle':'CircleAlert','alert-triangle':'TriangleAlert','clipboard-check':'ClipboardCheck'};if(a[n]&&lucide[a[n]])return lucide[a[n]];const p=n.split('-').map(w=>w.charAt(0).toUpperCase()+w.slice(1)).join('');return lucide[p]||(lucide.icons&&lucide.icons[n])||null;})(name);
|
|
if (ref.current && _def) {
|
|
ref.current.innerHTML = '';
|
|
const svg = lucide.createElement(_def);
|
|
svg.setAttribute('class', className);
|
|
ref.current.appendChild(svg);
|
|
}
|
|
}, [className]);
|
|
return <span ref={ref} className="inline-flex items-center" {...props} />;
|
|
};
|
|
|
|
const BookOpen = createIcon('book-open');
|
|
const Plus = createIcon('plus');
|
|
const Search = createIcon('search');
|
|
const Download = createIcon('download');
|
|
const X = createIcon('x');
|
|
const Edit = createIcon('edit');
|
|
const Trash2 = createIcon('trash-2');
|
|
const Car = createIcon('car');
|
|
const MapPin = createIcon('map-pin');
|
|
const User = createIcon('user');
|
|
const Calendar = createIcon('calendar');
|
|
const Navigation = createIcon('navigation');
|
|
const Copy = createIcon('copy');
|
|
const ArrowUpDown = createIcon('arrow-up-down');
|
|
|
|
function VehicleLogManagement() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// 차량 목록
|
|
const [vehicles, setVehicles] = useState([]);
|
|
const [selectedVehicle, setSelectedVehicle] = useState('all');
|
|
|
|
// 운행기록
|
|
const [logs, setLogs] = useState([]);
|
|
|
|
// 필터
|
|
const now = new Date();
|
|
const [filterYear, setFilterYear] = useState(now.getFullYear());
|
|
const [filterMonth, setFilterMonth] = useState(now.getMonth() + 1);
|
|
const [filterTripType, setFilterTripType] = useState('all');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
// 모달
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [modalMode, setModalMode] = useState('add');
|
|
const [editingItem, setEditingItem] = useState(null);
|
|
|
|
// 통계
|
|
const [summary, setSummary] = useState({ byType: {}, total: { count: 0, distance: 0 } });
|
|
|
|
const tripTypes = [
|
|
{ value: 'commute_to', label: '출근용', color: 'bg-blue-100 text-blue-700' },
|
|
{ value: 'commute_from', label: '퇴근용', color: 'bg-indigo-100 text-indigo-700' },
|
|
{ value: 'business', label: '업무용', color: 'bg-green-100 text-green-700' },
|
|
{ value: 'personal', label: '비업무', color: 'bg-gray-100 text-gray-700' }
|
|
];
|
|
|
|
const locationTypes = [
|
|
{ value: 'home', label: '자택' },
|
|
{ value: 'office', label: '회사' },
|
|
{ value: 'client', label: '거래처' },
|
|
{ value: 'other', label: '기타' }
|
|
];
|
|
|
|
const noteOptions = ['거래처방문', '제조시설등', '회의참석', '판촉활동', '교육등'];
|
|
|
|
const years = Array.from({ length: 5 }, (_, i) => now.getFullYear() - i);
|
|
const months = Array.from({ length: 12 }, (_, i) => i + 1);
|
|
|
|
// 데이터 로드
|
|
const loadVehicles = async () => {
|
|
try {
|
|
const response = await fetch('/finance/vehicle-logs/vehicles');
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setVehicles(result.data.map(v => ({
|
|
id: v.id,
|
|
plateNumber: v.plate_number,
|
|
model: v.model,
|
|
ownershipType: v.ownership_type,
|
|
mileage: v.mileage || 0
|
|
})));
|
|
}
|
|
} catch (error) {
|
|
console.error('차량 로드 실패:', error);
|
|
}
|
|
};
|
|
|
|
const loadLogs = async () => {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
year: filterYear,
|
|
month: filterMonth,
|
|
trip_type: filterTripType,
|
|
vehicle_id: selectedVehicle,
|
|
search: searchTerm
|
|
});
|
|
const response = await fetch(`/finance/vehicle-logs/list?${params}`);
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setLogs(result.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('운행기록 로드 실패:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadSummary = async () => {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
year: filterYear,
|
|
month: filterMonth,
|
|
vehicle_id: selectedVehicle
|
|
});
|
|
const response = await fetch(`/finance/vehicle-logs/summary?${params}`);
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setSummary(result.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('통계 로드 실패:', error);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadVehicles();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadLogs();
|
|
loadSummary();
|
|
}, [filterYear, filterMonth, filterTripType, selectedVehicle]);
|
|
|
|
const handleSearch = () => {
|
|
loadLogs();
|
|
};
|
|
|
|
// 헬퍼 함수
|
|
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
|
|
|
|
const getTripTypeInfo = (type) => {
|
|
return tripTypes.find(t => t.value === type) || { label: type, color: 'bg-gray-100 text-gray-700' };
|
|
};
|
|
|
|
const getLocationTypeLabel = (type) => {
|
|
const found = locationTypes.find(t => t.value === type);
|
|
return found ? found.label : type || '';
|
|
};
|
|
|
|
const getSelectedVehicleInfo = () => {
|
|
if (selectedVehicle === 'all') return null;
|
|
return vehicles.find(v => v.id === parseInt(selectedVehicle));
|
|
};
|
|
|
|
// 폼 상태
|
|
const initialFormState = {
|
|
logDate: new Date().toISOString().split('T')[0],
|
|
vehicleId: '',
|
|
department: '',
|
|
driverName: '',
|
|
tripType: 'business',
|
|
departureType: 'office',
|
|
departureName: '',
|
|
departureAddress: '',
|
|
arrivalType: 'client',
|
|
arrivalName: '',
|
|
arrivalAddress: '',
|
|
distanceKm: '',
|
|
note: ''
|
|
};
|
|
const [formData, setFormData] = useState(initialFormState);
|
|
|
|
// 운행기록 CRUD
|
|
const handleAdd = () => {
|
|
setModalMode('add');
|
|
setFormData({
|
|
...initialFormState,
|
|
vehicleId: selectedVehicle !== 'all' ? selectedVehicle : (vehicles[0]?.id || '')
|
|
});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleEdit = (item) => {
|
|
setModalMode('edit');
|
|
setEditingItem(item);
|
|
setFormData({
|
|
logDate: item.logDate,
|
|
vehicleId: item.vehicleId,
|
|
department: item.department || '',
|
|
driverName: item.driverName,
|
|
tripType: item.tripType,
|
|
departureType: item.departureType || '',
|
|
departureName: item.departureName || '',
|
|
departureAddress: item.departureAddress || '',
|
|
arrivalType: item.arrivalType || '',
|
|
arrivalName: item.arrivalName || '',
|
|
arrivalAddress: item.arrivalAddress || '',
|
|
distanceKm: item.distanceKm,
|
|
note: item.note || ''
|
|
});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!formData.vehicleId || !formData.driverName || !formData.distanceKm) {
|
|
alert('차량, 운전자명, 주행거리는 필수입니다.');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const payload = {
|
|
vehicle_id: parseInt(formData.vehicleId),
|
|
log_date: formData.logDate,
|
|
department: formData.department,
|
|
driver_name: formData.driverName,
|
|
trip_type: formData.tripType,
|
|
departure_type: formData.departureType || null,
|
|
departure_name: formData.departureName,
|
|
departure_address: formData.departureAddress,
|
|
arrival_type: formData.arrivalType || null,
|
|
arrival_name: formData.arrivalName,
|
|
arrival_address: formData.arrivalAddress,
|
|
distance_km: parseInt(formData.distanceKm) || 0,
|
|
note: formData.note
|
|
};
|
|
const url = modalMode === 'add' ? '/finance/vehicle-logs' : `/finance/vehicle-logs/${editingItem.id}`;
|
|
const method = modalMode === 'add' ? 'POST' : 'PUT';
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
await loadLogs();
|
|
await loadSummary();
|
|
setShowModal(false);
|
|
setEditingItem(null);
|
|
} else {
|
|
alert(result.message || '저장에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('저장 오류:', error);
|
|
alert('저장 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// 복사 기능: 현재 데이터를 복사해서 새 기록 추가 모드로 전환
|
|
const handleCopy = () => {
|
|
setModalMode('add');
|
|
setEditingItem(null);
|
|
};
|
|
|
|
// 출발지↔도착지 교환 + 출근↔퇴근 전환
|
|
const handleSwapLocations = () => {
|
|
setFormData(prev => {
|
|
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 handleDelete = async (id) => {
|
|
if (!confirm('정말 삭제하시겠습니까?')) return;
|
|
try {
|
|
const response = await fetch(`/finance/vehicle-logs/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content }
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
await loadLogs();
|
|
await loadSummary();
|
|
setShowModal(false);
|
|
} else {
|
|
alert(result.message || '삭제에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('삭제 오류:', error);
|
|
alert('삭제 중 오류가 발생했습니다.');
|
|
}
|
|
};
|
|
|
|
const handleDownload = () => {
|
|
const rows = [
|
|
['업무용승용차 운행기록부', `${filterYear}년 ${filterMonth}월`],
|
|
[],
|
|
['날짜', '부서', '성명', '구분', '출발지분류', '출발지명', '출발지주소', '도착지분류', '도착지명', '도착지주소', '주행km', '비고'],
|
|
...logs.map(item => [
|
|
item.logDate,
|
|
item.department || '',
|
|
item.driverName,
|
|
getTripTypeInfo(item.tripType).label,
|
|
getLocationTypeLabel(item.departureType),
|
|
item.departureName || '',
|
|
item.departureAddress || '',
|
|
getLocationTypeLabel(item.arrivalType),
|
|
item.arrivalName || '',
|
|
item.arrivalAddress || '',
|
|
item.distanceKm,
|
|
item.note || ''
|
|
])
|
|
];
|
|
const csvContent = rows.map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
|
|
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = `운행기록부_${filterYear}년${filterMonth}월.csv`;
|
|
link.click();
|
|
};
|
|
|
|
// 검색 필터링
|
|
const filteredLogs = logs.filter(item => {
|
|
if (!searchTerm) return true;
|
|
const search = searchTerm.toLowerCase();
|
|
return (item.driverName || '').toLowerCase().includes(search) ||
|
|
(item.departureName || '').toLowerCase().includes(search) ||
|
|
(item.arrivalName || '').toLowerCase().includes(search) ||
|
|
(item.note || '').toLowerCase().includes(search);
|
|
});
|
|
|
|
const vehicleInfo = getSelectedVehicleInfo();
|
|
|
|
return (
|
|
<div className="bg-gray-50 min-h-screen">
|
|
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
|
|
<div className="px-6 py-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-2 bg-emerald-100 rounded-xl"><BookOpen className="w-6 h-6 text-emerald-600" /></div>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-gray-900">차량일지</h1>
|
|
<p className="text-sm text-gray-500">업무용승용차 운행기록부</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg">
|
|
<Download className="w-4 h-4" /><span className="text-sm">Excel</span>
|
|
</button>
|
|
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg">
|
|
<Plus className="w-4 h-4" /><span className="text-sm font-medium">운행기록 등록</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* 차량 선택 및 기본 정보 */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<Car className="w-5 h-5 text-gray-500" />
|
|
<select
|
|
value={selectedVehicle}
|
|
onChange={(e) => setSelectedVehicle(e.target.value)}
|
|
className="px-3 py-2 border border-gray-300 rounded-lg font-medium"
|
|
>
|
|
<option value="all">전체 차량</option>
|
|
{vehicles.map(v => (
|
|
<option key={v.id} value={v.id}>{v.plateNumber} ({v.model})</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{vehicleInfo && (
|
|
<div className="flex items-center gap-4 text-sm text-gray-600">
|
|
<span>차종: <strong>{vehicleInfo.model}</strong></span>
|
|
<span>현재 주행거리: <strong>{formatCurrency(vehicleInfo.mileage)}km</strong></span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 통계 카드 */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm text-gray-500">전체</span>
|
|
<Navigation className="w-5 h-5 text-gray-400" />
|
|
</div>
|
|
<p className="text-xl font-bold text-gray-900">{formatCurrency(summary.total?.distance || 0)}km</p>
|
|
<p className="text-xs text-gray-400 mt-1">{summary.total?.count || 0}건</p>
|
|
</div>
|
|
{tripTypes.map(type => {
|
|
const data = summary.byType?.[type.value] || { count: 0, distance: 0 };
|
|
return (
|
|
<div key={type.value} className="bg-white rounded-xl border border-gray-200 p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className={`text-sm ${type.color.split(' ')[1]}`}>{type.label}</span>
|
|
</div>
|
|
<p className="text-xl font-bold text-gray-900">{formatCurrency(data.distance)}km</p>
|
|
<p className="text-xs text-gray-400 mt-1">{data.count}건</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 필터 */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="w-5 h-5 text-gray-400" />
|
|
<select value={filterYear} onChange={(e) => setFilterYear(parseInt(e.target.value))} className="px-5 py-2 border border-gray-300 rounded-lg min-w-[150px]">
|
|
{years.map(y => <option key={y} value={y}>{y}년</option>)}
|
|
</select>
|
|
<select value={filterMonth} onChange={(e) => setFilterMonth(parseInt(e.target.value))} className="px-5 py-2 border border-gray-300 rounded-lg min-w-[150px]">
|
|
{months.map(m => <option key={m} value={m}>{m}월</option>)}
|
|
</select>
|
|
</div>
|
|
<select value={filterTripType} onChange={(e) => setFilterTripType(e.target.value)} className="px-4 py-2 border border-gray-300 rounded-lg min-w-[120px]">
|
|
<option value="all">전체 구분</option>
|
|
{tripTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
</select>
|
|
<div className="flex-1 relative">
|
|
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
|
<input
|
|
type="text"
|
|
placeholder="운전자, 출발지, 도착지, 비고 검색..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 운행기록 테이블 */}
|
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">차량</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">부서/성명</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">구분</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">출발지</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">도착지</th>
|
|
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">주행km</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">비고</th>
|
|
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{loading ? (
|
|
<tr><td colSpan="9" className="px-6 py-12 text-center text-gray-400">로딩 중...</td></tr>
|
|
) : filteredLogs.length === 0 ? (
|
|
<tr><td colSpan="9" className="px-6 py-12 text-center text-gray-400">운행기록이 없습니다.</td></tr>
|
|
) : filteredLogs.map(item => {
|
|
const tripType = getTripTypeInfo(item.tripType);
|
|
return (
|
|
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
|
|
<td className="px-4 py-3 text-sm text-gray-600">{item.logDate}</td>
|
|
<td className="px-4 py-3">
|
|
<p className="text-sm font-medium text-gray-900">{item.plateNumber || '-'}</p>
|
|
<p className="text-xs text-gray-400">{item.model || ''}</p>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<p className="text-sm text-gray-900">{item.driverName}</p>
|
|
{item.department && <p className="text-xs text-gray-400">{item.department}</p>}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`px-2 py-1 rounded text-xs font-medium ${tripType.color}`}>{tripType.label}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<p className="text-sm text-gray-900">{item.departureName || getLocationTypeLabel(item.departureType) || '-'}</p>
|
|
{item.departureAddress && <p className="text-xs text-gray-400 truncate max-w-[150px]">{item.departureAddress}</p>}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<p className="text-sm text-gray-900">{item.arrivalName || getLocationTypeLabel(item.arrivalType) || '-'}</p>
|
|
{item.arrivalAddress && <p className="text-xs text-gray-400 truncate max-w-[150px]">{item.arrivalAddress}</p>}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm font-bold text-right text-emerald-600">{formatCurrency(item.distanceKm)}km</td>
|
|
<td className="px-4 py-3 text-sm text-gray-600">{item.note || '-'}</td>
|
|
<td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
|
|
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
|
|
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
{filteredLogs.length > 0 && (
|
|
<tfoot className="bg-gray-50 border-t border-gray-200">
|
|
<tr>
|
|
<td colSpan="6" className="px-4 py-3 text-sm font-semibold text-gray-700 text-right">합계</td>
|
|
<td className="px-4 py-3 text-sm font-bold text-right text-emerald-700">
|
|
{formatCurrency(filteredLogs.reduce((sum, item) => sum + item.distanceKm, 0))}km
|
|
</td>
|
|
<td colSpan="2"></td>
|
|
</tr>
|
|
</tfoot>
|
|
)}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 등록/수정 모달 */}
|
|
{showModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '운행기록 등록' : '운행기록 수정'}</h3>
|
|
<div className="flex items-center gap-1">
|
|
{modalMode === 'edit' && (
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
className="flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 rounded border border-blue-200"
|
|
title="이 기록을 복사하여 새로 추가"
|
|
>
|
|
<Copy className="w-3 h-3" />
|
|
복사
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={handleSwapLocations}
|
|
className="flex items-center gap-1 px-2 py-1 text-xs text-purple-600 hover:bg-purple-50 rounded border border-purple-200"
|
|
title="출발지↔도착지 교환, 출근↔퇴근 전환"
|
|
>
|
|
<ArrowUpDown className="w-3 h-3" />
|
|
출발↔도착
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{/* 기본 정보 */}
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">날짜 *</label>
|
|
<input
|
|
type="date"
|
|
value={formData.logDate}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, logDate: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">차량 *</label>
|
|
<select
|
|
value={formData.vehicleId}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, vehicleId: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
>
|
|
<option value="">선택하세요</option>
|
|
{vehicles.map(v => <option key={v.id} value={v.id}>{v.plateNumber} ({v.model})</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">구분 *</label>
|
|
<select
|
|
value={formData.tripType}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, tripType: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
>
|
|
{tripTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">부서</label>
|
|
<input
|
|
type="text"
|
|
value={formData.department}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, department: e.target.value }))}
|
|
placeholder="영업부"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">운전자 *</label>
|
|
<input
|
|
type="text"
|
|
value={formData.driverName}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, driverName: e.target.value }))}
|
|
placeholder="홍길동"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">주행거리 (km) *</label>
|
|
<input
|
|
type="number"
|
|
value={formData.distanceKm}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, distanceKm: e.target.value }))}
|
|
placeholder="0"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 출발지 */}
|
|
<div className="border-t pt-4">
|
|
<h4 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
|
<MapPin className="w-4 h-4 text-blue-500" /> 출발지
|
|
</h4>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">분류</label>
|
|
<select
|
|
value={formData.departureType}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, departureType: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
>
|
|
<option value="">선택</option>
|
|
{locationTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">출발지명</label>
|
|
<input
|
|
type="text"
|
|
value={formData.departureName}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, departureName: e.target.value }))}
|
|
placeholder="본사"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">주소</label>
|
|
<input
|
|
type="text"
|
|
value={formData.departureAddress}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, departureAddress: e.target.value }))}
|
|
placeholder="서울시 강남구..."
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 도착지 */}
|
|
<div className="border-t pt-4">
|
|
<h4 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
|
<MapPin className="w-4 h-4 text-emerald-500" /> 도착지
|
|
</h4>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">분류</label>
|
|
<select
|
|
value={formData.arrivalType}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, arrivalType: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
>
|
|
<option value="">선택</option>
|
|
{locationTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">도착지명</label>
|
|
<input
|
|
type="text"
|
|
value={formData.arrivalName}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, arrivalName: e.target.value }))}
|
|
placeholder="거래처A"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">주소</label>
|
|
<input
|
|
type="text"
|
|
value={formData.arrivalAddress}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, arrivalAddress: e.target.value }))}
|
|
placeholder="경기도 성남시..."
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 비고 */}
|
|
<div className="border-t pt-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">비고</label>
|
|
<div className="flex flex-wrap gap-2 mb-2">
|
|
{noteOptions.map(opt => (
|
|
<button
|
|
key={opt}
|
|
type="button"
|
|
onClick={() => setFormData(prev => ({ ...prev, note: opt }))}
|
|
className={`px-3 py-1 text-sm rounded-full border ${formData.note === opt ? 'bg-emerald-100 border-emerald-500 text-emerald-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}
|
|
>
|
|
{opt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={formData.note}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, note: e.target.value }))}
|
|
placeholder="직접 입력"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-3 mt-6">
|
|
{modalMode === 'edit' && (
|
|
<button
|
|
onClick={() => handleDelete(editingItem.id)}
|
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg"
|
|
>
|
|
삭제
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setShowModal(false)}
|
|
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="flex-1 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg disabled:opacity-50"
|
|
>
|
|
{saving ? '저장 중...' : (modalMode === 'add' ? '등록' : '저장')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const rootElement = document.getElementById('vehicle-logs-root');
|
|
if (rootElement) { ReactDOM.createRoot(rootElement).render(<VehicleLogManagement />); }
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|