Merge pull request #1 from khantey1998/feature/overview_page

Feature/overview page
This commit is contained in:
2026-03-19 09:25:58 +07:00
committed by GitHub
17 changed files with 121323 additions and 592 deletions

118964
dashboard.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,80 +11,156 @@ 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
/*
|--------------------------------------------------------------------------
| Helper: Resolve Epiweek Range
|--------------------------------------------------------------------------
*/
public function summary(Request $request)
private function getEpiRange(Request $request)
{
$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');
$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
/*
|--------------------------------------------------------------------------
| Program Dashboard
|--------------------------------------------------------------------------
*/
public function province(Request $request)
{
$surveillanceId = $request->query('surveillance_id');
$dateFrom = $request->query('date_from');
$dateTo = $request->query('date_to');
$data = $this->service->provinceDistribution(
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,
$dateFrom,
$dateTo
$range['startYear'],
$range['startWeek'],
$range['endYear'],
$range['endWeek']
);
return response()->json($data);
}
/**
* Pathogen distribution
/*
|--------------------------------------------------------------------------
| Province Map (Overview)
|--------------------------------------------------------------------------
*/
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
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);

View File

@@ -2,219 +2,686 @@
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()
private function totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{
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)
{
$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
/*
|--------------------------------------------------------------------------
| Fast SARI Summary (single query)
|--------------------------------------------------------------------------
*/
public function aggregateAllPrograms($periodType, $dateFrom, $dateTo)
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 = [];
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)
{
$query = SurveillanceCase::where('surveillance_id', $surveillanceId)
->whereBetween('case_date', [$dateFrom, $dateTo]);
switch ($periodType) {
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;
}
return $query->get();
}
/**
* Province distribution
/*
|--------------------------------------------------------------------------
| Program Dashboard
|--------------------------------------------------------------------------
*/
public function provinceDistribution($surveillanceId, $dateFrom, $dateTo)
public function programDashboardData($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{
return [
'summary' => $this->programSummary(
$surveillanceId,
$startYear,
$startWeek,
$endYear,
$endWeek
),
'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;
$week++;
if ($week > 52) {
$week = 1;
$year++;
}
}
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 (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("
site_province_name,
COUNT(*) as total
surveillance_cases.site_province_name,
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)
->whereBetween('case_date', [$dateFrom, $dateTo])
->groupBy('site_province_name')
->orderByDesc('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('sentinel_site_name')
->orderByDesc('total') // nice for chart
->get();
}
/**
* Pathogen distribution (positive only)
/*
|--------------------------------------------------------------------------
| Pathogen Distribution
|--------------------------------------------------------------------------
*/
public function pathogenDistribution($surveillanceId, $dateFrom, $dateTo)
public function pathogenDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{
return CaseLabResult::selectRaw("
case_lab_results.pathogen_name,
COUNT(*) as total
$total = $this->totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek);
$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)
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo])
->where('case_lab_results.is_positive', 1)
->groupBy('case_lab_results.pathogen_name')
->groupBy('pathogen')
->havingRaw("pathogen IS NOT NULL AND pathogen != ''")
->orderByDesc('total')
->get();
return $rows->map(function ($r) use ($total) {
$r->rate = $total > 0 ? round(($r->total / $total) * 100, 1) : 0;
return $r;
});
}
/**
* Positivity rate
*/
public function positivityRate($surveillanceId, $dateFrom, $dateTo)
public function subtypeDistribution($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();
$total = $this->totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek);
$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)
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo])
->where('case_lab_results.is_positive', 1)
->count();
return $total > 0
? round(($positive / $total) * 100, 1)
: 0;
->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("
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)
/*
|--------------------------------------------------------------------------
| Age Distribution
|--------------------------------------------------------------------------
*/
public function ageDistribution($surveillanceId, $dateFrom, $dateTo)
public function ageDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{
return SurveillanceCase::selectRaw("
CASE
WHEN patient_age_inday < 365 THEN '0-1y'
@@ -225,24 +692,63 @@ class DashboardService
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
/*
|--------------------------------------------------------------------------
| Sex Distribution
|--------------------------------------------------------------------------
*/
public function sexDistribution($surveillanceId, $dateFrom, $dateTo)
public function sexDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{
return SurveillanceCase::selectRaw("
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();
}
}

View File

@@ -3,6 +3,8 @@
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Database\Seeders\LongitudinalSurveillanceSeeder;
use Database\Seeders\SurveillanceSeeder;
class DatabaseSeeder extends Seeder
{

File diff suppressed because one or more lines are too long

View 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 + '%'
// }
// }
// }
// }
// });
// }

View 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
};
}
}

View 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);
});
});

View 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: '&copy; 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();
}

View File

@@ -2,53 +2,220 @@
@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>
<!-- 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="row mb-4">
<div class="col-md-3">
<div class="card p-3">
<h6>Total Cases</h6>
<h4 id="totalCases">-</h4>
<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="card">
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<h5>Epidemic Trend</h5>
<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>
<script>
document.addEventListener('DOMContentLoaded', function() {
<!-- PATHOGEN DISTRIBUTION -->
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body" style="height:520px">
const surveillanceId = {{ $selected->id }};
const dateFrom = '2026-01-01';
const dateTo = new Date().toISOString().split('T')[0];
<h6 class="fw-bold mb-3">Pathogen Distribution</h6>
fetch(`/api/dashboard/trend?surveillance_id=${surveillanceId}&period_type=week&date_from=${dateFrom}&date_to=${dateTo}`)
.then(res => res.json())
.then(data => {
<div style="height:460px; position:relative;">
<canvas id="pathogenChart"></canvas>
</div>
const labels = data.map(d => `${d.year}-${d.period}`);
const totals = data.map(d => d.total);
</div>
</div>
</div>
</div>
<!-- MAP + SITE+subtype -->
<div class="row g-3 mb-4">
new Chart(document.getElementById('trendChart'), {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '{{ $selected->code }}',
data: totals,
borderColor: 'blue',
fill: false
}]
}
});
});
<!-- MAP -->
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body" style="height:520px">
});
</script>
<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>
@endsection
@section('scripts')
<script>
window.SURVEILLANCE_ID = {{ $selected->id }};
window.PROGRAM_CODE = "{{ $selected->code }}";
</script>
<script src="/js/program.js"></script>
@endsection

View File

@@ -5,36 +5,57 @@
<div class="container-fluid">
<!-- 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>
<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>
<!-- Summary Cards -->
<div class="row mb-4" id="summary_cards"></div>
<div class="row flex-grow-1" id="summary_cards"></div>
<div class="row">
<div class="row flex-grow-1">
<!-- LEFT COLUMN -->
<div class="col-lg-8 d-flex flex-column">
<!-- 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="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0">Epidemic Trend</h5>
<select id="period_type" class="form-select w-auto">
<option value="week">Epiweek</option>
<option value="month">Month</option>
<option value="year">Year</option>
</select>
<div class="mb-3">
<h5 class="fw-bold mb-1">Epidemic Trend</h5>
<p class="text-muted small mb-0">
(based on selected epiweek range)
</p>
</div>
<canvas id="trendChart" height="110"></canvas>
<canvas id="trendChart" height="90"></canvas>
</div>
</div>
@@ -42,32 +63,58 @@
<!-- Alerts -->
<div class="card shadow-sm flex-grow-1">
<div class="card-body">
<h5 class="fw-bold">Recent Alerts & Notifications</h5>
<ul class="list-group list-group-flush mt-3">
<li class="list-group-item">
Monitoring influenza increase in selected provinces.
</li>
<li class="list-group-item">
🔔 SARS-CoV-2 positivity rate under review.
</li>
</ul>
</div>
</div>
</div>
<!-- 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">
<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%;">
<span class="text-muted">Province heatmap coming next</span>
<h5 class="fw-bold">Total Cases by Provinces</h5>
<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>
@@ -77,149 +124,6 @@
</div>
<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>
<script src="/js/overview.js"></script>
@endsection

View File

@@ -4,73 +4,79 @@
<head>
<title>NRML Dashboard</title>
<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">
<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>
body {
margin: 0;
}
/* SIDEBAR */
.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 */
/* HEADER */
.top-navbar {
height: 60px;
border-bottom: 4px solid #0B8F3C;
background: #FFFFFF;
background: #0B8F3C;
color: white;
display: flex;
align-items: center;
padding: 0 20px;
padding: 0 25px;
}
.brand-title {
font-weight: 600;
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 {
padding: 30px;
padding: 20px;
background: #f8f9fa;
min-height: calc(100vh - 60px);
}
@@ -95,51 +101,40 @@
<body>
<!-- Sidebar -->
<div class="sidebar d-flex flex-column justify-content-between">
<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>
<div class="mb-3">
<a href="#" class="nav-link-custom">
<span class="nav-icon">⚙️</span>
<span class="nav-text">Settings</span>
</a>
</div>
</div>
<!-- Main Wrapper -->
<div class="main-wrapper">
<!-- Top Navbar -->
<!-- TOP HEADER -->
<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 class="ms-auto small">
Last update: 12:05 |
Data latency: 510 min |
User: National - Read Only
</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 -->
<div class="main-wrapper">
<!-- Page Content -->
<div class="content-area">
@yield('content')
@@ -147,6 +142,7 @@
</div>
@yield('scripts')
</body>
</html>

View File

@@ -5,5 +5,6 @@ use App\Http\Controllers\Api\DashboardController;
Route::get('/dashboard/summary', [DashboardController::class, 'summary']);
Route::get('/dashboard/trend', [DashboardController::class, 'trend']);
Route::get('/dashboard/province', [DashboardController::class, 'province']);
Route::get('/dashboard/pathogen', [DashboardController::class, 'pathogen']);
Route::get('/dashboard/program', [DashboardController::class, 'program']);
Route::get('/dashboard/province-circles', [DashboardController::class, 'provinceCircles']);
Route::get('/dashboard/sentinel-map', [DashboardController::class, 'sentinelMap']);

View File

@@ -12,3 +12,6 @@ Route::get('/', function () {
Route::get('/dashboard', [DashboardController::class, 'overview']);
Route::get('/dashboard/{code}', [DashboardController::class, 'detail']);
Route::get('/test-change', function () {
return "TEST_CHANGE_WORKING";
});

View File

@@ -40,6 +40,7 @@ services:
- "3308:3306"
volumes:
- dbdata:/var/lib/mysql
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/dashboard.sql
networks:
- dashboard
@@ -62,5 +63,6 @@ services:
networks:
dashboard:
volumes:
dbdata: