Merge pull request #1 from khantey1998/feature/overview_page
Feature/overview page
This commit is contained in:
40
Dockerfile
40
Dockerfile
@@ -1,21 +1,21 @@
|
|||||||
FROM php:8.2-fpm
|
FROM php:8.2-fpm
|
||||||
|
|
||||||
WORKDIR /var/www
|
WORKDIR /var/www
|
||||||
|
|
||||||
# System dependencies
|
# System dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
git \
|
git \
|
||||||
curl \
|
curl \
|
||||||
zip \
|
zip \
|
||||||
unzip \
|
unzip \
|
||||||
libpng-dev \
|
libpng-dev \
|
||||||
libonig-dev \
|
libonig-dev \
|
||||||
libxml2-dev
|
libxml2-dev
|
||||||
|
|
||||||
# PHP Extensions
|
# PHP Extensions
|
||||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
||||||
|
|
||||||
# Install Composer
|
# Install Composer
|
||||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
CMD ["php-fpm"]
|
CMD ["php-fpm"]
|
||||||
118964
dashboard.sql
Normal file
118964
dashboard.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,80 +11,156 @@ class DashboardController extends Controller
|
|||||||
{
|
{
|
||||||
protected $service;
|
protected $service;
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
$programs = \App\Models\Surveillance::all();
|
|
||||||
return view('dashboard.index', compact('programs'));
|
|
||||||
}
|
|
||||||
public function __construct(DashboardService $service)
|
public function __construct(DashboardService $service)
|
||||||
{
|
{
|
||||||
$this->service = $service;
|
$this->service = $service;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Summary cards
|
|--------------------------------------------------------------------------
|
||||||
* GET /api/dashboard/summary?date_from=2026-01-01&date_to=2026-03-01
|
| Helper: Resolve Epiweek Range
|
||||||
*/
|
|--------------------------------------------------------------------------
|
||||||
public function summary(Request $request)
|
*/
|
||||||
|
|
||||||
|
private function getEpiRange(Request $request)
|
||||||
{
|
{
|
||||||
$dateFrom = $request->query('date_from', Carbon::now()->subDays(7)->toDateString());
|
$startYear = (int) $request->query('start_year');
|
||||||
$dateTo = $request->query('date_to', Carbon::now()->toDateString());
|
$startWeek = (int) $request->query('start_week');
|
||||||
|
$endYear = (int) $request->query('end_year');
|
||||||
|
$endWeek = (int) $request->query('end_week');
|
||||||
|
|
||||||
|
if (!$startYear || !$startWeek || !$endYear || !$endWeek) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'startYear' => $startYear,
|
||||||
|
'startWeek' => $startWeek,
|
||||||
|
'endYear' => $endYear,
|
||||||
|
'endWeek' => $endWeek
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Overview Summary Cards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function summary()
|
||||||
|
{
|
||||||
|
$dateFrom = Carbon::now()->subDays(7)->toDateString();
|
||||||
|
$dateTo = Carbon::now()->toDateString();
|
||||||
|
|
||||||
$data = $this->service->summaryCards($dateFrom, $dateTo);
|
$data = $this->service->summaryCards($dateFrom, $dateTo);
|
||||||
|
|
||||||
return response()->json($data);
|
return response()->json($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Trend chart
|
/*
|
||||||
* GET /api/dashboard/trend?surveillance_id=1&period_type=week&date_from=...&date_to=...
|
|--------------------------------------------------------------------------
|
||||||
*/
|
| Overview Trend Chart
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
public function trend(Request $request)
|
public function trend(Request $request)
|
||||||
{
|
{
|
||||||
$periodType = $request->query('period_type', 'week');
|
$range = $this->getEpiRange($request);
|
||||||
$dateFrom = $request->query('date_from');
|
|
||||||
$dateTo = $request->query('date_to');
|
if (!$range) {
|
||||||
|
return response()->json(['error' => 'Missing epiweek range'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
$data = $this->service->aggregateAllPrograms(
|
$data = $this->service->aggregateAllPrograms(
|
||||||
$periodType,
|
|
||||||
$dateFrom,
|
$range['startYear'],
|
||||||
$dateTo
|
$range['startWeek'],
|
||||||
|
$range['endYear'],
|
||||||
|
$range['endWeek']
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json($data);
|
return response()->json($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Province distribution
|
|
||||||
*/
|
|
||||||
public function province(Request $request)
|
|
||||||
{
|
|
||||||
$surveillanceId = $request->query('surveillance_id');
|
|
||||||
$dateFrom = $request->query('date_from');
|
|
||||||
$dateTo = $request->query('date_to');
|
|
||||||
|
|
||||||
$data = $this->service->provinceDistribution(
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Program Dashboard
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function program(Request $request)
|
||||||
|
{
|
||||||
|
$surveillanceId = (int) $request->query('surveillance_id');
|
||||||
|
|
||||||
|
if (!$surveillanceId) {
|
||||||
|
return response()->json(['error' => 'Missing surveillance_id'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$range = $this->getEpiRange($request);
|
||||||
|
|
||||||
|
if (!$range) {
|
||||||
|
return response()->json(['error' => 'Missing epiweek range'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->service->programDashboardData(
|
||||||
$surveillanceId,
|
$surveillanceId,
|
||||||
$dateFrom,
|
$range['startYear'],
|
||||||
$dateTo
|
$range['startWeek'],
|
||||||
|
$range['endYear'],
|
||||||
|
$range['endWeek']
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json($data);
|
return response()->json($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pathogen distribution
|
|
||||||
*/
|
|
||||||
public function pathogen(Request $request)
|
|
||||||
{
|
|
||||||
$surveillanceId = $request->query('surveillance_id');
|
|
||||||
$dateFrom = $request->query('date_from');
|
|
||||||
$dateTo = $request->query('date_to');
|
|
||||||
|
|
||||||
$data = $this->service->pathogenDistribution(
|
/*
|
||||||
$surveillanceId,
|
|--------------------------------------------------------------------------
|
||||||
$dateFrom,
|
| Province Map (Overview)
|
||||||
$dateTo
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function provinceCircles(Request $request)
|
||||||
|
{
|
||||||
|
$range = $this->getEpiRange($request);
|
||||||
|
|
||||||
|
if (!$range) {
|
||||||
|
return response()->json(['error' => 'Missing epiweek range'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->service->provinceCircles(
|
||||||
|
$range['startYear'],
|
||||||
|
$range['startWeek'],
|
||||||
|
$range['endYear'],
|
||||||
|
$range['endWeek']
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sentinel Map
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function sentinelMap(Request $request)
|
||||||
|
{
|
||||||
|
$range = $this->getEpiRange($request);
|
||||||
|
|
||||||
|
if (!$range) {
|
||||||
|
return response()->json(['error' => 'Missing epiweek range'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->service->sentinelMap(
|
||||||
|
$range['startYear'],
|
||||||
|
$range['startWeek'],
|
||||||
|
$range['endYear'],
|
||||||
|
$range['endWeek']
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json($data);
|
return response()->json($data);
|
||||||
|
|||||||
@@ -2,247 +2,753 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use App\Models\Surveillance;
|
use App\Models\Surveillance;
|
||||||
use App\Models\SurveillanceCase;
|
use App\Models\SurveillanceCase;
|
||||||
use App\Models\CaseLabResult;
|
use App\Models\CaseLabResult;
|
||||||
|
|
||||||
class DashboardService
|
class DashboardService
|
||||||
{
|
{
|
||||||
/**
|
private function totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
|
||||||
* Get all surveillance programs
|
|
||||||
*/
|
|
||||||
public function getPrograms()
|
|
||||||
{
|
{
|
||||||
return Surveillance::orderBy('id')->get();
|
return SurveillanceCase::where('surveillance_id', $surveillanceId)
|
||||||
|
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
|
||||||
|
$q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
|
||||||
|
->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
|
||||||
|
})
|
||||||
|
->distinct('lab_code')
|
||||||
|
->count('lab_code');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Summary cards (dynamic)
|
|--------------------------------------------------------------------------
|
||||||
*/
|
| Overview Summary Cards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
public function summaryCards($dateFrom, $dateTo)
|
public function summaryCards($dateFrom, $dateTo)
|
||||||
{
|
{
|
||||||
$programs = $this->getPrograms();
|
$programs = Surveillance::orderBy('id')->get();
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
$days = Carbon::parse($dateFrom)->diffInDays($dateTo);
|
|
||||||
|
|
||||||
foreach ($programs as $program) {
|
foreach ($programs as $program) {
|
||||||
|
|
||||||
$current = SurveillanceCase::where('surveillance_id', $program->id)
|
$current = SurveillanceCase::where('surveillance_id', $program->id)
|
||||||
->whereBetween('case_date', [$dateFrom, $dateTo])
|
->whereBetween('case_date', [$dateFrom, $dateTo])
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$previousFrom = Carbon::parse($dateFrom)->subDays($days + 1);
|
|
||||||
$previousTo = Carbon::parse($dateFrom)->subDay();
|
|
||||||
|
|
||||||
$previous = SurveillanceCase::where('surveillance_id', $program->id)
|
$previous = SurveillanceCase::where('surveillance_id', $program->id)
|
||||||
->whereBetween('case_date', [$previousFrom, $previousTo])
|
->whereBetween('case_date', [
|
||||||
|
date('Y-m-d', strtotime($dateFrom . ' -7 days')),
|
||||||
|
date('Y-m-d', strtotime($dateFrom . ' -1 day'))
|
||||||
|
])
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$percentChange = $previous > 0
|
$percentChange = $previous > 0
|
||||||
? round((($current - $previous) / $previous) * 100, 1)
|
? round((($current - $previous) / $previous) * 100, 1)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
$last24h = SurveillanceCase::where('surveillance_id', $program->id)
|
|
||||||
->where('case_date', '>=', Carbon::now()->subDay())
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$results[] = [
|
$results[] = [
|
||||||
'surveillance_id' => $program->id,
|
'surveillance_id' => $program->id,
|
||||||
'code' => $program->code,
|
'code' => $program->code,
|
||||||
'name_en' => $program->name_en,
|
|
||||||
'name_kh' => $program->name_kh,
|
|
||||||
'current_total' => $current,
|
'current_total' => $current,
|
||||||
'percent_change' => $percentChange,
|
'previous_total' => $previous,
|
||||||
'last_24h' => $last24h,
|
'percent_change' => $percentChange
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregate cases by period
|
|
||||||
* periodType: week | month | year
|
|
||||||
*/
|
|
||||||
|
|
||||||
public function aggregateAllPrograms($periodType, $dateFrom, $dateTo)
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fast SARI Summary (single query)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function programSummaryFast($surveillanceId, $year, $week)
|
||||||
{
|
{
|
||||||
$programs = Surveillance::all();
|
$row = SurveillanceCase::leftJoin(
|
||||||
|
'case_lab_results',
|
||||||
|
'surveillance_cases.lab_code',
|
||||||
|
'=',
|
||||||
|
'case_lab_results.lab_code'
|
||||||
|
)
|
||||||
|
|
||||||
|
->where('surveillance_cases.surveillance_id', $surveillanceId)
|
||||||
|
->where('surveillance_cases.year_data', $year)
|
||||||
|
->where('surveillance_cases.week_data', $week)
|
||||||
|
|
||||||
|
->selectRaw("
|
||||||
|
COUNT(DISTINCT surveillance_cases.lab_code) as total_cases,
|
||||||
|
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN case_lab_results.is_positive = 1
|
||||||
|
THEN surveillance_cases.lab_code
|
||||||
|
END) as overall_positive,
|
||||||
|
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN case_lab_results.is_positive = 1
|
||||||
|
AND (
|
||||||
|
LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
|
||||||
|
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
|
||||||
|
)
|
||||||
|
THEN surveillance_cases.lab_code
|
||||||
|
END) as influenza_positive,
|
||||||
|
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN case_lab_results.is_positive = 1
|
||||||
|
AND (
|
||||||
|
LOWER(case_lab_results.pathogen_name) LIKE '%covid%'
|
||||||
|
OR LOWER(case_lab_results.pathogen_name) LIKE '%sars%'
|
||||||
|
)
|
||||||
|
THEN surveillance_cases.lab_code
|
||||||
|
END) as covid_positive
|
||||||
|
")
|
||||||
|
|
||||||
|
->first();
|
||||||
|
|
||||||
|
|
||||||
|
if (!$row || $row->total_cases == 0) {
|
||||||
|
return [
|
||||||
|
'cases' => 0,
|
||||||
|
'overall_rate' => 0,
|
||||||
|
'influenza_rate' => 0,
|
||||||
|
'covid_rate' => 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cases' => $row->total_cases,
|
||||||
|
|
||||||
|
'overall_rate' => round(($row->overall_positive / $row->total_cases) * 100, 1),
|
||||||
|
|
||||||
|
'influenza_rate' => round(($row->influenza_positive / $row->total_cases) * 100, 1),
|
||||||
|
|
||||||
|
'covid_rate' => round(($row->covid_positive / $row->total_cases) * 100, 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Program Summary
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function programSummary($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
|
||||||
|
{
|
||||||
|
|
||||||
|
$prevWeek = $endWeek - 1;
|
||||||
|
$prevYear = $endYear;
|
||||||
|
|
||||||
|
if ($prevWeek <= 0) {
|
||||||
|
$prevWeek = 52;
|
||||||
|
$prevYear--;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latest = SurveillanceCase::where('surveillance_id', $surveillanceId)
|
||||||
|
->selectRaw("year_data, week_data")
|
||||||
|
->orderByDesc('year_data')
|
||||||
|
->orderByDesc('week_data')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$year = $latest->year_data;
|
||||||
|
$week = $latest->week_data;
|
||||||
|
$current = $this->programSummaryFast($surveillanceId, $year, $week);
|
||||||
|
$previous = $this->programSummaryFast($surveillanceId, $prevYear, $prevWeek);
|
||||||
|
$prevWeek = $week - 1;
|
||||||
|
$prevYear = $year;
|
||||||
|
|
||||||
|
if ($prevWeek <= 0) {
|
||||||
|
$prevWeek = 52;
|
||||||
|
$prevYear--;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous = $this->programSummaryFast($surveillanceId, $prevYear, $prevWeek);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'cases' => [
|
||||||
|
'current' => $current['cases'],
|
||||||
|
'previous' => $previous['cases']
|
||||||
|
],
|
||||||
|
|
||||||
|
'hospital_rate' => [
|
||||||
|
'current' => 0,
|
||||||
|
'previous' => 0
|
||||||
|
],
|
||||||
|
|
||||||
|
'icu_rate' => [
|
||||||
|
'current' => 0,
|
||||||
|
'previous' => 0
|
||||||
|
],
|
||||||
|
|
||||||
|
'positivity_rate' => [
|
||||||
|
'current' => $current['overall_rate'],
|
||||||
|
'previous' => $previous['overall_rate']
|
||||||
|
],
|
||||||
|
|
||||||
|
'influenza_rate' => [
|
||||||
|
'current' => $current['influenza_rate'],
|
||||||
|
'previous' => $previous['influenza_rate']
|
||||||
|
],
|
||||||
|
|
||||||
|
'covid_rate' => [
|
||||||
|
'current' => $current['covid_rate'],
|
||||||
|
'previous' => $previous['covid_rate']
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Overview Trend
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function aggregateAllPrograms($startYear, $startWeek, $endYear, $endWeek)
|
||||||
|
{
|
||||||
|
|
||||||
|
$data = SurveillanceCase::selectRaw("
|
||||||
|
surveillance_id,
|
||||||
|
year_data as year,
|
||||||
|
week_data as period,
|
||||||
|
COUNT(*) as total
|
||||||
|
")
|
||||||
|
|
||||||
|
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
|
||||||
|
|
||||||
|
$q->whereRaw(
|
||||||
|
"(year_data > ? OR (year_data = ? AND week_data >= ?))",
|
||||||
|
[$startYear, $startYear, $startWeek]
|
||||||
|
)
|
||||||
|
|
||||||
|
->whereRaw(
|
||||||
|
"(year_data < ? OR (year_data = ? AND week_data <= ?))",
|
||||||
|
[$endYear, $endYear, $endWeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
->groupBy('surveillance_id', 'year_data', 'week_data')
|
||||||
|
->orderBy('year_data')
|
||||||
|
->orderBy('week_data')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
|
||||||
|
$programs = Surveillance::pluck('code', 'id');
|
||||||
|
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
foreach ($programs as $program) {
|
foreach ($data as $row) {
|
||||||
|
|
||||||
$query = SurveillanceCase::where('surveillance_id', $program->id)
|
$code = $programs[$row->surveillance_id];
|
||||||
->whereBetween('case_date', [$dateFrom, $dateTo]);
|
|
||||||
|
|
||||||
switch ($periodType) {
|
$results[$code][] = $row;
|
||||||
|
|
||||||
case 'week':
|
|
||||||
$query->selectRaw("
|
|
||||||
YEAR(case_date) as year,
|
|
||||||
WEEK(case_date, 3) as period,
|
|
||||||
COUNT(*) as total
|
|
||||||
")
|
|
||||||
->groupByRaw("YEAR(case_date), WEEK(case_date, 3)")
|
|
||||||
->orderByRaw("YEAR(case_date), WEEK(case_date, 3)");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'month':
|
|
||||||
$query->selectRaw("
|
|
||||||
YEAR(case_date) as year,
|
|
||||||
MONTH(case_date) as period,
|
|
||||||
COUNT(*) as total
|
|
||||||
")
|
|
||||||
->groupByRaw("YEAR(case_date), MONTH(case_date)")
|
|
||||||
->orderByRaw("YEAR(case_date), MONTH(case_date)");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'year':
|
|
||||||
$query->selectRaw("
|
|
||||||
YEAR(case_date) as period,
|
|
||||||
COUNT(*) as total
|
|
||||||
")
|
|
||||||
->groupByRaw("YEAR(case_date)")
|
|
||||||
->orderByRaw("YEAR(case_date)");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$results[$program->code] = $query->get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
public function aggregateCases($surveillanceId, $periodType, $dateFrom, $dateTo)
|
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Program Dashboard
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function programDashboardData($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
|
||||||
{
|
{
|
||||||
$query = SurveillanceCase::where('surveillance_id', $surveillanceId)
|
|
||||||
->whereBetween('case_date', [$dateFrom, $dateTo]);
|
|
||||||
|
|
||||||
switch ($periodType) {
|
return [
|
||||||
|
|
||||||
case 'week':
|
'summary' => $this->programSummary(
|
||||||
$query->selectRaw("
|
$surveillanceId,
|
||||||
YEAR(case_date) as year,
|
$startYear,
|
||||||
WEEK(case_date, 3) as period,
|
$startWeek,
|
||||||
COUNT(*) as total
|
$endYear,
|
||||||
")
|
$endWeek
|
||||||
->groupByRaw("YEAR(case_date), WEEK(case_date, 3)")
|
),
|
||||||
->orderByRaw("YEAR(case_date), WEEK(case_date, 3)");
|
|
||||||
|
'trend' => $this->trendSingleProgram(
|
||||||
|
$surveillanceId,
|
||||||
|
$startYear,
|
||||||
|
$startWeek,
|
||||||
|
$endYear,
|
||||||
|
$endWeek
|
||||||
|
),
|
||||||
|
|
||||||
|
'pathogen_distribution' => $this->pathogenDistribution(
|
||||||
|
$surveillanceId,
|
||||||
|
$startYear,
|
||||||
|
$startWeek,
|
||||||
|
$endYear,
|
||||||
|
$endWeek
|
||||||
|
),
|
||||||
|
|
||||||
|
'age_distribution' => $this->ageDistribution(
|
||||||
|
$surveillanceId,
|
||||||
|
$startYear,
|
||||||
|
$startWeek,
|
||||||
|
$endYear,
|
||||||
|
$endWeek
|
||||||
|
),
|
||||||
|
|
||||||
|
'sex_distribution' => $this->sexDistribution(
|
||||||
|
$surveillanceId,
|
||||||
|
$startYear,
|
||||||
|
$startWeek,
|
||||||
|
$endYear,
|
||||||
|
$endWeek
|
||||||
|
),
|
||||||
|
|
||||||
|
'province_distribution' => $this->provinceProgram(
|
||||||
|
$surveillanceId,
|
||||||
|
$startYear,
|
||||||
|
$startWeek,
|
||||||
|
$endYear,
|
||||||
|
$endWeek
|
||||||
|
),
|
||||||
|
'virus_trend' => $this->virusTrend(
|
||||||
|
$surveillanceId,
|
||||||
|
$startYear,
|
||||||
|
$startWeek,
|
||||||
|
$endYear,
|
||||||
|
$endWeek
|
||||||
|
),
|
||||||
|
'subtype_distribution' => $this->subtypeDistribution(
|
||||||
|
$surveillanceId,
|
||||||
|
$startYear,
|
||||||
|
$startWeek,
|
||||||
|
$endYear,
|
||||||
|
$endWeek
|
||||||
|
),
|
||||||
|
'sentinel_sites' => $this->sentinelSites(
|
||||||
|
$surveillanceId,
|
||||||
|
$startYear,
|
||||||
|
$startWeek,
|
||||||
|
$endYear,
|
||||||
|
$endWeek
|
||||||
|
),
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Trend Single Program
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function trendSingleProgram($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
|
||||||
|
{
|
||||||
|
|
||||||
|
$rows = SurveillanceCase::leftJoin(
|
||||||
|
'case_lab_results',
|
||||||
|
'surveillance_cases.lab_code',
|
||||||
|
'=',
|
||||||
|
'case_lab_results.lab_code'
|
||||||
|
)
|
||||||
|
|
||||||
|
->where('surveillance_cases.surveillance_id', $surveillanceId)
|
||||||
|
|
||||||
|
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
|
||||||
|
|
||||||
|
$q->whereRaw(
|
||||||
|
"(surveillance_cases.year_data > ? OR (surveillance_cases.year_data = ? AND surveillance_cases.week_data >= ?))",
|
||||||
|
[$startYear, $startYear, $startWeek]
|
||||||
|
)
|
||||||
|
|
||||||
|
->whereRaw(
|
||||||
|
"(surveillance_cases.year_data < ? OR (surveillance_cases.year_data = ? AND surveillance_cases.week_data <= ?))",
|
||||||
|
[$endYear, $endYear, $endWeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
->selectRaw("
|
||||||
|
surveillance_cases.year_data as year,
|
||||||
|
surveillance_cases.week_data as period,
|
||||||
|
|
||||||
|
COUNT(DISTINCT surveillance_cases.lab_code) as total_samples,
|
||||||
|
|
||||||
|
-- Overall positivity rate
|
||||||
|
ROUND(
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN case_lab_results.is_positive = 1
|
||||||
|
THEN surveillance_cases.lab_code
|
||||||
|
END)
|
||||||
|
/ NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100
|
||||||
|
,1) as positivity_rate,
|
||||||
|
|
||||||
|
-- Influenza positivity rate
|
||||||
|
ROUND(
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN case_lab_results.is_positive = 1
|
||||||
|
AND (
|
||||||
|
LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
|
||||||
|
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
|
||||||
|
)
|
||||||
|
THEN surveillance_cases.lab_code
|
||||||
|
END)
|
||||||
|
/ NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100
|
||||||
|
,1) as influenza_rate,
|
||||||
|
|
||||||
|
-- COVID positivity rate
|
||||||
|
ROUND(
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN case_lab_results.is_positive = 1
|
||||||
|
AND (
|
||||||
|
case_lab_results.pathogen_name = 'Positive'
|
||||||
|
OR case_lab_results.pathogen_name = 'SARS-CoV-2'
|
||||||
|
)
|
||||||
|
AND case_lab_results.indicator LIKE '%Covid%'
|
||||||
|
THEN surveillance_cases.lab_code
|
||||||
|
END)
|
||||||
|
/ NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100
|
||||||
|
,1) as covid_rate
|
||||||
|
")
|
||||||
|
|
||||||
|
->groupBy(
|
||||||
|
'surveillance_cases.year_data',
|
||||||
|
'surveillance_cases.week_data'
|
||||||
|
)
|
||||||
|
|
||||||
|
->get()
|
||||||
|
->keyBy(fn($r) => $r->year . '-' . $r->period);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
$year = $startYear;
|
||||||
|
$week = $startWeek;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
|
||||||
|
$key = $year . '-' . $week;
|
||||||
|
|
||||||
|
if (isset($rows[$key])) {
|
||||||
|
|
||||||
|
$results[] = $rows[$key];
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'year' => $year,
|
||||||
|
'period' => $week,
|
||||||
|
'total_samples' => 0,
|
||||||
|
'positivity_rate' => 0
|
||||||
|
];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($year == $endYear && $week == $endWeek)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'month':
|
$week++;
|
||||||
$query->selectRaw("
|
|
||||||
YEAR(case_date) as year,
|
if ($week > 52) {
|
||||||
MONTH(case_date) as period,
|
$week = 1;
|
||||||
COUNT(*) as total
|
$year++;
|
||||||
")
|
}
|
||||||
->groupByRaw("YEAR(case_date), MONTH(case_date)")
|
|
||||||
->orderByRaw("YEAR(case_date), MONTH(case_date)");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'year':
|
|
||||||
$query->selectRaw("
|
|
||||||
YEAR(case_date) as period,
|
|
||||||
COUNT(*) as total
|
|
||||||
")
|
|
||||||
->groupByRaw("YEAR(case_date)")
|
|
||||||
->orderByRaw("YEAR(case_date)");
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->get();
|
return $results;
|
||||||
|
|
||||||
|
}
|
||||||
|
public function virusTrend($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
|
||||||
|
{
|
||||||
|
return CaseLabResult::join(
|
||||||
|
'surveillance_cases',
|
||||||
|
'case_lab_results.lab_code',
|
||||||
|
'=',
|
||||||
|
'surveillance_cases.lab_code'
|
||||||
|
)
|
||||||
|
|
||||||
|
->where('surveillance_cases.surveillance_id', $surveillanceId)
|
||||||
|
|
||||||
|
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
|
||||||
|
$q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
|
||||||
|
->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
|
||||||
|
})
|
||||||
|
|
||||||
|
->selectRaw("
|
||||||
|
surveillance_cases.week_data as period,
|
||||||
|
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN case_lab_results.is_positive = 1
|
||||||
|
AND (
|
||||||
|
LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
|
||||||
|
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
|
||||||
|
)
|
||||||
|
THEN surveillance_cases.lab_code
|
||||||
|
END) as influenza,
|
||||||
|
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN case_lab_results.is_positive = 1
|
||||||
|
AND (
|
||||||
|
case_lab_results.pathogen_name = 'Positive'
|
||||||
|
OR case_lab_results.pathogen_name = 'SARS-CoV-2'
|
||||||
|
)
|
||||||
|
AND case_lab_results.indicator LIKE '%Covid%'
|
||||||
|
THEN surveillance_cases.lab_code
|
||||||
|
END) as covid
|
||||||
|
")
|
||||||
|
|
||||||
|
->groupBy('surveillance_cases.week_data')
|
||||||
|
->orderBy('surveillance_cases.week_data')
|
||||||
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Province distribution
|
/*
|
||||||
*/
|
|--------------------------------------------------------------------------
|
||||||
public function provinceDistribution($surveillanceId, $dateFrom, $dateTo)
|
| Province Distribution (Program)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
public function provinceCircles($startYear, $startWeek, $endYear, $endWeek)
|
||||||
|
{
|
||||||
|
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')->where('case_lab_results.is_positive', 1)->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
|
||||||
|
$q->whereRaw("(surveillance_cases.year_data > ? OR (surveillance_cases.year_data = ? AND surveillance_cases.week_data >= ?))", [$startYear, $startYear, $startWeek])->whereRaw("(surveillance_cases.year_data < ? OR (surveillance_cases.year_data = ? AND surveillance_cases.week_data <= ?))", [$endYear, $endYear, $endWeek]);
|
||||||
|
})->groupBy('surveillance_cases.site_province_name', 'surveillance_cases.surveillance_id')->get();
|
||||||
|
}
|
||||||
|
public function provinceProgram($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
|
||||||
{
|
{
|
||||||
return SurveillanceCase::selectRaw("
|
return SurveillanceCase::selectRaw("
|
||||||
site_province_name,
|
surveillance_cases.site_province_name,
|
||||||
COUNT(*) as total
|
|
||||||
|
COUNT(DISTINCT surveillance_cases.lab_code) as total,
|
||||||
|
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN case_lab_results.is_positive = 1
|
||||||
|
THEN surveillance_cases.lab_code
|
||||||
|
END) as positive
|
||||||
|
")
|
||||||
|
|
||||||
|
->join(
|
||||||
|
'case_lab_results',
|
||||||
|
'surveillance_cases.lab_code',
|
||||||
|
'=',
|
||||||
|
'case_lab_results.lab_code'
|
||||||
|
)
|
||||||
|
|
||||||
|
->where('surveillance_cases.surveillance_id', $surveillanceId)
|
||||||
|
|
||||||
|
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
|
||||||
|
|
||||||
|
$q->whereRaw(
|
||||||
|
"(surveillance_cases.year_data > ? OR (surveillance_cases.year_data = ? AND surveillance_cases.week_data >= ?))",
|
||||||
|
[$startYear, $startYear, $startWeek]
|
||||||
|
)
|
||||||
|
|
||||||
|
->whereRaw(
|
||||||
|
"(surveillance_cases.year_data < ? OR (surveillance_cases.year_data = ? AND surveillance_cases.week_data <= ?))",
|
||||||
|
[$endYear, $endYear, $endWeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
->groupBy('surveillance_cases.site_province_name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| sentinel sites
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
public function sentinelSites($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
|
||||||
|
{
|
||||||
|
return SurveillanceCase::selectRaw("
|
||||||
|
sentinel_site_name as name,
|
||||||
|
COUNT(DISTINCT lab_code) as total
|
||||||
")
|
")
|
||||||
->where('surveillance_id', $surveillanceId)
|
->where('surveillance_id', $surveillanceId)
|
||||||
->whereBetween('case_date', [$dateFrom, $dateTo])
|
|
||||||
->groupBy('site_province_name')
|
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
|
||||||
->orderByDesc('total')
|
$q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
|
||||||
|
->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
|
||||||
|
})
|
||||||
|
|
||||||
|
->groupBy('sentinel_site_name')
|
||||||
|
->orderByDesc('total') // nice for chart
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Pathogen distribution (positive only)
|
|--------------------------------------------------------------------------
|
||||||
*/
|
| Pathogen Distribution
|
||||||
public function pathogenDistribution($surveillanceId, $dateFrom, $dateTo)
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function pathogenDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
|
||||||
{
|
{
|
||||||
return CaseLabResult::selectRaw("
|
$total = $this->totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek);
|
||||||
case_lab_results.pathogen_name,
|
|
||||||
COUNT(*) as total
|
$rows = CaseLabResult::join(
|
||||||
|
'surveillance_cases',
|
||||||
|
'case_lab_results.lab_code',
|
||||||
|
'=',
|
||||||
|
'surveillance_cases.lab_code'
|
||||||
|
)
|
||||||
|
->where('surveillance_cases.surveillance_id', $surveillanceId)
|
||||||
|
|
||||||
|
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
|
||||||
|
$q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
|
||||||
|
->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
|
||||||
|
})
|
||||||
|
|
||||||
|
->where('case_lab_results.is_positive', 1)
|
||||||
|
|
||||||
|
->selectRaw("
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
|
||||||
|
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
|
||||||
|
THEN 'Influenza'
|
||||||
|
|
||||||
|
WHEN case_lab_results.pathogen_name = 'Positive'
|
||||||
|
AND case_lab_results.indicator LIKE '%Covid%'
|
||||||
|
THEN 'SARS-CoV-2'
|
||||||
|
|
||||||
|
ELSE case_lab_results.pathogen_name
|
||||||
|
END as pathogen,
|
||||||
|
|
||||||
|
COUNT(DISTINCT surveillance_cases.lab_code) as total
|
||||||
")
|
")
|
||||||
->join('surveillance_cases', 'case_lab_results.lab_code', '=', 'surveillance_cases.lab_code')
|
|
||||||
->where('surveillance_cases.surveillance_id', $surveillanceId)
|
->groupBy('pathogen')
|
||||||
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo])
|
->havingRaw("pathogen IS NOT NULL AND pathogen != ''")
|
||||||
->where('case_lab_results.is_positive', 1)
|
|
||||||
->groupBy('case_lab_results.pathogen_name')
|
|
||||||
->orderByDesc('total')
|
->orderByDesc('total')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
return $rows->map(function ($r) use ($total) {
|
||||||
|
$r->rate = $total > 0 ? round(($r->total / $total) * 100, 1) : 0;
|
||||||
|
return $r;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
public function subtypeDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
|
||||||
/**
|
|
||||||
* Positivity rate
|
|
||||||
*/
|
|
||||||
public function positivityRate($surveillanceId, $dateFrom, $dateTo)
|
|
||||||
{
|
{
|
||||||
$total = CaseLabResult::join('surveillance_cases', 'case_lab_results.lab_code', '=', 'surveillance_cases.lab_code')
|
$total = $this->totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek);
|
||||||
->where('surveillance_cases.surveillance_id', $surveillanceId)
|
|
||||||
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo])
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$positive = CaseLabResult::join('surveillance_cases', 'case_lab_results.lab_code', '=', 'surveillance_cases.lab_code')
|
$rows = CaseLabResult::join(
|
||||||
|
'surveillance_cases',
|
||||||
|
'case_lab_results.lab_code',
|
||||||
|
'=',
|
||||||
|
'surveillance_cases.lab_code'
|
||||||
|
)
|
||||||
->where('surveillance_cases.surveillance_id', $surveillanceId)
|
->where('surveillance_cases.surveillance_id', $surveillanceId)
|
||||||
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo])
|
|
||||||
->where('case_lab_results.is_positive', 1)
|
->where('case_lab_results.is_positive', 1)
|
||||||
->count();
|
|
||||||
|
|
||||||
return $total > 0
|
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
|
||||||
? round(($positive / $total) * 100, 1)
|
$q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
|
||||||
: 0;
|
->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
|
||||||
|
})
|
||||||
|
|
||||||
|
->selectRaw("
|
||||||
|
subtype,
|
||||||
|
COUNT(DISTINCT surveillance_cases.lab_code) as total
|
||||||
|
")
|
||||||
|
|
||||||
|
->groupBy('subtype')
|
||||||
|
->havingRaw("subtype IS NOT NULL AND subtype != 'Positive' AND subtype != ''")
|
||||||
|
->orderByDesc('total')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $rows->map(function ($r) use ($total) {
|
||||||
|
$r->rate = $total > 0 ? round(($r->total / $total) * 100, 1) : 0;
|
||||||
|
return $r;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Age distribution (grouped)
|
/*
|
||||||
*/
|
|--------------------------------------------------------------------------
|
||||||
public function ageDistribution($surveillanceId, $dateFrom, $dateTo)
|
| Age Distribution
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function ageDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
|
||||||
{
|
{
|
||||||
|
|
||||||
return SurveillanceCase::selectRaw("
|
return SurveillanceCase::selectRaw("
|
||||||
CASE
|
CASE
|
||||||
WHEN patient_age_inday < 365 THEN '0-1y'
|
WHEN patient_age_inday < 365 THEN '0-1y'
|
||||||
WHEN patient_age_inday < 1825 THEN '1-5y'
|
WHEN patient_age_inday < 1825 THEN '1-5y'
|
||||||
WHEN patient_age_inday < 6570 THEN '5-18y'
|
WHEN patient_age_inday < 6570 THEN '5-18y'
|
||||||
WHEN patient_age_inday < 21900 THEN '18-60y'
|
WHEN patient_age_inday < 21900 THEN '18-60y'
|
||||||
ELSE '60+y'
|
ELSE '60+y'
|
||||||
END as age_group,
|
END as age_group,
|
||||||
COUNT(*) as total
|
COUNT(*) as total
|
||||||
")
|
")
|
||||||
|
|
||||||
->where('surveillance_id', $surveillanceId)
|
->where('surveillance_id', $surveillanceId)
|
||||||
->whereBetween('case_date', [$dateFrom, $dateTo])
|
|
||||||
|
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
|
||||||
|
|
||||||
|
$q->whereRaw(
|
||||||
|
"(year_data > ? OR (year_data = ? AND week_data >= ?))",
|
||||||
|
[$startYear, $startYear, $startWeek]
|
||||||
|
)
|
||||||
|
|
||||||
|
->whereRaw(
|
||||||
|
"(year_data < ? OR (year_data = ? AND week_data <= ?))",
|
||||||
|
[$endYear, $endYear, $endWeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
->groupBy('age_group')
|
->groupBy('age_group')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sex distribution
|
|
||||||
*/
|
/*
|
||||||
public function sexDistribution($surveillanceId, $dateFrom, $dateTo)
|
|--------------------------------------------------------------------------
|
||||||
|
| Sex Distribution
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function sexDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
|
||||||
{
|
{
|
||||||
|
|
||||||
return SurveillanceCase::selectRaw("
|
return SurveillanceCase::selectRaw("
|
||||||
patient_sex,
|
patient_sex,
|
||||||
COUNT(*) as total
|
COUNT(*) as total
|
||||||
")
|
")
|
||||||
|
|
||||||
->where('surveillance_id', $surveillanceId)
|
->where('surveillance_id', $surveillanceId)
|
||||||
->whereBetween('case_date', [$dateFrom, $dateTo])
|
|
||||||
|
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
|
||||||
|
|
||||||
|
$q->whereRaw(
|
||||||
|
"(year_data > ? OR (year_data = ? AND week_data >= ?))",
|
||||||
|
[$startYear, $startYear, $startWeek]
|
||||||
|
)
|
||||||
|
|
||||||
|
->whereRaw(
|
||||||
|
"(year_data < ? OR (year_data = ? AND week_data <= ?))",
|
||||||
|
[$endYear, $endYear, $endWeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
->groupBy('patient_sex')
|
->groupBy('patient_sex')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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
365
dashboard/public/js/dashboard/charts.js
Normal file
365
dashboard/public/js/dashboard/charts.js
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
Chart.register(ChartDataLabels);
|
||||||
|
const charts = {};
|
||||||
|
|
||||||
|
|
||||||
|
function buildStackedChart(canvasId, labels, datasets) {
|
||||||
|
const ctx = document.getElementById(canvasId);
|
||||||
|
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
if (charts[canvasId]) {
|
||||||
|
charts[canvasId].destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
charts[canvasId] = new Chart(ctx, {
|
||||||
|
|
||||||
|
type: "bar",
|
||||||
|
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: datasets,
|
||||||
|
datalabels: {
|
||||||
|
display: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [ChartDataLabels],
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
|
||||||
|
layout: {
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
bottom: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
align: 'center',
|
||||||
|
|
||||||
|
labels: {
|
||||||
|
padding: 20,
|
||||||
|
boxWidth: 10,
|
||||||
|
boxHeight: 10,
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
datalabels: {
|
||||||
|
color: "#000",
|
||||||
|
anchor: "end",
|
||||||
|
align: "top",
|
||||||
|
clamp: true,
|
||||||
|
clip: false,
|
||||||
|
font: {
|
||||||
|
weight: "bold",
|
||||||
|
size: 10
|
||||||
|
},
|
||||||
|
formatter: function (value) {
|
||||||
|
return value > 0 ? value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function buildChart(id, type, labels, data) {
|
||||||
|
|
||||||
|
const ctx = document.getElementById(id);
|
||||||
|
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
if (charts[id]) charts[id].destroy();
|
||||||
|
|
||||||
|
charts[id] = new Chart(ctx, {
|
||||||
|
type: type,
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
data: data,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.3,
|
||||||
|
barPercentage: 0.8,
|
||||||
|
categoryPercentage: 0.6,
|
||||||
|
maxBarThickness: 50
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
|
||||||
|
layout: {
|
||||||
|
padding: {
|
||||||
|
top: 30,
|
||||||
|
bottom: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
align: 'center',
|
||||||
|
display: type === 'pie' || type === 'doughnut',
|
||||||
|
labels: {
|
||||||
|
padding: 10,
|
||||||
|
boxWidth: 14,
|
||||||
|
boxHeight: 14,
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
datalabels: {
|
||||||
|
color: "#282626",
|
||||||
|
anchor: type === "bar" ? "end" : "center",
|
||||||
|
align: type === "bar" ? "top" : "center",
|
||||||
|
font: {
|
||||||
|
size: 10
|
||||||
|
},
|
||||||
|
formatter: function(value, ctx) {
|
||||||
|
const data = ctx.chart.data.datasets[0].data;
|
||||||
|
const total = data.reduce((a, b) => a + b, 0);
|
||||||
|
const percent = total ? (value / total * 100).toFixed(1) : 0;
|
||||||
|
return percent + '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
function buildMixedTrendChart(canvasId, labels, samples, fluRate, covidRate) {
|
||||||
|
|
||||||
|
const ctx = document.getElementById(canvasId);
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
if (charts[canvasId]) charts[canvasId].destroy();
|
||||||
|
|
||||||
|
charts[canvasId] = new Chart(ctx, {
|
||||||
|
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
label: 'Influenza %',
|
||||||
|
data: fluRate,
|
||||||
|
borderColor: '#fa2929',
|
||||||
|
backgroundColor: '#fa2929',
|
||||||
|
tension: 0.4,
|
||||||
|
yAxisID: 'y1',
|
||||||
|
pointStyle: 'line',
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
label: 'COVID-19 %',
|
||||||
|
data: covidRate,
|
||||||
|
borderColor: '#1976D2',
|
||||||
|
backgroundColor: '#1976D2',
|
||||||
|
tension: 0.4,
|
||||||
|
yAxisID: 'y1',
|
||||||
|
pointStyle: 'line',
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
label: 'Total Cases',
|
||||||
|
data: samples,
|
||||||
|
backgroundColor: '#0B8F3C',
|
||||||
|
borderRadius: 2,
|
||||||
|
barPercentage: 0.8,
|
||||||
|
categoryPercentage: 0.7,
|
||||||
|
yAxisID: 'y',
|
||||||
|
}
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [ChartDataLabels],
|
||||||
|
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
datalabels: {
|
||||||
|
align: "top",
|
||||||
|
anchor: "end",
|
||||||
|
color: "#555",
|
||||||
|
font: {
|
||||||
|
size: 10
|
||||||
|
},
|
||||||
|
formatter: function (value, context) {
|
||||||
|
if (Number(value) === 0) return null;
|
||||||
|
|
||||||
|
if (context.dataset.type === 'line') {
|
||||||
|
return value + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
padding: {
|
||||||
|
top: 30,
|
||||||
|
bottom: 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
position: 'left',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Cases'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
position: 'right',
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '% Positivity'
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// function buildMixedTrendChart(canvasId, labels, samples, positivity) {
|
||||||
|
|
||||||
|
// const ctx = document.getElementById(canvasId);
|
||||||
|
// if (!ctx) return;
|
||||||
|
|
||||||
|
// if (charts[canvasId]) charts[canvasId].destroy();
|
||||||
|
|
||||||
|
// charts[canvasId] = new Chart(ctx, {
|
||||||
|
// data: {
|
||||||
|
// labels: labels,
|
||||||
|
// datasets: [
|
||||||
|
// {
|
||||||
|
// type: 'line',
|
||||||
|
// label: '% Positive',
|
||||||
|
// data: positivity,
|
||||||
|
// borderColor: '#1e6ef2',
|
||||||
|
// borderWidth: 2,
|
||||||
|
// tension: 0.4,
|
||||||
|
// fill: false,
|
||||||
|
// pointRadius: 2,
|
||||||
|
// pointStyle: 'line',
|
||||||
|
// yAxisID: 'y1',
|
||||||
|
|
||||||
|
|
||||||
|
// },
|
||||||
|
|
||||||
|
// {
|
||||||
|
// type: 'bar',
|
||||||
|
// label: 'Total sample ',
|
||||||
|
// data: samples,
|
||||||
|
// backgroundColor: '#2ecc71',
|
||||||
|
// borderRadius: 2,
|
||||||
|
// barPercentage: 0.8,
|
||||||
|
// categoryPercentage: 0.7,
|
||||||
|
// yAxisID: 'y',
|
||||||
|
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// },
|
||||||
|
|
||||||
|
// plugins: [ChartDataLabels],
|
||||||
|
|
||||||
|
// options: {
|
||||||
|
// responsive: true,
|
||||||
|
// maintainAspectRatio: false,
|
||||||
|
|
||||||
|
// plugins: {
|
||||||
|
// legend: {
|
||||||
|
// position: 'bottom',
|
||||||
|
// align: 'center',
|
||||||
|
// labels: {
|
||||||
|
// usePointStyle: true,
|
||||||
|
// padding: 20,
|
||||||
|
// boxWidth: 30,
|
||||||
|
// font: { size: 12 }
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// datalabels: {
|
||||||
|
// align: "top",
|
||||||
|
// anchor: "end",
|
||||||
|
// color: "#555",
|
||||||
|
// font: {
|
||||||
|
// size: 10
|
||||||
|
// },
|
||||||
|
// formatter: function (value, context) {
|
||||||
|
|
||||||
|
// if (Number(value) === 0) return null;
|
||||||
|
// if (context.dataset.type === 'line') {
|
||||||
|
// console.log(value);
|
||||||
|
// return value + '%';
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return value;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
|
||||||
|
// layout: {
|
||||||
|
// padding: {
|
||||||
|
// top: 20,
|
||||||
|
// bottom: 50
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
|
||||||
|
// scales: {
|
||||||
|
// y: {
|
||||||
|
// position: 'left',
|
||||||
|
// title: {
|
||||||
|
// display: true,
|
||||||
|
// text: 'Total sample'
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
|
||||||
|
// y1: {
|
||||||
|
// position: 'right',
|
||||||
|
// grid: {
|
||||||
|
// drawOnChartArea: false
|
||||||
|
// },
|
||||||
|
// title: {
|
||||||
|
// display: true,
|
||||||
|
// text: '% Positive'
|
||||||
|
// },
|
||||||
|
// ticks: {
|
||||||
|
// callback: value => value + '%'
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
131
dashboard/public/js/dashboard/filter.js
Normal file
131
dashboard/public/js/dashboard/filter.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
class DashboardFilter {
|
||||||
|
|
||||||
|
constructor(onChange) {
|
||||||
|
this.onChange = onChange;
|
||||||
|
|
||||||
|
this.rangeSelect = document.getElementById("trend_range");
|
||||||
|
this.startYear = document.getElementById("start_year");
|
||||||
|
this.startWeek = document.getElementById("start_week");
|
||||||
|
this.endYear = document.getElementById("end_year");
|
||||||
|
this.endWeek = document.getElementById("end_week");
|
||||||
|
this.customContainer = document.getElementById("custom_range_container");
|
||||||
|
|
||||||
|
if (!this.rangeSelect) return;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
|
||||||
|
this.populateFilters();
|
||||||
|
|
||||||
|
this.rangeSelect.addEventListener("change", () => {
|
||||||
|
|
||||||
|
const val = this.rangeSelect.value;
|
||||||
|
|
||||||
|
if (val === "custom") {
|
||||||
|
this.customContainer.style.display = "flex";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.customContainer.style.display = "none";
|
||||||
|
|
||||||
|
const range = this.lastWeeks(parseInt(val));
|
||||||
|
|
||||||
|
this.apply(range);
|
||||||
|
this.trigger();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
["start_year","start_week","end_year","end_week"].forEach(id => {
|
||||||
|
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
|
||||||
|
if (el) el.addEventListener("change", ()=>this.trigger());
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultRange = this.lastWeeks(8);
|
||||||
|
|
||||||
|
this.apply(defaultRange);
|
||||||
|
this.trigger();
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger() {
|
||||||
|
|
||||||
|
this.onChange(
|
||||||
|
this.startYear.value,
|
||||||
|
this.startWeek.value,
|
||||||
|
this.endYear.value,
|
||||||
|
this.endWeek.value
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(range) {
|
||||||
|
|
||||||
|
this.startYear.value = range.startYear;
|
||||||
|
this.startWeek.value = range.startWeek;
|
||||||
|
this.endYear.value = range.endYear;
|
||||||
|
this.endWeek.value = range.endWeek;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
populateFilters() {
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
for (let y = year-10; y <= year; y++) {
|
||||||
|
|
||||||
|
this.startYear.innerHTML += `<option value="${y}">${y}</option>`;
|
||||||
|
this.endYear.innerHTML += `<option value="${y}">${y}</option>`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let w = 1; w <= 53; w++) {
|
||||||
|
|
||||||
|
this.startWeek.innerHTML += `<option value="${w}">W${w}</option>`;
|
||||||
|
this.endWeek.innerHTML += `<option value="${w}">W${w}</option>`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
getISOWeek(date) {
|
||||||
|
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(),date.getMonth(),date.getDate()));
|
||||||
|
|
||||||
|
d.setUTCDate(d.getUTCDate()+4-(d.getUTCDay()||7));
|
||||||
|
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(),0,1));
|
||||||
|
|
||||||
|
const week = Math.ceil((((d-yearStart)/86400000)+1)/7);
|
||||||
|
|
||||||
|
return {year:d.getUTCFullYear(),week};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
lastWeeks(n) {
|
||||||
|
|
||||||
|
const end = this.getISOWeek(new Date());
|
||||||
|
|
||||||
|
let startWeek = end.week-n+1;
|
||||||
|
let startYear = end.year;
|
||||||
|
|
||||||
|
while(startWeek<=0){
|
||||||
|
|
||||||
|
startWeek += 52;
|
||||||
|
startYear--;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startYear,
|
||||||
|
startWeek,
|
||||||
|
endYear:end.year,
|
||||||
|
endWeek:end.week
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
275
dashboard/public/js/overview.js
Normal file
275
dashboard/public/js/overview.js
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
let trendChart;
|
||||||
|
let map;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Load Summary Cards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
function loadSummary() {
|
||||||
|
fetch('/api/dashboard/summary')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
data.forEach(item => {
|
||||||
|
|
||||||
|
let trendColor = 'text-secondary';
|
||||||
|
|
||||||
|
if (item.percent_change > 0) trendColor = 'text-danger';
|
||||||
|
if (item.percent_change < 0) trendColor = 'text-success';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h6 class="fw-bold">${item.code}</h6>
|
||||||
|
<h3 class="mb-1">${item.current_total}</h3>
|
||||||
|
<small class="text-muted">Last 7 days</small>
|
||||||
|
</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;
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Load Trend Chart
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
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(data => {
|
||||||
|
|
||||||
|
if (trendChart) trendChart.destroy();
|
||||||
|
|
||||||
|
const labelsSet = new Set();
|
||||||
|
|
||||||
|
Object.values(data).forEach(program => {
|
||||||
|
program.forEach(row => {
|
||||||
|
labelsSet.add(`${row.year}-${row.period}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
SARI: '#2563eb',
|
||||||
|
ILI: '#10b981',
|
||||||
|
LBM: '#9333ea'
|
||||||
|
};
|
||||||
|
|
||||||
|
const datasets = [];
|
||||||
|
|
||||||
|
const allowedPrograms = ['SARI', 'ILI', 'LBM'];
|
||||||
|
|
||||||
|
Object.keys(data).forEach(code => {
|
||||||
|
|
||||||
|
if (!allowedPrograms.includes(code)) return;
|
||||||
|
|
||||||
|
const values = labels.map(label => {
|
||||||
|
|
||||||
|
const found = data[code].find(row => `${row.year}-${row.period}` === label);
|
||||||
|
|
||||||
|
return found ? found.total : 0;
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
label: code,
|
||||||
|
data: values,
|
||||||
|
borderColor: colors[code],
|
||||||
|
backgroundColor: colors[code],
|
||||||
|
borderWidth: 3,
|
||||||
|
pointRadius: 4,
|
||||||
|
fill: false,
|
||||||
|
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'), {
|
||||||
|
|
||||||
|
type: 'line',
|
||||||
|
|
||||||
|
data: {
|
||||||
|
labels: displayLabels,
|
||||||
|
datasets: datasets
|
||||||
|
},
|
||||||
|
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' }
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { stepSize: 1 }
|
||||||
|
},
|
||||||
|
x: { grid: { display: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Province Map
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
function loadProvinceMap(startYear, startWeek, endYear, endWeek) {
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
interactive: false
|
||||||
|
},
|
||||||
|
|
||||||
|
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,
|
||||||
|
2: 0,
|
||||||
|
3: 0.15
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
|
||||||
|
const lat = center.lat;
|
||||||
|
const lng = center.lng + offsets[row.surveillance_id];
|
||||||
|
|
||||||
|
const programName =
|
||||||
|
row.surveillance_id === 1 ? 'SARI' :
|
||||||
|
row.surveillance_id === 2 ? 'ILI' : 'LBM';
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
1: '#2563eb',
|
||||||
|
2: '#10b981',
|
||||||
|
3: '#9333ea'
|
||||||
|
};
|
||||||
|
|
||||||
|
L.circleMarker([lat, lng], {
|
||||||
|
|
||||||
|
radius: 9,
|
||||||
|
fillColor: colors[row.surveillance_id],
|
||||||
|
color: '#fff',
|
||||||
|
weight: 1,
|
||||||
|
fillOpacity: 0.9
|
||||||
|
|
||||||
|
})
|
||||||
|
.bindTooltip(`
|
||||||
|
<strong>${province}</strong><br>
|
||||||
|
${programName}<br>
|
||||||
|
Total: ${row.total}
|
||||||
|
`)
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Initialize Dashboard
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|
||||||
|
loadSummary();
|
||||||
|
|
||||||
|
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
|
||||||
|
|
||||||
|
loadTrend('week', startYear, startWeek, endYear, endWeek);
|
||||||
|
|
||||||
|
loadProvinceMap(startYear, startWeek, endYear, endWeek);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
307
dashboard/public/js/program.js
Normal file
307
dashboard/public/js/program.js
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
const standardPrograms = ['SARI', 'ILI', 'LBM'];
|
||||||
|
const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase();
|
||||||
|
let map;
|
||||||
|
let provinceLayer;
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|
||||||
|
|
||||||
|
if (!standardPrograms.includes(programCode)) return;
|
||||||
|
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
|
||||||
|
|
||||||
|
fetch(`/api/dashboard/program?surveillance_id=${window.SURVEILLANCE_ID}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(renderDashboard)
|
||||||
|
.catch(err => console.error("Dashboard API error:", err));
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function renderProvinceHeatmap(rows) {
|
||||||
|
|
||||||
|
const totals = {};
|
||||||
|
rows.forEach(r => {
|
||||||
|
totals[r.site_province_name] = {
|
||||||
|
total: Number(r.total),
|
||||||
|
positive: Number(r.positive)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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 contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
addProvinceLegend();
|
||||||
|
|
||||||
|
fetch('/geo/cambodia_provinces.geojson')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(geo => {
|
||||||
|
|
||||||
|
function getColor(value) {
|
||||||
|
if (value > 50) return "#b91c1c";
|
||||||
|
if (value >= 10) return "#ef4444";
|
||||||
|
if (value > 0) return "#fecaca";
|
||||||
|
return "#f3f4f600";
|
||||||
|
}
|
||||||
|
|
||||||
|
provinceLayer = L.geoJSON(geo, {
|
||||||
|
style: feature => {
|
||||||
|
|
||||||
|
const province = feature.properties.ADM1_EN;
|
||||||
|
const value = totals[province]?.total || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: "#444",
|
||||||
|
weight: 1,
|
||||||
|
fillColor: getColor(value),
|
||||||
|
fillOpacity: 0.7
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onEachFeature: (feature, layer) => {
|
||||||
|
const province = feature.properties.ADM1_EN;
|
||||||
|
const total = totals[province]?.total || 0;
|
||||||
|
const positive = totals[province]?.positive || 0;
|
||||||
|
const percent = total ? ((positive / total) * 100).toFixed(1) : 0;
|
||||||
|
console.log(province, total, positive, percent);
|
||||||
|
|
||||||
|
layer.bindTooltip(`
|
||||||
|
${province}<br>
|
||||||
|
Total: ${total}<br>
|
||||||
|
Positivity: ${percent}%
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function addProvinceLegend() {
|
||||||
|
|
||||||
|
const legend = L.control({ position: "bottomright" });
|
||||||
|
|
||||||
|
legend.onAdd = function () {
|
||||||
|
|
||||||
|
const div = L.DomUtil.create("div", "map-legend");
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div style="background:white;padding:10px 12px;border-radius:6px;
|
||||||
|
box-shadow:0 2px 6px rgba(0,0,0,0.2);font-size:12px;">
|
||||||
|
<div style="font-weight:600;margin-bottom:6px;">Cases</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;margin-bottom:4px;">
|
||||||
|
<span style="width:12px;height:12px;background:#b91c1c;
|
||||||
|
display:inline-block;margin-right:6px;"></span>
|
||||||
|
> 50
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;margin-bottom:4px;">
|
||||||
|
<span style="width:12px;height:12px;background:#ef4444;
|
||||||
|
display:inline-block;margin-right:6px;"></span>
|
||||||
|
10 – 50
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;margin-bottom:4px;">
|
||||||
|
<span style="width:12px;height:12px;background:#fecaca;
|
||||||
|
display:inline-block;margin-right:6px;"></span>
|
||||||
|
1 – 9
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;">
|
||||||
|
<span style="width:12px;height:12px;background:#f3f4f6;
|
||||||
|
display:inline-block;margin-right:6px;"></span>
|
||||||
|
0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return div;
|
||||||
|
};
|
||||||
|
|
||||||
|
legend.addTo(map);
|
||||||
|
}
|
||||||
|
function renderTrend(valueId, changeId, current, previous, suffix = '') {
|
||||||
|
|
||||||
|
const valueEl = document.getElementById(valueId);
|
||||||
|
const changeEl = document.getElementById(changeId);
|
||||||
|
|
||||||
|
if (!valueEl || !changeEl) return;
|
||||||
|
|
||||||
|
valueEl.textContent = current + suffix;
|
||||||
|
|
||||||
|
if (!previous) {
|
||||||
|
changeEl.innerHTML = "— No previous data";
|
||||||
|
changeEl.className = "text-muted";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = current - previous;
|
||||||
|
const percent = ((diff / previous) * 100).toFixed(1);
|
||||||
|
|
||||||
|
if (diff > 0) {
|
||||||
|
changeEl.innerHTML = `↑ +${percent}% from previous week`;
|
||||||
|
changeEl.className = "text-success";
|
||||||
|
}
|
||||||
|
else if (diff < 0) {
|
||||||
|
changeEl.innerHTML = `↓ ${percent}% from previous week`;
|
||||||
|
changeEl.className = "text-danger";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
changeEl.innerHTML = "— No significant change";
|
||||||
|
changeEl.className = "text-muted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderProgramTrend(rows) {
|
||||||
|
|
||||||
|
rows = rows || [];
|
||||||
|
const labels = rows.map(r => `W${r.period}`);
|
||||||
|
const samples = rows.map(r => r.total_samples || 0);
|
||||||
|
const fluRate = rows.map(r => r.influenza_rate || 0);
|
||||||
|
const covidRate = rows.map(r => r.covid_rate || 0);
|
||||||
|
|
||||||
|
buildMixedTrendChart(
|
||||||
|
'trendChart',
|
||||||
|
labels,
|
||||||
|
samples,
|
||||||
|
fluRate,
|
||||||
|
covidRate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function renderSummary(summary) {
|
||||||
|
|
||||||
|
summary = summary || {};
|
||||||
|
|
||||||
|
const cases = summary.cases || {};
|
||||||
|
const hospital = summary.hospital_rate || {};
|
||||||
|
const icu = summary.icu_rate || {};
|
||||||
|
const positivity = summary.positivity_rate || {};
|
||||||
|
|
||||||
|
renderTrend(
|
||||||
|
"totalCases",
|
||||||
|
"casesChange",
|
||||||
|
cases.current || 0,
|
||||||
|
cases.previous || 0
|
||||||
|
);
|
||||||
|
renderTrend(
|
||||||
|
"influenzaRate",
|
||||||
|
"influenzaChange",
|
||||||
|
summary.influenza_rate.current,
|
||||||
|
summary.influenza_rate.previous,
|
||||||
|
"%"
|
||||||
|
);
|
||||||
|
|
||||||
|
renderTrend(
|
||||||
|
"covidRate",
|
||||||
|
"covidChange",
|
||||||
|
summary.covid_rate.current,
|
||||||
|
summary.covid_rate.previous,
|
||||||
|
"%"
|
||||||
|
);
|
||||||
|
|
||||||
|
renderTrend(
|
||||||
|
"hospitalRate",
|
||||||
|
"hospitalChange",
|
||||||
|
hospital.current || 0,
|
||||||
|
hospital.previous || 0,
|
||||||
|
"%"
|
||||||
|
);
|
||||||
|
|
||||||
|
renderTrend(
|
||||||
|
"icuRate",
|
||||||
|
"icuChange",
|
||||||
|
icu.current || 0,
|
||||||
|
icu.previous || 0,
|
||||||
|
"%"
|
||||||
|
);
|
||||||
|
|
||||||
|
renderTrend(
|
||||||
|
"positivityRate",
|
||||||
|
"positivityChange",
|
||||||
|
positivity.current || 0,
|
||||||
|
positivity.previous || 0,
|
||||||
|
"%"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function renderDashboard(data) {
|
||||||
|
|
||||||
|
data = data || {};
|
||||||
|
|
||||||
|
|
||||||
|
renderProgramTrend(data.trend || []);
|
||||||
|
renderSummary(data.summary || {});
|
||||||
|
renderProvinceHeatmap(data.province_distribution || []);
|
||||||
|
// buildStackedChart(
|
||||||
|
// "pathogenChart",
|
||||||
|
// labels,
|
||||||
|
// [
|
||||||
|
// {
|
||||||
|
// label: "Influenza",
|
||||||
|
// data: influenza,
|
||||||
|
// backgroundColor: "#2E7D32"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// label: "SARS-CoV-2",
|
||||||
|
// data: covid,
|
||||||
|
// backgroundColor: "#A5D6A7"
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// );
|
||||||
|
const pathogenRows = (data.pathogen_distribution || [])
|
||||||
|
.sort((a, b) => b.total - a.total);
|
||||||
|
const colors = [
|
||||||
|
'#2563eb', '#10b981', '#f59e0b', '#ef4444',
|
||||||
|
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16'
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
buildChart(
|
||||||
|
'pathogenChart',
|
||||||
|
'doughnut',
|
||||||
|
pathogenRows.map(r => r.pathogen),
|
||||||
|
pathogenRows.map(r => r.total)
|
||||||
|
);
|
||||||
|
charts['pathogenChart'].data.datasets[0].backgroundColor = colors;
|
||||||
|
charts['pathogenChart'].update();
|
||||||
|
|
||||||
|
buildChart(
|
||||||
|
'ageChart',
|
||||||
|
'doughnut',
|
||||||
|
(data.age_distribution || []).map(r => r.age_group),
|
||||||
|
(data.age_distribution || []).map(r => r.total)
|
||||||
|
);
|
||||||
|
charts['ageChart'].data.datasets[0].backgroundColor = colors;
|
||||||
|
charts['ageChart'].update();
|
||||||
|
|
||||||
|
buildChart(
|
||||||
|
'sexChart',
|
||||||
|
'bar',
|
||||||
|
(data.sex_distribution || []).map(r => r.patient_sex),
|
||||||
|
(data.sex_distribution || []).map(r => r.total)
|
||||||
|
);
|
||||||
|
charts['sexChart'].data.datasets[0].backgroundColor = colors;
|
||||||
|
charts['sexChart'].update();
|
||||||
|
|
||||||
|
buildChart(
|
||||||
|
'subtypeChart',
|
||||||
|
'bar',
|
||||||
|
(data.subtype_distribution || []).map(r => r.subtype),
|
||||||
|
(data.subtype_distribution || []).map(r => r.total)
|
||||||
|
);
|
||||||
|
charts['subtypeChart'].data.datasets[0].backgroundColor = colors;
|
||||||
|
charts['subtypeChart'].update();
|
||||||
|
buildChart(
|
||||||
|
'sentinelChart',
|
||||||
|
'pie',
|
||||||
|
(data.sentinel_sites || []).map(r => r.name),
|
||||||
|
(data.sentinel_sites || []).map(r => r.total)
|
||||||
|
);
|
||||||
|
charts['sentinelChart'].data.datasets[0].backgroundColor = colors;
|
||||||
|
charts['sentinelChart'].update();
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,53 +2,220 @@
|
|||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
<h3>{{ $selected->code }} - {{ $selected->name_en }}</h3>
|
<div class="container-fluid">
|
||||||
|
|
||||||
|
<!-- PAGE TITLE -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
|
||||||
|
<h4 class="fw-bold">
|
||||||
|
{{ $selected->code }} Detail Page
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<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 class="row mb-4">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-3">
|
|
||||||
<h6>Total Cases</h6>
|
|
||||||
<h4 id="totalCases">-</h4>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- STATUS -->
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
<b>Current {{ $selected->code }} Status:</b>
|
||||||
|
<span id="activityStatus">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- SUMMARY CARDS -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<small>Total Cases Reported (latest epiweek)</small>
|
||||||
|
<h3 id="totalCases">0</h3>
|
||||||
|
<small id="casesChange" class="text-muted">— No change</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<small>Overall Positivity Rate</small>
|
||||||
|
<h3 id="positivityRate">0%</h3>
|
||||||
|
<small id="positivityChange" class="text-muted">— No change</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<small>Influenza Rate</small>
|
||||||
|
<h3 id="influenzaRate">0%</h3>
|
||||||
|
<small id="influenzaChange" class="text-muted">— No change</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<small>SARS-CoV-2 Rate</small>
|
||||||
|
<h3 id="covidRate">0%</h3>
|
||||||
|
<small id="covidChange" class="text-muted">— No change</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- TREND CHART (PRIMARY) -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body" style="height:520px;">
|
||||||
|
|
||||||
|
<h6 class="fw-bold mb-3">
|
||||||
|
Case Trends & Positivity Rate by Epiweek
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<div style="height:460px; position:relative;">
|
||||||
|
<canvas id="trendChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PATHOGEN DISTRIBUTION -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body" style="height:520px">
|
||||||
|
|
||||||
|
<h6 class="fw-bold mb-3">Pathogen Distribution</h6>
|
||||||
|
|
||||||
|
<div style="height:460px; position:relative;">
|
||||||
|
<canvas id="pathogenChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- MAP + SITE+subtype -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
|
||||||
|
<!-- MAP -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body" style="height:520px">
|
||||||
|
|
||||||
|
<h6 class="fw-bold mb-3">Cases by Province</h6>
|
||||||
|
|
||||||
|
<div id="provinceMap" style="height:450px;"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body" style="height:520px">
|
||||||
|
|
||||||
|
<h6 class="fw-bold mb-3">Sentinel Sites & Influenza Subtypes</h6>
|
||||||
|
|
||||||
|
<div class="row ">
|
||||||
|
|
||||||
|
<!-- SENTINEL PIE -->
|
||||||
|
<div class="col-md-6 d-flex flex-column">
|
||||||
|
<small class="text-muted mb-2">Cases by Sentinel Site</small>
|
||||||
|
<div style="height: 460px; position:relative;">
|
||||||
|
<canvas id="sentinelChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SUBTYPE -->
|
||||||
|
<div class="col-md-6 d-flex flex-column">
|
||||||
|
<small class="text-muted mb-2">Influenza Subtypes</small>
|
||||||
|
<div style="height: 460px; position:relative;">
|
||||||
|
<canvas id="subtypeChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DEMOGRAPHICS -->
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body" style="height:400px">
|
||||||
|
<div class="row h-100">
|
||||||
|
<div class="col-md-6 d-flex flex-column">
|
||||||
|
<h6 class="fw-bold mb-3">Age Distribution</h6>
|
||||||
|
|
||||||
|
<div style="height:360px; position:relative;">
|
||||||
|
<canvas id="ageChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex flex-column">
|
||||||
|
<h6 class="fw-bold mb-3">Sex Distribution</h6>
|
||||||
|
|
||||||
|
<div style="height:360px; position:relative;">
|
||||||
|
<canvas id="sexChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body" style="height:400px">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
@endsection
|
||||||
<div class="card-body">
|
|
||||||
<h5>Epidemic Trend</h5>
|
|
||||||
<canvas id="trendChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
@section('scripts')
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
|
|
||||||
const surveillanceId = {{ $selected->id }};
|
<script>
|
||||||
const dateFrom = '2026-01-01';
|
window.SURVEILLANCE_ID = {{ $selected->id }};
|
||||||
const dateTo = new Date().toISOString().split('T')[0];
|
window.PROGRAM_CODE = "{{ $selected->code }}";
|
||||||
|
</script>
|
||||||
|
|
||||||
fetch(`/api/dashboard/trend?surveillance_id=${surveillanceId}&period_type=week&date_from=${dateFrom}&date_to=${dateTo}`)
|
<script src="/js/program.js"></script>
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
|
|
||||||
const labels = data.map(d => `${d.year}-${d.period}`);
|
|
||||||
const totals = data.map(d => d.total);
|
|
||||||
|
|
||||||
new Chart(document.getElementById('trendChart'), {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: labels,
|
|
||||||
datasets: [{
|
|
||||||
label: '{{ $selected->code }}',
|
|
||||||
data: totals,
|
|
||||||
borderColor: 'blue',
|
|
||||||
fill: false
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
@@ -5,36 +5,57 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-2 flex-shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
|
||||||
|
<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 -->
|
|
||||||
<div class="row mb-4" id="summary_cards"></div>
|
|
||||||
|
|
||||||
<div class="row">
|
<!-- Summary Cards -->
|
||||||
|
<div class="row flex-grow-1" id="summary_cards"></div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row flex-grow-1">
|
||||||
|
|
||||||
<!-- LEFT COLUMN -->
|
<!-- LEFT COLUMN -->
|
||||||
<div class="col-lg-8 d-flex flex-column">
|
<div class="col-lg-8 d-flex flex-column">
|
||||||
|
|
||||||
<!-- Trend Chart -->
|
<!-- Trend Chart -->
|
||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-3 flex-grow-1">
|
||||||
<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">
|
(based on selected epiweek range)
|
||||||
<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="90"></canvas>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,32 +63,58 @@
|
|||||||
<!-- Alerts -->
|
<!-- Alerts -->
|
||||||
<div class="card shadow-sm flex-grow-1">
|
<div class="card shadow-sm flex-grow-1">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
||||||
<h5 class="fw-bold">Recent Alerts & Notifications</h5>
|
<h5 class="fw-bold">Recent Alerts & Notifications</h5>
|
||||||
|
|
||||||
<ul class="list-group list-group-flush mt-3">
|
<ul class="list-group list-group-flush mt-3">
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
⚠ Monitoring influenza increase in selected provinces.
|
⚠ Monitoring influenza increase in selected provinces.
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
🔔 SARS-CoV-2 positivity rate under review.
|
🔔 SARS-CoV-2 positivity rate under review.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- RIGHT COLUMN -->
|
<!-- RIGHT COLUMN -->
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4 d-flex flex-column">
|
||||||
|
|
||||||
<div class="card shadow-sm h-100">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="fw-bold">Cases by Provinces</h5>
|
|
||||||
<p class="text-muted small">(% change vs last period)</p>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-center align-items-center" style="height: 100%;">
|
<h5 class="fw-bold">Total Cases by Provinces</h5>
|
||||||
<span class="text-muted">Province heatmap coming next</span>
|
<p class="text-muted small">(based on selected epiweek range)</p>
|
||||||
|
|
||||||
|
<div id="provinceMap" style="height:50vh;"></div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-center align-items-center gap-4 mt-4 small">
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
style="display:inline-block;width:10px;height:10px;background:#2563eb;border-radius:50%;margin-right:6px;"></span>
|
||||||
|
SARI
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
style="display:inline-block;width:10px;height:10px;background:#10b981;border-radius:50%;margin-right:6px;"></span>
|
||||||
|
ILI
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
style="display:inline-block;width:10px;height:10px;background:#9333ea;border-radius:50%;margin-right:6px;"></span>
|
||||||
|
LBM
|
||||||
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,149 +124,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/js/overview.js"></script>
|
||||||
|
|
||||||
let trendChart;
|
|
||||||
|
|
||||||
function loadSummary(dateFrom, dateTo) {
|
|
||||||
|
|
||||||
fetch(`/api/dashboard/summary?date_from=${dateFrom}&date_to=${dateTo}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
data.forEach(item => {
|
|
||||||
|
|
||||||
const colorClass = item.percent_change >= 0
|
|
||||||
? 'text-danger'
|
|
||||||
: 'text-success';
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="fw-bold">${item.code}</h6>
|
|
||||||
<h3 class="mb-1">${item.current_total}</h3>
|
|
||||||
<small class="${colorClass}">
|
|
||||||
${item.percent_change}% vs last period
|
|
||||||
</small>
|
|
||||||
<div class="small text-muted mt-1">
|
|
||||||
+${item.last_24h} in 24h
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('summary_cards').innerHTML = html;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadTrend(periodType, dateFrom, dateTo) {
|
|
||||||
|
|
||||||
fetch(`/api/dashboard/trend?period_type=${periodType}&date_from=${dateFrom}&date_to=${dateTo}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
console.log(data);
|
|
||||||
|
|
||||||
if (trendChart) trendChart.destroy();
|
|
||||||
|
|
||||||
const labelsSet = new Set();
|
|
||||||
|
|
||||||
Object.values(data).forEach(program => {
|
|
||||||
program.forEach(row => {
|
|
||||||
|
|
||||||
let label;
|
|
||||||
|
|
||||||
if (periodType === 'year') {
|
|
||||||
label = row.period.toString();
|
|
||||||
} else {
|
|
||||||
label = `${row.year}-${row.period}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
labelsSet.add(label);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const labels = Array.from(labelsSet).sort();
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
SARI: '#2563eb',
|
|
||||||
ILI: '#10b981',
|
|
||||||
LBM: '#9333ea'
|
|
||||||
};
|
|
||||||
|
|
||||||
const datasets = [];
|
|
||||||
|
|
||||||
Object.keys(data).forEach(code => {
|
|
||||||
|
|
||||||
const values = labels.map(label => {
|
|
||||||
|
|
||||||
const found = data[code].find(row => {
|
|
||||||
|
|
||||||
let rowLabel;
|
|
||||||
|
|
||||||
if (periodType === 'year') {
|
|
||||||
rowLabel = row.period.toString();
|
|
||||||
} else {
|
|
||||||
rowLabel = `${row.year}-${row.period}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowLabel === label;
|
|
||||||
});
|
|
||||||
|
|
||||||
return found ? found.total : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
datasets.push({
|
|
||||||
label: code,
|
|
||||||
data: values,
|
|
||||||
borderColor: colors[code] || '#000',
|
|
||||||
fill: false,
|
|
||||||
tension: 0.3
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
trendChart = new Chart(document.getElementById('trendChart'), {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: labels,
|
|
||||||
datasets: datasets
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'bottom'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
const past = new Date();
|
|
||||||
past.setDate(past.getDate() - 30);
|
|
||||||
|
|
||||||
const dateFrom = past.toISOString().split('T')[0];
|
|
||||||
const dateTo = today;
|
|
||||||
|
|
||||||
loadSummary(dateFrom, dateTo);
|
|
||||||
loadTrend('week', dateFrom, dateTo);
|
|
||||||
|
|
||||||
document.getElementById('period_type')
|
|
||||||
.addEventListener('change', function () {
|
|
||||||
loadTrend(this.value, dateFrom, dateTo);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
@@ -4,73 +4,79 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>NRML Dashboard</title>
|
<title>NRML Dashboard</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
<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@3.9.1"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<script src="/js/dashboard/filter.js"></script>
|
||||||
|
<script src="/js/dashboard/charts.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 {
|
||||||
padding: 30px;
|
padding: 20px;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
}
|
}
|
||||||
@@ -95,51 +101,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">
|
||||||
@yield('content')
|
@yield('content')
|
||||||
@@ -147,6 +142,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@yield('scripts')
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -5,5 +5,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/program', [DashboardController::class, 'program']);
|
||||||
Route::get('/dashboard/pathogen', [DashboardController::class, 'pathogen']);
|
Route::get('/dashboard/province-circles', [DashboardController::class, 'provinceCircles']);
|
||||||
|
Route::get('/dashboard/sentinel-map', [DashboardController::class, 'sentinelMap']);
|
||||||
@@ -11,4 +11,7 @@ Route::get('/', function () {
|
|||||||
|
|
||||||
|
|
||||||
Route::get('/dashboard', [DashboardController::class, 'overview']);
|
Route::get('/dashboard', [DashboardController::class, 'overview']);
|
||||||
Route::get('/dashboard/{code}', [DashboardController::class, 'detail']);
|
Route::get('/dashboard/{code}', [DashboardController::class, 'detail']);
|
||||||
|
Route::get('/test-change', function () {
|
||||||
|
return "TEST_CHANGE_WORKING";
|
||||||
|
});
|
||||||
@@ -1,66 +1,68 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: dashboard_app
|
container_name: dashboard_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /var/www
|
working_dir: /var/www
|
||||||
volumes:
|
volumes:
|
||||||
- ./dashboard:/var/www
|
- ./dashboard:/var/www
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql
|
- mysql
|
||||||
networks:
|
networks:
|
||||||
- dashboard
|
- dashboard
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: dashboard_nginx
|
container_name: dashboard_nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8000:80"
|
- "8000:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./dashboard:/var/www
|
- ./dashboard:/var/www
|
||||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
networks:
|
networks:
|
||||||
- dashboard
|
- dashboard
|
||||||
|
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8.0
|
image: mysql:8.0
|
||||||
container_name: dashboard_mysql
|
container_name: dashboard_mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MYSQL_DATABASE: dashboard
|
MYSQL_DATABASE: dashboard
|
||||||
MYSQL_ROOT_PASSWORD: root
|
MYSQL_ROOT_PASSWORD: root
|
||||||
MYSQL_USER: laravel
|
MYSQL_USER: laravel
|
||||||
MYSQL_PASSWORD: secret
|
MYSQL_PASSWORD: secret
|
||||||
ports:
|
ports:
|
||||||
- "3308:3306"
|
- "3308:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/mysql
|
- dbdata:/var/lib/mysql
|
||||||
networks:
|
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/dashboard.sql
|
||||||
- dashboard
|
networks:
|
||||||
|
- dashboard
|
||||||
phpmyadmin:
|
|
||||||
image: phpmyadmin/phpmyadmin
|
phpmyadmin:
|
||||||
container_name: dashboard_pma
|
image: phpmyadmin/phpmyadmin
|
||||||
restart: unless-stopped
|
container_name: dashboard_pma
|
||||||
depends_on:
|
restart: unless-stopped
|
||||||
- mysql
|
depends_on:
|
||||||
ports:
|
- mysql
|
||||||
- "8083:80"
|
ports:
|
||||||
environment:
|
- "8083:80"
|
||||||
PMA_HOST: mysql
|
environment:
|
||||||
PMA_PORT: 3306
|
PMA_HOST: mysql
|
||||||
PMA_USER: laravel
|
PMA_PORT: 3306
|
||||||
PMA_PASSWORD: secret
|
PMA_USER: laravel
|
||||||
networks:
|
PMA_PASSWORD: secret
|
||||||
- dashboard
|
networks:
|
||||||
|
- dashboard
|
||||||
networks:
|
|
||||||
dashboard:
|
networks:
|
||||||
|
dashboard:
|
||||||
volumes:
|
|
||||||
dbdata:
|
|
||||||
|
volumes:
|
||||||
|
dbdata:
|
||||||
|
|||||||
40
nginx.conf
40
nginx.conf
@@ -1,21 +1,21 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
index index.php index.html;
|
index index.php index.html;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
root /var/www/public;
|
root /var/www/public;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.php?$query_string;
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ \.php$ {
|
location ~ \.php$ {
|
||||||
fastcgi_pass app:9000;
|
fastcgi_pass app:9000;
|
||||||
fastcgi_index index.php;
|
fastcgi_index index.php;
|
||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ /\.ht {
|
location ~ /\.ht {
|
||||||
deny all;
|
deny all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user