Merge branch 'feature/exportcharts'

This commit is contained in:
2026-04-29 09:15:48 +07:00
7 changed files with 748 additions and 561 deletions

View File

@@ -64,26 +64,101 @@ class DashboardService
return $results; return $results;
} }
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Fast SARI Summary (single query) | Program Summary
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
public function programSummaryFast($surveillanceId, $year, $week) public function afiTrend($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{ {
$row = SurveillanceCase::leftJoin( 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]
);
})
->where(function ($q) {
$q->whereNotNull('case_lab_results.pathogen_name')
->orWhereRaw("LOWER(case_lab_results.indicator) LIKE '%serum%'");
})
->selectRaw("
surveillance_cases.year_data as year,
surveillance_cases.week_data as period,
CASE
WHEN LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
THEN 'Influenza'
ELSE case_lab_results.pathogen_name
END as pathogen,
CASE
WHEN LOWER(case_lab_results.indicator) LIKE '%serum%' THEN 'Serum'
ELSE 'PCR'
END as test_type,
COUNT(case_lab_results.id) as total_tests,
COUNT(DISTINCT surveillance_cases.lab_code) as total_tested,
COUNT(CASE
WHEN case_lab_results.is_positive = 1
THEN surveillance_cases.lab_code
END) as total_positive
")
->groupBy(
'surveillance_cases.year_data',
'surveillance_cases.week_data',
'pathogen',
'test_type'
)
//->havingRaw("pathogen IS NOT NULL AND pathogen != ''")
->orderBy('surveillance_cases.year_data')
->orderBy('surveillance_cases.week_data')
->get()
->map(function ($r) {
$r->positivity_rate = $r->total_tested > 0
? round(($r->total_positive / $r->total_tested) * 100, 1)
: 0;
return $r;
});
}
public function programSummaryFast($surveillanceId, $year = null, $week = null, $dateFrom = null, $dateTo = null)
{
$query = SurveillanceCase::leftJoin(
'case_lab_results', 'case_lab_results',
'surveillance_cases.lab_code', 'surveillance_cases.lab_code',
'=', '=',
'case_lab_results.lab_code' 'case_lab_results.lab_code'
) )
->where('surveillance_cases.surveillance_id', $surveillanceId);
->where('surveillance_cases.surveillance_id', $surveillanceId) if ($dateFrom && $dateTo) {
->where('surveillance_cases.year_data', $year) $query->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo]);
->where('surveillance_cases.week_data', $week) } else {
$query->where('surveillance_cases.year_data', $year)
->where('surveillance_cases.week_data', $week);
}
->selectRaw(" $row = $query->selectRaw("
COUNT(DISTINCT surveillance_cases.lab_code) as total_cases, COUNT(DISTINCT surveillance_cases.lab_code) as total_cases,
COUNT(DISTINCT CASE COUNT(DISTINCT CASE
@@ -108,10 +183,7 @@ class DashboardService
) )
THEN surveillance_cases.lab_code THEN surveillance_cases.lab_code
END) as covid_positive END) as covid_positive
") ")->first();
->first();
if (!$row || $row->total_cases == 0) { if (!$row || $row->total_cases == 0) {
return [ return [
@@ -124,135 +196,64 @@ class DashboardService
return [ return [
'cases' => $row->total_cases, 'cases' => $row->total_cases,
'overall_rate' => round(($row->overall_positive / $row->total_cases) * 100, 1), 'overall_rate' => round(($row->overall_positive / $row->total_cases) * 100, 1),
'influenza_rate' => round(($row->influenza_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), 'covid_rate' => round(($row->covid_positive / $row->total_cases) * 100, 1),
]; ];
} }
/*
|--------------------------------------------------------------------------
| Program Summary
|--------------------------------------------------------------------------
*/
public function afiTrend($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]);
})
->where('case_lab_results.is_positive', 1)
->whereNotNull('case_lab_results.pathogen_name')
->where('case_lab_results.pathogen_name', '!=', '')
->where('case_lab_results.pathogen_name', '!=', 'Positive')
->selectRaw("
surveillance_cases.year_data as year,
surveillance_cases.week_data as period,
CASE
WHEN LOWER(case_lab_results.pathogen_name) LIKE '%influenza%' THEN 'Influenza'
ELSE case_lab_results.pathogen_name
END as pathogen,
COUNT(DISTINCT surveillance_cases.lab_code) as total
")
->groupBy(
'surveillance_cases.year_data',
'surveillance_cases.week_data',
'pathogen'
)
->orderBy('surveillance_cases.year_data')
->orderBy('surveillance_cases.week_data')
->get();
}
public function programSummary($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) public function programSummary($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{ {
$dateTo = now()->toDateString();
$dateFrom = now()->subDays(7)->toDateString();
$prevWeek = $endWeek - 1; $current = $this->programSummaryFast(
$prevYear = $endYear; $surveillanceId,
null,
null,
$dateFrom,
$dateTo
);
if ($prevWeek <= 0) { $prevFrom = date('Y-m-d', strtotime($dateFrom . ' -7 days'));
$prevWeek = 52; $prevTo = date('Y-m-d', strtotime($dateFrom . ' -1 day'));
$prevYear--;
}
$latest = SurveillanceCase::where('surveillance_id', $surveillanceId) $previous = $this->programSummaryFast(
->selectRaw("year_data, week_data") $surveillanceId,
->orderByDesc('year_data') null,
->orderByDesc('week_data') null,
->first(); $prevFrom,
$prevTo
$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 [ return [
'cases' => [ 'cases' => [
'current' => $current['cases'], 'current' => $current['cases'],
'previous' => $previous['cases'] 'previous' => $previous['cases']
], ],
'hospital_rate' => [ 'hospital_rate' => [
'current' => 0, 'current' => 0,
'previous' => 0 'previous' => 0
], ],
'icu_rate' => [ 'icu_rate' => [
'current' => 0, 'current' => 0,
'previous' => 0 'previous' => 0
], ],
'positivity_rate' => [ 'positivity_rate' => [
'current' => $current['overall_rate'], 'current' => $current['overall_rate'],
'previous' => $previous['overall_rate'] 'previous' => $previous['overall_rate']
], ],
'influenza_rate' => [ 'influenza_rate' => [
'current' => $current['influenza_rate'], 'current' => $current['influenza_rate'],
'previous' => $previous['influenza_rate'] 'previous' => $previous['influenza_rate']
], ],
'covid_rate' => [ 'covid_rate' => [
'current' => $current['covid_rate'], 'current' => $current['covid_rate'],
'previous' => $previous['covid_rate'] 'previous' => $previous['covid_rate']
], ],
]; ];
} }
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Overview Trend | Overview Trend
@@ -437,7 +438,7 @@ class DashboardService
COUNT(DISTINCT surveillance_cases.lab_code) as total_samples, COUNT(DISTINCT surveillance_cases.lab_code) as total_samples,
-- Overall positivity rate -- Overall positivity
ROUND( ROUND(
COUNT(DISTINCT CASE COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1 WHEN case_lab_results.is_positive = 1
@@ -446,32 +447,51 @@ class DashboardService
/ NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100 / NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100
,1) as positivity_rate, ,1) as positivity_rate,
-- Influenza positivity rate -- Influenza %
ROUND( ROUND(
COUNT(DISTINCT CASE COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1 WHEN case_lab_results.is_positive = 1
AND ( AND (
LOWER(case_lab_results.pathogen_name) LIKE '%influenza%' LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
) )
THEN surveillance_cases.lab_code THEN surveillance_cases.lab_code
END) END)
/ NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100 / NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100
,1) as influenza_rate, ,1) as influenza_rate,
-- COVID positivity rate -- COVID %
ROUND( ROUND(
COUNT(DISTINCT CASE COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1 WHEN case_lab_results.is_positive = 1
AND ( AND (
case_lab_results.pathogen_name = 'Positive' case_lab_results.pathogen_name = 'COVID-19'
OR case_lab_results.pathogen_name = 'SARS-CoV-2' OR case_lab_results.pathogen_name = 'SARS-CoV-2'
) )
AND case_lab_results.indicator LIKE '%Covid%' AND case_lab_results.indicator LIKE '%COVID%'
THEN surveillance_cases.lab_code THEN surveillance_cases.lab_code
END) END)
/ NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100 / NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100
,1) as covid_rate ,1) as covid_rate,
-- EV %
ROUND(
COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1
AND LOWER(case_lab_results.pathogen_name) LIKE '%enterovirus%'
THEN surveillance_cases.lab_code
END)
/ NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100
,1) as ev_rate,
-- MPOX %
ROUND(
COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1
AND LOWER(case_lab_results.pathogen_name) LIKE '%mpox%'
THEN surveillance_cases.lab_code
END)
/ NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100
,1) as mpox_rate
") ")
->groupBy( ->groupBy(
@@ -546,7 +566,6 @@ class DashboardService
WHEN case_lab_results.is_positive = 1 WHEN case_lab_results.is_positive = 1
AND ( AND (
LOWER(case_lab_results.pathogen_name) LIKE '%influenza%' LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
) )
THEN surveillance_cases.lab_code THEN surveillance_cases.lab_code
END) as influenza, END) as influenza,
@@ -559,7 +578,19 @@ class DashboardService
) )
AND case_lab_results.indicator LIKE '%Covid%' AND case_lab_results.indicator LIKE '%Covid%'
THEN surveillance_cases.lab_code THEN surveillance_cases.lab_code
END) as covid END) as covid,
COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1
AND LOWER(case_lab_results.pathogen_name) LIKE '%enterovirus%'
THEN surveillance_cases.lab_code
END) as ev,
COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1
AND LOWER(case_lab_results.pathogen_name) LIKE '%mpox%'
THEN surveillance_cases.lab_code
END) as mpox
") ")
->groupBy('surveillance_cases.week_data') ->groupBy('surveillance_cases.week_data')

View File

@@ -78,9 +78,7 @@ function buildStackedChart(canvasId, labels, datasets) {
data: { data: {
labels: labels, labels: labels,
datasets: datasets, datasets: datasets,
datalabels: {
display: true
}
}, },
plugins: [ChartDataLabels], plugins: [ChartDataLabels],
@@ -109,19 +107,22 @@ function buildStackedChart(canvasId, labels, datasets) {
pointStyle: 'circle' 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;
// }
// }
datalabels: { datalabels: {
color: "#000", display: false
anchor: "end",
align: "top",
clamp: true,
clip: false,
font: {
weight: "bold",
size: 10
},
formatter: function (value) {
return value > 0 ? value : null;
}
} }
}, },
@@ -152,6 +153,7 @@ function buildChart(id, type, labels, data) {
} }
const isHorizontal = id === 'sexChart'; const isHorizontal = id === 'sexChart';
const isAgeChart = id === 'ageChart'; const isAgeChart = id === 'ageChart';
const isSentinelChart = id === 'sentinelChart';
const options = { const options = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -165,7 +167,7 @@ function buildChart(id, type, labels, data) {
plugins: { plugins: {
legend: { legend: {
position: isAgeChart ? 'left' : 'bottom', position: isAgeChart || isSentinelChart ? 'left' : 'bottom',
align: 'center', align: 'center',
display: (ctx) => { display: (ctx) => {
const chart = ctx.chart; const chart = ctx.chart;
@@ -277,117 +279,53 @@ function buildChart(id, type, labels, data) {
options: options options: options
}); });
} }
function buildMixedTrendChart(canvasId, labels, samples, fluRate, covidRate) { function buildMixedTrendChart(canvasId, labels, samples, lines) {
const ctx = document.getElementById(canvasId); const ctx = document.getElementById(canvasId);
if (!ctx) return; if (!ctx) return;
if (!labels.length) labels = [''];
if (!fluRate.length) fluRate = [0];
if (!covidRate.length) covidRate = [0];
if (!samples.length) samples = [0];
if (charts[canvasId]) charts[canvasId].destroy(); if (charts[canvasId]) charts[canvasId].destroy();
charts[canvasId] = new Chart(ctx, { const datasets = [];
data: { lines.forEach(line => {
labels: labels, datasets.push({
datasets: [
{
type: 'line', type: 'line',
label: 'Influenza %', label: line.label,
data: fluRate, data: line.data,
borderColor: '#fa2929', borderColor: line.color,
backgroundColor: '#fa2929', backgroundColor: line.color,
tension: 0.4, tension: 0.4,
yAxisID: 'y1', yAxisID: 'y1',
pointStyle: 'line', pointStyle: 'line'
});
});
}, datasets.push({
{
type: 'line',
label: 'COVID-19 %',
data: covidRate,
borderColor: '#1976D2',
backgroundColor: '#1976D2',
tension: 0.4,
yAxisID: 'y1',
pointStyle: 'line',
},
{
type: 'bar', type: 'bar',
label: 'Total Cases', label: 'Total Cases',
data: samples, data: samples,
backgroundColor: '#0B8F3C', backgroundColor: '#0B8F3C',
borderRadius: 2, yAxisID: 'y'
barPercentage: 0.8, });
categoryPercentage: 0.7,
yAxisID: 'y',
}
]
},
plugins: [ChartDataLabels],
charts[canvasId] = new Chart(ctx, {
data: { labels, datasets },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { legend: { position: 'bottom' },
position: 'bottom',
labels: {
usePointStyle: true,
padding: 20
}
},
datalabels: { datalabels: {
align: "top", display: false
anchor: "end",
color: "#555",
display: false,
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: { scales: {
y: { y: { title: { display: true, text: 'Cases' } },
position: 'left',
title: {
display: true,
text: 'Cases'
}
},
y1: { y1: {
position: 'right', position: 'right',
grid: { drawOnChartArea: false }, grid: { drawOnChartArea: false },
title: { title: { display: true, text: '% Positivity' }
display: true,
text: '% Positivity'
},
} }
} }
} }

View File

@@ -72,50 +72,70 @@ function formatChartName(id) {
} }
async function exportSelectedCharts() { async function exportSelectedCharts() {
console.log("Exporting selected charts...");
const { jsPDF } = window.jspdf; const { jsPDF } = window.jspdf;
const pdf = new jsPDF("p", "mm", "a4"); const pdf = new jsPDF("l", "mm", "a4");
const margin = 15;
const pageWidth = 297;
const pageHeight = 210;
const contentTop = 35;
const contentHeight = pageHeight - contentTop - 10;
const contentWidth = pageWidth - margin * 2;
const selected = document.querySelectorAll("#chartList input:checked"); const selected = document.querySelectorAll("#chartList input:checked");
const margin = 10; const loadingDiv = document.createElement("div");
const pageWidth = 210; Object.assign(loadingDiv.style, {
const usableWidth = pageWidth - margin * 2; position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgb(255, 255, 255)",
color: "#fff",
padding: "20px",
borderRadius: "10px",
zIndex: "10000"
});
loadingDiv.innerHTML = "Generating PDF...";
document.body.appendChild(loadingDiv);
const gap = 5; function drawHeader() {
const chartWidth = (usableWidth - gap) / 2; pdf.setFillColor(255, 255, 255);
pdf.rect(0, 0, pageWidth, 30, "F");
let y = 35;
pdf.setFontSize(18); pdf.setFontSize(18);
pdf.setFont(undefined, "bold"); pdf.setFont(undefined, "bold");
pdf.text(getReportTitle(), margin, 15); pdf.setTextColor(0);
pdf.text(getReportTitle(), margin, 14);
pdf.setFontSize(10); pdf.setFontSize(9);
pdf.setFont(undefined, "normal"); pdf.setTextColor(100);
pdf.text(`Generated: ${new Date().toLocaleString()}`, margin, 22); pdf.text(`Generated: ${new Date().toLocaleString()}`, margin, 22);
pdf.setDrawColor(200); pdf.setDrawColor(220);
pdf.line(margin, 25, pageWidth - margin, 25); pdf.line(margin, 28, pageWidth - margin, 28);
}
drawHeader();
const items = []; const items = [];
for (const cb of selected) { for (const cb of selected) {
if (cb.value === "provinceMap") { if (cb.value === "provinceMap") {
const mapImg = await getMapImage(); loadingDiv.innerHTML = "Processing map...";
const img = await getMapImage();
if (mapImg) { if (img) {
items.push({ items.push({
id: "provinceMap", id: "provinceMap",
type: "map", type: "map",
img: mapImg img
}); });
} }
} else {
continue;
}
const chart = charts[cb.value]; const chart = charts[cb.value];
if (!chart) continue; if (!chart) continue;
@@ -125,159 +145,299 @@ async function exportSelectedCharts() {
chart chart
}); });
} }
let i = 0;
const FIXED_HEIGHT = 70;
const FIXED_WIDTH = chartWidth;
while (i < items.length) {
const left = items[i];
const right = items[i + 1] || null;
let leftHeight = FIXED_HEIGHT;
let rightHeight = 0;
const xLeft = margin;
pdf.setFontSize(11);
pdf.setFont(undefined, "bold");
pdf.text(formatChartName(left.id), xLeft, y - 3);
let leftWidth = FIXED_WIDTH;
let leftImg;
if (left.type === "map") {
leftWidth = FIXED_WIDTH;
leftHeight = 60;
leftImg = left.img;
} else {
leftImg = left.chart.toBase64Image();
if (["doughnut", "pie"].includes(left.chart.config.type)) {
const size = Math.min(FIXED_WIDTH, FIXED_HEIGHT);
leftWidth = size;
leftHeight = size;
} else {
leftHeight = FIXED_HEIGHT;
}
} }
const xLeftAdjusted = xLeft + (FIXED_WIDTH - leftWidth) / 2; for (let i = 0; i < items.length; i++) {
const item = items[i];
pdf.addImage( loadingDiv.innerHTML = `Rendering ${formatChartName(item.id)} (${i + 1}/${items.length})`;
leftImg,
"PNG",
xLeftAdjusted,
y,
leftWidth,
leftHeight
);
if (right) { if (i !== 0) {
const xRight = margin + chartWidth + gap;
pdf.setFontSize(11);
pdf.setFont(undefined, "bold");
pdf.text(formatChartName(right.id), xRight, y - 3);
let rightWidth = FIXED_WIDTH;
let rightImg;
if (right.type === "map") {
rightWidth = FIXED_WIDTH;
rightHeight = 60;
rightImg = right.img;
} else {
rightImg = right.chart.toBase64Image();
if (["doughnut", "pie"].includes(right.chart.config.type)) {
const size = Math.min(FIXED_WIDTH, FIXED_HEIGHT);
rightWidth = size;
rightHeight = size;
} else {
rightHeight = FIXED_HEIGHT;
}
}
const xRightAdjusted = xRight + (FIXED_WIDTH - rightWidth) / 2;
pdf.addImage(
rightImg,
"PNG",
xRightAdjusted,
y,
rightWidth,
rightHeight
);
i += 2;
} else {
i += 1;
}
// ===== ROW HEIGHT =====
const rowHeight = Math.max(leftHeight, rightHeight);
y += rowHeight + 12;
// ===== PAGE BREAK =====
if (y > 260) {
pdf.addPage(); pdf.addPage();
y = 30; drawHeader();
}
const cardX = margin;
const cardY = contentTop - 5;
const cardWidth = contentWidth;
const cardHeight = contentHeight;
pdf.setFillColor(255, 255, 255);
pdf.roundedRect(cardX, cardY, cardWidth, cardHeight, 3, 3, "F");
pdf.setDrawColor(230);
pdf.roundedRect(cardX, cardY, cardWidth, cardHeight, 3, 3);
pdf.setFontSize(13);
pdf.setFont(undefined, "bold");
pdf.setTextColor(0);
pdf.text(formatChartName(item.id), cardX + 5, cardY + 8);
let img;
let width;
let height;
if (item.type === "map") {
img = item.img;
width = cardWidth - 50;
height = width * 0.65;
} else {
const canvas = item.chart.canvas;
const tempCanvas = document.createElement("canvas");
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
const ctx = tempCanvas.getContext("2d", { willReadFrequently: true });
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(canvas, 0, 0);
img = tempCanvas.toDataURL("image/png");
const ratio = canvas.height / canvas.width;
width = cardWidth - 10;
height = width * ratio;
if (height > cardHeight - 20) {
height = cardHeight - 20;
width = height / ratio;
} }
} }
const x = cardX + (cardWidth - width) / 2;
const contentTopOffset = 14;
const availableHeight = cardHeight - contentTopOffset;
const y = cardY + contentTopOffset + (availableHeight - height) / 2;
pdf.addImage(img, "PNG", x, y, width, height);
}
document.body.removeChild(loadingDiv);
pdf.save("dashboard_report.pdf"); pdf.save("dashboard_report.pdf");
closeChartSelector(); closeChartSelector();
} }
function prepareMapForExport() { function prepareMapForExport() {
if (!window.map) return; if (!window.map) return;
const bounds = L.featureGroup(Object.values(map._layers)).getBounds(); const bounds = L.latLngBounds([
[10.3, 102.3],
[14.7, 107.6]
]);
if (bounds.isValid()) { map.fitBounds(bounds);
map.setView([12.5, 104.9], 6);
}
}
function exportFullDashboard() {
}
async function exportFullDashboard() {
console.log("Exporting full charts...");
const el = document.querySelector(".content-area"); const el = document.querySelector(".content-area");
html2canvas(el, { scale: 2 }).then(canvas => { const mapEl = document.getElementById("provinceMap");
let originalMapHTML = null;
if (mapEl) {
originalMapHTML = mapEl.innerHTML;
const mapImg = await getMapImage();
if (mapImg) {
mapEl.innerHTML = `<img src="${mapImg}" style="width:100%;height:100%;object-fit:cover;" />`;
}
}
prepareMapForExport();
await new Promise(resolve => setTimeout(resolve, 800));
const canvas = await html2canvas(el, {
scale: 3,
useCORS: true,
scrollY: -window.scrollY
});
if (mapEl && originalMapHTML !== null) {
mapEl.innerHTML = originalMapHTML;
map.invalidateSize();
}
const img = canvas.toDataURL("image/png"); const img = canvas.toDataURL("image/png");
const { jsPDF } = window.jspdf; const { jsPDF } = window.jspdf;
const pdf = new jsPDF("p", "mm", "a4"); const pdf = new jsPDF("l", "mm", "a4");
const width = 210; const pageWidth = 297;
const height = (canvas.height * width) / canvas.width; const pageHeight = 210;
pdf.addImage(img, "PNG", 0, 0, width, height); const ratio = Math.min(
pageWidth / canvas.width,
pageHeight / canvas.height
);
const imgWidth = canvas.width * ratio;
const imgHeight = canvas.height * ratio;
const x = (pageWidth - imgWidth) / 2;
const y = (pageHeight - imgHeight) / 2;
pdf.addImage(img, "PNG", x, y, imgWidth, imgHeight);
pdf.save("full_dashboard.pdf"); pdf.save("full_dashboard.pdf");
});
closeChartSelector(); closeChartSelector();
} }
function getColor(value) {
if (value > 50) return "#b91c1c";
if (value >= 10) return "#ef4444";
if (value > 0) return "#fecaca";
return "#f3f4f600";
}
async function getMapImage() { async function getMapImage() {
const original = document.getElementById("provinceMap");
if (!original) return null;
const mapEl = document.getElementById("provinceMap"); const width = original.offsetWidth;
if (!mapEl) return null; const height = original.offsetHeight;
prepareMapForExport(); const canvas = document.createElement("canvas");
canvas.width = width * 3;
canvas.height = height * 3;
// wait for map tiles to re-render const ctx = canvas.getContext("2d");
await new Promise(resolve => setTimeout(resolve, 500)); ctx.scale(3, 3);
const canvas = await html2canvas(mapEl, {
useCORS: true, ctx.fillStyle = "#ffffff";
scale: 2 ctx.fillRect(0, 0, width, height);
const zoom = map.getZoom();
const projection = map.options.crs;
const projectedRings = [];
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
const totals = {};
const rows = window.latestProvinceData || [];
rows.forEach(r => {
const province = normalizeProvince(r.patient_province, window.validProvinces);
console.log(province, totals[province]);
if (!province) return;
if (!totals[province]) {
totals[province] = { total: 0, positive: 0 };
}
totals[province].total += Number(r.total);
totals[province].positive += Number(r.positive);
}); });
function getColor(value) {
if (value > 50) return "#b91c1c";
if (value >= 10) return "#ef4444";
if (value > 0) return "#fecaca";
return "#f3f4f600";
}
map.eachLayer(layer => {
if (!layer.toGeoJSON) return;
const geo = layer.toGeoJSON();
const features = geo.type === "FeatureCollection"
? geo.features
: [geo];
features.forEach(f => {
if (!f.geometry) return;
const coords = f.geometry.coordinates;
const polygons = f.geometry.type === "MultiPolygon"
? coords
: [coords];
polygons.forEach(poly => {
poly.forEach(ring => {
const projected = ring.map(([lng, lat]) => {
const latlng = L.latLng(lat, lng);
const point = projection.latLngToPoint(latlng, zoom);
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
return [point.x, point.y];
});
projectedRings.push({
points: projected,
properties: f.properties
});
});
});
});
});
const padding = 10;
const scaleX = (width - padding * 2) / (maxX - minX);
const scaleY = (height - padding * 2) / (maxY - minY);
const scale = Math.min(scaleX, scaleY);
const offsetX = (width - (maxX - minX) * scale) / 2;
const offsetY = (height - (maxY - minY) * scale) / 2;
projectedRings.forEach(({ points, properties }) => {
ctx.beginPath();
points.forEach(([x, y], i) => {
const drawX = (x - minX) * scale + offsetX;
const drawY = (y - minY) * scale + offsetY;
if (i === 0) ctx.moveTo(drawX, drawY);
else ctx.lineTo(drawX, drawY);
});
ctx.closePath();
const province = properties.ADM1_EN;
const value = totals[province]?.total || 0;
ctx.fillStyle = getColor(value);
ctx.fill();
ctx.strokeStyle = "#444";
ctx.lineWidth = 1;
ctx.stroke();
});
const legend = document.querySelector(".map-legend");
if (legend) {
const legendCanvas = await html2canvas(legend, {
scale: 3,
backgroundColor: null
});
const lw = legend.offsetWidth;
const lh = legend.offsetHeight;
ctx.drawImage(
legendCanvas,
width - lw - 10,
height - lh - 10,
lw,
lh
);
}
return canvas.toDataURL("image/png"); return canvas.toDataURL("image/png");
} }
function getReportTitle() { function getReportTitle() {

View File

@@ -114,7 +114,7 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
const datasets = []; const datasets = [];
const allowedPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS', 'SEQ']; const allowedPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS'];
Object.keys(data).forEach(code => { Object.keys(data).forEach(code => {
@@ -144,6 +144,7 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
}); });
trendChart = new Chart(document.getElementById('trendChart'), { trendChart = new Chart(document.getElementById('trendChart'), {
type: 'line', type: 'line',
data: { data: {
labels: displayLabels, labels: displayLabels,
@@ -184,6 +185,7 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
} }
} }
}); });
charts['trendChart'] = trendChart;
}); });
} }
@@ -266,6 +268,7 @@ function loadInfluenzaSubtypeDistribution(periodType, startYear, startWeek, endY
}, },
plugins: [ChartDataLabels] plugins: [ChartDataLabels]
}); });
charts['influenzaSubtypeDistribution'] = influenzaSubtypeChart;
}); });
} }
@@ -346,6 +349,7 @@ function loadCovidDistributedByAgeGroup(periodType, startYear, startWeek, endYea
} }
} }
}); });
charts['covidDistributedByAgeGroup'] = covidDistributedByAgeChart;
}); });
} }
@@ -443,6 +447,7 @@ function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, en
} }
} }
}); });
charts['covidLineageFrequency'] = covidLineageFrequencyChart;
// ------------------------- // -------------------------
// Custom right-side scrollable legend // Custom right-side scrollable legend

View File

@@ -1,12 +1,16 @@
const standardPrograms = ['SARI', 'ILI', 'LBM', 'AFI']; const standardPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS'];
const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase(); const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase();
let map; let map;
let provinceLayer;
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
if (!standardPrograms.includes(programCode)) return; if (!standardPrograms.includes(programCode)) return;
new DashboardFilter((startYear, startWeek, endYear, endWeek) => { new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
const elements = document.querySelectorAll(".report-period");
elements.forEach(el => {
el.textContent = 'Week ' + startWeek + ' of ' + startYear + ' to ' + 'Week ' + endWeek + ' of ' + endYear
});
fetch(`/api/dashboard/program?surveillance_id=${window.SURVEILLANCE_ID}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${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(res => res.json())
@@ -15,6 +19,7 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
}); });
function normalizeProvince(name, validSet) { function normalizeProvince(name, validSet) {
if (!name || !validSet) return null; if (!name || !validSet) return null;
@@ -46,6 +51,7 @@ function normalizeProvince(name, validSet) {
return match || null; return match || null;
} }
function renderAFIDashboard(data) { function renderAFIDashboard(data) {
const pathogenRows = (data.pathogen_distribution || []) const pathogenRows = (data.pathogen_distribution || [])
.sort((a, b) => b.total - a.total); .sort((a, b) => b.total - a.total);
@@ -54,16 +60,22 @@ function renderAFIDashboard(data) {
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16', '#e11dba', '#f6f63b' '#8b5cf6', '#14b8a6', '#f97316', '#84cc16', '#e11dba', '#f6f63b'
]; ];
const rows = data.afi_trend || [];
const pcr = rows.filter(r => r.test_type === 'PCR');
const serum = rows.filter(r => r.test_type === 'Serum');
renderAFITrend(pcr, 'trendChart', colors);
//renderAFITrend(serum, 'pathogenChart', colors);
renderSummary(data.summary); renderSummary(data.summary);
renderAFITrend(data.afi_trend);
renderProvinceHeatmap(data.province_distribution); renderProvinceHeatmap(data.province_distribution);
renderPathogenChart(data.pathogen_distribution);
renderDemographics(data); renderDemographics(data);
renderPathogenChart(data.pathogen_distribution || []);
renderSentinel(data.sentinel_sites || []); renderSentinel(data.sentinel_sites || []);
renderSubtypeChart(data.subtype_distribution || []); renderSubtypeChart(data.subtype_distribution || []);
charts['pathogenChart'].data.datasets[0].backgroundColor = colors;
charts['pathogenChart'].update();
charts['ageChart'].data.datasets[0].backgroundColor = colors; charts['ageChart'].data.datasets[0].backgroundColor = colors;
charts['ageChart'].update(); charts['ageChart'].update();
charts['sexChart'].data.datasets[0].backgroundColor = colors; charts['sexChart'].data.datasets[0].backgroundColor = colors;
@@ -72,8 +84,11 @@ function renderAFIDashboard(data) {
charts['sentinelChart'].update(); charts['sentinelChart'].update();
charts['subtypeChart'].data.datasets[0].backgroundColor = colors; charts['subtypeChart'].data.datasets[0].backgroundColor = colors;
charts['subtypeChart'].update(); charts['subtypeChart'].update();
charts['pathogenChart'].data.datasets[0].backgroundColor = colors;
charts['pathogenChart'].update();
} }
function renderProvinceHeatmap(rows) { function renderProvinceHeatmap(rows) {
window.latestProvinceData = rows;
if (map) map.remove(); if (map) map.remove();
@@ -92,7 +107,7 @@ function renderProvinceHeatmap(rows) {
const validProvinces = new Set( const validProvinces = new Set(
geo.features.map(f => f.properties.ADM1_EN) geo.features.map(f => f.properties.ADM1_EN)
); );
window.validProvinces = validProvinces;
const totals = {}; const totals = {};
rows.forEach(r => { rows.forEach(r => {
@@ -116,7 +131,7 @@ function renderProvinceHeatmap(rows) {
return "#f3f4f600"; return "#f3f4f600";
} }
provinceLayer = L.geoJSON(geo, { window.provinceLayer = L.geoJSON(geo, {
style: feature => { style: feature => {
const province = feature.properties.ADM1_EN; const province = feature.properties.ADM1_EN;
@@ -124,9 +139,9 @@ function renderProvinceHeatmap(rows) {
return { return {
color: "#444", color: "#444",
weight: 1, weight: 1.5,
fillColor: getColor(value), fillColor: getColor(value),
fillOpacity: 0.7 fillOpacity: 0.8
}; };
}, },
onEachFeature: (feature, layer) => { onEachFeature: (feature, layer) => {
@@ -233,12 +248,27 @@ function renderProgramTrend(rows) {
const fluRate = rows.map(r => r.influenza_rate || 0); const fluRate = rows.map(r => r.influenza_rate || 0);
const covidRate = rows.map(r => r.covid_rate || 0); const covidRate = rows.map(r => r.covid_rate || 0);
const lines = [
{ label: 'Influenza %', data: fluRate, color: '#fa2929' },
{ label: 'COVID-19 %', data: covidRate, color: '#1976D2' }
];
// ✅ ONLY NDS gets EV + Mpox
if (programCode === 'NDS') {
const evRate = rows.map(r => r.ev_rate || 0);
const mpoxRate = rows.map(r => r.mpox_rate || 0);
lines.push(
{ label: 'EV %', data: evRate, color: '#f59e0b' },
{ label: 'Mpox %', data: mpoxRate, color: '#8b5cf6' }
);
}
buildMixedTrendChart( buildMixedTrendChart(
'trendChart', 'trendChart',
labels, labels,
samples, samples,
fluRate, lines
covidRate
); );
} }
function renderSummary(summary) { function renderSummary(summary) {
@@ -365,74 +395,43 @@ function renderDashboard(data) {
//AFI //AFI
function renderAFITrend(rows) { function renderAFITrend(rows, canvasId, colors) {
if (!rows.length) { if (!rows || !rows.length) {
buildStackedChart('trendChart', [], []); buildStackedChart(canvasId, [], []);
return; return;
} }
const { labels, datasets } = transformAFIData(rows); const cleanRows = rows.filter(r => r.pathogen);
buildStackedChart('trendChart', labels, datasets); const keyFn = r => `${r.year}-${r.period}`;
}
function transformAFIData(rows) {
const grouped = {}; const map = {};
const pathogensSet = new Set(); cleanRows.forEach(r => {
const key = keyFn(r);
rows.forEach(r => { if (!map[key]) map[key] = {};
map[key][r.pathogen] = Number(r.total_tests || r.total || 0);
const key = `${r.year}-W${r.period}`;
if (!grouped[key]) {
grouped[key] = {};
}
grouped[key][r.pathogen] = r.total;
pathogensSet.add(r.pathogen);
}); });
const labels = Object.keys(grouped).sort((a, b) => { const keys = Object.keys(map).sort((a, b) => {
const [yA, wA] = a.split('-W').map(Number); const [y1, w1] = a.split('-').map(Number);
const [yB, wB] = b.split('-W').map(Number); const [y2, w2] = b.split('-').map(Number);
return yA === yB ? wA - wB : yA - yB; return y1 !== y2 ? y1 - y2 : w1 - w2;
}); });
const pathogenTotals = {}; const labels = keys.map(k => `W${k.split('-')[1]}`);
rows.forEach(r => { const pathogens = [...new Set(cleanRows.map(r => r.pathogen))];
pathogenTotals[r.pathogen] = (pathogenTotals[r.pathogen] || 0) + r.total;
});
const pathogens = Object.keys(pathogenTotals) const datasets = pathogens.map((p, i) => ({
.sort((a, b) => pathogenTotals[b] - pathogenTotals[a]);
const datasets = pathogens.map(p => ({
label: p, label: p,
data: labels.map(l => grouped[l][p] || 0), data: keys.map(k => map[k][p] || 0),
backgroundColor: getColorForPathogen(p) backgroundColor: colors[i % colors.length]
})); }));
return { labels: labels.map(l => l.split('-')[1]), datasets }; buildStackedChart(canvasId, labels, datasets);
} }
function getColorForPathogen(name) {
const colors = {
Dengue: '#2563eb',
Chikungunya: '#10b981',
Zika: '#f59e0b',
Leptospira: '#ef4444',
Rickettsia: '#8b5cf6',
Salmonella: '#f97316',
Plasmodium: '#14b8a6',
Influenza: '#84cc16'
};
if (colors[name]) return colors[name];
return `hsl(${Math.floor(Math.random() * 360)}, 70%, 60%)`;
}
function renderPathogenChart(rows) { function renderPathogenChart(rows) {
buildChart( buildChart(
'pathogenChart', 'pathogenChart',

View File

@@ -77,12 +77,14 @@
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-md-8"> <div class="col-md-8">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body" style="height:520px;"> <div class="card-body" style="height:560px;">
<h6 class="fw-bold mb-3"> <h6 class="fw-bold mb-3">
Case Trends & Positivity Rate by Epiweek Case Trends & Positivity Rate by Epiweek
</h6> </h6>
<p class="text-muted small report-period">
(based on selected epiweek range)
</p>
<div style="height:460px; position:relative;"> <div style="height:460px; position:relative;">
<canvas id="trendChart"></canvas> <canvas id="trendChart"></canvas>
</div> </div>
@@ -94,10 +96,12 @@
<!-- PATHOGEN DISTRIBUTION --> <!-- PATHOGEN DISTRIBUTION -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body" style="height:520px"> <div class="card-body" style="height:560px">
<h6 class="fw-bold mb-3">Pathogen Distribution</h6> <h6 class="fw-bold mb-3">Pathogen Distribution</h6>
<p class="text-muted small report-period">
(based on selected epiweek range)
</p>
<div style="height:460px; position:relative;"> <div style="height:460px; position:relative;">
<canvas id="pathogenChart"></canvas> <canvas id="pathogenChart"></canvas>
</div> </div>
@@ -112,10 +116,12 @@
<!-- MAP --> <!-- MAP -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body" style="height:520px"> <div class="card-body" style="height:560px">
<h6 class="fw-bold mb-3">Cases by Province</h6> <h6 class="fw-bold mb-3">Cases by Province</h6>
<p class="text-muted small report-period">
(based on selected epiweek range)
</p>
<div id="provinceMap" style="height:450px;"></div> <div id="provinceMap" style="height:450px;"></div>
</div> </div>
@@ -123,7 +129,7 @@
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body" style="height:520px"> <div class="card-body" style="height:560px">
<h6 class="fw-bold mb-3">Sentinel Sites & Influenza Subtypes</h6> <h6 class="fw-bold mb-3">Sentinel Sites & Influenza Subtypes</h6>
@@ -132,6 +138,9 @@
<!-- SENTINEL PIE --> <!-- SENTINEL PIE -->
<div class="col-md-6 d-flex flex-column"> <div class="col-md-6 d-flex flex-column">
<small class="text-muted mb-2">Cases by Sentinel Site</small> <small class="text-muted mb-2">Cases by Sentinel Site</small>
<p class="text-muted small report-period">
(based on selected epiweek range)
</p>
<div style="height: 460px; position:relative;"> <div style="height: 460px; position:relative;">
<canvas id="sentinelChart"></canvas> <canvas id="sentinelChart"></canvas>
</div> </div>
@@ -140,6 +149,9 @@
<!-- SUBTYPE --> <!-- SUBTYPE -->
<div class="col-md-6 d-flex flex-column"> <div class="col-md-6 d-flex flex-column">
<small class="text-muted mb-2">Influenza Subtypes</small> <small class="text-muted mb-2">Influenza Subtypes</small>
<p class="text-muted small report-period">
(based on selected epiweek range)
</p>
<div style="height: 460px; position:relative;"> <div style="height: 460px; position:relative;">
<canvas id="subtypeChart"></canvas> <canvas id="subtypeChart"></canvas>
</div> </div>
@@ -156,8 +168,11 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body" style="height:400px"> <div class="card-body" style="height:480px">
<h6 class="fw-bold mb-3">Sex Distribution</h6> <h6 class="fw-bold mb-3">Sex Distribution</h6>
<p class="text-muted small report-period">
(based on selected epiweek range)
</p>
<div style="height:360px; position:relative;"> <div style="height:360px; position:relative;">
<canvas id="sexChart"></canvas> <canvas id="sexChart"></canvas>
</div> </div>
@@ -167,8 +182,11 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body" style="height:400px"> <div class="card-body" style="height:480px">
<h6 class="fw-bold mb-3">Age Distribution</h6> <h6 class="fw-bold mb-3">Age Distribution</h6>
<p class="text-muted small report-period">
(based on selected epiweek range)
</p>
<div style="height:360px; position:relative;"> <div style="height:360px; position:relative;">
<canvas id="ageChart"></canvas> <canvas id="ageChart"></canvas>
</div> </div>

View File

@@ -8,7 +8,7 @@
<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@3.9.1"></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> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script>
<script src="https://cdn.jsdelivr.net/npm/html-to-image@1.11.11/dist/html-to-image.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script> <script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
@@ -20,7 +20,9 @@
<script src="/js/dashboard/export.js"></script> <script src="/js/dashboard/export.js"></script>
<style> <style>
body { margin: 0; } body {
margin: 0;
}
.top-navbar { .top-navbar {
height: 60px; height: 60px;
@@ -69,7 +71,9 @@
font-size: 14px; font-size: 14px;
} }
.nav-item:hover { background: #cce0d4; } .nav-item:hover {
background: #cce0d4;
}
.active-tab { .active-tab {
color: #0B8F3C; color: #0B8F3C;
@@ -88,13 +92,22 @@
border: 1px solid #E5E7EB; border: 1px solid #E5E7EB;
} }
.form-select { border-radius: 0px !important; } .form-select {
.shadow-sm { box-shadow: none !important; } border-radius: 0px !important;
}
.card h3 { color: #0B8F3C; } .shadow-sm {
box-shadow: none !important;
}
/* EXPORT */ .card h3 {
.export-control { position: relative; } color: #0B8F3C;
}
.export-control {
position: relative;
}
#exportItems { #exportItems {
display: flex; display: flex;
@@ -147,8 +160,16 @@
transition: all 0.5s ease-in-out; transition: all 0.5s ease-in-out;
} }
.slide.active { left: 0; opacity: 1; z-index: 2; } .slide.active {
.slide.prev { left: -100%; opacity: 0; } left: 0;
opacity: 1;
z-index: 2;
}
.slide.prev {
left: -100%;
opacity: 0;
}
.slide-btn { .slide-btn {
position: absolute; position: absolute;
@@ -162,17 +183,31 @@
z-index: 10; z-index: 10;
} }
.prev-btn { right: 75px; } .prev-btn {
.next-btn { right: 25px; } right: 75px;
}
.next-btn {
right: 25px;
}
.slide-btn:hover { .slide-btn:hover {
background: rgba(7, 120, 24, 0.8); background: rgba(7, 120, 24, 0.8);
} }
@media print { @media print {
#floatingExport { display: none !important; } #floatingExport {
.nav-bar, .top-navbar { display: none !important; } display: none !important;
.card { page-break-inside: avoid; } }
.nav-bar,
.top-navbar {
display: none !important;
}
.card {
page-break-inside: avoid;
}
} }
</style> </style>
</head> </head>
@@ -266,4 +301,5 @@
</script> </script>
</body> </body>
</html> </html>