From c2b820fc6d5b3725a55a7e62c818f68636fe8037 Mon Sep 17 00:00:00 2001 From: Khantey Long Date: Fri, 13 Mar 2026 15:49:01 +0700 Subject: [PATCH] working on detail page for sari, lil, amd lbm --- Dockerfile | 40 +- .../Controllers/Api/DashboardController.php | 221 +++--- dashboard/app/Services/DashboardService.php | 654 +++++++++++++----- dashboard/public/js/dashboard/charts.js | 108 +++ dashboard/public/js/dashboard/filter.js | 132 ++++ dashboard/public/js/overview.js | 275 ++++++++ dashboard/public/js/program.js | 155 +++++ .../views/dashboard/detail.blade.php | 254 +++++-- .../views/dashboard/overview.blade.php | 545 ++------------- .../resources/views/layouts/app.blade.php | 12 +- dashboard/routes/api.php | 7 +- dashboard/routes/web.php | 5 +- docker-compose.yml | 135 ++-- nginx.conf | 40 +- 14 files changed, 1627 insertions(+), 956 deletions(-) create mode 100644 dashboard/public/js/dashboard/charts.js create mode 100644 dashboard/public/js/dashboard/filter.js create mode 100644 dashboard/public/js/overview.js create mode 100644 dashboard/public/js/program.js diff --git a/Dockerfile b/Dockerfile index 077d8ee..af83013 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,21 @@ -FROM php:8.2-fpm - -WORKDIR /var/www - -# System dependencies -RUN apt-get update && apt-get install -y \ - git \ - curl \ - zip \ - unzip \ - libpng-dev \ - libonig-dev \ - libxml2-dev - -# PHP Extensions -RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd - -# Install Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - +FROM php:8.2-fpm + +WORKDIR /var/www + +# System dependencies +RUN apt-get update && apt-get install -y \ + git \ + curl \ + zip \ + unzip \ + libpng-dev \ + libonig-dev \ + libxml2-dev + +# PHP Extensions +RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd + +# Install Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + CMD ["php-fpm"] \ No newline at end of file diff --git a/dashboard/app/Http/Controllers/Api/DashboardController.php b/dashboard/app/Http/Controllers/Api/DashboardController.php index 8d56812..3b960a3 100644 --- a/dashboard/app/Http/Controllers/Api/DashboardController.php +++ b/dashboard/app/Http/Controllers/Api/DashboardController.php @@ -6,169 +6,160 @@ use Illuminate\Http\Request; use App\Http\Controllers\Controller; use App\Services\DashboardService; use Carbon\Carbon; -use App\Models\SurveillanceCase; class DashboardController extends Controller { protected $service; - public function index() - { - $programs = \App\Models\Surveillance::all(); - return view('dashboard.index', compact('programs')); - } public function __construct(DashboardService $service) { $this->service = $service; } - /** - * Summary cards - * GET /api/dashboard/summary?date_from=2026-01-01&date_to=2026-03-01 - */ - public function summary(Request $request) + /* + |-------------------------------------------------------------------------- + | Helper: Resolve Epiweek Range + |-------------------------------------------------------------------------- + */ + + private function getEpiRange(Request $request) { - if ($request->has('start_year')) { - - $startYear = $request->query('start_year'); - $startWeek = $request->query('start_week'); - - $endYear = $request->query('end_year'); - $endWeek = $request->query('end_week'); - - $dateFrom = Carbon::now()->setISODate($startYear, $startWeek)->startOfWeek()->toDateString(); - $dateTo = Carbon::now()->setISODate($endYear, $endWeek)->endOfWeek()->toDateString(); - - } else { - - $dateFrom = $request->query('date_from', Carbon::now()->subDays(7)->toDateString()); - $dateTo = $request->query('date_to', Carbon::now()->toDateString()); + $startYear = (int) $request->query('start_year'); + $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); 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) { - $periodType = $request->query('period_type', 'week'); - - if ($request->has('start_year')) { - - $startYear = $request->query('start_year'); - $startWeek = $request->query('start_week'); - - $endYear = $request->query('end_year'); - $endWeek = $request->query('end_week'); - - $dateFrom = Carbon::now()->setISODate($startYear, $startWeek)->startOfWeek()->toDateString(); - $dateTo = Carbon::now()->setISODate($endYear, $endWeek)->endOfWeek()->toDateString(); - - } else { - - $dateFrom = $request->query('date_from'); - $dateTo = $request->query('date_to'); + $range = $this->getEpiRange($request); + if (!$range) { + return response()->json(['error' => 'Missing epiweek range'], 400); } $data = $this->service->aggregateAllPrograms( - $periodType, - $dateFrom, - $dateTo + $range['startYear'], + $range['startWeek'], + $range['endYear'], + $range['endWeek'] ); return response()->json($data); } - /** - * Province distribution - */ - public function province(Request $request) + + /* + |-------------------------------------------------------------------------- + | Program Dashboard + |-------------------------------------------------------------------------- + */ + + public function program(Request $request) { - if ($request->has('start_year')) { - - $startYear = $request->query('start_year'); - $startWeek = $request->query('start_week'); - - $endYear = $request->query('end_year'); - $endWeek = $request->query('end_week'); - - $dateFrom = Carbon::now() - ->setISODate($startYear, $startWeek) - ->startOfWeek() - ->toDateString(); - - $dateTo = Carbon::now() - ->setISODate($endYear, $endWeek) - ->endOfWeek() - ->toDateString(); - - } else { - - $dateFrom = $request->query('date_from'); - $dateTo = $request->query('date_to'); + $surveillanceId = (int) $request->query('surveillance_id'); + if (!$surveillanceId) { + return response()->json(['error' => 'Missing surveillance_id'], 400); } - $rows = $this->service->provinceDistribution($dateFrom, $dateTo); + $range = $this->getEpiRange($request); - $result = []; - - foreach ($rows as $row) { - $result[$row->site_province_name] = $row->total; + if (!$range) { + return response()->json(['error' => 'Missing epiweek range'], 400); } - return response()->json($result); - } - public function sentinelMap(Request $request) - { - $startYear = $request->query('start_year'); - $startWeek = $request->query('start_week'); - - $endYear = $request->query('end_year'); - $endWeek = $request->query('end_week'); - - $dateFrom = Carbon::now()->setISODate($startYear, $startWeek)->startOfWeek(); - $dateTo = Carbon::now()->setISODate($endYear, $endWeek)->endOfWeek(); - - $data = $this->service->sentinelMap($dateFrom, $dateTo); + $data = $this->service->programDashboardData( + $surveillanceId, + $range['startYear'], + $range['startWeek'], + $range['endYear'], + $range['endWeek'] + ); return response()->json($data); } + + + /* + |-------------------------------------------------------------------------- + | Province Map (Overview) + |-------------------------------------------------------------------------- + */ + public function provinceCircles(Request $request) { - $startYear = $request->query('start_year'); - $startWeek = $request->query('start_week'); + $range = $this->getEpiRange($request); - $endYear = $request->query('end_year'); - $endWeek = $request->query('end_week'); + if (!$range) { + return response()->json(['error' => 'Missing epiweek range'], 400); + } - $dateFrom = Carbon::now()->setISODate($startYear, $startWeek)->startOfWeek(); - $dateTo = Carbon::now()->setISODate($endYear, $endWeek)->endOfWeek(); - - $data = $this->service->provinceCircles($dateFrom, $dateTo); + $data = $this->service->provinceCircles( + $range['startYear'], + $range['startWeek'], + $range['endYear'], + $range['endWeek'] + ); 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, - $dateTo + + /* + |-------------------------------------------------------------------------- + | 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); diff --git a/dashboard/app/Services/DashboardService.php b/dashboard/app/Services/DashboardService.php index 8ae592b..c00720b 100644 --- a/dashboard/app/Services/DashboardService.php +++ b/dashboard/app/Services/DashboardService.php @@ -2,210 +2,420 @@ namespace App\Services; -use Carbon\Carbon; use App\Models\Surveillance; use App\Models\SurveillanceCase; use App\Models\CaseLabResult; class DashboardService { - /** - * Get all surveillance programs - */ - public function getPrograms() - { - return Surveillance::orderBy('id')->get(); - } - /** - * Summary cards (dynamic) - */ + /* + |-------------------------------------------------------------------------- + | Overview Summary Cards + |-------------------------------------------------------------------------- + */ + public function summaryCards($dateFrom, $dateTo) { - $programs = $this->getPrograms(); + $programs = Surveillance::orderBy('id')->get(); $results = []; - $days = Carbon::parse($dateFrom)->diffInDays($dateTo); - foreach ($programs as $program) { $current = SurveillanceCase::where('surveillance_id', $program->id) ->whereBetween('case_date', [$dateFrom, $dateTo]) ->count(); - $previousFrom = Carbon::parse($dateFrom)->subDays($days + 1); - $previousTo = Carbon::parse($dateFrom)->subDay(); - $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(); $percentChange = $previous > 0 ? round((($current - $previous) / $previous) * 100, 1) : 0; - $last24h = SurveillanceCase::where('surveillance_id', $program->id) - ->where('case_date', '>=', Carbon::now()->subDay()) - ->count(); - $results[] = [ 'surveillance_id' => $program->id, 'code' => $program->code, - 'name_en' => $program->name_en, - 'name_kh' => $program->name_kh, 'current_total' => $current, - 'percent_change' => $percentChange, - 'last_24h' => $last24h, + 'previous_total' => $previous, + 'percent_change' => $percentChange ]; } return $results; } - /** - * Aggregate cases by period - * periodType: week | month | year - */ - public function aggregateAllPrograms($periodType, $dateFrom, $dateTo) + /* + |-------------------------------------------------------------------------- + | Fast SARI Summary (single query) + |-------------------------------------------------------------------------- + */ + + public function sariSummaryFast($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.indicator = 'SARI Influenza Test' + AND case_lab_results.is_positive = 1 + THEN surveillance_cases.lab_code + END) as influenza_positive, + + COUNT(DISTINCT CASE + WHEN case_lab_results.indicator = 'SARI Covid Test' + AND case_lab_results.is_positive = 1 + 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--; + } + + $current = $this->sariSummaryFast($surveillanceId, $endYear, $endWeek); + $previous = $this->sariSummaryFast($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($periodType, $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 = []; - foreach ($programs as $program) { + foreach ($data as $row) { - $query = SurveillanceCase::where('surveillance_id', $program->id) - ->whereBetween('case_date', [$dateFrom, $dateTo]); + $code = $programs[$row->surveillance_id]; - 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; } - 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': - $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; + 'summary' => $this->programSummary( + $surveillanceId, + $startYear, + $startWeek, + $endYear, + $endWeek + ), - 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; + 'trend' => $this->trendSingleProgram( + $surveillanceId, + $startYear, + $startWeek, + $endYear, + $endWeek + ), - case 'year': - $query->selectRaw(" - YEAR(case_date) as period, - COUNT(*) as total - ") - ->groupByRaw("YEAR(case_date)") - ->orderByRaw("YEAR(case_date)"); - break; - } + '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->provinceCirclesProgram( + $surveillanceId, + $startYear, + $startWeek, + $endYear, + $endWeek + ) + + ]; - return $query->get(); } - /** - * Province distribution - */ - public function provinceDistribution($dateFrom, $dateTo) + + + /* + |-------------------------------------------------------------------------- + | Trend Single Program + |-------------------------------------------------------------------------- + */ + + public function trendSingleProgram($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) { - return SurveillanceCase::selectRaw(" - site_province_name, - COUNT(*) as total - ") - ->join('case_lab_results', 'surveillance_cases.lab_code', '=', 'case_lab_results.lab_code') - ->whereIn('surveillance_cases.surveillance_id', [1, 2, 3]) // SARI ILI LBM - ->where('case_lab_results.is_positive', 1) + $rows = SurveillanceCase::leftJoin( + 'case_lab_results', + 'surveillance_cases.lab_code', + '=', + 'case_lab_results.lab_code' + ) - ->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo]) + ->where('surveillance_cases.surveillance_id', $surveillanceId) - ->groupBy('site_province_name') - ->orderByDesc('total') - ->get(); - } - public function sentinelMap($dateFrom, $dateTo) - { - return SurveillanceCase::selectRaw(" - sentinel_site_id, - sentinel_site_name, - site_province_name, - surveillance_id, - COUNT(*) as total - ") - ->join('case_lab_results', 'surveillance_cases.lab_code', '=', 'case_lab_results.lab_code') + ->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) { - ->whereIn('surveillance_cases.surveillance_id', [1, 2, 3]) - ->where('case_lab_results.is_positive', 1) + $q->whereRaw( + "(surveillance_cases.year_data > ? OR (surveillance_cases.year_data = ? AND surveillance_cases.week_data >= ?))", + [$startYear, $startYear, $startWeek] + ) - ->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo]) + ->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, + + ROUND( + SUM(CASE WHEN case_lab_results.is_positive = 1 THEN 1 ELSE 0 END) + / NULLIF(COUNT(*),0) * 100,1 + ) as positivity_rate + ") ->groupBy( - 'sentinel_site_id', - 'sentinel_site_name', - 'site_province_name', - 'surveillance_id' + 'surveillance_cases.year_data', + 'surveillance_cases.week_data' ) - ->get(); + + ->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; + + $week++; + + if ($week > 52) { + $week = 1; + $year++; + } + + } + + return $results; + } - public function provinceCircles($dateFrom, $dateTo) + + + + /* + |-------------------------------------------------------------------------- + | Province Distribution (Program) + |-------------------------------------------------------------------------- + */ + + public function provinceCirclesProgram($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) { + return SurveillanceCase::selectRaw(" - surveillance_cases.site_province_name, - surveillance_cases.surveillance_id, - COUNT(*) as total - ") + surveillance_cases.site_province_name, + COUNT(DISTINCT surveillance_cases.lab_code) as total + ") + ->join( 'case_lab_results', 'surveillance_cases.lab_code', @@ -213,90 +423,152 @@ class DashboardService 'case_lab_results.lab_code' ) - ->whereIn('surveillance_cases.surveillance_id', [1, 2, 3]) + ->where('surveillance_cases.surveillance_id', $surveillanceId) ->where('case_lab_results.is_positive', 1) - ->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo]) + ->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) { - ->groupBy( - 'surveillance_cases.site_province_name', - 'surveillance_cases.surveillance_id' - ) + $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(); + } - /** - * Pathogen distribution (positive only) - */ - public function pathogenDistribution($surveillanceId, $dateFrom, $dateTo) + + + /* + |-------------------------------------------------------------------------- + | Pathogen Distribution + |-------------------------------------------------------------------------- + */ + + public function pathogenDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) { + return CaseLabResult::selectRaw(" - case_lab_results.pathogen_name, + pathogen_name, COUNT(*) as total - ") - ->join('surveillance_cases', 'case_lab_results.lab_code', '=', 'surveillance_cases.lab_code') + ") + + ->join( + 'surveillance_cases', + 'case_lab_results.lab_code', + '=', + 'surveillance_cases.lab_code' + ) + ->where('surveillance_cases.surveillance_id', $surveillanceId) - ->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo]) ->where('case_lab_results.is_positive', 1) - ->groupBy('case_lab_results.pathogen_name') + + ->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('pathogen_name') ->orderByDesc('total') ->get(); + } - /** - * Positivity rate - */ - public function positivityRate($surveillanceId, $dateFrom, $dateTo) + + + /* + |-------------------------------------------------------------------------- + | Age Distribution + |-------------------------------------------------------------------------- + */ + + public function ageDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) { - $total = CaseLabResult::join('surveillance_cases', 'case_lab_results.lab_code', '=', 'surveillance_cases.lab_code') - ->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') - ->where('surveillance_cases.surveillance_id', $surveillanceId) - ->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo]) - ->where('case_lab_results.is_positive', 1) - ->count(); - - return $total > 0 - ? round(($positive / $total) * 100, 1) - : 0; - } - - /** - * Age distribution (grouped) - */ - public function ageDistribution($surveillanceId, $dateFrom, $dateTo) - { return SurveillanceCase::selectRaw(" CASE - WHEN patient_age_inday < 365 THEN '0-1y' - WHEN patient_age_inday < 1825 THEN '1-5y' - WHEN patient_age_inday < 6570 THEN '5-18y' - WHEN patient_age_inday < 21900 THEN '18-60y' - ELSE '60+y' + WHEN patient_age_inday < 365 THEN '0-1y' + WHEN patient_age_inday < 1825 THEN '1-5y' + WHEN patient_age_inday < 6570 THEN '5-18y' + WHEN patient_age_inday < 21900 THEN '18-60y' + ELSE '60+y' END as age_group, COUNT(*) as total - ") + ") + ->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') ->get(); + } - /** - * Sex distribution - */ - public function sexDistribution($surveillanceId, $dateFrom, $dateTo) + + + /* + |-------------------------------------------------------------------------- + | Sex Distribution + |-------------------------------------------------------------------------- + */ + + public function sexDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) { + return SurveillanceCase::selectRaw(" - patient_sex, - COUNT(*) as total + patient_sex, + COUNT(*) as total ") + ->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') ->get(); + } + } \ No newline at end of file diff --git a/dashboard/public/js/dashboard/charts.js b/dashboard/public/js/dashboard/charts.js new file mode 100644 index 0000000..ce7b4fd --- /dev/null +++ b/dashboard/public/js/dashboard/charts.js @@ -0,0 +1,108 @@ +const charts = {}; + +function buildChart(id, type, labels, data, label = 'Cases') { + + 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: [{ + label: label, + data: data, + borderWidth: 2, + tension: 0.3 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false + } + }); + +} +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: 4, + pointStyle: 'line', + yAxisID: 'y1' + }, + { + type: 'bar', + label: 'Total sample', + data: samples, + backgroundColor: '#2ecc71', + borderRadius: 6, + barPercentage: 0.6, + pointStyle: 'rect', + categoryPercentage: 0.7, + yAxisID: 'y', + }, + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + align: 'center', + labels: { + usePointStyle: true, + padding: 20, + boxWidth: 30, + font: { + size: 12 + } + } + } + }, + layout: { + padding: { + 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 + '%' + } + } + } + } + }); +} \ No newline at end of file diff --git a/dashboard/public/js/dashboard/filter.js b/dashboard/public/js/dashboard/filter.js new file mode 100644 index 0000000..6951a9a --- /dev/null +++ b/dashboard/public/js/dashboard/filter.js @@ -0,0 +1,132 @@ +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 += ``; + this.endYear.innerHTML += ``; + + } + + for (let w = 1; w <= 53; w++) { + + this.startWeek.innerHTML += ``; + this.endWeek.innerHTML += ``; + + } + + } + + 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 + }; + + } + +} \ No newline at end of file diff --git a/dashboard/public/js/overview.js b/dashboard/public/js/overview.js new file mode 100644 index 0000000..ceb29db --- /dev/null +++ b/dashboard/public/js/overview.js @@ -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 += ` +
+
+
+ +
+ +
+
${item.code}
+

${item.current_total}

+ Last 7 days +
+ +
+ +
+ ${item.percent_change > 0 ? '▲' : item.percent_change < 0 ? '▼' : '–'} + ${Math.abs(item.percent_change)}% +
+ + + ${item.previous_total ?? 0} last week + + +
+ +
+ +
+
+
+ `; + }); + + 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(` + ${province}
+ ${programName}
+ 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); + + }); + +}); \ No newline at end of file diff --git a/dashboard/public/js/program.js b/dashboard/public/js/program.js new file mode 100644 index 0000000..d815dcb --- /dev/null +++ b/dashboard/public/js/program.js @@ -0,0 +1,155 @@ +const standardPrograms = ['SARI', 'ILI', 'LBM']; +const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase(); + +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 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 positivity = rows.map(r => r.positivity_rate || 0); + + buildMixedTrendChart( + 'trendChart', + labels, + samples, + positivity + ); +} +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) { + console.log("SUMMARY:", data.summary); + data = data || {}; + + + renderProgramTrend(data.trend || []); + renderSummary(data.summary || {}); + buildChart( + 'provinceChart', + 'bar', + (data.province_distribution || []).map(r => r.site_province_name), + (data.province_distribution || []).map(r => r.total) + ); + + buildChart( + 'pathogenChart', + 'bar', + (data.pathogen_distribution || []).map(r => r.pathogen_name), + (data.pathogen_distribution || []).map(r => r.total), + 'Positive' + ); + + buildChart( + 'ageChart', + 'doughnut', + (data.age_distribution || []).map(r => r.age_group), + (data.age_distribution || []).map(r => r.total) + ); + + buildChart( + 'sexChart', + 'bar', + (data.sex_distribution || []).map(r => r.patient_sex), + (data.sex_distribution || []).map(r => r.total) + ); + +} diff --git a/dashboard/resources/views/dashboard/detail.blade.php b/dashboard/resources/views/dashboard/detail.blade.php index 841c70c..461fbb7 100644 --- a/dashboard/resources/views/dashboard/detail.blade.php +++ b/dashboard/resources/views/dashboard/detail.blade.php @@ -2,53 +2,217 @@ @section('content') -

{{ $selected->code }} - {{ $selected->name_en }}

+
+ + +
+ +

+ {{ $selected->code }} Detail Page +

+ +
+ + + + + +
-
-
-
-
Total Cases
-

-

+ + + +
+ Current {{ $selected->code }} Status: + Loading... +
+ + + +
+ +
+
+
+ + Total Cases Reported (Last 7 Days) +

0

+ + + — No change + + +
+
+
+ +
+
+
+ + Overall Positivity Rate +

0%

+ + + — No change + + +
+
+
+ +
+
+
+ + Influenza Rate +

0%

+ + + — No change + + +
+
+
+ + +
+
+
+ + SARS-Cov-2 Rate +

0%

+ + + — No change + + +
+
+
+ + + + +
+ + + + +
+
+ +
+
Case Trends and Positivity Rate by Epiweek
+ +
+ + + +
+
+ + + + +
+ +
+ +
+
+ +
+ Cases by Province +
+ + + +
+
+ +
+ + +
+ +
+
+ +
+ Pathogen Distribution +
+ + + +
+
+ +
+ +
+ + + + +
+ +
+ +
+
+ +
Age Distribution
+ + + +
+
+ +
+ + +
+ +
+
+ +
Sex Distribution
+ + + +
+
+ +
+ +
+ +
-
- -
-
-
Epidemic Trend
- -
-
- - +@endsection +@section('scripts') + + @endsection \ No newline at end of file diff --git a/dashboard/resources/views/dashboard/overview.blade.php b/dashboard/resources/views/dashboard/overview.blade.php index 95c9a7d..397fe2e 100644 --- a/dashboard/resources/views/dashboard/overview.blade.php +++ b/dashboard/resources/views/dashboard/overview.blade.php @@ -5,48 +5,47 @@
-
+

Dashboard Overview

- National surveillance summary
+
-
+ - + +
- -
-
+ +
+ + +
-
+
@@ -56,7 +55,7 @@

- +
@@ -64,35 +63,58 @@
+
Recent Alerts & Notifications
  • ⚠ Monitoring influenza increase in selected provinces.
  • +
  • 🔔 SARS-CoV-2 positivity rate under review.
+
- -
-
+ +
+ +
-
Cases by Provinces
-

(% change vs last period)

- -
-
- SARI - ILI - LBM + +
Total Cases by Provinces
+

(based on selected epiweek range)

+ +
+ +
+ + + + SARI + + + + + ILI + + + + + LBM + +
+
@@ -102,459 +124,6 @@
- + @endsection \ No newline at end of file diff --git a/dashboard/resources/views/layouts/app.blade.php b/dashboard/resources/views/layouts/app.blade.php index e054759..998ec9a 100644 --- a/dashboard/resources/views/layouts/app.blade.php +++ b/dashboard/resources/views/layouts/app.blade.php @@ -7,7 +7,9 @@ - + + +