feat(reports): enhance compliance table tooltips, yearly comparison filtering, and horizontal scrolling
This commit is contained in:
1 parent
ae2cfe1495
commit
3da79ec92f
4 files changed
+412
-142
No files matched your search
@@ -314,12 +314,10 @@ exports.getYearlyComparison = (req, res) => {
|
||||
)
|
||||
) sly ON s.id = sly.student_id
|
||||
GROUP BY ay.id, ay.year_range
|
||||
ORDER BY ay.id DESC
|
||||
LIMIT 5
|
||||
ORDER BY ay.year_range ASC
|
||||
`;
|
||||
const data = db.prepare(query).all();
|
||||
// Return oldest first for charting (left to right)
|
||||
res.json(data.reverse());
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Failed to fetch yearly comparison', error: error.message });
|
||||
}
|
||||
|
||||
+27
-16
@@ -387,18 +387,22 @@ Files backup/restore - mirror mode or zip -->
|
||||
<!-- # DOCUMENTS SCREEN
|
||||
- archive files hide in list -> move archive files into in more icon where the switch grid includes, show the "archives" when the student folder opened or if the student have archived files -> hide the archives files in the documents list, only show completed files -->
|
||||
|
||||
<!-- # REPORTS SCREEN
|
||||
- compliance tab -> "students per year" section -> year filter list all acad year -> deafult selected is 4 consecutive years (ex. 2022-2023, 2023-2024, 2024-2025, 2025-2026)
|
||||
- horizontal scroll bar hint for android app
|
||||
- filter by year default as active year
|
||||
- compliance hover on missing count shows all missing documents separated jhs shs
|
||||
- compliance tab -> "Student list" section -> remove missing requirements -> make the missing column hover just like on "STUDENT SCREEN", can see missing documents separate jhs and shs same from "STUDENT SCREEN" -->
|
||||
|
||||
---
|
||||
|
||||
# DONT DO
|
||||
|
||||
- recycle bin -> search history not delete history
|
||||
|
||||
- reports screen -> compliance -> students per year -> year filter
|
||||
- reports screen -> horizontal scroll bar hint
|
||||
- report screen -> dynamic year filter default as active year
|
||||
- reports screen -> compliance hover on missing count shows all missing documents separated jhs shs
|
||||
|
||||
|
||||
- document screen -> upload document limit document type limit -> duplicate files
|
||||
- user management -> expired the jwt of deactivated user
|
||||
|
||||
- dashboard school -> add missing students by document type kpi
|
||||
@@ -406,23 +410,30 @@ Files backup/restore - mirror mode or zip -->
|
||||
- dashboard screen -> user friendly logs/histories
|
||||
- dashboard screen -> assign section modal
|
||||
|
||||
- student screen -> add new student screen -> remove N/A on suffix dropdown
|
||||
|
||||
- notification separate admin and teacher
|
||||
|
||||
- bug in bulk add student enrollment
|
||||
|
||||
# CHINE
|
||||
- forget password sa admin side (email)
|
||||
- search history dapat naalis
|
||||
- lrn should not be fixed
|
||||
- n/a add student in suffix part -> remove
|
||||
- inactive academic year
|
||||
- multiple selection of files in select document type
|
||||
- how about when the pages needed to upload have two pages or more?
|
||||
- archive still popping up even after archiving & overall number of docs are inaccurate in docs status
|
||||
- overall number of docs are inaccurate in docs status
|
||||
- table header on students directory
|
||||
- accurate reports
|
||||
Aug 28 2026:
|
||||
- missing kpi in dashboard
|
||||
- grade level by teacher
|
||||
- report all export
|
||||
|
||||
Dashboard
|
||||
Document breakdown- missing docs
|
||||
those who didnt submit
|
||||
What specific year level have complied or partially complied
|
||||
Documents (no. of completed) over ilan ang docu
|
||||
Orient ang client as extension proj of our sch
|
||||
Graph depending sa no. of 4ps
|
||||
|
||||
features
|
||||
- submit missing requirement
|
||||
- specific yr level dashboard
|
||||
- number of completed
|
||||
- how many document
|
||||
---
|
||||
|
||||
# TIS_RMS Server Manager (dont do)
|
||||
|
||||
@@ -12,9 +12,30 @@ final selectedAcademicYearIdProvider =
|
||||
NotifierProvider<SelectedYearNotifier, int?>(() => SelectedYearNotifier());
|
||||
|
||||
class SelectedYearNotifier extends Notifier<int?> {
|
||||
bool _hasExplicitSelection = false;
|
||||
bool get hasExplicitSelection => _hasExplicitSelection;
|
||||
|
||||
@override
|
||||
int? build() => null;
|
||||
void select(int? id) => state = id;
|
||||
int? build() {
|
||||
_hasExplicitSelection = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
void select(int? id) {
|
||||
_hasExplicitSelection = true;
|
||||
state = id;
|
||||
}
|
||||
|
||||
void setDefaultIfUnset(int activeYearId) {
|
||||
if (!_hasExplicitSelection && state == null) {
|
||||
state = activeYearId;
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_hasExplicitSelection = false;
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Academic years list
|
||||
@@ -87,7 +108,7 @@ final missingDocsFilterExpandedProvider = NotifierProvider<_BoolNotifier, bool>(
|
||||
|
||||
// ── Yearly Comparison Filters ─────────────────────────────────────────────────
|
||||
|
||||
/// Selected years for yearly comparison (empty = all years)
|
||||
/// Selected years for yearly comparison (empty = default 4 consecutive years)
|
||||
final yearlyComparisonSelectedYearsProvider =
|
||||
NotifierProvider<SelectedYearsNotifier, Set<String>>(
|
||||
() => SelectedYearsNotifier(),
|
||||
@@ -107,6 +128,8 @@ class SelectedYearsNotifier extends Notifier<Set<String>> {
|
||||
state = current;
|
||||
}
|
||||
|
||||
void setYears(Set<String> years) => state = years;
|
||||
|
||||
void clear() => state = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +32,13 @@ class ReportsScreen extends ConsumerStatefulWidget {
|
||||
class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
bool _isExporting = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final ScrollController _tableHorizontalScrollController = ScrollController();
|
||||
final ScrollController _chartHorizontalScrollController = ScrollController();
|
||||
ProviderSubscription<String>? _tabListener;
|
||||
Timer? _pollingTimer;
|
||||
|
||||
int _currentPage = 0;
|
||||
int _rowsPerPage = 10;
|
||||
int _lastTotalRows = -1;
|
||||
int _selectedViewMode = 0; // 0: DepEd Transparency Board, 1: Compliance & Analytics, 2: Combined
|
||||
|
||||
// Table search & sort state
|
||||
@@ -99,6 +100,8 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
_pollingTimer?.cancel();
|
||||
_tabListener?.close();
|
||||
_scrollController.dispose();
|
||||
_tableHorizontalScrollController.dispose();
|
||||
_chartHorizontalScrollController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -477,6 +480,25 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final statsAsync = ref.watch(reportStatsProvider);
|
||||
final storageAsync = ref.watch(storageStatsProvider);
|
||||
final yearsAsync = ref.watch(academicYearsProvider);
|
||||
final selectedYearId = ref.watch(selectedAcademicYearIdProvider);
|
||||
final selectedYearNotifier = ref.read(selectedAcademicYearIdProvider.notifier);
|
||||
|
||||
// Auto-select active academic year on initial load if not explicitly chosen
|
||||
if (!selectedYearNotifier.hasExplicitSelection && selectedYearId == null && yearsAsync.hasValue) {
|
||||
final yearsList = yearsAsync.value ?? [];
|
||||
if (yearsList.isNotEmpty) {
|
||||
final activeYear = yearsList.firstWhere(
|
||||
(y) => y.status.toLowerCase() == 'active',
|
||||
orElse: () => yearsList.last,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
selectedYearNotifier.setDefaultIfUnset(activeYear.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
@@ -982,7 +1004,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
data: (yearsList) =>
|
||||
DropdownButtonFormField<int?>(
|
||||
isExpanded: true,
|
||||
initialValue: selectedYearId,
|
||||
value: selectedYearId,
|
||||
decoration: _filterDecoration(
|
||||
'School Year',
|
||||
),
|
||||
@@ -2038,7 +2060,114 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Interactive Compliance Table ──────────────────────────────────────────
|
||||
// ── Missing Documents Hover Tooltip ──────────────────────────────────────────
|
||||
Widget _buildMissingDocsTooltip({
|
||||
required BuildContext context,
|
||||
required int missingCount,
|
||||
required String? missingRequirementsStr,
|
||||
required Widget child,
|
||||
}) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
String tooltipMessage = '';
|
||||
|
||||
if (missingCount == 0 ||
|
||||
missingRequirementsStr == null ||
|
||||
missingRequirementsStr.trim().isEmpty) {
|
||||
tooltipMessage = 'All documents completed';
|
||||
} else {
|
||||
final rawList = missingRequirementsStr
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final jhsDocs = rawList
|
||||
.where((d) => d.toUpperCase().startsWith('[JHS]'))
|
||||
.map((d) => d.replaceFirst(RegExp(r'^\[JHS\]\s*', caseSensitive: false), '').trim())
|
||||
.toList();
|
||||
final shsDocs = rawList
|
||||
.where((d) => d.toUpperCase().startsWith('[SHS]'))
|
||||
.map((d) => d.replaceFirst(RegExp(r'^\[SHS\]\s*', caseSensitive: false), '').trim())
|
||||
.toList();
|
||||
final otherDocs = rawList
|
||||
.where((d) =>
|
||||
!d.toUpperCase().startsWith('[JHS]') &&
|
||||
!d.toUpperCase().startsWith('[SHS]'))
|
||||
.map((d) => d.trim())
|
||||
.toList();
|
||||
|
||||
final lines = <String>['Missing Documents:'];
|
||||
if (jhsDocs.isNotEmpty) {
|
||||
lines.add('JHS:');
|
||||
lines.addAll(jhsDocs.map((d) => ' • $d'));
|
||||
}
|
||||
if (shsDocs.isNotEmpty) {
|
||||
lines.add('SHS:');
|
||||
lines.addAll(shsDocs.map((d) => ' • $d'));
|
||||
}
|
||||
if (otherDocs.isNotEmpty) {
|
||||
if (jhsDocs.isNotEmpty || shsDocs.isNotEmpty) lines.add('Other:');
|
||||
lines.addAll(otherDocs.map((d) => ' • $d'));
|
||||
}
|
||||
|
||||
tooltipMessage = lines.join('\n');
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: tooltipMessage,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurfaceCard : const Color(0xFF2C3437),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : Colors.transparent,
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black26,
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
),
|
||||
preferBelow: false,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Horizontal Scroll Hint for Mobile/Android ───────────────────────────────
|
||||
Widget _buildHorizontalScrollHint(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.swap_horiz_rounded,
|
||||
size: 15,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Scroll horizontally to view more',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Interactive Compliance Table ──────────────────────────────────────────
|
||||
Widget _buildComplianceTable(ReportStats data) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final showOnlyMissing = ref.watch(showOnlyMissingDocsProvider);
|
||||
@@ -2052,94 +2181,71 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
activeStatus != null ||
|
||||
showOnlyMissing;
|
||||
|
||||
var filteredStudents = showOnlyMissing
|
||||
? data.students.where((s) => s.missingCount > 0).toList()
|
||||
: List<ReportStudent>.from(data.students);
|
||||
|
||||
// Apply search
|
||||
// Filter students
|
||||
final filteredStudents = data.students.where((student) {
|
||||
if (showOnlyMissing && student.missingCount == 0) return false;
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final q = _searchQuery.toLowerCase();
|
||||
filteredStudents = filteredStudents
|
||||
.where(
|
||||
(s) =>
|
||||
s.fullName.toLowerCase().contains(q) ||
|
||||
s.lrn.toLowerCase().contains(q),
|
||||
)
|
||||
.toList();
|
||||
final matchLrn = student.lrn.toLowerCase().contains(q);
|
||||
final matchName = student.fullName.toLowerCase().contains(q);
|
||||
if (!matchLrn && !matchName) return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
// Apply sort
|
||||
// Sort students
|
||||
if (_sortColumn != null) {
|
||||
filteredStudents.sort((a, b) {
|
||||
int cmp;
|
||||
int comparison = 0;
|
||||
switch (_sortColumn) {
|
||||
case 'name':
|
||||
cmp = a.fullName.compareTo(b.fullName);
|
||||
break;
|
||||
case 'lrn':
|
||||
cmp = a.lrn.compareTo(b.lrn);
|
||||
comparison = a.lrn.compareTo(b.lrn);
|
||||
break;
|
||||
case 'name':
|
||||
comparison = a.fullName.compareTo(b.fullName);
|
||||
break;
|
||||
case 'missing':
|
||||
cmp = a.missingCount.compareTo(b.missingCount);
|
||||
comparison = a.missingCount.compareTo(b.missingCount);
|
||||
break;
|
||||
default:
|
||||
cmp = 0;
|
||||
}
|
||||
return _sortAscending ? cmp : -cmp;
|
||||
return _sortAscending ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
final totalRows = filteredStudents.length;
|
||||
if (totalRows != _lastTotalRows) {
|
||||
Future.microtask(() {
|
||||
if (mounted) {
|
||||
final totalPages = (totalRows / _rowsPerPage).ceil().clamp(1, 99999);
|
||||
final startIndex = (_currentPage * _rowsPerPage).clamp(0, totalRows);
|
||||
final endIndex = (startIndex + _rowsPerPage).clamp(0, totalRows);
|
||||
final paginatedStudents = totalRows == 0
|
||||
? <ReportStudent>[]
|
||||
: filteredStudents.sublist(startIndex, endIndex);
|
||||
|
||||
Widget sortableHeader(String label, String columnKey) {
|
||||
final isSelected = _sortColumn == columnKey;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_currentPage = 0;
|
||||
_lastTotalRows = totalRows;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final totalPages = totalRows > 0 ? (totalRows / _rowsPerPage).ceil() : 1;
|
||||
final startIndex = _currentPage * _rowsPerPage;
|
||||
final endIndex = (startIndex + _rowsPerPage > totalRows)
|
||||
? totalRows
|
||||
: startIndex + _rowsPerPage;
|
||||
|
||||
final safeStartIndex = startIndex.clamp(0, totalRows);
|
||||
final safeEndIndex = endIndex.clamp(0, totalRows);
|
||||
final paginatedStudents = filteredStudents.sublist(
|
||||
safeStartIndex,
|
||||
safeEndIndex,
|
||||
);
|
||||
|
||||
// Sortable column header
|
||||
Widget sortableHeader(String label, String colKey) {
|
||||
final isActive = _sortColumn == colKey;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
if (_sortColumn == colKey) {
|
||||
if (_sortColumn == columnKey) {
|
||||
_sortAscending = !_sortAscending;
|
||||
} else {
|
||||
_sortColumn = colKey;
|
||||
_sortColumn = columnKey;
|
||||
_sortAscending = true;
|
||||
_currentPage = 0;
|
||||
}
|
||||
}),
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(width: 3),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
isActive
|
||||
isSelected
|
||||
? (_sortAscending
|
||||
? Icons.arrow_upward_rounded
|
||||
: Icons.arrow_downward_rounded)
|
||||
: Icons.unfold_more_rounded,
|
||||
size: 13,
|
||||
color: isActive
|
||||
? Icons.arrow_upward
|
||||
: Icons.arrow_downward)
|
||||
: Icons.unfold_more,
|
||||
size: 14,
|
||||
color: isSelected
|
||||
? AppColors.primaryGreen
|
||||
: (isDark ? AppColors.darkTextMuted : Colors.grey.shade400),
|
||||
),
|
||||
@@ -2160,14 +2266,14 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Student List',
|
||||
'Student Document Compliance',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -2176,7 +2282,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tap a student to see their full profile. You can sort by LRN, Name, or missing count.',
|
||||
'List of students with their document compliance status.',
|
||||
style: TextStyle(
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
@@ -2185,18 +2291,17 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryGreen.withValues(alpha: 0.08),
|
||||
color: AppColors.primaryGreen.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${data.students.length} students',
|
||||
'Total: $totalRows',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -2207,7 +2312,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// ── Search Field ─────────────────────────────────────────
|
||||
// ── Search Field ───────────────────────────────────────────
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (val) => setState(() {
|
||||
@@ -2270,7 +2375,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// ── Clear All Chip ─────────────────────────────────────────
|
||||
// ── Clear All Chip ─────────────────────────────────────────
|
||||
if (hasActiveFilters || _searchQuery.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
ActionChip(
|
||||
@@ -2354,12 +2459,6 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
),
|
||||
),
|
||||
DataColumn(label: sortableHeader('Missing', 'missing')),
|
||||
const DataColumn(
|
||||
label: Text(
|
||||
'Missing Requirements',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
rows: paginatedStudents.isEmpty
|
||||
? <DataRow>[
|
||||
@@ -2375,7 +2474,6 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
DataCell(Text('')),
|
||||
DataCell(Text('')),
|
||||
DataCell(Text('')),
|
||||
DataCell(Text('')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -2473,7 +2571,11 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Container(
|
||||
_buildMissingDocsTooltip(
|
||||
context: context,
|
||||
missingCount: student.missingCount,
|
||||
missingRequirementsStr: student.missingRequirements,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
@@ -2487,8 +2589,35 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
? const Color(0xFF76BA8A).withValues(alpha: 0.14)
|
||||
: Colors.green.shade50),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: student.missingCount > 0
|
||||
? (isDark
|
||||
? const Color(0xFFD67878).withValues(alpha: 0.3)
|
||||
: Colors.red.shade200)
|
||||
: (isDark
|
||||
? const Color(0xFF76BA8A).withValues(alpha: 0.3)
|
||||
: Colors.green.shade200),
|
||||
width: 0.8,
|
||||
),
|
||||
child: Text(
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
student.missingCount > 0
|
||||
? Icons.info_outline_rounded
|
||||
: Icons.check_circle_outline_rounded,
|
||||
size: 13,
|
||||
color: student.missingCount > 0
|
||||
? (isDark
|
||||
? const Color(0xFFD67878)
|
||||
: Colors.red.shade700)
|
||||
: (isDark
|
||||
? const Color(0xFF76BA8A)
|
||||
: Colors.green.shade700),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
student.missingCount.toString(),
|
||||
style: TextStyle(
|
||||
color: student.missingCount > 0
|
||||
@@ -2502,25 +2631,9 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
student.missingRequirements ??
|
||||
'None — Complete',
|
||||
style: TextStyle(
|
||||
color: student.missingCount > 0
|
||||
? (isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: Colors.grey.shade700)
|
||||
: (isDark
|
||||
? const Color(0xFF76BA8A)
|
||||
: Colors.green.shade700),
|
||||
fontSize: 12,
|
||||
fontStyle: student.missingCount > 0
|
||||
? FontStyle.normal
|
||||
: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -2530,9 +2643,21 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
if (isWideTable) {
|
||||
return tableWidget;
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHorizontalScrollHint(context),
|
||||
Scrollbar(
|
||||
controller: _tableHorizontalScrollController,
|
||||
thumbVisibility: true,
|
||||
trackVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
controller: _tableHorizontalScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: tableWidget,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -2601,7 +2726,9 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
'Showing ${safeStartIndex + 1} - $safeEndIndex of $totalRows',
|
||||
totalRows == 0
|
||||
? 'Showing 0 of 0'
|
||||
: 'Showing ${startIndex + 1} - $endIndex of $totalRows',
|
||||
style: TextStyle(
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
@@ -3260,24 +3387,36 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Underline Filter Dropdown Row ─────────────────────────────────
|
||||
// ── Underline Filter Dropdown Row ──────────────────────────────────────────
|
||||
yearlyAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, e) => const SizedBox.shrink(),
|
||||
data: (allData) {
|
||||
// All available year strings from DB, sorted ascending
|
||||
final allYearStrings = allData.map((d) => d.year).toSet().toList()
|
||||
// Combine all available academic years from setup and data
|
||||
final allSysYears = (ref.watch(academicYearsProvider).asData?.value ?? [])
|
||||
.map((y) => y.yearRange)
|
||||
.toSet();
|
||||
final allDataYears = allData.map((d) => d.year).toSet();
|
||||
final allYearStrings = {...allSysYears, ...allDataYears}.toList()
|
||||
..sort();
|
||||
|
||||
final default4 = allYearStrings.length <= 4
|
||||
? allYearStrings
|
||||
: allYearStrings.sublist(allYearStrings.length - 4);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// Year multi-select dropdown
|
||||
_buildUnderlineDropdown(
|
||||
label: selectedYears.isEmpty
|
||||
? 'Last 4 Years'
|
||||
? (default4.length > 1
|
||||
? '${default4.first} – ${default4.last}'
|
||||
: 'Latest 4 Years')
|
||||
: selectedYears.length == 1
|
||||
? selectedYears.first
|
||||
: selectedYears.length == allYearStrings.length
|
||||
? 'All Years'
|
||||
: '${selectedYears.length} Years',
|
||||
icon: Icons.calendar_today_outlined,
|
||||
onTap: (btnCtx) => _showYearMultiSelectMenu(
|
||||
@@ -3319,7 +3458,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
|
||||
const SizedBox(height: AppSizes.p24),
|
||||
|
||||
// ── Chart ─────────────────────────────────────────────────────────
|
||||
// ── Chart ──────────────────────────────────────────────────────────
|
||||
yearlyAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
loading: () => const Center(
|
||||
@@ -3372,9 +3511,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
constraints.maxWidth > (data.length * 150.0)
|
||||
? constraints.maxWidth
|
||||
: (data.length * 150.0);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(
|
||||
final Widget chartWidget = SizedBox(
|
||||
width: chartWidth,
|
||||
height: 300,
|
||||
child: BarChart(
|
||||
@@ -3501,14 +3638,34 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (chartWidth <= constraints.maxWidth) {
|
||||
return chartWidget;
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHorizontalScrollHint(context),
|
||||
Scrollbar(
|
||||
controller: _chartHorizontalScrollController,
|
||||
thumbVisibility: true,
|
||||
trackVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
controller: _chartHorizontalScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: chartWidget,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// ── Legend (dynamic based on selected statuses) ───────────────────
|
||||
// ── Legend (dynamic based on selected statuses) ─────────────────────
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final statuses = ref.watch(
|
||||
@@ -3644,13 +3801,20 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
final currentSelected = ref.watch(
|
||||
yearlyComparisonSelectedYearsProvider,
|
||||
);
|
||||
return Column(
|
||||
final default4 = allYears.length <= 4
|
||||
? allYears.toSet()
|
||||
: allYears.sublist(allYears.length - 4).toSet();
|
||||
|
||||
return SizedBox(
|
||||
width: 290,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Select School Years',
|
||||
@@ -3659,8 +3823,55 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
yearlyComparisonSelectedYearsProvider
|
||||
.notifier,
|
||||
)
|
||||
.setYears(default4);
|
||||
},
|
||||
child: const Text(
|
||||
'Latest 4',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
yearlyComparisonSelectedYearsProvider
|
||||
.notifier,
|
||||
)
|
||||
.setYears(allYears.toSet());
|
||||
},
|
||||
child: const Text(
|
||||
'All Years',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
@@ -3670,16 +3881,24 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
.clear();
|
||||
},
|
||||
child: const Text(
|
||||
'Clear All',
|
||||
'Clear',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
...allYears.map((year) {
|
||||
final isChecked = currentSelected.contains(year);
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 280),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: allYears.map((year) {
|
||||
final isChecked = currentSelected.isEmpty
|
||||
? default4.contains(year)
|
||||
: currentSelected.contains(year);
|
||||
return CheckboxListTile(
|
||||
dense: true,
|
||||
value: isChecked,
|
||||
@@ -3687,15 +3906,34 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
activeColor: AppColors.primaryGreen,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
onChanged: (val) {
|
||||
if (currentSelected.isEmpty) {
|
||||
final updated = Set<String>.from(default4);
|
||||
if (updated.contains(year)) {
|
||||
updated.remove(year);
|
||||
} else {
|
||||
updated.add(year);
|
||||
}
|
||||
ref
|
||||
.read(
|
||||
yearlyComparisonSelectedYearsProvider.notifier,
|
||||
yearlyComparisonSelectedYearsProvider
|
||||
.notifier,
|
||||
)
|
||||
.setYears(updated);
|
||||
} else {
|
||||
ref
|
||||
.read(
|
||||
yearlyComparisonSelectedYearsProvider
|
||||
.notifier,
|
||||
)
|
||||
.toggle(year);
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user