diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index a3fc3ad..e6306aa 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -1065,8 +1065,8 @@ Files backup/restore - mirror mode or zip --> - [x] Implement mobile bottom sheet for filtering (grade, section, status, school year) with active filter chips. ## Priority 3: Status-Driven Documents & Requirements Workflow -- [ ] Integrate document requirements directly into Documents module workflow (All Documents, Requirements, Upload/Import, Print Queue). -- [ ] Add status system badges (✓ Complete, ⚠ Pending, ! Missing, ○ Archived) across document lists. +- [x] Integrate document requirements directly into Documents module workflow (All Documents, Requirements, Upload/Import, Print Queue). +- [x] Add status system badges (✓ Complete, ⚠ Pending, ! Missing, ○ Archived) across document lists. ## Priority 4: Action-Oriented Dashboard - [ ] Add "Needs Attention" operational card (missing SF9/SF10, documents requiring verification). diff --git a/frontend/lib/ui/providers/student_provider.dart b/frontend/lib/ui/providers/student_provider.dart index b5b11d5..ca39977 100644 --- a/frontend/lib/ui/providers/student_provider.dart +++ b/frontend/lib/ui/providers/student_provider.dart @@ -280,6 +280,28 @@ final studentPageProvider = FutureProvider.autoDispose(( ); }); +// ============================================================ +// Enrolled students for Requirements module (excludes non-enrolled/archived) +// ============================================================ +final enrolledStudentsForRequirementsProvider = FutureProvider.autoDispose>(( + ref, +) async { + final repo = ref.read(studentRepositoryProvider); + + final sub = repo.onStudentChanged.listen((_) { + ref.invalidateSelf(); + }); + + ref.onDispose(() => sub.cancel()); + + final page = await repo.getStudents( + status: 'Enrolled', + limit: 1000, + ); + return page.students; +}); + + // ============================================================ // Mutation notifier — handles Create / Update / Delete // ============================================================ diff --git a/frontend/lib/ui/screens/documents/documents_screen.dart b/frontend/lib/ui/screens/documents/documents_screen.dart index 75e6c02..9c06a3c 100644 --- a/frontend/lib/ui/screens/documents/documents_screen.dart +++ b/frontend/lib/ui/screens/documents/documents_screen.dart @@ -35,6 +35,7 @@ import 'widgets/bulk_operations_bar.dart'; import 'widgets/styled_folder_icon.dart'; import '../../../domain/entities/document_model.dart'; import 'widgets/folder_filter_dialog.dart'; +import 'widgets/document_requirements_view.dart'; class DocumentsScreen extends ConsumerStatefulWidget { final String userRole; @@ -61,6 +62,7 @@ class _DocumentsScreenState extends ConsumerState Timer? _debounce; bool _isGridView = false; late final TabController _tabController; + bool _skipFilterClearOnNextTabChange = false; // --- Windows Explorer State Variables --- int? _openedFolderStudentId; @@ -91,8 +93,8 @@ class _DocumentsScreenState extends ConsumerState @override void initState() { super.initState(); - // 2 tabs: 0=Folders, 1=Documents - _tabController = TabController(length: 2, vsync: this); + // 3 tabs: 0=Folders, 1=Documents, 2=Requirements + _tabController = TabController(length: 3, vsync: this); _searchFocusNode.addListener(_onSearchFocusChanged); _pollingTimer = Timer.periodic(const Duration(seconds: 15), (_) { @@ -104,6 +106,8 @@ class _DocumentsScreenState extends ConsumerState ref.invalidate(studentFoldersProvider); } else if (_tabController.index == 1) { ref.invalidate(documentPageProvider); + } else if (_tabController.index == 2) { + ref.invalidate(requirementsSettingsProvider); } } }); @@ -156,13 +160,20 @@ class _DocumentsScreenState extends ConsumerState _selectedDocumentIds.clear(); }); - _clearFilters(); + if (_skipFilterClearOnNextTabChange) { + _skipFilterClearOnNextTabChange = false; + } else { + _clearFilters(); + } // Refresh data to keep UI real-time ref.invalidate(documentPageProvider); ref.invalidate(foldersProvider); ref.invalidate(studentFoldersProvider); ref.invalidate(trashDocumentsProvider); + if (_tabController.index == 2) { + ref.invalidate(requirementsSettingsProvider); + } if (_tabController.index != 0 && _openedFolderStudentId != null) { setState(() { @@ -869,6 +880,22 @@ class _DocumentsScreenState extends ConsumerState ), ], ), + + // Tab 2: Document Requirements Workflow + DocumentRequirementsView( + userRole: widget.userRole, + onFilterByDocumentType: (type) { + _skipFilterClearOnNextTabChange = true; + setState(() { + _selectedDocumentType = type; + }); + _tabController.animateTo(1); + _applyFilters(); + }, + onSwitchToDocumentsTab: () { + _tabController.animateTo(1); + }, + ), ], ), ), @@ -999,6 +1026,17 @@ class _DocumentsScreenState extends ConsumerState isMobile: isMobile, isMobileOrAndroid: isMobileOrAndroid, ), + const SizedBox(width: 2), + _buildSegmentedTabItem( + index: 2, + icon: Icons.assignment_outlined, + activeIcon: Icons.assignment_rounded, + label: isMobile ? 'Reqs' : 'Requirements', + isSelected: activeIndex == 2, + isDark: isDark, + isMobile: isMobile, + isMobileOrAndroid: isMobileOrAndroid, + ), ], ), ); @@ -1208,7 +1246,7 @@ class _DocumentsScreenState extends ConsumerState ], // Action buttons - if (!isMobile && !isFolderOpened) ...[ + if (!isMobile && !isFolderOpened && _tabController.index != 2) ...[ Tooltip( richMessage: (_searchController.text.isNotEmpty || query.search.isNotEmpty) ? const TextSpan(text: 'Clear Search') diff --git a/frontend/lib/ui/screens/documents/widgets/document_requirements_view.dart b/frontend/lib/ui/screens/documents/widgets/document_requirements_view.dart new file mode 100644 index 0000000..cf477a2 --- /dev/null +++ b/frontend/lib/ui/screens/documents/widgets/document_requirements_view.dart @@ -0,0 +1,950 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/constants/app_colors.dart'; +import '../../../../domain/entities/document_requirement_model.dart'; +import '../../../../domain/entities/student_model.dart'; +import '../../../providers/document_provider.dart'; +import '../../../providers/student_provider.dart'; +import 'requirements_settings_modal.dart'; +import 'student_profile_modal.dart'; +import 'upload_ocr_modal.dart'; + +class DocumentRequirementsView extends ConsumerStatefulWidget { + final String userRole; + final ValueChanged? onFilterByDocumentType; + final VoidCallback? onSwitchToDocumentsTab; + + const DocumentRequirementsView({ + super.key, + required this.userRole, + this.onFilterByDocumentType, + this.onSwitchToDocumentsTab, + }); + + @override + ConsumerState createState() => + _DocumentRequirementsViewState(); +} + +class _DocumentRequirementsViewState + extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final settingsAsync = ref.watch(requirementsSettingsProvider); + final enrolledStudentsAsync = ref.watch(enrolledStudentsForRequirementsProvider); + + return settingsAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: AppColors.primaryGreen), + ), + error: (e, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.error), + const SizedBox(height: 12), + Text( + 'Failed to load document requirements', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + '$e', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white70 : AppColors.textSecondary, + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + ref.invalidate(requirementsSettingsProvider); + }, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ), + data: (settings) { + // Collect all actively enrolled students to calculate missing statistics. + // Archived, graduated, transferred, or dropped students are strictly excluded. + final allEnrolled = enrolledStudentsAsync.asData?.value ?? []; + final students = allEnrolled + .where((s) => s.status.toLowerCase() == 'enrolled') + .toList(); + + // Filter requirements by local search + final filteredJhs = settings.jhs.where((r) { + if (_searchQuery.isEmpty) return true; + return r.name.toLowerCase().contains(_searchQuery.toLowerCase()) || + (r.description?.toLowerCase().contains(_searchQuery.toLowerCase()) ?? false); + }).toList(); + + final filteredShs = settings.shs.where((r) { + if (_searchQuery.isEmpty) return true; + return r.name.toLowerCase().contains(_searchQuery.toLowerCase()) || + (r.description?.toLowerCase().contains(_searchQuery.toLowerCase()) ?? false); + }).toList(); + + // Calculate compliance stats + int totalMissingDocsCount = 0; + final Set studentsWithMissingDocs = {}; + + for (final s in students) { + if (s.missingDocumentsCount > 0 || s.missingDocuments.isNotEmpty) { + totalMissingDocsCount += s.missingDocumentsCount > 0 + ? s.missingDocumentsCount + : s.missingDocuments.length; + studentsWithMissingDocs.add(s.id); + } + } + + return RefreshIndicator( + color: AppColors.primaryGreen, + onRefresh: () async { + ref.invalidate(requirementsSettingsProvider); + ref.invalidate(enrolledStudentsForRequirementsProvider); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top Operational Banner / Summary + _buildSummaryBanner( + isDark: isDark, + totalJhs: settings.jhs.where((r) => r.isEnabled).length, + totalShs: settings.shs.where((r) => r.isEnabled).length, + studentsNeedingAttention: studentsWithMissingDocs.length, + totalMissingDocs: totalMissingDocsCount, + ), + const SizedBox(height: 16), + + // Controls Bar: Search & Manage Button + _buildControlsBar(isDark), + const SizedBox(height: 16), + + // Category 1: Junior High School (JHS) Requirements + _buildCategoryGroup( + title: 'Junior High School (JHS)', + subtitle: 'Grades 7 to 10 requirements checklist & compliance', + icon: Icons.school_rounded, + badgeColor: AppColors.primaryGreen, + requirements: filteredJhs, + students: students, + isDark: isDark, + isJhs: true, + ), + const SizedBox(height: 20), + + // Category 2: Senior High School (SHS) Requirements + _buildCategoryGroup( + title: 'Senior High School (SHS)', + subtitle: 'Grades 11 to 12 requirements checklist & compliance', + icon: Icons.history_edu_rounded, + badgeColor: const Color(0xFF1565C0), + requirements: filteredShs, + students: students, + isDark: isDark, + isJhs: false, + ), + const SizedBox(height: 24), + ], + ), + ), + ); + }, + ); + } + + // ══════════════════════════════════════════════════════════════════════════ + // SUMMARY BANNER + // ══════════════════════════════════════════════════════════════════════════ + Widget _buildSummaryBanner({ + required bool isDark, + required int totalJhs, + required int totalShs, + required int studentsNeedingAttention, + required int totalMissingDocs, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isDark ? AppColors.darkBorder : Colors.grey.shade200, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: isDark ? 0.25 : 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.rule_folder_rounded, + size: 22, + color: AppColors.primaryGreen, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Document Requirements & Compliance', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'Department of Education official document standards & tracking', + style: TextStyle( + fontSize: 12, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + const Divider(height: 1), + const SizedBox(height: 14), + // Metric chips + Wrap( + spacing: 10, + runSpacing: 8, + children: [ + _buildMetricPill( + label: 'JHS Active', + value: '$totalJhs items', + icon: Icons.check_circle_outline, + color: AppColors.primaryGreen, + isDark: isDark, + ), + _buildMetricPill( + label: 'SHS Active', + value: '$totalShs items', + icon: Icons.check_circle_outline, + color: const Color(0xFF1565C0), + isDark: isDark, + ), + if (studentsNeedingAttention > 0) + _buildMetricPill( + label: 'Needs Attention', + value: '$studentsNeedingAttention students', + icon: Icons.warning_amber_rounded, + color: Colors.orange.shade800, + isDark: isDark, + ) + else + _buildMetricPill( + label: 'Status', + value: 'All Complete', + icon: Icons.verified_rounded, + color: AppColors.primaryGreen, + isDark: isDark, + ), + ], + ), + ], + ), + ); + } + + Widget _buildMetricPill({ + required String label, + required String value, + required IconData icon, + required Color color, + required bool isDark, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: isDark ? 0.18 : 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: isDark ? 0.35 : 0.25)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text( + '$label: ', + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w500, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + Text( + value, + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ); + } + + // ══════════════════════════════════════════════════════════════════════════ + // CONTROLS BAR: SEARCH & CONFIGURE + // ══════════════════════════════════════════════════════════════════════════ + Widget _buildControlsBar(bool isDark) { + return Row( + children: [ + Expanded( + child: Container( + height: 38, + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isDark ? AppColors.darkBorder : Colors.grey.shade300, + ), + ), + child: TextField( + controller: _searchController, + onChanged: (val) => setState(() => _searchQuery = val.trim()), + decoration: InputDecoration( + hintText: 'Search requirements (e.g. SF9, PSA, SF10)...', + hintStyle: TextStyle( + fontSize: 12.5, + color: isDark ? Colors.white38 : Colors.grey.shade500, + ), + prefixIcon: Icon( + Icons.search, + size: 18, + color: isDark ? Colors.white54 : Colors.grey.shade600, + ), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + border: InputBorder.none, + ), + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white : AppColors.textPrimary, + ), + ), + ), + ), + if (widget.userRole != 'teacher') ...[ + const SizedBox(width: 10), + SizedBox( + height: 38, + child: OutlinedButton.icon( + onPressed: () { + showDialog( + context: context, + builder: (_) => const RequirementsSettingsModal(), + ); + }, + icon: const Icon(Icons.tune_rounded, size: 16), + label: const Text( + 'Configure', + style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w600), + ), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primaryGreen, + side: const BorderSide(color: AppColors.primaryGreen), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(horizontal: 14), + ), + ), + ), + ], + ], + ); + } + + // ══════════════════════════════════════════════════════════════════════════ + // CATEGORY GROUP (JHS / SHS) + // ══════════════════════════════════════════════════════════════════════════ + Widget _buildCategoryGroup({ + required String title, + required String subtitle, + required IconData icon, + required Color badgeColor, + required List requirements, + required List students, + required bool isDark, + required bool isJhs, + }) { + if (requirements.isEmpty && _searchQuery.isNotEmpty) { + return const SizedBox.shrink(); + } + + return Container( + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isDark ? AppColors.darkBorder : Colors.grey.shade200, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section Header + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), + child: Row( + children: [ + Icon(icon, size: 20, color: badgeColor), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : AppColors.textPrimary, + ), + ), + Text( + subtitle, + style: TextStyle( + fontSize: 11, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: isDark ? 0.25 : 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${requirements.length} Requirements', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: badgeColor, + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + + // Requirements Items + if (requirements.isEmpty) + Padding( + padding: const EdgeInsets.all(24.0), + child: Center( + child: Text( + 'No requirements found.', + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white54 : Colors.grey.shade600, + ), + ), + ), + ) + else + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: requirements.length, + separatorBuilder: (ctx, i) => Divider( + height: 1, + color: isDark ? AppColors.darkBorder : Colors.grey.shade100, + ), + itemBuilder: (context, index) { + final req = requirements[index]; + return _buildRequirementRow( + req: req, + students: students, + isDark: isDark, + isJhs: isJhs, + ); + }, + ), + ], + ), + ); + } + + // ══════════════════════════════════════════════════════════════════════════ + // REQUIREMENT ROW + // ══════════════════════════════════════════════════════════════════════════ + Widget _buildRequirementRow({ + required DocumentRequirementModel req, + required List students, + required bool isDark, + required bool isJhs, + }) { + // Filter relevant students based on JHS (grades 7-10) vs SHS (grades 11-12) + final categoryStudents = students.where((s) { + final grade = s.latestGradeLevel; + if (grade == null) return true; + if (isJhs) return grade <= 10; + return grade >= 11; + }).toList(); + + // Find students missing this specific document requirement + final missingStudents = categoryStudents.where((s) { + final cleanReqName = req.name.toLowerCase().trim(); + final hasMissing = s.missingDocuments.any((doc) { + final cleanDoc = doc.toLowerCase().trim(); + return cleanDoc == cleanReqName || + cleanDoc.contains(cleanReqName) || + cleanReqName.contains(cleanDoc); + }); + return hasMissing; + }).toList(); + + final isEnabled = req.isEnabled; + final missingCount = missingStudents.length; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Icon badge + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: (isEnabled ? AppColors.primaryGreen : Colors.grey) + .withValues(alpha: isDark ? 0.20 : 0.10), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + isEnabled ? Icons.assignment_turned_in_outlined : Icons.remove_circle_outline, + size: 20, + color: isEnabled ? AppColors.primaryGreen : Colors.grey, + ), + ), + const SizedBox(width: 12), + + // Title & Description + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + req.name, + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + // Standard status badge (✓ Active or ○ Inactive) + _buildActiveBadge(isEnabled, isDark), + ], + ), + const SizedBox(height: 3), + if (req.description != null && req.description!.isNotEmpty) + Text( + req.description!, + style: TextStyle( + fontSize: 11.5, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + // Compliance Status Badge (✓ Complete or ⚠ Missing) + if (isEnabled) + _buildCompliancePill( + missingCount: missingCount, + totalStudents: categoryStudents.length, + isDark: isDark, + ) + else + Text( + 'Requirement currently optional or inactive', + style: TextStyle( + fontSize: 11, + fontStyle: FontStyle.italic, + color: isDark ? Colors.white38 : Colors.grey.shade500, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + + // Action Buttons + if (isEnabled) ...[ + if (missingCount > 0) + IconButton( + icon: const Icon(Icons.people_outline_rounded, size: 20), + color: Colors.orange.shade800, + tooltip: 'View $missingCount missing students', + onPressed: () => _showMissingStudentsSheet( + context, + reqName: req.name, + missingStudents: missingStudents, + isDark: isDark, + ), + ), + IconButton( + icon: const Icon(Icons.filter_list_rounded, size: 20), + color: AppColors.primaryGreen, + tooltip: 'Filter in All Documents', + onPressed: () { + widget.onFilterByDocumentType?.call(req.name); + widget.onSwitchToDocumentsTab?.call(); + }, + ), + ], + ], + ), + ); + } + + // ══════════════════════════════════════════════════════════════════════════ + // STATUS BADGES ACCORDING TO DESIGN SYSTEM + // ══════════════════════════════════════════════════════════════════════════ + Widget _buildActiveBadge(bool isEnabled, bool isDark) { + if (isEnabled) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: isDark ? 0.22 : 0.10), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: AppColors.primaryGreen.withValues(alpha: isDark ? 0.45 : 0.30), + width: 0.5, + ), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check, size: 10, color: AppColors.primaryGreen), + SizedBox(width: 2), + Text( + 'Required', + style: TextStyle( + fontSize: 9.5, + fontWeight: FontWeight.bold, + color: AppColors.primaryGreen, + ), + ), + ], + ), + ); + } else { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: isDark ? Colors.white10 : Colors.grey.shade200, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.radio_button_unchecked, size: 9, color: Colors.grey.shade600), + const SizedBox(width: 2), + Text( + 'Inactive', + style: TextStyle( + fontSize: 9.5, + fontWeight: FontWeight.w500, + color: isDark ? Colors.white54 : Colors.grey.shade600, + ), + ), + ], + ), + ); + } + } + + Widget _buildCompliancePill({ + required int missingCount, + required int totalStudents, + required bool isDark, + }) { + if (missingCount == 0) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: isDark ? 0.20 : 0.10), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: AppColors.primaryGreen.withValues(alpha: isDark ? 0.40 : 0.25), + width: 0.5, + ), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline, size: 12, color: AppColors.primaryGreen), + SizedBox(width: 4), + Text( + '✓ Complete • All students compliant', + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.primaryGreen, + ), + ), + ], + ), + ); + } else { + final color = Colors.orange.shade800; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: color.withValues(alpha: isDark ? 0.22 : 0.10), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: color.withValues(alpha: isDark ? 0.45 : 0.30), + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.warning_amber_rounded, size: 12, color: color), + const SizedBox(width: 4), + Text( + '⚠ $missingCount students missing', + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // MISSING STUDENTS BOTTOM SHEET / MODAL + // ══════════════════════════════════════════════════════════════════════════ + void _showMissingStudentsSheet( + BuildContext context, { + required String reqName, + required List missingStudents, + required bool isDark, + }) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle + const SizedBox(height: 10), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + + // Title + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + Icons.warning_amber_rounded, + size: 20, + color: Colors.orange.shade800, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Missing: $reqName', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : AppColors.textPrimary, + ), + ), + Text( + '${missingStudents.length} students have not submitted this requirement', + style: TextStyle( + fontSize: 12, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ), + const Divider(height: 20), + + // Student List + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: missingStudents.length, + separatorBuilder: (ctx, i) => Divider( + height: 1, + color: isDark ? AppColors.darkBorder : Colors.grey.shade100, + ), + itemBuilder: (context, i) { + final student = missingStudents[i]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + title: Text( + student.listDisplayName, + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : AppColors.textPrimary, + ), + ), + subtitle: Text( + 'LRN: ${student.lrn} • ${student.gradeSection}', + style: TextStyle( + fontSize: 11.5, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.cloud_upload_outlined, size: 20), + tooltip: 'Upload $reqName', + color: AppColors.primaryGreen, + onPressed: () { + Navigator.of(ctx).pop(); + UploadOcrModal.show( + context, + prefilledStudentId: student.id, + prefilledLrn: student.lrn, + ); + }, + ), + IconButton( + icon: const Icon(Icons.person_outline_rounded, size: 20), + tooltip: 'View Profile', + color: isDark ? Colors.white70 : Colors.black87, + onPressed: () { + Navigator.of(ctx).pop(); + showStudentProfileModal( + context, + studentId: student.id, + userRole: widget.userRole, + ); + }, + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart b/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart index c049a42..823ca82 100644 --- a/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart +++ b/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart @@ -472,7 +472,7 @@ class _FileFolderCardState extends State { ), ), ), - // Bottom section: File name & metadata + // Bottom section: File name, metadata & status badge Padding( padding: const EdgeInsets.fromLTRB(8, 6, 8, 8), child: Column( @@ -481,7 +481,7 @@ class _FileFolderCardState extends State { children: [ Text( widget.document.fileName, - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.w600, @@ -493,17 +493,25 @@ class _FileFolderCardState extends State { ), ), const SizedBox(height: 3), - Text( - '${FileIconHelper.formatFileSize(widget.document.fileSize ?? widget.document.size)} • ${formatShortDate(widget.document.createdAt)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10, - color: isDark - ? AppColors.darkTextMuted - : AppColors.textMuted, - ), + Row( + children: [ + Expanded( + child: Text( + '${FileIconHelper.formatFileSize(widget.document.fileSize ?? widget.document.size)} • ${formatShortDate(widget.document.createdAt)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + color: isDark + ? AppColors.darkTextMuted + : AppColors.textMuted, + ), + ), + ), + ], ), + const SizedBox(height: 4), + _buildStatusBadge(widget.document.status, isDark), ], ), ), @@ -650,6 +658,8 @@ class _FileFolderCardState extends State { ), ), ), + const SizedBox(width: 6), + _buildStatusBadge(widget.document.status, isDark), if (widget.document.documentType != null && widget.document.documentType!.isNotEmpty) ...[ const SizedBox(width: 6), @@ -714,4 +724,69 @@ class _FileFolderCardState extends State { ), ); } + + /// Standardized status system badge: + /// ✓ Complete / Verified, ! Missing, ○ Archived + Widget _buildStatusBadge(String? status, bool isDark) { + final norm = (status ?? 'Completed').trim().toLowerCase(); + + Color fg; + Color bg; + Color border; + String label; + IconData icon; + + if (norm == 'completed' || norm == 'complete' || norm == 'verified') { + fg = isDark ? const Color(0xFF81C784) : const Color(0xFF2E7D32); + bg = isDark ? const Color(0x334CAF50) : const Color(0x182E7D32); + border = isDark ? const Color(0x4D81C784) : const Color(0x332E7D32); + label = 'Complete'; + icon = Icons.check_rounded; + } else if (norm == 'missing') { + fg = isDark ? const Color(0xFFEF5350) : const Color(0xFFC62828); + bg = isDark ? const Color(0x33F44336) : const Color(0x1AC62828); + border = isDark ? const Color(0x4DEF5350) : const Color(0x33C62828); + label = 'Missing'; + icon = Icons.priority_high_rounded; + } else if (norm == 'archived') { + fg = isDark ? const Color(0xFFB0BEC5) : const Color(0xFF546E7A); + bg = isDark ? const Color(0x3378909C) : const Color(0x1A546E7A); + border = isDark ? const Color(0x4DB0BEC5) : const Color(0x33546E7A); + label = 'Archived'; + icon = Icons.circle_outlined; + } else { + fg = isDark ? const Color(0xFF81C784) : const Color(0xFF2E7D32); + bg = isDark ? const Color(0x334CAF50) : const Color(0x182E7D32); + border = isDark ? const Color(0x4D81C784) : const Color(0x332E7D32); + label = 'Complete'; + icon = Icons.check_rounded; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: border, width: 0.7), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 10.5, color: fg), + const SizedBox(width: 3), + Text( + label, + style: TextStyle( + fontSize: 9.5, + fontWeight: FontWeight.w600, + color: fg, + height: 1.1, + ), + ), + ], + ), + ); + } } + diff --git a/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart b/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart index 75200c0..3fc5991 100644 --- a/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart +++ b/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart @@ -369,38 +369,6 @@ class _StudentProfileModalBodyState extends ConsumerState { _StudentProfileTab _selectedTab = _StudentProfileTab.overview; - Future _handleVerifyDocument(DocumentModel doc) async { - const newStatus = 'Completed'; - try { - await ref - .read(documentRepositoryProvider) - .updateDocumentStatus(doc.id, newStatus); - ref.invalidate(studentDocumentsProvider(widget.studentId)); - ref.invalidate(missingRequirementsProvider(widget.studentId)); - ref.invalidate(studentDetailProvider(widget.studentId)); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Document marked as verified'), - backgroundColor: AppColors.primaryGreen, - duration: Duration(seconds: 2), - behavior: SnackBarBehavior.floating, - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to update status: $e'), - backgroundColor: AppColors.error, - behavior: SnackBarBehavior.floating, - ), - ); - } - } - } - Future _handleAddToPrintQueue(DocumentModel doc) async { try { await ref.read(printQueueMutationProvider.notifier).addToQueue(doc.id); @@ -1190,17 +1158,12 @@ class _StudentProfileModalBodyState bool isDark, ) { final bool isMobile = MediaQuery.of(context).size.width < 500; - final isPending = doc.status.toLowerCase() == 'pending'; - final isRejected = doc.status.toLowerCase() == 'rejected'; - final statusColor = isPending - ? AppColors.warning - : (isRejected ? AppColors.error : AppColors.success); - final statusIcon = isPending - ? Icons.hourglass_top_rounded - : (isRejected ? Icons.cancel_outlined : Icons.check_circle_rounded); - final statusLabel = isPending - ? 'Pending' - : (isRejected ? 'Rejected' : 'Verified'); + final isArchived = doc.status.toLowerCase() == 'archived'; + final statusColor = isArchived ? Colors.grey : AppColors.success; + final statusIcon = isArchived + ? Icons.archive_outlined + : Icons.check_circle_rounded; + final statusLabel = isArchived ? 'Archived' : 'Verified'; return Container( margin: const EdgeInsets.only(bottom: 8), @@ -1348,22 +1311,7 @@ class _StudentProfileModalBodyState ), ), ), - if (widget.userRole.toLowerCase() != 'teacher' && isPending) - Tooltip( - message: 'Mark as Verified', - child: IconButton( - icon: const Icon( - Icons.check_circle_outline_rounded, - size: 17, - color: AppColors.success, - ), - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.all(2), - constraints: - const BoxConstraints(minWidth: 26, minHeight: 26), - onPressed: () => _handleVerifyDocument(doc), - ), - ), + Tooltip( message: 'Add to Print Queue', child: IconButton( diff --git a/frontend/lib/ui/screens/students/widgets/student_filter_dialog.dart b/frontend/lib/ui/screens/students/widgets/student_filter_dialog.dart index fbfaf6a..f85c1a0 100644 --- a/frontend/lib/ui/screens/students/widgets/student_filter_dialog.dart +++ b/frontend/lib/ui/screens/students/widgets/student_filter_dialog.dart @@ -85,7 +85,7 @@ class _StudentFilterDialogState extends ConsumerState { static const _docStatusSortItems = [ 'Default', 'Completed', - 'Pending', + 'Missing', ]; static const _allTabStatusItems = [ 'All Status', @@ -446,13 +446,13 @@ class _StudentFilterDialogState extends ConsumerState { ? 'Completed' : ((_pendingSortBy == 'doc_status' && _pendingSortOrder == 'desc') - ? 'Pending' + ? 'Missing' : 'Default'), onSelected: (v) => setState(() { if (v == 'Completed') { _pendingSortBy = 'doc_status'; _pendingSortOrder = 'asc'; - } else if (v == 'Pending') { + } else if (v == 'Missing') { _pendingSortBy = 'doc_status'; _pendingSortOrder = 'desc'; } else {