feat(reports): add inactive card, update student status breakdown donut, and fix summary banner width

This commit is contained in:
ssbg04 committed 2026-08-18 21:00:19 +08:00
1 parent 2e370a5faf
commit 8aaefb2aa4
3 files changed
+52 -12

No files matched your search

+2 -1
View File
@@ -48,10 +48,11 @@ exports.getStats = (req, res) => {
const whereSql = whereClauses.length > 0 ? 'AND ' + whereClauses.join(' AND ') : ''; const whereSql = whereClauses.length > 0 ? 'AND ' + whereClauses.join(' AND ') : '';
const enrollWhereSql = enrollWhereClauses.length > 0 ? 'AND ' + enrollWhereClauses.join(' AND ') : ''; const enrollWhereSql = enrollWhereClauses.length > 0 ? 'AND ' + enrollWhereClauses.join(' AND ') : '';
// 1. Fetch counts of active, dropped, transferee, graduated, and 4Ps beneficiaries // 1. Fetch counts of active, inactive, dropped, transferee, graduated, and 4Ps beneficiaries
const countsQuery = ` const countsQuery = `
SELECT SELECT
COUNT(DISTINCT CASE WHEN s.status = 'Enrolled' THEN s.id END) as active, COUNT(DISTINCT CASE WHEN s.status = 'Enrolled' THEN s.id END) as active,
COUNT(DISTINCT CASE WHEN s.status = 'Inactive' THEN s.id END) as inactive,
COUNT(DISTINCT CASE WHEN s.status = 'Dropped' THEN s.id END) as dropped, COUNT(DISTINCT CASE WHEN s.status = 'Dropped' THEN s.id END) as dropped,
COUNT(DISTINCT CASE WHEN s.status = 'Transferred' THEN s.id END) as transferee, COUNT(DISTINCT CASE WHEN s.status = 'Transferred' THEN s.id END) as transferee,
COUNT(DISTINCT CASE WHEN s.status = 'Graduated' THEN s.id END) as graduated, COUNT(DISTINCT CASE WHEN s.status = 'Graduated' THEN s.id END) as graduated,
@@ -20,6 +20,7 @@ class AcademicYear {
/// KPI summary numbers for the Reports screen. /// KPI summary numbers for the Reports screen.
class StudentCounts { class StudentCounts {
final int active; final int active;
final int inactive;
final int dropped; final int dropped;
final int transferee; final int transferee;
final int graduated; final int graduated;
@@ -27,6 +28,7 @@ class StudentCounts {
const StudentCounts({ const StudentCounts({
required this.active, required this.active,
this.inactive = 0,
required this.dropped, required this.dropped,
required this.transferee, required this.transferee,
required this.graduated, required this.graduated,
@@ -35,6 +37,7 @@ class StudentCounts {
factory StudentCounts.fromJson(Map<String, dynamic> j) => StudentCounts( factory StudentCounts.fromJson(Map<String, dynamic> j) => StudentCounts(
active: (j['active'] as num?)?.toInt() ?? 0, active: (j['active'] as num?)?.toInt() ?? 0,
inactive: (j['inactive'] as num?)?.toInt() ?? 0,
dropped: (j['dropped'] as num?)?.toInt() ?? 0, dropped: (j['dropped'] as num?)?.toInt() ?? 0,
transferee: (j['transferee'] as num?)?.toInt() ?? 0, transferee: (j['transferee'] as num?)?.toInt() ?? 0,
graduated: (j['graduated'] as num?)?.toInt() ?? 0, graduated: (j['graduated'] as num?)?.toInt() ?? 0,
@@ -1253,14 +1253,20 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
final students = reportData.students; final students = reportData.students;
final breakdown = reportData.missingDocsBreakdown; final breakdown = reportData.missingDocsBreakdown;
final total = final total = counts.active +
counts.active + counts.dropped + counts.transferee + counts.graduated; counts.inactive +
counts.dropped +
counts.transferee +
counts.graduated;
final compliantCount = students.where((s) => s.missingCount == 0).length; final compliantCount = students.where((s) => s.missingCount == 0).length;
final withIssuesCount = students.where((s) => s.missingCount > 0).length; final withIssuesCount = students.where((s) => s.missingCount > 0).length;
final totalMissing = breakdown.fold<int>(0, (a, b) => a + b.count); final totalMissing = breakdown.fold<int>(0, (a, b) => a + b.count);
final complianceRate = final complianceRate =
students.isNotEmpty ? (compliantCount / students.length * 100) : 0.0; students.isNotEmpty ? (compliantCount / students.length * 100) : 0.0;
final inactiveRate = total > 0
? (counts.inactive / total * 100).toStringAsFixed(1)
: '0.0';
final gradRate = total > 0 final gradRate = total > 0
? (counts.graduated / total * 100).toStringAsFixed(1) ? (counts.graduated / total * 100).toStringAsFixed(1)
: '0.0'; : '0.0';
@@ -1402,12 +1408,12 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// ── Secondary Status Row (5 cards) ─────────────────────────── // ── Secondary Status Row (6 cards) ───────────────────────────
LayoutBuilder( LayoutBuilder(
builder: (ctx, constraints) { builder: (ctx, constraints) {
final cols = constraints.maxWidth >= 900 final cols = constraints.maxWidth >= 1050
? 5 ? 6
: (constraints.maxWidth >= 600 ? 3 : 2); : (constraints.maxWidth >= 750 ? 3 : 2);
return GridView( return GridView(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@@ -1425,6 +1431,13 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
icon: Icons.check_circle_outline, icon: Icons.check_circle_outline,
iconColor: AppColors.primaryGreen, iconColor: AppColors.primaryGreen,
), ),
StatCard(
title: 'Inactive',
value: counts.inactive.toString(),
subtitle: 'Rate: $inactiveRate%',
icon: Icons.pause_circle_outline,
iconColor: Colors.blueGrey,
),
StatCard( StatCard(
title: 'Dropouts', title: 'Dropouts',
value: counts.dropped.toString(), value: counts.dropped.toString(),
@@ -2660,7 +2673,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
} }
// ── Summary Banner ──────────────────────────────────────────────────────── // ── Summary Banner ────────────────────────────────────────────────────────
Widget _buildSummaryBanner(ReportStats data) { Widget _buildSummaryBanner(ReportStats data) {
final total = data.students.length; final total = data.students.length;
final compliant = data.students.where((s) => s.missingCount == 0).length; final compliant = data.students.where((s) => s.missingCount == 0).length;
@@ -2677,6 +2690,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
: Colors.red; : Colors.red;
return Container( return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@@ -2685,7 +2699,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
AppColors.primaryGreen.withValues(alpha: 0.02), AppColors.primaryGreen.withValues(alpha: 0.02),
], ],
), ),
borderRadius: BorderRadius.circular(AppSizes.radiusMedium), borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
border: Border.all( border: Border.all(
color: AppColors.primaryGreen.withValues(alpha: 0.2), color: AppColors.primaryGreen.withValues(alpha: 0.2),
), ),
@@ -2705,8 +2719,13 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
'${complianceRate.toStringAsFixed(1)}%', 'Complete Rate', '${complianceRate.toStringAsFixed(1)}%', 'Complete Rate',
rateColor), rateColor),
]; ];
if (constraints.maxWidth < 600) { if (constraints.maxWidth < 750) {
return Wrap(spacing: 20, runSpacing: 12, children: items); return Wrap(
spacing: 20,
runSpacing: 12,
alignment: WrapAlignment.spaceAround,
children: items,
);
} }
final row = <Widget>[]; final row = <Widget>[];
for (int i = 0; i < items.length; i++) { for (int i = 0; i < items.length; i++) {
@@ -2759,10 +2778,11 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
); );
} }
// ── Status Donut Chart ──────────────────────────────────────────────────── // ── Status Donut Chart ───────────────────────────────────────────────────
Widget _buildStatusDonutChart(StudentCounts counts) { Widget _buildStatusDonutChart(StudentCounts counts) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final total = counts.active + final total = counts.active +
counts.inactive +
counts.dropped + counts.dropped +
counts.transferee + counts.transferee +
counts.graduated; counts.graduated;
@@ -2815,6 +2835,19 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
color: Colors.white, color: Colors.white,
), ),
), ),
if (counts.inactive > 0)
PieChartSectionData(
color: Colors.blueGrey,
value: counts.inactive.toDouble(),
title:
'${(counts.inactive / total * 100).toStringAsFixed(0)}%',
radius: 55,
titleStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
if (counts.dropped > 0) if (counts.dropped > 0)
PieChartSectionData( PieChartSectionData(
color: Colors.red, color: Colors.red,
@@ -2890,6 +2923,9 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
children: [ children: [
_buildLegendItem( _buildLegendItem(
AppColors.primaryGreen, 'Active (${counts.active})'), AppColors.primaryGreen, 'Active (${counts.active})'),
if (counts.inactive > 0)
_buildLegendItem(
Colors.blueGrey, 'Inactive (${counts.inactive})'),
_buildLegendItem(Colors.red, 'Dropped (${counts.dropped})'), _buildLegendItem(Colors.red, 'Dropped (${counts.dropped})'),
_buildLegendItem( _buildLegendItem(
Colors.blue, 'Graduated (${counts.graduated})'), Colors.blue, 'Graduated (${counts.graduated})'),