add sequencing tab

This commit is contained in:
2026-03-30 11:04:03 +07:00
parent 34358e3ee7
commit f0a5079b15
12 changed files with 125979 additions and 271 deletions

77579
case_lab_results.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -155,19 +155,49 @@ class DashboardController extends Controller
// return response()->json(['error' => 'Missing epiweek range'], 400); // return response()->json(['error' => 'Missing epiweek range'], 400);
// } // }
return response()->json($data); // return response()->json($data);
} // }
public function fetchSourceData(){ public function fetchSourceData()
try{ {
try {
$this->dataRetrievalService->getSurveillanceData(); $this->dataRetrievalService->getSurveillanceData();
return response()->json(['message' => 'Data loaded successfully!'], 200); return response()->json(['message' => 'Data loaded successfully!'], 200);
} } catch (\Exception $e) {
catch (\Exception $e)
{
return response()->json(['error' => 'Data loaded unsuccessfully!'], 400); return response()->json(['error' => 'Data loaded unsuccessfully!'], 400);
} }
} }
/*
|--------------------------------------------------------------------------
| Sequencing Dashboard
|--------------------------------------------------------------------------
*/
public function sequencing(Request $request)
{
$surveillanceId = (int) $request->query('surveillance_id');
$range = $this->getEpiRange($request);
if (!$surveillanceId || !$range) {
return response()->json(['error' => 'Missing parameters'], 400);
}
return response()->json([
'trend' => $this->service->sequencingTrend(
$surveillanceId,
$range['startYear'],
$range['startWeek'],
$range['endYear'],
$range['endWeek']
),'distribution' => $this->service->subtypeDistribution(
$surveillanceId,
$range['startYear'],
$range['startWeek'],
$range['endYear'],
$range['endWeek']
)
]);
}
} }

View File

@@ -727,28 +727,20 @@ class DashboardService
$q->select('lab_code') $q->select('lab_code')
->from('surveillance_cases') ->from('surveillance_cases')
->where('surveillance_id', $surveillanceId) ->where('surveillance_id', $surveillanceId)
->whereRaw( ->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
"(year_data > ? OR (year_data = ? AND week_data >= ?))", ->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
[$startYear, $startYear, $startWeek]
)
->whereRaw(
"(year_data < ? OR (year_data = ? AND week_data <= ?))",
[$endYear, $endYear, $endWeek]
);
}) })
->where('case_lab_results.is_positive', 1) ->where('case_lab_results.is_positive', 1)
->selectRaw(" ->selectRaw("
case_lab_results.subtype, COALESCE(NULLIF(case_lab_results.subtype, ''), 'Unsubtyped') as subtype,
COUNT(DISTINCT case_lab_results.lab_code) as total COUNT(DISTINCT case_lab_results.lab_code) as total
") ")
->whereNotNull('case_lab_results.subtype')
->where('case_lab_results.subtype', '!=', '')
->where('case_lab_results.subtype', '!=', 'Positive') ->where('case_lab_results.subtype', '!=', 'Positive')
->groupBy('case_lab_results.subtype') ->groupBy('subtype')
->orderByDesc('total') ->orderByDesc('total')
->get(); ->get();
@@ -846,5 +838,40 @@ class DashboardService
->get(); ->get();
} }
/*
|--------------------------------------------------------------------------
| sequencing trend
|--------------------------------------------------------------------------
*/
public function sequencingTrend($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('case_lab_results.is_positive', 1)
->whereNotNull('subtype')
->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,
subtype,
COUNT(DISTINCT surveillance_cases.lab_code) as total
")
->groupBy('period', 'subtype')
->orderBy('period')
->get();
}
} }

View File

@@ -481,3 +481,5 @@ function renderSubtypeChart(rows) {
rows.map(r => r.total) rows.map(r => r.total)
); );
} }
//Seq

View File

@@ -0,0 +1,238 @@
let sequencingChart;
document.addEventListener("DOMContentLoaded", () => {
const canvas = document.getElementById('sequencingChart');
if (!canvas) return;
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
fetch(`/api/dashboard/sequencing?surveillance_id=${window.SURVEILLANCE_ID}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
.then(res => res.json())
.then(data => {
renderSequencingChart(data.trend || []);
renderSequencingCountChart(data.trend || []);
renderSequencingPieChart(data.distribution || []);
renderSequencingTotalChart(data.trend || []);
});
});
});
function renderSequencingCountChart(rows) {
const ctx = document.getElementById('sequencingCountChart');
Chart.getChart('sequencingCountChart')?.destroy();
rows = processTopSubtypes(rows);
const weeks = [...new Set(rows.map(r => r.period))];
const subtypes = [...new Set(rows.map(r => r.subtype))];
const colors = [
'#2563eb', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16'
];
const datasets = subtypes.map((sub, i) => ({
label: sub,
data: weeks.map(w => {
const found = rows.find(r => r.period === w && r.subtype === sub);
return found ? found.total : 0;
}),
backgroundColor: colors[i % colors.length]
}));
new Chart(ctx, {
type: 'bar',
data: {
labels: weeks.map(w => `W${w}`),
datasets
},
options: {
maintainAspectRatio: false,
scales: {
x: { stacked: true },
y: { stacked: true }
},
plugins: {
legend: {
display: true,
position: 'bottom',
align: 'center'
}
}
}
});
}
function renderSequencingPieChart(rows) {
const ctx = document.getElementById('sequencingPieChart');
Chart.getChart('sequencingPieChart')?.destroy();
const top = rows.slice(0, 8);
const labels = top.map(r => r.subtype);
const values = top.map(r => r.total);
new Chart(ctx, {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values
}]
},
options: {
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
},
}
}
});
}
function renderSequencingTotalChart(rows) {
const ctx = document.getElementById('sequencingTotalChart');
Chart.getChart('sequencingTotalChart')?.destroy();
const totals = {};
rows.forEach(r => {
totals[r.period] = (totals[r.period] || 0) + Number(r.total);
});
const weeks = Object.keys(totals);
const values = Object.values(totals);
new Chart(ctx, {
type: 'bar',
data: {
labels: weeks.map(w => `W${w}`),
datasets: [{
label: 'Total Samples',
data: values,
backgroundColor: '#0B8F3C'
}]
},
options: {
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
}
});
}
function processTopSubtypes(rows) {
const totals = {};
rows.forEach(r => {
totals[r.subtype] = (totals[r.subtype] || 0) + Number(r.total);
});
const sorted = Object.entries(totals)
.sort((a, b) => b[1] - a[1]);
const top = sorted.slice(0, 8).map(([k]) => k);
return rows.map(r => {
if (!top.includes(r.subtype)) {
return { ...r, subtype: 'Others' };
}
return r;
});
}
function renderSequencingChart(rows) {
rows = processTopSubtypes(rows);
const ctx = document.getElementById('sequencingChart');
if (sequencingChart) {
sequencingChart.destroy();
}
const colors = [
'#2563eb', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16',
'#6b7280'
];
const aggregated = {};
rows.forEach(r => {
const key = `${r.period}_${r.subtype}`;
if (!aggregated[key]) {
aggregated[key] = { ...r };
} else {
aggregated[key].total += Number(r.total);
}
});
const cleanRows = Object.values(aggregated);
const weeks = [...new Set(cleanRows.map(r => r.period))];
const subtypes = [...new Set(cleanRows.map(r => r.subtype))]
.sort((a, b) => {
const sum = s => cleanRows
.filter(r => r.subtype === s)
.reduce((t, r) => t + r.total, 0);
return sum(b) - sum(a);
});
const datasets = subtypes.map((sub, i) => {
return {
label: sub,
data: weeks.map(w => {
const weekRows = cleanRows.filter(r => r.period === w);
const total = weekRows.reduce((s, r) => s + Number(r.total), 0);
const found = weekRows.find(r => r.subtype === sub);
return total ? ((found?.total || 0) / total) * 100 : 0;
}),
fill: true,
tension: 0.3,
backgroundColor: colors[i % colors.length],
borderColor: colors[i % colors.length]
};
});
sequencingChart = new Chart(ctx, {
type: 'line',
data: {
labels: weeks.map(w => `W${w}`),
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom',
align: 'center'
},
datalabels: {
display: false
}
},
scales: {
x: { stacked: true },
y: {
stacked: true,
max: 100,
ticks: {
callback: v => v + '%'
}
}
}
}
});
}

View File

@@ -21,29 +21,14 @@
</select> </select>
<div id="custom_range_container" style="display:none;" class="align-items-center gap-1"> <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_year" class="form-select"></select>
<select id="start_week" class="form-select"></select> <select id="start_week" class="form-select"></select>
<span class="mx-1">to</span> <span class="mx-1">to</span>
<select id="end_year" class="form-select"></select> <select id="end_year" class="form-select"></select>
<select id="end_week" class="form-select"></select> <select id="end_week" class="form-select"></select>
</div> </div>
</div> </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 --> <!-- SUMMARY CARDS -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">

View File

@@ -1,223 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>NRML Surveillance Dashboard</title>
<!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body class="bg-light">
<div class="container-fluid py-4">
<h3 class="mb-4">NRML Surveillance Dashboard</h3>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label">Surveillance Program</label>
<select id="surveillance_id" class="form-select">
@foreach($programs as $program)
<option value="{{ $program->id }}">
{{ $program->code }} - {{ $program->name_en }}
</option>
@endforeach
</select>
</div>
<div class="col-md-2">
<label class="form-label">Period</label>
<select id="period_type" class="form-select">
<option value="week">Epiweek</option>
<option value="month">Month</option>
<option value="year">Year</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">Date From</label>
<input type="date" id="date_from" class="form-control">
</div>
<div class="col-md-2">
<label class="form-label">Date To</label>
<input type="date" id="date_to" class="form-control">
</div>
<div class="col-md-2">
<button onclick="loadDashboard()" class="btn btn-primary w-100">
Apply
</button>
</div>
</div>
</div>
<!-- Summary Cards -->
<div class="row" id="summary_cards"></div>
<!-- Trend Chart -->
<div class="card mt-4">
<div class="card-body">
<h5>Epidemic Trend</h5>
<canvas id="trendChart"></canvas>
</div>
</div>
<!-- Province Table -->
<div class="card mt-4">
<div class="card-body">
<h5>Cases by Province</h5>
<table class="table table-bordered" id="provinceTable">
<thead>
<tr>
<th>Province</th>
<th>Total Cases</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<script>
let trendChart;
function loadDashboard() {
const surveillanceId = document.getElementById('surveillance_id').value;
const periodType = document.getElementById('period_type').value;
const dateFrom = document.getElementById('date_from').value;
const dateTo = document.getElementById('date_to').value;
loadSummary(dateFrom, dateTo);
loadTrend(periodType, dateFrom, dateTo);
loadProvince(surveillanceId, dateFrom, dateTo);
}
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 => {
html += `
<div class="col-md-3 mb-3">
<div class="card shadow-sm">
<div class="card-body">
<h6>${item.code}</h6>
<h4>${item.current_total}</h4>
<small class="${item.percent_change >= 0 ? 'text-success' : 'text-danger'}">
${item.percent_change}%
</small>
</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 => {
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();
const datasets = [];
const colors = {
SARI: 'red',
ILI: 'blue',
LBM: 'green'
};
Object.keys(data).forEach(code => {
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] || 'black',
fill: false
});
});
trendChart = new Chart(document.getElementById('trendChart'), {
type: 'line',
data: {
labels: labels,
datasets: datasets
}
});
});
}
function loadProvince(surveillanceId, dateFrom, dateTo) {
fetch(`/api/dashboard/province?surveillance_id=${surveillanceId}&date_from=${dateFrom}&date_to=${dateTo}`)
.then(res => res.json())
.then(data => {
let html = '';
data.forEach(item => {
html += `
<tr>
<td>${item.site_province_name}</td>
<td>${item.total}</td>
</tr>
`;
});
document.querySelector('#provinceTable tbody').innerHTML = html;
});
}
document.addEventListener('DOMContentLoaded', function() {
const today = new Date().toISOString().split('T')[0];
const past = new Date();
past.setDate(past.getDate() - 30);
document.getElementById('date_from').value = past.toISOString().split('T')[0];
document.getElementById('date_to').value = today;
loadDashboard();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,69 @@
@extends('layouts.app')
@section('content')
<div class="container-fluid">
<div class="d-flex justify-content-between mb-3">
<h4 class="fw-bold">Sequencing Analysis</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>
<div class="card">
<div class="card-body" style="height:600px;">
<canvas id="sequencingChart"></canvas>
</div>
</div>
<div class="row mt-3">
<!-- Counts -->
<div class="col-md-6">
<div class="card">
<div class="card-body" style="height:600px;">
<h6 class="fw-bold">Lineage Counts Over Time</h6>
<canvas id="sequencingCountChart"></canvas>
</div>
</div>
</div>
<!-- Distribution -->
<div class="col-md-6">
<div class="card">
<div class="card-body" style="height:600px;">
<h6 class="fw-bold">Total Sequenced Samples Over Time</h6>
<canvas id="sequencingTotalChart" style="height: 550px;"></canvas>
</div>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
window.SURVEILLANCE_ID = 6;
</script>
<script src="/js/sequencing.js"></script>
@endsection

View File

@@ -40,7 +40,7 @@
display: flex; display: flex;
background: white; background: white;
border-bottom: 1px solid #dcdcdc; border-bottom: 1px solid #dcdcdc;
padding-left: 15px; padding: 0 20px;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1000; z-index: 1000;
@@ -49,6 +49,17 @@
} }
/* NAV ITEMS */ /* NAV ITEMS */
.btn-theme-outline {
background-color: #fff;
color: #0B8F3C;
border: 1px solid #0B8F3C;
}
.btn-theme-outline:hover {
background-color: #cce0d4;
color: #0B8F3C;
}
.nav-item { .nav-item {
padding: 12px 18px; padding: 12px 18px;
text-decoration: none; text-decoration: none;
@@ -92,7 +103,7 @@
} }
.card { .card {
border-radius: 10px; border-radius: 0px !important;
border: 1px solid #E5E7EB; border: 1px solid #E5E7EB;
} }
@@ -112,9 +123,7 @@
</div> </div>
<div class="ms-auto small"> <div class="ms-auto small">
Last update: 12:05 | Last update: 12:05 | 2026-03-15
Data latency: 510 min |
User: National - Read Only
</div> </div>
</div> </div>
@@ -126,16 +135,34 @@
Overview Overview
</a> </a>
@foreach($programs as $program) <!-- @foreach($programs as $program)
<a href="/dashboard/{{ strtolower($program->code) }}" <a href="/dashboard/{{ strtolower($program->code) }}"
class="nav-item {{ request()->is('dashboard/' . strtolower($program->code)) ? 'active-tab' : '' }}"> class="nav-item {{ request()->is('dashboard/' . strtolower($program->code)) ? 'active-tab' : '' }}">
{{ $program->code }} {{ $program->code }}
</a> </a>
@endforeach -->
@foreach($programs->where('code', '!=', 'NDS') as $program)
@if($program->code === 'SEQ')
<a href="/dashboard/seq"
class="nav-item {{ request()->is('dashboard/seq') ? 'active-tab' : '' }}">
SEQ
</a>
@else
<a href="/dashboard/{{ strtolower($program->code) }}"
class="nav-item {{ request()->is('dashboard/' . strtolower($program->code)) ? 'active-tab' : '' }}">
{{ $program->code }}
</a>
@endif
@endforeach @endforeach
<button type="button" onclick="reloadDataSource()" class="btn btn-sm btn-warning" style="height: 27px; position: absolute; right: 40px; top: 70px;"> <div class="ms-auto d-flex align-items-center gap-4 pe-3">
Refresh Data <button type="button" onclick="reloadDataSource()" class="btn btn-sm btn-theme-outline">
</button> Refresh Data
</button>
</div>
</div> </div>
@@ -163,4 +190,4 @@
</body> </body>
</html> </html>

View File

@@ -9,3 +9,4 @@ Route::get('/dashboard/program', [DashboardController::class, 'program']);
Route::get('/dashboard/province-circles', [DashboardController::class, 'provinceCircles']); Route::get('/dashboard/province-circles', [DashboardController::class, 'provinceCircles']);
Route::get('/dashboard/sentinel-map', [DashboardController::class, 'sentinelMap']); Route::get('/dashboard/sentinel-map', [DashboardController::class, 'sentinelMap']);
Route::get('/dashboard/reload', [DashboardController::class, 'fetchSourceData']); Route::get('/dashboard/reload', [DashboardController::class, 'fetchSourceData']);
Route::get('/dashboard/sequencing', [DashboardController::class, 'sequencing']);

View File

@@ -9,9 +9,9 @@ Route::get('/', function () {
}); });
Route::get('/dashboard/seq', function () {
return view('dashboard.sequencing');
});
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";
});

47973
surveillance_cases.sql Normal file

File diff suppressed because it is too large Load Diff