446 lines
20 KiB
PHP
446 lines
20 KiB
PHP
@extends('layouts.app')
|
||
|
||
@section('title', '総合ダッシュボード')
|
||
|
||
@section('content')
|
||
<div class="content-header">
|
||
<div class="container-fluid">
|
||
<div class="row mb-2">
|
||
<div class="col-sm-6">
|
||
<h1 class="m-0 text-dark">総合ダッシュボード</h1>
|
||
</div>
|
||
<div class="col-sm-6">
|
||
<ol class="breadcrumb float-sm-right">
|
||
<li class="breadcrumb-item"><a href="{{ route('information') }}">ホーム</a></li>
|
||
<li class="breadcrumb-item active">総合ダッシュボード</li>
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="content">
|
||
<div class="container-fluid">
|
||
<!-- 全体統計情報 -->
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="card card-primary">
|
||
<div class="card-header">
|
||
<h3 class="card-title">全体統計</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-lg-2 col-6">
|
||
<div class="small-box bg-info">
|
||
<div class="inner">
|
||
<h3>{{ $totalStats['total_cities'] }}</h3>
|
||
<p>自治体数</p>
|
||
</div>
|
||
<div class="icon">
|
||
<i class="fa fa-map-marker"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-2 col-6">
|
||
<div class="small-box bg-success">
|
||
<div class="inner">
|
||
<h3>{{ $totalStats['total_parks'] }}</h3>
|
||
<p>駐輪場数</p>
|
||
</div>
|
||
<div class="icon">
|
||
<i class="fa fa-building"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-2 col-6">
|
||
<div class="small-box bg-warning">
|
||
<div class="inner">
|
||
<h3>{{ number_format($totalStats['total_contracts']) }}</h3>
|
||
<p>総契約数</p>
|
||
</div>
|
||
<div class="icon">
|
||
<i class="fa fa-file-text"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-2 col-6">
|
||
<div class="small-box bg-danger">
|
||
<div class="inner">
|
||
<h3>{{ number_format($totalStats['total_waiting']) }}</h3>
|
||
<p>総予約待ち</p>
|
||
</div>
|
||
<div class="icon">
|
||
<i class="fa fa-clock-o"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-2 col-6">
|
||
<div class="small-box bg-purple">
|
||
<div class="inner">
|
||
<h3>{{ number_format($totalStats['total_capacity']) }}</h3>
|
||
<p>総収容台数</p>
|
||
</div>
|
||
<div class="icon">
|
||
<i class="fa fa-bicycle"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-2 col-6">
|
||
<div class="small-box bg-teal">
|
||
<div class="inner">
|
||
<h3>{{ $totalStats['total_utilization_rate'] }}%</h3>
|
||
<p>全体利用率</p>
|
||
</div>
|
||
<div class="icon">
|
||
<i class="fa fa-pie-chart"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 自治体別統計表 -->
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">自治体別統計</h3>
|
||
<div class="card-tools">
|
||
<button type="button" class="btn btn-tool" data-card-widget="collapse">
|
||
<i class="fa fa-minus"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table class="table table-bordered table-striped table-hover" id="cityStatsTable">
|
||
<thead class="thead-dark">
|
||
<tr>
|
||
<th>自治体名</th>
|
||
<th class="text-center">駐輪場数</th>
|
||
<th class="text-center">契約数</th>
|
||
<th class="text-center">利用者数</th>
|
||
<th class="text-center">予約待ち</th>
|
||
<th class="text-center">収容台数</th>
|
||
<th class="text-center">利用率</th>
|
||
<th class="text-center">空き台数</th>
|
||
<th class="text-center">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach($cityStats as $stat)
|
||
<tr>
|
||
<td>
|
||
<strong>{{ $stat['city']->city_name }}</strong>
|
||
</td>
|
||
<td class="text-center">
|
||
{{ $stat['parks_count'] }}
|
||
</td>
|
||
<td class="text-center">
|
||
{{ number_format($stat['contracts_count']) }}
|
||
</td>
|
||
<td class="text-center">
|
||
{{ number_format($stat['users_count']) }}
|
||
</td>
|
||
<td class="text-center">
|
||
@if($stat['waiting_count'] > 0)
|
||
<span class="badge badge-warning">{{ number_format($stat['waiting_count']) }}</span>
|
||
@else
|
||
<span class="badge badge-success">0</span>
|
||
@endif
|
||
</td>
|
||
<td class="text-center">
|
||
{{ number_format($stat['capacity']) }}
|
||
</td>
|
||
<td class="text-center">
|
||
@php
|
||
$rate = $stat['utilization_rate'];
|
||
$badgeClass = $rate >= 90 ? 'danger' : ($rate >= 70 ? 'warning' : 'success');
|
||
@endphp
|
||
<span class="badge badge-{{ $badgeClass }}">{{ $rate }}%</span>
|
||
</td>
|
||
<td class="text-center">
|
||
@if($stat['available_spaces'] > 0)
|
||
<span class="text-success">{{ number_format($stat['available_spaces']) }}</span>
|
||
@else
|
||
<span class="text-danger">満車</span>
|
||
@endif
|
||
</td>
|
||
<td class="text-center">
|
||
<a href="{{ route('city_dashboard', ['city_id' => $stat['city']->city_id]) }}"
|
||
class="btn btn-sm btn-primary">
|
||
<i class="fa fa-eye"></i> 詳細
|
||
</a>
|
||
</td>
|
||
</tr>
|
||
@endforeach
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 利用率チャート -->
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">自治体別利用率</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
<canvas id="utilizationChart" width="400" height="300"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">予約待ち状況</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
<canvas id="waitingChart" width="400" height="300"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- アラート表示 -->
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="card card-warning">
|
||
<div class="card-header">
|
||
<h3 class="card-title">⚠️ 注意が必要な自治体</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
@php
|
||
$alertCities = collect($cityStats)->filter(function($stat) {
|
||
return $stat['utilization_rate'] >= 90 || $stat['waiting_count'] > 10;
|
||
});
|
||
@endphp
|
||
|
||
@if($alertCities->count() > 0)
|
||
<div class="row">
|
||
@foreach($alertCities as $stat)
|
||
<div class="col-md-4">
|
||
<div class="alert alert-warning">
|
||
<h5><i class="icon fa fa-exclamation-triangle"></i> {{ $stat['city']->city_name }}</h5>
|
||
@if($stat['utilization_rate'] >= 90)
|
||
<p>利用率が {{ $stat['utilization_rate'] }}% と高くなっています</p>
|
||
@endif
|
||
@if($stat['waiting_count'] > 10)
|
||
<p>予約待ちが {{ $stat['waiting_count'] }}人 います</p>
|
||
@endif
|
||
<a href="{{ route('city_dashboard', ['city_id' => $stat['city']->city_id]) }}"
|
||
class="btn btn-sm btn-warning">詳細確認</a>
|
||
</div>
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
@else
|
||
<div class="alert alert-success">
|
||
<i class="icon fa fa-check"></i> 現在、特に注意が必要な自治体はありません。
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
@endsection
|
||
|
||
@push('scripts')
|
||
<script src="{{ asset('plugins/chart.js/Chart.min.js') }}"></script>
|
||
<script>
|
||
(function() {
|
||
// window.onload で全体を囲むことで、他の onload と競合しないようにする
|
||
window.onload = function() {
|
||
try {
|
||
console.log('[dashboard] start');
|
||
|
||
// DataTable 初期化
|
||
try {
|
||
$('#cityStatsTable').DataTable({
|
||
pageLength: 10,
|
||
order: [[ 6, "desc" ]],
|
||
columnDefs: [
|
||
{ orderable: false, targets: 8 }
|
||
],
|
||
language: {
|
||
lengthMenu: " _MENU_ 件表示",
|
||
search: "検索:",
|
||
info: "_TOTAL_ 件中 _START_ から _END_ まで表示",
|
||
infoEmpty: "0 件中 0 から 0 まで表示",
|
||
infoFiltered: "(全 _MAX_ 件より抽出)",
|
||
paginate: {
|
||
first: "最初",
|
||
last: "最後",
|
||
next: "次",
|
||
previous: "前"
|
||
},
|
||
zeroRecords: "該当するデータがありません"
|
||
}
|
||
});
|
||
|
||
} catch (e) {
|
||
console.error('[dashboard] DataTable error:', e);
|
||
}
|
||
|
||
if (typeof Chart === 'undefined') {
|
||
console.error('[dashboard] Chart.js is undefined(Chart.min.js が読めていない)');
|
||
return;
|
||
}
|
||
console.log('[dashboard] Chart.js version =', Chart.version || '(unknown)');
|
||
|
||
// ===== 数据(末尾カンマ回避)=====
|
||
var utilLabels = [
|
||
@foreach($cityStats as $stat)
|
||
'{{ $stat['city']->city_name }}'@if(!$loop->last),@endif
|
||
@endforeach
|
||
];
|
||
var utilData = [
|
||
@foreach($cityStats as $stat)
|
||
{{ (int)$stat['utilization_rate'] }}@if(!$loop->last),@endif
|
||
@endforeach
|
||
];
|
||
|
||
var waitLabels = [
|
||
@foreach($cityStats as $stat)
|
||
'{{ $stat['city']->city_name }}'@if(!$loop->last),@endif
|
||
@endforeach
|
||
];
|
||
var waitData = [
|
||
@foreach($cityStats as $stat)
|
||
{{ (int)$stat['waiting_count'] }}@if(!$loop->last),@endif
|
||
@endforeach
|
||
];
|
||
|
||
console.log('[dashboard] util labels/data len =', utilLabels.length, utilData.length);
|
||
console.log('[dashboard] wait labels/data len =', waitLabels.length, waitData.length);
|
||
|
||
function genBarColors(n, alpha) {
|
||
var colors = [];
|
||
for (var i = 0; i < n; i++) {
|
||
var hue = Math.floor((360 / Math.max(n, 1)) * i);
|
||
colors.push('hsla(' + hue + ',70%,55%,' + (alpha || 0.8) + ')');
|
||
}
|
||
return colors;
|
||
}
|
||
|
||
// ===== 利用率(bar)=====
|
||
var c1 = document.getElementById('utilizationChart');
|
||
if (!c1) {
|
||
console.error('[dashboard] utilizationChart not found');
|
||
return;
|
||
}
|
||
|
||
var utilConfigV2 = {
|
||
type: 'bar',
|
||
data: {
|
||
labels: utilLabels,
|
||
datasets: [{
|
||
label: '利用率(%)',
|
||
data: utilData,
|
||
backgroundColor: genBarColors(utilData.length, 0.8),
|
||
borderWidth: 1
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
legend: { display: false },
|
||
tooltips: {
|
||
callbacks: {
|
||
label: function(tooltipItem) {
|
||
return '利用率: ' + tooltipItem.yLabel + '%';
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
xAxes: [{ ticks: { autoSkip: false, maxRotation: 60, minRotation: 60, fontSize: 10} }],
|
||
yAxes: [{
|
||
ticks: { beginAtZero: true, max: 100 },
|
||
scaleLabel: { display: true, labelString: '利用率(%)' }
|
||
}]
|
||
}
|
||
}
|
||
};
|
||
|
||
// 旧实例があれば破棄(再描画で真っ白になる事故防止)
|
||
if (window._utilChart && window._utilChart.destroy) window._utilChart.destroy();
|
||
window._utilChart = new Chart(c1.getContext('2d'), utilConfigV2);
|
||
console.log('[dashboard] utilization chart rendered');
|
||
|
||
// ===== 予約待ち(bar)=====
|
||
var c2 = document.getElementById('waitingChart');
|
||
if (!c2) {
|
||
console.error('[dashboard] waitingChart not found');
|
||
return;
|
||
}
|
||
|
||
var waitConfigV2 = {
|
||
type: 'bar',
|
||
data: {
|
||
labels: waitLabels,
|
||
datasets: [{
|
||
label: '予約待ち人数',
|
||
data: waitData,
|
||
backgroundColor: genBarColors(waitData.length, 0.8),
|
||
borderWidth: 1
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
legend: { display: false },
|
||
tooltips: {
|
||
callbacks: {
|
||
label: function(tooltipItem) {
|
||
return '予約待ち: ' + tooltipItem.yLabel + '人';
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
xAxes: [{ ticks: { autoSkip: false, maxRotation: 60, minRotation: 60, fontSize: 10} }],
|
||
yAxes: [{
|
||
ticks: { beginAtZero: true },
|
||
scaleLabel: { display: true, labelString: '人数' }
|
||
}]
|
||
}
|
||
}
|
||
};
|
||
|
||
if (window._waitChart && window._waitChart.destroy) window._waitChart.destroy();
|
||
window._waitChart = new Chart(c2.getContext('2d'), waitConfigV2);
|
||
console.log('[dashboard] waiting chart rendered');
|
||
|
||
} catch (e) {
|
||
console.error('[dashboard] fatal error:', e);
|
||
}
|
||
};
|
||
})();
|
||
</script>
|
||
|
||
<style>
|
||
/* Chart.js v2 で maintainAspectRatio:false を使用する場合、canvas に固定高さが必要 */
|
||
#utilizationChart, #waitingChart { height: 300px !important; }
|
||
|
||
/* ページネーションボタン同士に余白を追加し、「前123次」のように詰まるのを防止 */
|
||
.dataTables_wrapper .dataTables_paginate .paginate_button{
|
||
margin: 0 4px !important;
|
||
display: inline-block !important;
|
||
}
|
||
/* 現在ページボタンにも適度な内側余白を設定 */
|
||
.dataTables_wrapper .dataTables_paginate .paginate_button{
|
||
padding: .2rem .55rem !important;
|
||
}
|
||
|
||
</style>
|
||
@endpush
|
||
|