change layout, update content, and change database
This commit is contained in:
118964
dashboard.sql
Normal file
118964
dashboard.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ use Illuminate\Http\Request;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\DashboardService;
|
use App\Services\DashboardService;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use App\Models\SurveillanceCase;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
@@ -27,8 +28,23 @@ class DashboardController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function summary(Request $request)
|
public function summary(Request $request)
|
||||||
{
|
{
|
||||||
$dateFrom = $request->query('date_from', Carbon::now()->subDays(7)->toDateString());
|
if ($request->has('start_year')) {
|
||||||
$dateTo = $request->query('date_to', Carbon::now()->toDateString());
|
|
||||||
|
$startYear = $request->query('start_year');
|
||||||
|
$startWeek = $request->query('start_week');
|
||||||
|
|
||||||
|
$endYear = $request->query('end_year');
|
||||||
|
$endWeek = $request->query('end_week');
|
||||||
|
|
||||||
|
$dateFrom = Carbon::now()->setISODate($startYear, $startWeek)->startOfWeek()->toDateString();
|
||||||
|
$dateTo = Carbon::now()->setISODate($endYear, $endWeek)->endOfWeek()->toDateString();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
$dateFrom = $request->query('date_from', Carbon::now()->subDays(7)->toDateString());
|
||||||
|
$dateTo = $request->query('date_to', Carbon::now()->toDateString());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
$data = $this->service->summaryCards($dateFrom, $dateTo);
|
$data = $this->service->summaryCards($dateFrom, $dateTo);
|
||||||
|
|
||||||
@@ -42,8 +58,24 @@ class DashboardController extends Controller
|
|||||||
public function trend(Request $request)
|
public function trend(Request $request)
|
||||||
{
|
{
|
||||||
$periodType = $request->query('period_type', 'week');
|
$periodType = $request->query('period_type', 'week');
|
||||||
$dateFrom = $request->query('date_from');
|
|
||||||
$dateTo = $request->query('date_to');
|
if ($request->has('start_year')) {
|
||||||
|
|
||||||
|
$startYear = $request->query('start_year');
|
||||||
|
$startWeek = $request->query('start_week');
|
||||||
|
|
||||||
|
$endYear = $request->query('end_year');
|
||||||
|
$endWeek = $request->query('end_week');
|
||||||
|
|
||||||
|
$dateFrom = Carbon::now()->setISODate($startYear, $startWeek)->startOfWeek()->toDateString();
|
||||||
|
$dateTo = Carbon::now()->setISODate($endYear, $endWeek)->endOfWeek()->toDateString();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
$dateFrom = $request->query('date_from');
|
||||||
|
$dateTo = $request->query('date_to');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
$data = $this->service->aggregateAllPrograms(
|
$data = $this->service->aggregateAllPrograms(
|
||||||
$periodType,
|
$periodType,
|
||||||
@@ -59,19 +91,71 @@ class DashboardController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function province(Request $request)
|
public function province(Request $request)
|
||||||
{
|
{
|
||||||
$surveillanceId = $request->query('surveillance_id');
|
if ($request->has('start_year')) {
|
||||||
$dateFrom = $request->query('date_from');
|
|
||||||
$dateTo = $request->query('date_to');
|
|
||||||
|
|
||||||
$data = $this->service->provinceDistribution(
|
$startYear = $request->query('start_year');
|
||||||
$surveillanceId,
|
$startWeek = $request->query('start_week');
|
||||||
$dateFrom,
|
|
||||||
$dateTo
|
$endYear = $request->query('end_year');
|
||||||
);
|
$endWeek = $request->query('end_week');
|
||||||
|
|
||||||
|
$dateFrom = Carbon::now()
|
||||||
|
->setISODate($startYear, $startWeek)
|
||||||
|
->startOfWeek()
|
||||||
|
->toDateString();
|
||||||
|
|
||||||
|
$dateTo = Carbon::now()
|
||||||
|
->setISODate($endYear, $endWeek)
|
||||||
|
->endOfWeek()
|
||||||
|
->toDateString();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
$dateFrom = $request->query('date_from');
|
||||||
|
$dateTo = $request->query('date_to');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->service->provinceDistribution($dateFrom, $dateTo);
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$result[$row->site_province_name] = $row->total;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($result);
|
||||||
|
}
|
||||||
|
public function sentinelMap(Request $request)
|
||||||
|
{
|
||||||
|
$startYear = $request->query('start_year');
|
||||||
|
$startWeek = $request->query('start_week');
|
||||||
|
|
||||||
|
$endYear = $request->query('end_year');
|
||||||
|
$endWeek = $request->query('end_week');
|
||||||
|
|
||||||
|
$dateFrom = Carbon::now()->setISODate($startYear, $startWeek)->startOfWeek();
|
||||||
|
$dateTo = Carbon::now()->setISODate($endYear, $endWeek)->endOfWeek();
|
||||||
|
|
||||||
|
$data = $this->service->sentinelMap($dateFrom, $dateTo);
|
||||||
|
|
||||||
return response()->json($data);
|
return response()->json($data);
|
||||||
}
|
}
|
||||||
|
public function provinceCircles(Request $request)
|
||||||
|
{
|
||||||
|
$startYear = $request->query('start_year');
|
||||||
|
$startWeek = $request->query('start_week');
|
||||||
|
|
||||||
|
$endYear = $request->query('end_year');
|
||||||
|
$endWeek = $request->query('end_week');
|
||||||
|
|
||||||
|
$dateFrom = Carbon::now()->setISODate($startYear, $startWeek)->startOfWeek();
|
||||||
|
$dateTo = Carbon::now()->setISODate($endYear, $endWeek)->endOfWeek();
|
||||||
|
|
||||||
|
$data = $this->service->provinceCircles($dateFrom, $dateTo);
|
||||||
|
|
||||||
|
return response()->json($data);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Pathogen distribution
|
* Pathogen distribution
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -158,18 +158,72 @@ class DashboardService
|
|||||||
/**
|
/**
|
||||||
* Province distribution
|
* Province distribution
|
||||||
*/
|
*/
|
||||||
public function provinceDistribution($surveillanceId, $dateFrom, $dateTo)
|
public function provinceDistribution($dateFrom, $dateTo)
|
||||||
{
|
{
|
||||||
return SurveillanceCase::selectRaw("
|
return SurveillanceCase::selectRaw("
|
||||||
site_province_name,
|
site_province_name,
|
||||||
COUNT(*) as total
|
COUNT(*) as total
|
||||||
")
|
")
|
||||||
->where('surveillance_id', $surveillanceId)
|
->join('case_lab_results', 'surveillance_cases.lab_code', '=', 'case_lab_results.lab_code')
|
||||||
->whereBetween('case_date', [$dateFrom, $dateTo])
|
|
||||||
|
->whereIn('surveillance_cases.surveillance_id', [1, 2, 3]) // SARI ILI LBM
|
||||||
|
->where('case_lab_results.is_positive', 1)
|
||||||
|
|
||||||
|
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo])
|
||||||
|
|
||||||
->groupBy('site_province_name')
|
->groupBy('site_province_name')
|
||||||
->orderByDesc('total')
|
->orderByDesc('total')
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
public function sentinelMap($dateFrom, $dateTo)
|
||||||
|
{
|
||||||
|
return SurveillanceCase::selectRaw("
|
||||||
|
sentinel_site_id,
|
||||||
|
sentinel_site_name,
|
||||||
|
site_province_name,
|
||||||
|
surveillance_id,
|
||||||
|
COUNT(*) as total
|
||||||
|
")
|
||||||
|
->join('case_lab_results', 'surveillance_cases.lab_code', '=', 'case_lab_results.lab_code')
|
||||||
|
|
||||||
|
->whereIn('surveillance_cases.surveillance_id', [1, 2, 3])
|
||||||
|
->where('case_lab_results.is_positive', 1)
|
||||||
|
|
||||||
|
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo])
|
||||||
|
|
||||||
|
->groupBy(
|
||||||
|
'sentinel_site_id',
|
||||||
|
'sentinel_site_name',
|
||||||
|
'site_province_name',
|
||||||
|
'surveillance_id'
|
||||||
|
)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
public function provinceCircles($dateFrom, $dateTo)
|
||||||
|
{
|
||||||
|
return SurveillanceCase::selectRaw("
|
||||||
|
surveillance_cases.site_province_name,
|
||||||
|
surveillance_cases.surveillance_id,
|
||||||
|
COUNT(*) as total
|
||||||
|
")
|
||||||
|
->join(
|
||||||
|
'case_lab_results',
|
||||||
|
'surveillance_cases.lab_code',
|
||||||
|
'=',
|
||||||
|
'case_lab_results.lab_code'
|
||||||
|
)
|
||||||
|
|
||||||
|
->whereIn('surveillance_cases.surveillance_id', [1, 2, 3])
|
||||||
|
->where('case_lab_results.is_positive', 1)
|
||||||
|
|
||||||
|
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo])
|
||||||
|
|
||||||
|
->groupBy(
|
||||||
|
'surveillance_cases.site_province_name',
|
||||||
|
'surveillance_cases.surveillance_id'
|
||||||
|
)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pathogen distribution (positive only)
|
* Pathogen distribution (positive only)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
use Database\Seeders\LongitudinalSurveillanceSeeder;
|
||||||
|
use Database\Seeders\SurveillanceSeeder;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
{
|
{
|
||||||
|
|||||||
32
dashboard/public/geo/cambodia_provinces.geojson
Normal file
32
dashboard/public/geo/cambodia_provinces.geojson
Normal file
File diff suppressed because one or more lines are too long
@@ -10,6 +10,31 @@
|
|||||||
<h3 class="fw-bold mb-1">Dashboard Overview</h3>
|
<h3 class="fw-bold mb-1">Dashboard Overview</h3>
|
||||||
<small class="text-muted">National surveillance summary</small>
|
<small class="text-muted">National surveillance summary</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
|
||||||
|
|
||||||
|
<select id="trend_range" class="form-select w-auto">
|
||||||
|
<option value="8" selected>Last 8 weeks</option>
|
||||||
|
<option value="12">Last 12 weeks</option>
|
||||||
|
<option value="26">Last 26 weeks</option>
|
||||||
|
<option value="custom">Custom range</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div id="custom_range_container" style="display:none;" class="align-items-center gap-1">
|
||||||
|
|
||||||
|
<select id="start_year" class="form-select"></select>
|
||||||
|
<select id="start_week" class="form-select"></select>
|
||||||
|
|
||||||
|
<span class="mx-1">to</span>
|
||||||
|
|
||||||
|
<select id="end_year" class="form-select"></select>
|
||||||
|
<select id="end_week" class="form-select"></select>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Cards -->
|
<!-- Summary Cards -->
|
||||||
@@ -24,14 +49,11 @@
|
|||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="mb-3">
|
||||||
<h5 class="fw-bold mb-0">Epidemic Trend</h5>
|
<h5 class="fw-bold mb-1">Epidemic Trend</h5>
|
||||||
|
<p class="text-muted small mb-0">
|
||||||
<select id="period_type" class="form-select w-auto">
|
Weekly reported cases by surveillance program
|
||||||
<option value="week">Epiweek</option>
|
</p>
|
||||||
<option value="month">Month</option>
|
|
||||||
<option value="year">Year</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<canvas id="trendChart" height="110"></canvas>
|
<canvas id="trendChart" height="110"></canvas>
|
||||||
@@ -64,9 +86,12 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="fw-bold">Cases by Provinces</h5>
|
<h5 class="fw-bold">Cases by Provinces</h5>
|
||||||
<p class="text-muted small">(% change vs last period)</p>
|
<p class="text-muted small">(% change vs last period)</p>
|
||||||
|
|
||||||
<div class="d-flex justify-content-center align-items-center" style="height: 100%;">
|
<div id="provinceMap" style="height:420px;"></div>
|
||||||
<span class="text-muted">Province heatmap coming next</span>
|
<div class="d-flex gap-3 mb-2 small">
|
||||||
|
<span><span style="color:#2563eb; font-size:2rem;">●</span> SARI</span>
|
||||||
|
<span><span style="color:#10b981; font-size:2rem;">●</span> ILI</span>
|
||||||
|
<span><span style="color:#9333ea; font-size:2rem;">●</span> LBM</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +106,15 @@
|
|||||||
|
|
||||||
let trendChart;
|
let trendChart;
|
||||||
|
|
||||||
function loadSummary(dateFrom, dateTo) {
|
function loadSummary() {
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const past = new Date();
|
||||||
|
|
||||||
|
past.setDate(today.getDate() - 7);
|
||||||
|
|
||||||
|
const dateFrom = past.toISOString().split('T')[0];
|
||||||
|
const dateTo = today.toISOString().split('T')[0];
|
||||||
|
|
||||||
fetch(`/api/dashboard/summary?date_from=${dateFrom}&date_to=${dateTo}`)
|
fetch(`/api/dashboard/summary?date_from=${dateFrom}&date_to=${dateTo}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -91,38 +124,88 @@
|
|||||||
|
|
||||||
data.forEach(item => {
|
data.forEach(item => {
|
||||||
|
|
||||||
const colorClass = item.percent_change >= 0
|
let trendColor = 'text-secondary';
|
||||||
? 'text-danger'
|
|
||||||
: 'text-success';
|
|
||||||
|
|
||||||
html += `
|
if (item.percent_change > 0) trendColor = 'text-danger';
|
||||||
<div class="col-md-4 mb-3">
|
if (item.percent_change < 0) trendColor = 'text-success';
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-body">
|
html += `<div class="col-md-2 mb-3">
|
||||||
<h6 class="fw-bold">${item.code}</h6>
|
<div class="card shadow-sm h-100">
|
||||||
<h3 class="mb-1">${item.current_total}</h3>
|
<div class="card-body">
|
||||||
<small class="${colorClass}">
|
|
||||||
${item.percent_change}% vs last period
|
<div class="d-flex justify-content-between">
|
||||||
</small>
|
|
||||||
<div class="small text-muted mt-1">
|
<div>
|
||||||
+${item.last_24h} in 24h
|
<h6 class="fw-bold">${item.code}</h6>
|
||||||
</div>
|
<h3 class="mb-1">${item.current_total}</h3>
|
||||||
</div>
|
<small class="text-muted">Last 7 days</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
`;
|
<div class="text-end">
|
||||||
|
|
||||||
|
<div class="${trendColor} fw-bold">
|
||||||
|
${item.percent_change > 0 ? '▲' : item.percent_change < 0 ? '▼' : '–'}
|
||||||
|
${Math.abs(item.percent_change)}%
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small class="text-muted">
|
||||||
|
${item.previous_total ?? 0} last week
|
||||||
|
</small>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('summary_cards').innerHTML = html;
|
document.getElementById('summary_cards').innerHTML = html;
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function getCurrentEpiWeek() {
|
||||||
|
|
||||||
function loadTrend(periodType, dateFrom, dateTo) {
|
const today = new Date();
|
||||||
|
const firstJan = new Date(today.getFullYear(), 0, 1);
|
||||||
|
|
||||||
fetch(`/api/dashboard/trend?period_type=${periodType}&date_from=${dateFrom}&date_to=${dateTo}`)
|
const days = Math.floor((today - firstJan) / 86400000);
|
||||||
|
const week = Math.ceil((days + firstJan.getDay() + 1) / 7);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: today.getFullYear(),
|
||||||
|
week: week
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function calculateRange(weeksBack) {
|
||||||
|
|
||||||
|
const current = getCurrentEpiWeek();
|
||||||
|
|
||||||
|
let endYear = current.year;
|
||||||
|
let endWeek = current.week;
|
||||||
|
|
||||||
|
let startYear = endYear;
|
||||||
|
let startWeek = endWeek - weeksBack + 1;
|
||||||
|
|
||||||
|
while (startWeek <= 0) {
|
||||||
|
startWeek += 52;
|
||||||
|
startYear--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startYear,
|
||||||
|
startWeek,
|
||||||
|
endYear,
|
||||||
|
endWeek
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
|
||||||
|
|
||||||
|
fetch(`/api/dashboard/trend?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
console.log(data);
|
|
||||||
|
|
||||||
if (trendChart) trendChart.destroy();
|
if (trendChart) trendChart.destroy();
|
||||||
|
|
||||||
@@ -143,18 +226,31 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const labels = Array.from(labelsSet).sort();
|
const labels = Array.from(labelsSet).sort((a, b) => {
|
||||||
|
|
||||||
|
const [yearA, weekA] = a.split('-').map(Number);
|
||||||
|
const [yearB, weekB] = b.split('-').map(Number);
|
||||||
|
|
||||||
|
if (yearA !== yearB) return yearA - yearB;
|
||||||
|
|
||||||
|
return weekA - weekB;
|
||||||
|
});
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
SARI: '#2563eb',
|
SARI: '#2563eb',
|
||||||
ILI: '#10b981',
|
ILI: '#10b981',
|
||||||
LBM: '#9333ea'
|
LBM: '#9333ea',
|
||||||
|
NDS: '#f59e0b',
|
||||||
};
|
};
|
||||||
|
|
||||||
const datasets = [];
|
const datasets = [];
|
||||||
|
|
||||||
|
const allowedPrograms = ['SARI', 'ILI', 'LBM', 'NDS'];
|
||||||
|
|
||||||
Object.keys(data).forEach(code => {
|
Object.keys(data).forEach(code => {
|
||||||
|
|
||||||
|
if (!allowedPrograms.includes(code)) return;
|
||||||
|
|
||||||
const values = labels.map(label => {
|
const values = labels.map(label => {
|
||||||
|
|
||||||
const found = data[code].find(row => {
|
const found = data[code].find(row => {
|
||||||
@@ -169,7 +265,6 @@
|
|||||||
|
|
||||||
return rowLabel === label;
|
return rowLabel === label;
|
||||||
});
|
});
|
||||||
|
|
||||||
return found ? found.total : 0;
|
return found ? found.total : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,49 +272,289 @@
|
|||||||
label: code,
|
label: code,
|
||||||
data: values,
|
data: values,
|
||||||
borderColor: colors[code] || '#000',
|
borderColor: colors[code] || '#000',
|
||||||
|
backgroundColor: colors[code],
|
||||||
|
borderWidth: 3,
|
||||||
|
pointRadius: 4,
|
||||||
fill: false,
|
fill: false,
|
||||||
tension: 0.3
|
tension: 0.3
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const displayLabels = labels.map(l => {
|
||||||
|
const [year, week] = l.split('-');
|
||||||
|
return `W${String(week).padStart(2, '0')}`;
|
||||||
|
});
|
||||||
trendChart = new Chart(document.getElementById('trendChart'), {
|
trendChart = new Chart(document.getElementById('trendChart'), {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: labels,
|
labels: displayLabels,
|
||||||
datasets: datasets
|
datasets: datasets
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
|
||||||
responsive: true,
|
responsive: true,
|
||||||
|
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom'
|
position: 'bottom'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
|
||||||
|
scales: {
|
||||||
|
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
document.getElementById('trendChart').style.opacity = 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCustomInputs(show) {
|
||||||
|
|
||||||
|
const container = document.getElementById('custom_range_container');
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.style.display = show ? 'flex' : 'none';
|
||||||
|
|
||||||
|
}
|
||||||
|
function refreshTrend() {
|
||||||
|
|
||||||
|
|
||||||
|
const startYear = document.getElementById('start_year').value;
|
||||||
|
const startWeek = document.getElementById('start_week').value;
|
||||||
|
|
||||||
|
const endYear = document.getElementById('end_year').value;
|
||||||
|
const endWeek = document.getElementById('end_week').value;
|
||||||
|
loadProvinceMap();
|
||||||
|
loadTrend('week', startYear, startWeek, endYear, endWeek);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateFilters() {
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
const startYear = document.getElementById('start_year');
|
||||||
|
const endYear = document.getElementById('end_year');
|
||||||
|
|
||||||
|
for (let y = currentYear - 10 ; y <= currentYear; y++) {
|
||||||
|
|
||||||
|
startYear.innerHTML += `<option value="${y}">${y}</option>`;
|
||||||
|
endYear.innerHTML += `<option value="${y}">${y}</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startWeek = document.getElementById('start_week');
|
||||||
|
const endWeek = document.getElementById('end_week');
|
||||||
|
|
||||||
|
for (let w = 1; w <= 53; w++) {
|
||||||
|
|
||||||
|
startWeek.innerHTML += `<option value="${w}">W${w}</option>`;
|
||||||
|
endWeek.innerHTML += `<option value="${w}">W${w}</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
populateFilters();
|
||||||
const past = new Date();
|
toggleCustomInputs(false);
|
||||||
past.setDate(past.getDate() - 30);
|
document.getElementById('custom_range_container').style.display = 'none';
|
||||||
|
loadProvinceMap();
|
||||||
|
|
||||||
const dateFrom = past.toISOString().split('T')[0];
|
const defaultRange = calculateRange(8);
|
||||||
const dateTo = today;
|
|
||||||
|
|
||||||
loadSummary(dateFrom, dateTo);
|
document.getElementById('start_year').value = defaultRange.startYear;
|
||||||
loadTrend('week', dateFrom, dateTo);
|
document.getElementById('start_week').value = defaultRange.startWeek;
|
||||||
|
|
||||||
document.getElementById('period_type')
|
document.getElementById('end_year').value = defaultRange.endYear;
|
||||||
|
document.getElementById('end_week').value = defaultRange.endWeek;
|
||||||
|
|
||||||
|
loadTrend(
|
||||||
|
'week',
|
||||||
|
defaultRange.startYear,
|
||||||
|
defaultRange.startWeek,
|
||||||
|
defaultRange.endYear,
|
||||||
|
defaultRange.endWeek
|
||||||
|
);
|
||||||
|
|
||||||
|
loadSummary();
|
||||||
|
document.getElementById('trend_range')
|
||||||
.addEventListener('change', function () {
|
.addEventListener('change', function () {
|
||||||
loadTrend(this.value, dateFrom, dateTo);
|
|
||||||
|
const value = this.value;
|
||||||
|
|
||||||
|
if (value === 'custom') {
|
||||||
|
|
||||||
|
toggleCustomInputs(true);
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCustomInputs(false);
|
||||||
|
|
||||||
|
const range = calculateRange(parseInt(value));
|
||||||
|
|
||||||
|
document.getElementById('start_year').value = range.startYear;
|
||||||
|
document.getElementById('start_week').value = range.startWeek;
|
||||||
|
|
||||||
|
document.getElementById('end_year').value = range.endYear;
|
||||||
|
document.getElementById('end_week').value = range.endWeek;
|
||||||
|
document.getElementById('trendChart').style.opacity = 0.4;
|
||||||
|
refreshTrend();
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
['start_year', 'start_week', 'end_year', 'end_week']
|
||||||
|
.forEach(id => {
|
||||||
|
document.getElementById(id)
|
||||||
|
.addEventListener('change', refreshTrend);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let map;
|
||||||
|
|
||||||
|
function getColor(value) {
|
||||||
|
|
||||||
|
if (value > 50) return '#b91c1c';
|
||||||
|
if (value > 25) return '#dc2626';
|
||||||
|
if (value > 10) return '#f97316';
|
||||||
|
if (value > 5) return '#facc15';
|
||||||
|
|
||||||
|
return '#e5e7eb';
|
||||||
|
}
|
||||||
|
const colors = {
|
||||||
|
1: '#2563eb', // SARI
|
||||||
|
2: '#10b981', // ILI
|
||||||
|
3: '#9333ea' // LBM
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadSentinelMap() {
|
||||||
|
|
||||||
|
fetch(`/api/dashboard/sentinel-map?...`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
|
||||||
|
data.forEach(site => {
|
||||||
|
|
||||||
|
L.circleMarker(
|
||||||
|
[site.lat, site.lng],
|
||||||
|
{
|
||||||
|
radius: Math.sqrt(site.total) * 3,
|
||||||
|
fillColor: colors[site.surveillance_id],
|
||||||
|
color: '#fff',
|
||||||
|
weight: 1,
|
||||||
|
fillOpacity: 0.9
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.bindTooltip(`
|
||||||
|
${site.sentinel_site_name}<br>
|
||||||
|
Cases: ${site.total}
|
||||||
|
`)
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadProvinceMap() {
|
||||||
|
|
||||||
|
if (map) map.remove();
|
||||||
|
|
||||||
|
map = L.map('provinceMap').setView([12.7, 104.9], 7);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const startYear = document.getElementById('start_year').value;
|
||||||
|
const startWeek = document.getElementById('start_week').value;
|
||||||
|
|
||||||
|
const endYear = document.getElementById('end_year').value;
|
||||||
|
const endWeek = document.getElementById('end_week').value;
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
1: '#2563eb', // SARI
|
||||||
|
2: '#10b981', // ILI
|
||||||
|
3: '#9333ea' // LBM
|
||||||
|
};
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fetch('/geo/cambodia_provinces.geojson').then(r => r.json()),
|
||||||
|
fetch(`/api/dashboard/province-circles?start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`).then(r => r.json())
|
||||||
|
])
|
||||||
|
.then(([geojson, data]) => {
|
||||||
|
|
||||||
|
L.geoJSON(geojson, {
|
||||||
|
style: {
|
||||||
|
fillOpacity: 0,
|
||||||
|
color: '#ccc',
|
||||||
|
weight: 1
|
||||||
|
},
|
||||||
|
|
||||||
|
onEachFeature: function (feature, layer) {
|
||||||
|
|
||||||
|
const province = feature.properties.ADM1_EN;
|
||||||
|
|
||||||
|
const center = layer.getBounds().getCenter();
|
||||||
|
|
||||||
|
const rows = data.filter(d => d.site_province_name === province);
|
||||||
|
const offsets = {
|
||||||
|
1: -0.15, // SARI left
|
||||||
|
2: 0, // ILI center
|
||||||
|
3: 0.15 // LBM right
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
|
||||||
|
const radius = Math.sqrt(row.total) * 4;
|
||||||
|
|
||||||
|
const lat = center.lat;
|
||||||
|
const lng = center.lng + offsets[row.surveillance_id];
|
||||||
|
|
||||||
|
L.circleMarker([lat, lng], {
|
||||||
|
radius: radius,
|
||||||
|
fillColor: colors[row.surveillance_id],
|
||||||
|
color: '#fff',
|
||||||
|
weight: 1,
|
||||||
|
fillOpacity: 0.9
|
||||||
|
})
|
||||||
|
.bindTooltip(`
|
||||||
|
<strong>${province}</strong><br>
|
||||||
|
${row.surveillance_id === 1 ? 'SARI' :
|
||||||
|
row.surveillance_id === 2 ? 'ILI' : 'LBM'}<br>
|
||||||
|
Cases: ${row.total}
|
||||||
|
`)
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
@@ -7,66 +7,67 @@
|
|||||||
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css"/>
|
||||||
|
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SIDEBAR */
|
/* HEADER */
|
||||||
.sidebar {
|
|
||||||
width: 220px;
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
background-color: #0B8F3C;
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link-custom {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
text-decoration: none;
|
|
||||||
display: block;
|
|
||||||
padding: 14px 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link-custom:hover {
|
|
||||||
background-color: #06632A;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-link {
|
|
||||||
border-left: 5px solid #F4C430;
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-wrapper {
|
|
||||||
margin-left: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-area {
|
|
||||||
padding: 30px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
min-height: calc(100vh - 60px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TOP NAVBAR */
|
|
||||||
.top-navbar {
|
.top-navbar {
|
||||||
height: 60px;
|
height: 60px;
|
||||||
border-bottom: 4px solid #0B8F3C;
|
background: #0B8F3C;
|
||||||
background: #FFFFFF;
|
color: white;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 20px;
|
padding: 0 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-title {
|
.brand-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #1E63B6;
|
}
|
||||||
|
|
||||||
|
/* NAV BAR */
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #dcdcdc;
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NAV ITEMS */
|
||||||
|
.nav-item {
|
||||||
|
padding: 12px 18px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #262626;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
font-size:14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: #cce0d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ACTIVE TAB */
|
||||||
|
.active-tab {
|
||||||
|
color: #0B8F3C;
|
||||||
|
border-bottom: 3px solid #0B8F3C;
|
||||||
|
background: #e5efe8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CONTENT */
|
||||||
|
.content-area {
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-area {
|
.content-area {
|
||||||
@@ -95,50 +96,40 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- TOP HEADER -->
|
||||||
<div class="sidebar d-flex flex-column justify-content-between">
|
<div class="top-navbar">
|
||||||
|
|
||||||
<div>
|
|
||||||
|
|
||||||
<a href="/dashboard" class="nav-link-custom {{ request()->is('dashboard') ? 'active-link' : '' }}">
|
|
||||||
<span class="nav-text">Overview</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
@foreach($programs as $program)
|
|
||||||
<a href="/dashboard/{{ strtolower($program->code) }}"
|
|
||||||
class="nav-link-custom {{ request()->is('dashboard/' . strtolower($program->code)) ? 'active-link' : '' }}">
|
|
||||||
<span class="nav-text">{{ $program->code }}</span>
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
|
|
||||||
|
<div class="brand-title">
|
||||||
|
National Reference Medical Laboratory Surveillance Dashboard
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="ms-auto small">
|
||||||
<a href="#" class="nav-link-custom">
|
Last update: 12:05 |
|
||||||
<span class="nav-icon">⚙️</span>
|
Data latency: 5–10 min |
|
||||||
<span class="nav-text">Settings</span>
|
User: National - Read Only
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- NAVIGATION BAR -->
|
||||||
|
<div class="nav-bar">
|
||||||
|
|
||||||
|
<a href="/dashboard" class="nav-item {{ request()->is('dashboard') ? 'active-tab' : '' }}">
|
||||||
|
Overview
|
||||||
|
</a>
|
||||||
|
|
||||||
|
@foreach($programs as $program)
|
||||||
|
<a href="/dashboard/{{ strtolower($program->code) }}"
|
||||||
|
class="nav-item {{ request()->is('dashboard/' . strtolower($program->code)) ? 'active-tab' : '' }}">
|
||||||
|
{{ $program->code }}
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Wrapper -->
|
<!-- Main Wrapper -->
|
||||||
<div class="main-wrapper">
|
<div class="main-wrapper">
|
||||||
|
|
||||||
<!-- Top Navbar -->
|
|
||||||
<div class="top-navbar">
|
|
||||||
|
|
||||||
<img src="{{ asset('images/nrml-logo.png') }}" class="brand-logo" alt="NRML Logo">
|
|
||||||
|
|
||||||
<div class="brand-title">
|
|
||||||
National Reference Medical Laboratory Surveillance Dashboard
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ms-auto text-muted small">
|
|
||||||
Status: Active Surveillance
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ use App\Http\Controllers\Api\DashboardController;
|
|||||||
Route::get('/dashboard/summary', [DashboardController::class, 'summary']);
|
Route::get('/dashboard/summary', [DashboardController::class, 'summary']);
|
||||||
Route::get('/dashboard/trend', [DashboardController::class, 'trend']);
|
Route::get('/dashboard/trend', [DashboardController::class, 'trend']);
|
||||||
Route::get('/dashboard/province', [DashboardController::class, 'province']);
|
Route::get('/dashboard/province', [DashboardController::class, 'province']);
|
||||||
Route::get('/dashboard/pathogen', [DashboardController::class, 'pathogen']);
|
Route::get('/dashboard/pathogen', [DashboardController::class, 'pathogen']);
|
||||||
|
Route::get('/dashboard/sentinel-map', [DashboardController::class, 'sentinelMap']);
|
||||||
|
Route::get('/dashboard/province-circles', [DashboardController::class, 'provinceCircles']);
|
||||||
@@ -39,7 +39,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3308:3306"
|
- "3308:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/mysql
|
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/dashboard.sql
|
||||||
networks:
|
networks:
|
||||||
- dashboard
|
- dashboard
|
||||||
|
|
||||||
@@ -62,5 +62,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
dashboard:
|
dashboard:
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dbdata:
|
dbdata:
|
||||||
|
|||||||
Reference in New Issue
Block a user