working on detail page for sari, lil, amd lbm

This commit is contained in:
2026-03-13 15:49:01 +07:00
parent 519d0924c8
commit c2b820fc6d
14 changed files with 1627 additions and 956 deletions

View File

@@ -6,169 +6,160 @@ use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\DashboardService; use App\Services\DashboardService;
use Carbon\Carbon; use Carbon\Carbon;
use App\Models\SurveillanceCase;
class DashboardController extends Controller class DashboardController extends Controller
{ {
protected $service; protected $service;
public function index()
{
$programs = \App\Models\Surveillance::all();
return view('dashboard.index', compact('programs'));
}
public function __construct(DashboardService $service) public function __construct(DashboardService $service)
{ {
$this->service = $service; $this->service = $service;
} }
/** /*
* Summary cards |--------------------------------------------------------------------------
* GET /api/dashboard/summary?date_from=2026-01-01&date_to=2026-03-01 | Helper: Resolve Epiweek Range
|--------------------------------------------------------------------------
*/ */
public function summary(Request $request)
private function getEpiRange(Request $request)
{ {
if ($request->has('start_year')) { $startYear = (int) $request->query('start_year');
$startWeek = (int) $request->query('start_week');
$startYear = $request->query('start_year'); $endYear = (int) $request->query('end_year');
$startWeek = $request->query('start_week'); $endWeek = (int) $request->query('end_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());
if (!$startYear || !$startWeek || !$endYear || !$endWeek) {
return null;
} }
return [
'startYear' => $startYear,
'startWeek' => $startWeek,
'endYear' => $endYear,
'endWeek' => $endWeek
];
}
/*
|--------------------------------------------------------------------------
| Overview Summary Cards
|--------------------------------------------------------------------------
*/
public function summary()
{
$dateFrom = Carbon::now()->subDays(7)->toDateString();
$dateTo = Carbon::now()->toDateString();
$data = $this->service->summaryCards($dateFrom, $dateTo); $data = $this->service->summaryCards($dateFrom, $dateTo);
return response()->json($data); return response()->json($data);
} }
/**
* Trend chart /*
* GET /api/dashboard/trend?surveillance_id=1&period_type=week&date_from=...&date_to=... |--------------------------------------------------------------------------
| Overview Trend Chart
|--------------------------------------------------------------------------
*/ */
public function trend(Request $request) public function trend(Request $request)
{ {
$periodType = $request->query('period_type', 'week'); $range = $this->getEpiRange($request);
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');
if (!$range) {
return response()->json(['error' => 'Missing epiweek range'], 400);
} }
$data = $this->service->aggregateAllPrograms( $data = $this->service->aggregateAllPrograms(
$periodType, $range['startYear'],
$dateFrom, $range['startWeek'],
$dateTo $range['endYear'],
$range['endWeek']
); );
return response()->json($data); return response()->json($data);
} }
/**
* Province distribution /*
|--------------------------------------------------------------------------
| Program Dashboard
|--------------------------------------------------------------------------
*/ */
public function province(Request $request)
public function program(Request $request)
{ {
if ($request->has('start_year')) { $surveillanceId = (int) $request->query('surveillance_id');
$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');
if (!$surveillanceId) {
return response()->json(['error' => 'Missing surveillance_id'], 400);
} }
$rows = $this->service->provinceDistribution($dateFrom, $dateTo); $range = $this->getEpiRange($request);
$result = []; if (!$range) {
return response()->json(['error' => 'Missing epiweek range'], 400);
foreach ($rows as $row) {
$result[$row->site_province_name] = $row->total;
} }
return response()->json($result); $data = $this->service->programDashboardData(
} $surveillanceId,
public function sentinelMap(Request $request) $range['startYear'],
{ $range['startWeek'],
$startYear = $request->query('start_year'); $range['endYear'],
$startWeek = $request->query('start_week'); $range['endWeek']
);
$endYear = $request->query('end_year');
$endWeek = $request->query('end_week');
$dateFrom = Carbon::now()->setISODate($startYear, $startWeek)->startOfWeek();
$dateTo = Carbon::now()->setISODate($endYear, $endWeek)->endOfWeek();
$data = $this->service->sentinelMap($dateFrom, $dateTo);
return response()->json($data); return response()->json($data);
} }
/*
|--------------------------------------------------------------------------
| Province Map (Overview)
|--------------------------------------------------------------------------
*/
public function provinceCircles(Request $request) public function provinceCircles(Request $request)
{ {
$startYear = $request->query('start_year'); $range = $this->getEpiRange($request);
$startWeek = $request->query('start_week');
$endYear = $request->query('end_year'); if (!$range) {
$endWeek = $request->query('end_week'); return response()->json(['error' => 'Missing epiweek range'], 400);
}
$dateFrom = Carbon::now()->setISODate($startYear, $startWeek)->startOfWeek(); $data = $this->service->provinceCircles(
$dateTo = Carbon::now()->setISODate($endYear, $endWeek)->endOfWeek(); $range['startYear'],
$range['startWeek'],
$data = $this->service->provinceCircles($dateFrom, $dateTo); $range['endYear'],
$range['endWeek']
);
return response()->json($data); return response()->json($data);
} }
/**
* Pathogen distribution
*/
public function pathogen(Request $request)
{
$surveillanceId = $request->query('surveillance_id');
$dateFrom = $request->query('date_from');
$dateTo = $request->query('date_to');
$data = $this->service->pathogenDistribution(
$surveillanceId, /*
$dateFrom, |--------------------------------------------------------------------------
$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); return response()->json($data);

View File

@@ -2,210 +2,420 @@
namespace App\Services; namespace App\Services;
use Carbon\Carbon;
use App\Models\Surveillance; use App\Models\Surveillance;
use App\Models\SurveillanceCase; use App\Models\SurveillanceCase;
use App\Models\CaseLabResult; use App\Models\CaseLabResult;
class DashboardService class DashboardService
{ {
/**
* Get all surveillance programs
*/
public function getPrograms()
{
return Surveillance::orderBy('id')->get();
}
/** /*
* Summary cards (dynamic) |--------------------------------------------------------------------------
| Overview Summary Cards
|--------------------------------------------------------------------------
*/ */
public function summaryCards($dateFrom, $dateTo) public function summaryCards($dateFrom, $dateTo)
{ {
$programs = $this->getPrograms(); $programs = Surveillance::orderBy('id')->get();
$results = []; $results = [];
$days = Carbon::parse($dateFrom)->diffInDays($dateTo);
foreach ($programs as $program) { foreach ($programs as $program) {
$current = SurveillanceCase::where('surveillance_id', $program->id) $current = SurveillanceCase::where('surveillance_id', $program->id)
->whereBetween('case_date', [$dateFrom, $dateTo]) ->whereBetween('case_date', [$dateFrom, $dateTo])
->count(); ->count();
$previousFrom = Carbon::parse($dateFrom)->subDays($days + 1);
$previousTo = Carbon::parse($dateFrom)->subDay();
$previous = SurveillanceCase::where('surveillance_id', $program->id) $previous = SurveillanceCase::where('surveillance_id', $program->id)
->whereBetween('case_date', [$previousFrom, $previousTo]) ->whereBetween('case_date', [
date('Y-m-d', strtotime($dateFrom . ' -7 days')),
date('Y-m-d', strtotime($dateFrom . ' -1 day'))
])
->count(); ->count();
$percentChange = $previous > 0 $percentChange = $previous > 0
? round((($current - $previous) / $previous) * 100, 1) ? round((($current - $previous) / $previous) * 100, 1)
: 0; : 0;
$last24h = SurveillanceCase::where('surveillance_id', $program->id)
->where('case_date', '>=', Carbon::now()->subDay())
->count();
$results[] = [ $results[] = [
'surveillance_id' => $program->id, 'surveillance_id' => $program->id,
'code' => $program->code, 'code' => $program->code,
'name_en' => $program->name_en,
'name_kh' => $program->name_kh,
'current_total' => $current, 'current_total' => $current,
'percent_change' => $percentChange, 'previous_total' => $previous,
'last_24h' => $last24h, 'percent_change' => $percentChange
]; ];
} }
return $results; return $results;
} }
/**
* Aggregate cases by period /*
* periodType: week | month | year |--------------------------------------------------------------------------
| Fast SARI Summary (single query)
|--------------------------------------------------------------------------
*/ */
public function aggregateAllPrograms($periodType, $dateFrom, $dateTo) 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 = []; $results = [];
foreach ($programs as $program) { foreach ($data as $row) {
$query = SurveillanceCase::where('surveillance_id', $program->id) $code = $programs[$row->surveillance_id];
->whereBetween('case_date', [$dateFrom, $dateTo]);
switch ($periodType) { $results[$code][] = $row;
case 'week':
$query->selectRaw("
YEAR(case_date) as year,
WEEK(case_date, 3) as period,
COUNT(*) as total
")
->groupByRaw("YEAR(case_date), WEEK(case_date, 3)")
->orderByRaw("YEAR(case_date), WEEK(case_date, 3)");
break;
case 'month':
$query->selectRaw("
YEAR(case_date) as year,
MONTH(case_date) as period,
COUNT(*) as total
")
->groupByRaw("YEAR(case_date), MONTH(case_date)")
->orderByRaw("YEAR(case_date), MONTH(case_date)");
break;
case 'year':
$query->selectRaw("
YEAR(case_date) as period,
COUNT(*) as total
")
->groupByRaw("YEAR(case_date)")
->orderByRaw("YEAR(case_date)");
break;
}
$results[$program->code] = $query->get();
} }
return $results; return $results;
} }
public function aggregateCases($surveillanceId, $periodType, $dateFrom, $dateTo)
{
$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, | Program Dashboard
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
*/ */
public function provinceDistribution($dateFrom, $dateTo)
public function programDashboardData($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 return [
->where('case_lab_results.is_positive', 1)
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo]) '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->provinceCirclesProgram(
$surveillanceId,
$startYear,
$startWeek,
$endYear,
$endWeek
)
];
->groupBy('site_province_name')
->orderByDesc('total')
->get();
} }
public function sentinelMap($dateFrom, $dateTo)
/*
|--------------------------------------------------------------------------
| Trend Single Program
|--------------------------------------------------------------------------
*/
public function trendSingleProgram($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{ {
return SurveillanceCase::selectRaw("
sentinel_site_id, $rows = SurveillanceCase::leftJoin(
sentinel_site_name, 'case_lab_results',
site_province_name, 'surveillance_cases.lab_code',
surveillance_id, '=',
COUNT(*) as total '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,
ROUND(
SUM(CASE WHEN case_lab_results.is_positive = 1 THEN 1 ELSE 0 END)
/ NULLIF(COUNT(*),0) * 100,1
) as positivity_rate
") ")
->join('case_lab_results', 'surveillance_cases.lab_code', '=', 'case_lab_results.lab_code')
->whereIn('surveillance_cases.surveillance_id', [1, 2, 3])
->where('case_lab_results.is_positive', 1)
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo])
->groupBy( ->groupBy(
'sentinel_site_id', 'surveillance_cases.year_data',
'sentinel_site_name', 'surveillance_cases.week_data'
'site_province_name',
'surveillance_id'
) )
->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
];
} }
public function provinceCircles($dateFrom, $dateTo)
if ($year == $endYear && $week == $endWeek)
break;
$week++;
if ($week > 52) {
$week = 1;
$year++;
}
}
return $results;
}
/*
|--------------------------------------------------------------------------
| Province Distribution (Program)
|--------------------------------------------------------------------------
*/
public function provinceCirclesProgram($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{ {
return SurveillanceCase::selectRaw(" return SurveillanceCase::selectRaw("
surveillance_cases.site_province_name, surveillance_cases.site_province_name,
surveillance_cases.surveillance_id, COUNT(DISTINCT surveillance_cases.lab_code) as total
COUNT(*) as total
") ")
->join( ->join(
'case_lab_results', 'case_lab_results',
'surveillance_cases.lab_code', 'surveillance_cases.lab_code',
@@ -213,62 +423,85 @@ class DashboardService
'case_lab_results.lab_code' '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) ->where('case_lab_results.is_positive', 1)
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo]) ->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
->groupBy( $q->whereRaw(
'surveillance_cases.site_province_name', "(surveillance_cases.year_data > ? OR (surveillance_cases.year_data = ? AND surveillance_cases.week_data >= ?))",
'surveillance_cases.surveillance_id' [$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(); ->get();
} }
/**
* Pathogen distribution (positive only)
/*
|--------------------------------------------------------------------------
| Pathogen Distribution
|--------------------------------------------------------------------------
*/ */
public function pathogenDistribution($surveillanceId, $dateFrom, $dateTo)
public function pathogenDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{ {
return CaseLabResult::selectRaw(" return CaseLabResult::selectRaw("
case_lab_results.pathogen_name, pathogen_name,
COUNT(*) as total 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) ->where('surveillance_cases.surveillance_id', $surveillanceId)
->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo])
->where('case_lab_results.is_positive', 1) ->where('case_lab_results.is_positive', 1)
->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') ->orderByDesc('total')
->get(); ->get();
} }
/**
* Positivity rate
/*
|--------------------------------------------------------------------------
| Age Distribution
|--------------------------------------------------------------------------
*/ */
public function positivityRate($surveillanceId, $dateFrom, $dateTo)
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(" return SurveillanceCase::selectRaw("
CASE CASE
WHEN patient_age_inday < 365 THEN '0-1y' WHEN patient_age_inday < 365 THEN '0-1y'
@@ -279,24 +512,63 @@ class DashboardService
END as age_group, END as age_group,
COUNT(*) as total COUNT(*) as total
") ")
->where('surveillance_id', $surveillanceId) ->where('surveillance_id', $surveillanceId)
->whereBetween('case_date', [$dateFrom, $dateTo])
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
$q->whereRaw(
"(year_data > ? OR (year_data = ? AND week_data >= ?))",
[$startYear, $startYear, $startWeek]
)
->whereRaw(
"(year_data < ? OR (year_data = ? AND week_data <= ?))",
[$endYear, $endYear, $endWeek]
);
})
->groupBy('age_group') ->groupBy('age_group')
->get(); ->get();
} }
/**
* Sex distribution
/*
|--------------------------------------------------------------------------
| Sex Distribution
|--------------------------------------------------------------------------
*/ */
public function sexDistribution($surveillanceId, $dateFrom, $dateTo)
public function sexDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{ {
return SurveillanceCase::selectRaw(" return SurveillanceCase::selectRaw("
patient_sex, patient_sex,
COUNT(*) as total COUNT(*) as total
") ")
->where('surveillance_id', $surveillanceId) ->where('surveillance_id', $surveillanceId)
->whereBetween('case_date', [$dateFrom, $dateTo])
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
$q->whereRaw(
"(year_data > ? OR (year_data = ? AND week_data >= ?))",
[$startYear, $startYear, $startWeek]
)
->whereRaw(
"(year_data < ? OR (year_data = ? AND week_data <= ?))",
[$endYear, $endYear, $endWeek]
);
})
->groupBy('patient_sex') ->groupBy('patient_sex')
->get(); ->get();
} }
} }

View File

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

View File

@@ -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 += `<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,155 @@
const standardPrograms = ['SARI', 'ILI', 'LBM'];
const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase();
document.addEventListener("DOMContentLoaded", () => {
if (!standardPrograms.includes(programCode)) return;
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
fetch(`/api/dashboard/program?surveillance_id=${window.SURVEILLANCE_ID}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
.then(res => res.json())
.then(renderDashboard)
.catch(err => console.error("Dashboard API error:", err));
});
});
function renderTrend(valueId, changeId, current, previous, suffix = '') {
const valueEl = document.getElementById(valueId);
const changeEl = document.getElementById(changeId);
if (!valueEl || !changeEl) return;
valueEl.textContent = current + suffix;
if (!previous) {
changeEl.innerHTML = "— No previous data";
changeEl.className = "text-muted";
return;
}
const diff = current - previous;
const percent = ((diff / previous) * 100).toFixed(1);
if (diff > 0) {
changeEl.innerHTML = `↑ +${percent}% from previous week`;
changeEl.className = "text-success";
}
else if (diff < 0) {
changeEl.innerHTML = `${percent}% from previous week`;
changeEl.className = "text-danger";
}
else {
changeEl.innerHTML = "— No significant change";
changeEl.className = "text-muted";
}
}
function renderProgramTrend(rows) {
rows = rows || [];
const labels = rows.map(r => `W${r.period}`);
const samples = rows.map(r => r.total_samples || 0);
const positivity = rows.map(r => r.positivity_rate || 0);
buildMixedTrendChart(
'trendChart',
labels,
samples,
positivity
);
}
function renderSummary(summary) {
summary = summary || {};
const cases = summary.cases || {};
const hospital = summary.hospital_rate || {};
const icu = summary.icu_rate || {};
const positivity = summary.positivity_rate || {};
renderTrend(
"totalCases",
"casesChange",
cases.current || 0,
cases.previous || 0
);
renderTrend(
"influenzaRate",
"influenzaChange",
summary.influenza_rate.current,
summary.influenza_rate.previous,
"%"
);
renderTrend(
"covidRate",
"covidChange",
summary.covid_rate.current,
summary.covid_rate.previous,
"%"
);
renderTrend(
"hospitalRate",
"hospitalChange",
hospital.current || 0,
hospital.previous || 0,
"%"
);
renderTrend(
"icuRate",
"icuChange",
icu.current || 0,
icu.previous || 0,
"%"
);
renderTrend(
"positivityRate",
"positivityChange",
positivity.current || 0,
positivity.previous || 0,
"%"
);
}
function renderDashboard(data) {
console.log("SUMMARY:", data.summary);
data = data || {};
renderProgramTrend(data.trend || []);
renderSummary(data.summary || {});
buildChart(
'provinceChart',
'bar',
(data.province_distribution || []).map(r => r.site_province_name),
(data.province_distribution || []).map(r => r.total)
);
buildChart(
'pathogenChart',
'bar',
(data.pathogen_distribution || []).map(r => r.pathogen_name),
(data.pathogen_distribution || []).map(r => r.total),
'Positive'
);
buildChart(
'ageChart',
'doughnut',
(data.age_distribution || []).map(r => r.age_group),
(data.age_distribution || []).map(r => r.total)
);
buildChart(
'sexChart',
'bar',
(data.sex_distribution || []).map(r => r.patient_sex),
(data.sex_distribution || []).map(r => r.total)
);
}

View File

@@ -2,53 +2,217 @@
@section('content') @section('content')
<h3>{{ $selected->code }} - {{ $selected->name_en }}</h3> <div class="container-fluid">
<!-- PAGE TITLE -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold">
{{ $selected->code }} Detail Page
</h4>
<div class="d-flex align-items-center gap-2">
<select id="trend_range" class="form-select w-auto">
<option value="8" selected>Last 8 weeks</option>
<option value="12">Last 12 weeks</option>
<option value="26">Last 26 weeks</option>
<option value="custom">Custom range</option>
</select>
<div id="custom_range_container" style="display:none;" class="align-items-center gap-1">
<select id="start_year" class="form-select"></select>
<select id="start_week" class="form-select"></select>
<span class="mx-1">to</span>
<select id="end_year" class="form-select"></select>
<select id="end_week" class="form-select"></select>
</div>
</div>
</div>
<!-- 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="col-md-3">
<div class="card p-3"> <div class="card shadow-sm">
<h6>Total Cases</h6>
<h4 id="totalCases">-</h4>
</div>
</div>
</div>
<div class="card">
<div class="card-body"> <div class="card-body">
<h5>Epidemic Trend</h5>
<canvas id="trendChart"></canvas> <small>Total Cases Reported (Last 7 Days)</small>
<h3 id="totalCases">0</h3>
<small id="casesChange" class="text-muted">
No change
</small>
</div>
</div>
</div> </div>
</div>
<script> <div class="col-md-3">
document.addEventListener('DOMContentLoaded', function() { <div class="card shadow-sm">
<div class="card-body">
const surveillanceId = {{ $selected->id }}; <small>Overall Positivity Rate</small>
const dateFrom = '2026-01-01'; <h3 id="positivityRate">0%</h3>
const dateTo = new Date().toISOString().split('T')[0];
fetch(`/api/dashboard/trend?surveillance_id=${surveillanceId}&period_type=week&date_from=${dateFrom}&date_to=${dateTo}`) <small id="positivityChange" class="text-muted">
.then(res => res.json()) No change
.then(data => { </small>
const labels = data.map(d => `${d.year}-${d.period}`); </div>
const totals = data.map(d => d.total); </div>
</div>
new Chart(document.getElementById('trendChart'), { <div class="col-md-3">
type: 'line', <div class="card shadow-sm">
data: { <div class="card-body">
labels: labels,
datasets: [{
label: '{{ $selected->code }}',
data: totals,
borderColor: 'blue',
fill: false
}]
}
});
});
}); <small>Influenza Rate</small>
</script> <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 -->
<div class="card shadow-sm mb-4">
<div class="card-body" style="height:500px;">
<div class="d-flex justify-content-between mb-2">
<h6 class="fw-bold">Case Trends and Positivity Rate by Epiweek</h6>
</div>
<canvas id="trendChart"></canvas>
</div>
</div>
<!-- PROVINCE + PATHOGEN -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body" style="height:300px">
<h6 class="fw-bold mb-3">
Cases by Province
</h6>
<canvas id="provinceChart"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body" style="height:300px">
<h6 class="fw-bold mb-3">
Pathogen Distribution
</h6>
<canvas id="pathogenChart"></canvas>
</div>
</div>
</div>
</div>
<!-- DEMOGRAPHIC -->
<div class="row g-3">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body" style="height:300px">
<h6 class="fw-bold mb-3">Age Distribution</h6>
<canvas id="ageChart"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body" style="height:300px">
<h6 class="fw-bold mb-3">Sex Distribution</h6>
<canvas id="sexChart"></canvas>
</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 @endsection

View File

@@ -5,14 +5,12 @@
<div class="container-fluid"> <div class="container-fluid">
<!-- Page Header --> <!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-2 flex-shrink-0">
<div> <div>
<h3 class="fw-bold mb-1">Dashboard Overview</h3> <h3 class="fw-bold mb-1">Dashboard Overview</h3>
<small class="text-muted">National surveillance summary</small>
</div> </div>
<div class="d-flex align-items-center gap-2 mb-3">
<div class="d-flex align-items-center gap-2">
<div class="d-flex align-items-center gap-2 mb-3">
<select id="trend_range" class="form-select w-auto"> <select id="trend_range" class="form-select w-auto">
<option value="8" selected>Last 8 weeks</option> <option value="8" selected>Last 8 weeks</option>
@@ -35,18 +33,19 @@
</div> </div>
</div> </div>
</div>
<!-- Summary Cards --> <!-- 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 --> <!-- LEFT COLUMN -->
<div class="col-lg-8 d-flex flex-column"> <div class="col-lg-8 d-flex flex-column">
<!-- Trend Chart --> <!-- Trend Chart -->
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-3 flex-grow-1">
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
@@ -56,7 +55,7 @@
</p> </p>
</div> </div>
<canvas id="trendChart" height="110"></canvas> <canvas id="trendChart" height="90"></canvas>
</div> </div>
</div> </div>
@@ -64,497 +63,67 @@
<!-- Alerts --> <!-- Alerts -->
<div class="card shadow-sm flex-grow-1"> <div class="card shadow-sm flex-grow-1">
<div class="card-body"> <div class="card-body">
<h5 class="fw-bold">Recent Alerts & Notifications</h5> <h5 class="fw-bold">Recent Alerts & Notifications</h5>
<ul class="list-group list-group-flush mt-3"> <ul class="list-group list-group-flush mt-3">
<li class="list-group-item"> <li class="list-group-item">
Monitoring influenza increase in selected provinces. Monitoring influenza increase in selected provinces.
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
🔔 SARS-CoV-2 positivity rate under review. 🔔 SARS-CoV-2 positivity rate under review.
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
<!-- RIGHT COLUMN --> <!-- RIGHT COLUMN -->
<div class="col-lg-4"> <div class="col-lg-4 d-flex flex-column">
<div class="card shadow-sm h-100"> <div class="card shadow-sm">
<div class="card-body">
<h5 class="fw-bold">Cases by Provinces</h5>
<p class="text-muted small">(% change vs last period)</p>
<div id="provinceMap" style="height:420px;"></div>
<div class="d-flex gap-3 mb-2 small">
<span><span style="color:#2563eb; font-size:2rem;"></span> SARI</span>
<span><span style="color:#10b981; font-size:2rem;"></span> ILI</span>
<span><span style="color:#9333ea; font-size:2rem;"></span> LBM</span>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let trendChart;
function loadSummary() {
const today = new Date();
const past = new Date();
past.setDate(today.getDate() - 7);
const dateFrom = past.toISOString().split('T')[0];
const dateTo = today.toISOString().split('T')[0];
fetch(`/api/dashboard/summary?date_from=${dateFrom}&date_to=${dateTo}`)
.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="card-body">
<div class="d-flex justify-content-between"> <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>
<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>
<div class="text-end">
<div class="${trendColor} fw-bold">
${item.percent_change > 0 ? '▲' : item.percent_change < 0 ? '▼' : ''}
${Math.abs(item.percent_change)}%
</div> </div>
</div>
<small class="text-muted">
${item.previous_total ?? 0} last week
</small>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
`;
});
document.getElementById('summary_cards').innerHTML = html; <script src="/js/overview.js"></script>
});
}
function getCurrentEpiWeek() {
const today = new Date();
const firstJan = new Date(today.getFullYear(), 0, 1);
const days = Math.floor((today - firstJan) / 86400000);
const week = Math.ceil((days + firstJan.getDay() + 1) / 7);
return {
year: today.getFullYear(),
week: week
};
}
function calculateRange(weeksBack) {
const current = getCurrentEpiWeek();
let endYear = current.year;
let endWeek = current.week;
let startYear = endYear;
let startWeek = endWeek - weeksBack + 1;
while (startWeek <= 0) {
startWeek += 52;
startYear--;
}
return {
startYear,
startWeek,
endYear,
endWeek
};
}
function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
fetch(`/api/dashboard/trend?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
.then(res => res.json())
.then(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((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',
NDS: '#f59e0b',
};
const datasets = [];
const allowedPrograms = ['SARI', 'ILI', 'LBM', 'NDS'];
Object.keys(data).forEach(code => {
if (!allowedPrograms.includes(code)) return;
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',
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
}
}
}
}
});
document.getElementById('trendChart').style.opacity = 1;
});
}
function toggleCustomInputs(show) {
const container = document.getElementById('custom_range_container');
if (!container) return;
container.style.display = show ? 'flex' : 'none';
}
function refreshTrend() {
const startYear = document.getElementById('start_year').value;
const startWeek = document.getElementById('start_week').value;
const endYear = document.getElementById('end_year').value;
const endWeek = document.getElementById('end_week').value;
loadProvinceMap();
loadTrend('week', startYear, startWeek, endYear, endWeek);
}
function populateFilters() {
const currentYear = new Date().getFullYear();
const startYear = document.getElementById('start_year');
const endYear = document.getElementById('end_year');
for (let y = currentYear - 10 ; y <= currentYear; y++) {
startYear.innerHTML += `<option value="${y}">${y}</option>`;
endYear.innerHTML += `<option value="${y}">${y}</option>`;
}
const startWeek = document.getElementById('start_week');
const endWeek = document.getElementById('end_week');
for (let w = 1; w <= 53; w++) {
startWeek.innerHTML += `<option value="${w}">W${w}</option>`;
endWeek.innerHTML += `<option value="${w}">W${w}</option>`;
}
}
document.addEventListener('DOMContentLoaded', function () {
populateFilters();
toggleCustomInputs(false);
document.getElementById('custom_range_container').style.display = 'none';
loadProvinceMap();
const defaultRange = calculateRange(8);
document.getElementById('start_year').value = defaultRange.startYear;
document.getElementById('start_week').value = defaultRange.startWeek;
document.getElementById('end_year').value = defaultRange.endYear;
document.getElementById('end_week').value = defaultRange.endWeek;
loadTrend(
'week',
defaultRange.startYear,
defaultRange.startWeek,
defaultRange.endYear,
defaultRange.endWeek
);
loadSummary();
document.getElementById('trend_range')
.addEventListener('change', function () {
const value = this.value;
if (value === 'custom') {
toggleCustomInputs(true);
return;
}
toggleCustomInputs(false);
const range = calculateRange(parseInt(value));
document.getElementById('start_year').value = range.startYear;
document.getElementById('start_week').value = range.startWeek;
document.getElementById('end_year').value = range.endYear;
document.getElementById('end_week').value = range.endWeek;
document.getElementById('trendChart').style.opacity = 0.4;
refreshTrend();
});
['start_year', 'start_week', 'end_year', 'end_week']
.forEach(id => {
document.getElementById(id)
.addEventListener('change', refreshTrend);
});
});
let map;
function getColor(value) {
if (value > 50) return '#b91c1c';
if (value > 25) return '#dc2626';
if (value > 10) return '#f97316';
if (value > 5) return '#facc15';
return '#e5e7eb';
}
const colors = {
1: '#2563eb', // SARI
2: '#10b981', // ILI
3: '#9333ea' // LBM
};
function loadSentinelMap() {
fetch(`/api/dashboard/sentinel-map?...`)
.then(r => r.json())
.then(data => {
data.forEach(site => {
L.circleMarker(
[site.lat, site.lng],
{
radius: Math.sqrt(site.total) * 3,
fillColor: colors[site.surveillance_id],
color: '#fff',
weight: 1,
fillOpacity: 0.9
}
)
.bindTooltip(`
${site.sentinel_site_name}<br>
Cases: ${site.total}
`)
.addTo(map);
});
});
}
function loadProvinceMap() {
if (map) map.remove();
map = L.map('provinceMap').setView([12.7, 104.9], 7);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
}).addTo(map);
const startYear = document.getElementById('start_year').value;
const startWeek = document.getElementById('start_week').value;
const endYear = document.getElementById('end_year').value;
const endWeek = document.getElementById('end_week').value;
const colors = {
1: '#2563eb', // SARI
2: '#10b981', // ILI
3: '#9333ea' // LBM
};
Promise.all([
fetch('/geo/cambodia_provinces.geojson').then(r => r.json()),
fetch(`/api/dashboard/province-circles?start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`).then(r => r.json())
])
.then(([geojson, data]) => {
L.geoJSON(geojson, {
style: {
fillOpacity: 0,
color: '#ccc',
weight: 1
},
onEachFeature: function (feature, layer) {
const province = feature.properties.ADM1_EN;
const center = layer.getBounds().getCenter();
const rows = data.filter(d => d.site_province_name === province);
const offsets = {
1: -0.15, // SARI left
2: 0, // ILI center
3: 0.15 // LBM right
};
rows.forEach(row => {
const radius = Math.sqrt(row.total) * 4;
const lat = center.lat;
const lng = center.lng + offsets[row.surveillance_id];
L.circleMarker([lat, lng], {
radius: radius,
fillColor: colors[row.surveillance_id],
color: '#fff',
weight: 1,
fillOpacity: 0.9
})
.bindTooltip(`
<strong>${province}</strong><br>
${row.surveillance_id === 1 ? 'SARI' :
row.surveillance_id === 2 ? 'ILI' : 'LBM'}<br>
Cases: ${row.total}
`)
.addTo(map);
});
}
}).addTo(map);
});
}
</script>
@endsection @endsection

View File

@@ -7,7 +7,9 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css"/> <script src="/js/dashboard/filter.js"></script>
<script src="/js/dashboard/charts.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<style> <style>
@@ -45,7 +47,7 @@
color: #262626; color: #262626;
font-weight: 500; font-weight: 500;
border-bottom: 3px solid transparent; border-bottom: 3px solid transparent;
font-size:14px; font-size: 14px;
} }
.nav-item:hover { .nav-item:hover {
@@ -71,7 +73,7 @@
} }
.content-area { .content-area {
padding: 30px; padding: 20px;
background: #f8f9fa; background: #f8f9fa;
min-height: calc(100vh - 60px); min-height: calc(100vh - 60px);
} }
@@ -130,7 +132,6 @@
<!-- Main Wrapper --> <!-- Main Wrapper -->
<div class="main-wrapper"> <div class="main-wrapper">
<!-- Page Content --> <!-- Page Content -->
<div class="content-area"> <div class="content-area">
@yield('content') @yield('content')
@@ -138,6 +139,7 @@
</div> </div>
@yield('scripts')
</body> </body>
</html> </html>

View File

@@ -5,7 +5,6 @@ use App\Http\Controllers\Api\DashboardController;
Route::get('/dashboard/summary', [DashboardController::class, 'summary']); Route::get('/dashboard/summary', [DashboardController::class, 'summary']);
Route::get('/dashboard/trend', [DashboardController::class, 'trend']); Route::get('/dashboard/trend', [DashboardController::class, 'trend']);
Route::get('/dashboard/province', [DashboardController::class, 'province']); Route::get('/dashboard/program', [DashboardController::class, 'program']);
Route::get('/dashboard/pathogen', [DashboardController::class, 'pathogen']);
Route::get('/dashboard/sentinel-map', [DashboardController::class, 'sentinelMap']);
Route::get('/dashboard/province-circles', [DashboardController::class, 'provinceCircles']); 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', [DashboardController::class, 'overview']);
Route::get('/dashboard/{code}', [DashboardController::class, 'detail']); Route::get('/dashboard/{code}', [DashboardController::class, 'detail']);
Route::get('/test-change', function () {
return "TEST_CHANGE_WORKING";
});

View File

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