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 += ` +
(% change vs last period)
- - -(based on selected epiweek range)
+ + + +