feat:차량일지(운행기록부) 기능 구현
- VehicleLog 모델 생성 - VehicleLogController (CRUD, 엑셀 다운로드) - 차량일지 라우트 추가 (/finance/vehicle-logs/*) - React 기반 UI (vehicle-logs.blade.php) - VehicleLogMenuSeeder (법인차량관리 > 차량일지 메뉴) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
266
app/Http/Controllers/Finance/VehicleLogController.php
Normal file
266
app/Http/Controllers/Finance/VehicleLogController.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CorporateVehicle;
|
||||
use App\Models\VehicleLog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class VehicleLogController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('finance.vehicle-logs'));
|
||||
}
|
||||
|
||||
$tenantId = session('tenant_id', 1);
|
||||
$vehicles = CorporateVehicle::where('tenant_id', $tenantId)
|
||||
->where('status', 'active')
|
||||
->orderBy('plate_number')
|
||||
->get();
|
||||
|
||||
return view('finance.vehicle-logs', [
|
||||
'vehicles' => $vehicles,
|
||||
'tripTypes' => VehicleLog::tripTypeLabels(),
|
||||
'locationTypes' => VehicleLog::locationTypeLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('tenant_id', 1);
|
||||
|
||||
$request->validate([
|
||||
'vehicle_id' => 'required|integer',
|
||||
'year' => 'required|integer',
|
||||
'month' => 'required|integer|min:1|max:12',
|
||||
]);
|
||||
|
||||
$vehicleId = $request->vehicle_id;
|
||||
$year = $request->year;
|
||||
$month = $request->month;
|
||||
|
||||
// 차량 정보
|
||||
$vehicle = CorporateVehicle::where('tenant_id', $tenantId)
|
||||
->findOrFail($vehicleId);
|
||||
|
||||
// 해당 월의 운행 기록
|
||||
$startDate = sprintf('%04d-%02d-01', $year, $month);
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$logs = VehicleLog::where('tenant_id', $tenantId)
|
||||
->where('vehicle_id', $vehicleId)
|
||||
->whereBetween('log_date', [$startDate, $endDate])
|
||||
->orderBy('log_date')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
// 월별 합계
|
||||
$totals = [
|
||||
'business_km' => $logs->whereIn('trip_type', ['commute_to', 'commute_from', 'business'])->sum('distance_km'),
|
||||
'personal_km' => $logs->where('trip_type', 'personal')->sum('distance_km'),
|
||||
'total_km' => $logs->sum('distance_km'),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'vehicle' => $vehicle,
|
||||
'logs' => $logs,
|
||||
'totals' => $totals,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('tenant_id', 1);
|
||||
|
||||
$request->validate([
|
||||
'vehicle_id' => 'required|integer|exists:corporate_vehicles,id',
|
||||
'log_date' => 'required|date',
|
||||
'driver_name' => 'required|string|max:50',
|
||||
'trip_type' => 'required|in:commute_to,commute_from,business,personal',
|
||||
'distance_km' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
// 해당 차량이 현재 테넌트의 것인지 확인
|
||||
CorporateVehicle::where('tenant_id', $tenantId)
|
||||
->findOrFail($request->vehicle_id);
|
||||
|
||||
$log = VehicleLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'vehicle_id' => $request->vehicle_id,
|
||||
'log_date' => $request->log_date,
|
||||
'department' => $request->department,
|
||||
'driver_name' => $request->driver_name,
|
||||
'trip_type' => $request->trip_type,
|
||||
'departure_type' => $request->departure_type,
|
||||
'departure_name' => $request->departure_name,
|
||||
'departure_address' => $request->departure_address,
|
||||
'arrival_type' => $request->arrival_type,
|
||||
'arrival_name' => $request->arrival_name,
|
||||
'arrival_address' => $request->arrival_address,
|
||||
'distance_km' => $request->distance_km,
|
||||
'note' => $request->note,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '운행기록이 등록되었습니다.',
|
||||
'data' => $log,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('tenant_id', 1);
|
||||
|
||||
$log = VehicleLog::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'log_date' => 'required|date',
|
||||
'driver_name' => 'required|string|max:50',
|
||||
'trip_type' => 'required|in:commute_to,commute_from,business,personal',
|
||||
'distance_km' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
$log->update([
|
||||
'log_date' => $request->log_date,
|
||||
'department' => $request->department,
|
||||
'driver_name' => $request->driver_name,
|
||||
'trip_type' => $request->trip_type,
|
||||
'departure_type' => $request->departure_type,
|
||||
'departure_name' => $request->departure_name,
|
||||
'departure_address' => $request->departure_address,
|
||||
'arrival_type' => $request->arrival_type,
|
||||
'arrival_name' => $request->arrival_name,
|
||||
'arrival_address' => $request->arrival_address,
|
||||
'distance_km' => $request->distance_km,
|
||||
'note' => $request->note,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '운행기록이 수정되었습니다.',
|
||||
'data' => $log,
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('tenant_id', 1);
|
||||
|
||||
$log = VehicleLog::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
$log->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '운행기록이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$tenantId = session('tenant_id', 1);
|
||||
|
||||
$request->validate([
|
||||
'vehicle_id' => 'required|integer',
|
||||
'year' => 'required|integer',
|
||||
'month' => 'required|integer|min:1|max:12',
|
||||
]);
|
||||
|
||||
$vehicleId = $request->vehicle_id;
|
||||
$year = $request->year;
|
||||
$month = $request->month;
|
||||
|
||||
$vehicle = CorporateVehicle::where('tenant_id', $tenantId)
|
||||
->findOrFail($vehicleId);
|
||||
|
||||
$startDate = sprintf('%04d-%02d-01', $year, $month);
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$logs = VehicleLog::where('tenant_id', $tenantId)
|
||||
->where('vehicle_id', $vehicleId)
|
||||
->whereBetween('log_date', [$startDate, $endDate])
|
||||
->orderBy('log_date')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('운행기록부');
|
||||
|
||||
// 기본 정보
|
||||
$sheet->setCellValue('A1', '업무용승용차 운행기록부');
|
||||
$sheet->setCellValue('A3', '차량번호');
|
||||
$sheet->setCellValue('B3', $vehicle->plate_number);
|
||||
$sheet->setCellValue('C3', '차종');
|
||||
$sheet->setCellValue('D3', $vehicle->model);
|
||||
$sheet->setCellValue('E3', '구분');
|
||||
$sheet->setCellValue('F3', $this->getOwnershipTypeLabel($vehicle->ownership_type));
|
||||
$sheet->setCellValue('A4', '조회기간');
|
||||
$sheet->setCellValue('B4', sprintf('%d년 %d월', $year, $month));
|
||||
|
||||
// 헤더
|
||||
$headers = ['일자', '부서', '성명', '구분', '출발지', '도착지', '주행km', '비고'];
|
||||
$col = 'A';
|
||||
foreach ($headers as $header) {
|
||||
$sheet->setCellValue($col . '6', $header);
|
||||
$col++;
|
||||
}
|
||||
|
||||
// 데이터
|
||||
$row = 7;
|
||||
$tripTypeLabels = VehicleLog::tripTypeLabels();
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$sheet->setCellValue('A' . $row, $log->log_date->format('Y-m-d'));
|
||||
$sheet->setCellValue('B' . $row, $log->department ?? '');
|
||||
$sheet->setCellValue('C' . $row, $log->driver_name);
|
||||
$sheet->setCellValue('D' . $row, $tripTypeLabels[$log->trip_type] ?? $log->trip_type);
|
||||
$sheet->setCellValue('E' . $row, $log->departure_name ?? '');
|
||||
$sheet->setCellValue('F' . $row, $log->arrival_name ?? '');
|
||||
$sheet->setCellValue('G' . $row, $log->distance_km);
|
||||
$sheet->setCellValue('H' . $row, $log->note ?? '');
|
||||
$row++;
|
||||
}
|
||||
|
||||
// 합계
|
||||
$businessKm = $logs->whereIn('trip_type', ['commute_to', 'commute_from', 'business'])->sum('distance_km');
|
||||
$personalKm = $logs->where('trip_type', 'personal')->sum('distance_km');
|
||||
$totalKm = $logs->sum('distance_km');
|
||||
|
||||
$sheet->setCellValue('A' . $row, '합계');
|
||||
$sheet->setCellValue('F' . $row, '업무용: ' . number_format($businessKm) . 'km');
|
||||
$sheet->setCellValue('G' . $row, number_format($totalKm));
|
||||
$sheet->setCellValue('H' . $row, '비업무: ' . number_format($personalKm) . 'km');
|
||||
|
||||
$filename = sprintf('운행기록부_%s_%d년%d월.xlsx', $vehicle->plate_number, $year, $month);
|
||||
|
||||
return response()->streamDownload(function () use ($spreadsheet) {
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save('php://output');
|
||||
}, $filename, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
]);
|
||||
}
|
||||
|
||||
private function getOwnershipTypeLabel(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'corporate' => '회사',
|
||||
'rent' => '렌트',
|
||||
'lease' => '리스',
|
||||
default => $type,
|
||||
};
|
||||
}
|
||||
}
|
||||
86
app/Models/VehicleLog.php
Normal file
86
app/Models/VehicleLog.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class VehicleLog extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'vehicle_id',
|
||||
'log_date',
|
||||
'department',
|
||||
'driver_name',
|
||||
'trip_type',
|
||||
'departure_type',
|
||||
'departure_name',
|
||||
'departure_address',
|
||||
'arrival_type',
|
||||
'arrival_name',
|
||||
'arrival_address',
|
||||
'distance_km',
|
||||
'note',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'log_date' => 'date',
|
||||
'distance_km' => 'integer',
|
||||
];
|
||||
|
||||
// trip_type 상수
|
||||
public const TRIP_TYPE_COMMUTE_TO = 'commute_to';
|
||||
public const TRIP_TYPE_COMMUTE_FROM = 'commute_from';
|
||||
public const TRIP_TYPE_BUSINESS = 'business';
|
||||
public const TRIP_TYPE_PERSONAL = 'personal';
|
||||
|
||||
// location_type 상수
|
||||
public const LOCATION_TYPE_HOME = 'home';
|
||||
public const LOCATION_TYPE_OFFICE = 'office';
|
||||
public const LOCATION_TYPE_CLIENT = 'client';
|
||||
public const LOCATION_TYPE_OTHER = 'other';
|
||||
|
||||
public static function tripTypeLabels(): array
|
||||
{
|
||||
return [
|
||||
self::TRIP_TYPE_COMMUTE_TO => '출근용',
|
||||
self::TRIP_TYPE_COMMUTE_FROM => '퇴근용',
|
||||
self::TRIP_TYPE_BUSINESS => '업무용',
|
||||
self::TRIP_TYPE_PERSONAL => '비업무',
|
||||
];
|
||||
}
|
||||
|
||||
public static function locationTypeLabels(): array
|
||||
{
|
||||
return [
|
||||
self::LOCATION_TYPE_HOME => '자택',
|
||||
self::LOCATION_TYPE_OFFICE => '회사',
|
||||
self::LOCATION_TYPE_CLIENT => '거래처',
|
||||
self::LOCATION_TYPE_OTHER => '기타',
|
||||
];
|
||||
}
|
||||
|
||||
public function vehicle(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CorporateVehicle::class, 'vehicle_id');
|
||||
}
|
||||
|
||||
public function getTripTypeLabelAttribute(): string
|
||||
{
|
||||
return self::tripTypeLabels()[$this->trip_type] ?? $this->trip_type;
|
||||
}
|
||||
|
||||
public function getDepartureTypeLabelAttribute(): string
|
||||
{
|
||||
return self::locationTypeLabels()[$this->departure_type] ?? ($this->departure_type ?? '');
|
||||
}
|
||||
|
||||
public function getArrivalTypeLabelAttribute(): string
|
||||
{
|
||||
return self::locationTypeLabels()[$this->arrival_type] ?? ($this->arrival_type ?? '');
|
||||
}
|
||||
}
|
||||
110
database/seeders/VehicleLogMenuSeeder.php
Normal file
110
database/seeders/VehicleLogMenuSeeder.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Commons\Menu;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* 차량일지 메뉴 시더
|
||||
* - 재무관리 > 법인차량관리 하위에 차량일지 메뉴 추가
|
||||
*/
|
||||
class VehicleLogMenuSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$tenantId = 1;
|
||||
|
||||
// 법인차량관리 메뉴 찾기
|
||||
$vehicleMenu = Menu::where('tenant_id', $tenantId)
|
||||
->where('name', '법인차량관리')
|
||||
->first();
|
||||
|
||||
if (!$vehicleMenu) {
|
||||
$this->command->error('법인차량관리 메뉴를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 차량일지 메뉴가 이미 있는지 확인
|
||||
$existingMenu = Menu::where('tenant_id', $tenantId)
|
||||
->where('name', '차량일지')
|
||||
->where('parent_id', $vehicleMenu->id)
|
||||
->first();
|
||||
|
||||
if ($existingMenu) {
|
||||
$this->command->info('차량일지 메뉴가 이미 존재합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 법인차량관리가 그룹 메뉴인지 확인
|
||||
// 그룹 메뉴가 아니면 그룹으로 변경
|
||||
if ($vehicleMenu->url) {
|
||||
// 기존 URL을 차량목록으로 변경
|
||||
$vehicleListMenu = Menu::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $vehicleMenu->id,
|
||||
'name' => '차량목록',
|
||||
'url' => $vehicleMenu->url,
|
||||
'icon' => 'car',
|
||||
'sort_order' => 1,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$this->command->info("차량목록 하위 메뉴 생성: {$vehicleListMenu->url}");
|
||||
|
||||
// 법인차량관리를 그룹 메뉴로 변경
|
||||
$vehicleMenu->url = null;
|
||||
$vehicleMenu->save();
|
||||
$this->command->info('법인차량관리를 그룹 메뉴로 변경');
|
||||
|
||||
// 차량일지 메뉴 생성
|
||||
$vehicleLogMenu = Menu::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $vehicleMenu->id,
|
||||
'name' => '차량일지',
|
||||
'url' => '/finance/vehicle-logs',
|
||||
'icon' => 'file-text',
|
||||
'sort_order' => 2,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$this->command->info("차량일지 메뉴 생성: {$vehicleLogMenu->url}");
|
||||
|
||||
// 차량정비 메뉴가 있으면 순서 조정
|
||||
$maintenanceMenu = Menu::where('tenant_id', $tenantId)
|
||||
->where('name', '차량정비')
|
||||
->where('parent_id', $vehicleMenu->id)
|
||||
->first();
|
||||
|
||||
if ($maintenanceMenu) {
|
||||
$maintenanceMenu->sort_order = 3;
|
||||
$maintenanceMenu->save();
|
||||
}
|
||||
} else {
|
||||
// 이미 그룹 메뉴인 경우 차량일지만 추가
|
||||
// 기존 하위 메뉴 순서 확인
|
||||
$maxSortOrder = Menu::where('parent_id', $vehicleMenu->id)
|
||||
->max('sort_order') ?? 0;
|
||||
|
||||
$vehicleLogMenu = Menu::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $vehicleMenu->id,
|
||||
'name' => '차량일지',
|
||||
'url' => '/finance/vehicle-logs',
|
||||
'icon' => 'file-text',
|
||||
'sort_order' => $maxSortOrder + 1,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$this->command->info("차량일지 메뉴 생성: {$vehicleLogMenu->url}");
|
||||
}
|
||||
|
||||
// 결과 출력
|
||||
$this->command->info('');
|
||||
$this->command->info('=== 법인차량관리 하위 메뉴 ===');
|
||||
$children = Menu::where('parent_id', $vehicleMenu->id)
|
||||
->orderBy('sort_order')
|
||||
->get(['name', 'url', 'sort_order']);
|
||||
|
||||
foreach ($children as $child) {
|
||||
$this->command->info("{$child->sort_order}. {$child->name} ({$child->url})");
|
||||
}
|
||||
}
|
||||
}
|
||||
674
resources/views/finance/vehicle-logs.blade.php
Normal file
674
resources/views/finance/vehicle-logs.blade.php
Normal file
@@ -0,0 +1,674 @@
|
||||
@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')
|
||||
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
|
||||
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
|
||||
<script>
|
||||
window.INITIAL_DATA = {
|
||||
vehicles: @json($vehicles),
|
||||
tripTypes: @json($tripTypes),
|
||||
locationTypes: @json($locationTypes)
|
||||
};
|
||||
</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(() => {
|
||||
if (ref.current && lucide.icons[name]) {
|
||||
ref.current.innerHTML = '';
|
||||
const svg = lucide.createElement(lucide.icons[name]);
|
||||
svg.setAttribute('class', className);
|
||||
ref.current.appendChild(svg);
|
||||
}
|
||||
}, [className]);
|
||||
return <span ref={ref} className="inline-flex items-center" {...props} />;
|
||||
};
|
||||
|
||||
const Car = createIcon('car');
|
||||
const Plus = createIcon('plus');
|
||||
const Download = createIcon('download');
|
||||
const X = createIcon('x');
|
||||
const Loader = createIcon('loader-2');
|
||||
const Calendar = createIcon('calendar');
|
||||
const ChevronLeft = createIcon('chevron-left');
|
||||
const ChevronRight = createIcon('chevron-right');
|
||||
const FileText = createIcon('file-text');
|
||||
const MapPin = createIcon('map-pin');
|
||||
const User = createIcon('user');
|
||||
const Edit = createIcon('edit');
|
||||
const Trash2 = createIcon('trash-2');
|
||||
|
||||
function VehicleLogsManagement() {
|
||||
const { vehicles, tripTypes, locationTypes } = window.INITIAL_DATA;
|
||||
|
||||
const currentDate = new Date();
|
||||
const [selectedVehicle, setSelectedVehicle] = useState(vehicles[0]?.id || null);
|
||||
const [selectedYear, setSelectedYear] = useState(currentDate.getFullYear());
|
||||
const [selectedMonth, setSelectedMonth] = useState(currentDate.getMonth() + 1);
|
||||
|
||||
const [vehicleInfo, setVehicleInfo] = useState(null);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [totals, setTotals] = useState({ business_km: 0, personal_km: 0, total_km: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState('add');
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
|
||||
const initialFormState = {
|
||||
vehicle_id: selectedVehicle,
|
||||
log_date: '',
|
||||
department: '',
|
||||
driver_name: '',
|
||||
trip_type: 'business',
|
||||
departure_type: 'office',
|
||||
departure_name: '',
|
||||
departure_address: '',
|
||||
arrival_type: 'client',
|
||||
arrival_name: '',
|
||||
arrival_address: '',
|
||||
distance_km: '',
|
||||
note: ''
|
||||
};
|
||||
const [formData, setFormData] = useState(initialFormState);
|
||||
|
||||
const ownershipTypeLabels = {
|
||||
corporate: '회사',
|
||||
rent: '렌트',
|
||||
lease: '리스'
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!selectedVehicle) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({
|
||||
vehicle_id: selectedVehicle,
|
||||
year: selectedYear,
|
||||
month: selectedMonth
|
||||
});
|
||||
const response = await fetch(`/finance/vehicle-logs/list?${params}`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setVehicleInfo(result.data.vehicle);
|
||||
setLogs(result.data.logs);
|
||||
setTotals(result.data.totals);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load logs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, [selectedVehicle, selectedYear, selectedMonth]);
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
if (selectedMonth === 1) {
|
||||
setSelectedMonth(12);
|
||||
setSelectedYear(prev => prev - 1);
|
||||
} else {
|
||||
setSelectedMonth(prev => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
if (selectedMonth === 12) {
|
||||
setSelectedMonth(1);
|
||||
setSelectedYear(prev => prev + 1);
|
||||
} else {
|
||||
setSelectedMonth(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
const today = new Date();
|
||||
const dateStr = `${selectedYear}-${String(selectedMonth).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
setModalMode('add');
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
...initialFormState,
|
||||
vehicle_id: selectedVehicle,
|
||||
log_date: dateStr,
|
||||
driver_name: vehicleInfo?.driver || ''
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (log) => {
|
||||
setModalMode('edit');
|
||||
setEditingItem(log);
|
||||
setFormData({
|
||||
vehicle_id: log.vehicle_id,
|
||||
log_date: log.log_date?.split('T')[0] || '',
|
||||
department: log.department || '',
|
||||
driver_name: log.driver_name || '',
|
||||
trip_type: log.trip_type || 'business',
|
||||
departure_type: log.departure_type || '',
|
||||
departure_name: log.departure_name || '',
|
||||
departure_address: log.departure_address || '',
|
||||
arrival_type: log.arrival_type || '',
|
||||
arrival_name: log.arrival_name || '',
|
||||
arrival_address: log.arrival_address || '',
|
||||
distance_km: log.distance_km || '',
|
||||
note: log.note || ''
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.log_date || !formData.driver_name || !formData.distance_km) {
|
||||
alert('날짜, 운전자, 주행거리는 필수입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
distance_km: parseInt(formData.distance_km) || 0
|
||||
};
|
||||
|
||||
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();
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
} else {
|
||||
alert(result.message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
setShowModal(false);
|
||||
} else {
|
||||
alert(result.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const params = new URLSearchParams({
|
||||
vehicle_id: selectedVehicle,
|
||||
year: selectedYear,
|
||||
month: selectedMonth
|
||||
});
|
||||
window.location.href = `/finance/vehicle-logs/export?${params}`;
|
||||
};
|
||||
|
||||
const getTripTypeColor = (type) => {
|
||||
switch(type) {
|
||||
case 'commute_to': return 'bg-blue-100 text-blue-700';
|
||||
case 'commute_from': return 'bg-indigo-100 text-indigo-700';
|
||||
case 'business': return 'bg-emerald-100 text-emerald-700';
|
||||
case 'personal': return 'bg-orange-100 text-orange-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num) => num ? Number(num).toLocaleString() : '0';
|
||||
|
||||
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"><FileText 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={handleExport} disabled={!selectedVehicle || logs.length === 0} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Download className="w-4 h-4" /><span className="text-sm">Excel</span>
|
||||
</button>
|
||||
<button onClick={handleAdd} disabled={!selectedVehicle} className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<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-col md:flex-row gap-4 items-center">
|
||||
{/* 차량 선택 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">차량 선택</label>
|
||||
<select
|
||||
value={selectedVehicle || ''}
|
||||
onChange={(e) => setSelectedVehicle(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="">차량을 선택하세요</option>
|
||||
{vehicles.map(v => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.plate_number} - {v.model} ({v.driver || '미지정'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 기간 선택 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handlePrevMonth} className="p-2 hover:bg-gray-100 rounded-lg">
|
||||
<ChevronLeft className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg">
|
||||
<Calendar className="w-5 h-5 text-gray-500" />
|
||||
<span className="font-medium text-gray-900">{selectedYear}년 {selectedMonth}월</span>
|
||||
</div>
|
||||
<button onClick={handleNextMonth} className="p-2 hover:bg-gray-100 rounded-lg">
|
||||
<ChevronRight className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차량 정보 카드 */}
|
||||
{vehicleInfo && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">차량번호</p>
|
||||
<p className="font-bold text-gray-900">{vehicleInfo.plate_number}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">차종</p>
|
||||
<p className="font-medium text-gray-900">{vehicleInfo.model}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">명의구분</p>
|
||||
<p className="font-medium text-gray-900">{ownershipTypeLabels[vehicleInfo.ownership_type] || vehicleInfo.ownership_type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">운전자</p>
|
||||
<p className="font-medium text-gray-900">{vehicleInfo.driver || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">현재 주행거리</p>
|
||||
<p className="font-medium text-gray-900">{formatNumber(vehicleInfo.mileage)} km</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 월별 합계 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl border border-emerald-200 p-6 bg-emerald-50/30">
|
||||
<p className="text-sm text-emerald-700 mb-1">업무용 주행거리</p>
|
||||
<p className="text-2xl font-bold text-emerald-600">{formatNumber(totals.business_km)} km</p>
|
||||
<p className="text-xs text-emerald-500 mt-1">출퇴근 + 업무용</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6 bg-orange-50/30">
|
||||
<p className="text-sm text-orange-700 mb-1">비업무용 주행거리</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{formatNumber(totals.personal_km)} km</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<p className="text-sm text-gray-500 mb-1">총 주행거리</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatNumber(totals.total_km)} km</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{selectedYear}년 {selectedMonth}월</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 운행기록 테이블 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">일자</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">부서</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">성명</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">구분</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">출발지</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">도착지</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-gray-600">주행km</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">비고</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-medium text-gray-600 w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan="9" className="text-center py-12 text-gray-400">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-emerald-500 border-t-transparent rounded-full mx-auto mb-4"></div>
|
||||
<p>운행기록을 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : !selectedVehicle ? (
|
||||
<tr>
|
||||
<td colSpan="9" className="text-center py-12 text-gray-400">
|
||||
<Car className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>차량을 선택하세요.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="9" className="text-center py-12 text-gray-400">
|
||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>등록된 운행기록이 없습니다.</p>
|
||||
<button onClick={handleAdd} className="mt-4 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm">
|
||||
운행기록 추가하기
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map(log => (
|
||||
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
{log.log_date?.split('T')[0]}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{log.department || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 font-medium">
|
||||
{log.driver_name}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getTripTypeColor(log.trip_type)}`}>
|
||||
{tripTypes[log.trip_type] || log.trip_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
{log.departure_type && (
|
||||
<span className="text-xs text-gray-400">[{locationTypes[log.departure_type]}]</span>
|
||||
)}
|
||||
<span>{log.departure_name || '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
{log.arrival_type && (
|
||||
<span className="text-xs text-gray-400">[{locationTypes[log.arrival_type]}]</span>
|
||||
)}
|
||||
<span>{log.arrival_name || '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 font-medium text-right">
|
||||
{formatNumber(log.distance_km)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{log.note || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleEdit(log)}
|
||||
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</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">
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{modalMode === 'add' ? '운행기록 추가' : '운행기록 수정'}
|
||||
</h3>
|
||||
<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.log_date}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, log_date: 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>
|
||||
<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.driver_name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, driver_name: e.target.value }))}
|
||||
placeholder="운전자명"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">구분 *</label>
|
||||
<select
|
||||
value={formData.trip_type}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, trip_type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
{Object.entries(tripTypes).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">주행거리(km) *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.distance_km}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, distance_km: 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 border-gray-200 pt-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3 flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-blue-500" /> 출발지
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">분류</label>
|
||||
<select
|
||||
value={formData.departure_type}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, departure_type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{Object.entries(locationTypes).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">장소명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.departure_name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, departure_name: e.target.value }))}
|
||||
placeholder="장소명"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">주소</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.departure_address}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, departure_address: e.target.value }))}
|
||||
placeholder="주소"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도착지 */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3 flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-emerald-500" /> 도착지
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">분류</label>
|
||||
<select
|
||||
value={formData.arrival_type}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, arrival_type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{Object.entries(locationTypes).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">장소명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.arrival_name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, arrival_name: e.target.value }))}
|
||||
placeholder="장소명"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">주소</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.arrival_address}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, arrival_address: e.target.value }))}
|
||||
placeholder="주소"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">비고</label>
|
||||
<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"
|
||||
disabled={saving}
|
||||
>
|
||||
삭제
|
||||
</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"
|
||||
disabled={saving}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex-1 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg flex items-center justify-center gap-2"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving && <Loader className="w-4 h-4 animate-spin" />}
|
||||
{modalMode === 'add' ? '등록' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById('vehicle-logs-root');
|
||||
if (rootElement) { ReactDOM.createRoot(rootElement).render(<VehicleLogsManagement />); }
|
||||
</script>
|
||||
@endverbatim
|
||||
@endpush
|
||||
@@ -815,6 +815,15 @@
|
||||
Route::post('/corporate-vehicles', [\App\Http\Controllers\Finance\CorporateVehicleController::class, 'store'])->name('corporate-vehicles.store');
|
||||
Route::put('/corporate-vehicles/{id}', [\App\Http\Controllers\Finance\CorporateVehicleController::class, 'update'])->name('corporate-vehicles.update');
|
||||
Route::delete('/corporate-vehicles/{id}', [\App\Http\Controllers\Finance\CorporateVehicleController::class, 'destroy'])->name('corporate-vehicles.destroy');
|
||||
|
||||
// 차량일지 (운행기록부)
|
||||
Route::get('/vehicle-logs', [\App\Http\Controllers\Finance\VehicleLogController::class, 'index'])->name('vehicle-logs');
|
||||
Route::get('/vehicle-logs/list', [\App\Http\Controllers\Finance\VehicleLogController::class, 'list'])->name('vehicle-logs.list');
|
||||
Route::post('/vehicle-logs', [\App\Http\Controllers\Finance\VehicleLogController::class, 'store'])->name('vehicle-logs.store');
|
||||
Route::put('/vehicle-logs/{id}', [\App\Http\Controllers\Finance\VehicleLogController::class, 'update'])->name('vehicle-logs.update');
|
||||
Route::delete('/vehicle-logs/{id}', [\App\Http\Controllers\Finance\VehicleLogController::class, 'destroy'])->name('vehicle-logs.destroy');
|
||||
Route::get('/vehicle-logs/export', [\App\Http\Controllers\Finance\VehicleLogController::class, 'export'])->name('vehicle-logs.export');
|
||||
|
||||
Route::get('/vehicle-maintenance', function () {
|
||||
if (request()->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('finance.vehicle-maintenance'));
|
||||
|
||||
Reference in New Issue
Block a user