feat: implement dashboard KPIs, drag-and-drop upload for documents, and teacher management styling
This commit is contained in:
1 parent
3e85e5738b
commit
652716de43
10 files changed
+1600
-9
No files matched your search
@@ -163,3 +163,184 @@ class PaginatedUserHistory {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Dashboard KPI models — used by the chart section on the dashboard
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class DigitalizationCategory {
|
||||
final int total;
|
||||
final int digitized;
|
||||
const DigitalizationCategory({required this.total, required this.digitized});
|
||||
double get percent => total == 0 ? 0 : digitized / total;
|
||||
factory DigitalizationCategory.fromJson(Map<String, dynamic> j) =>
|
||||
DigitalizationCategory(
|
||||
total: (j['total'] as num?)?.toInt() ?? 0,
|
||||
digitized: (j['digitized'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class DigitalizationData {
|
||||
final DigitalizationCategory jhs;
|
||||
final DigitalizationCategory shs;
|
||||
final DigitalizationCategory overall;
|
||||
const DigitalizationData({required this.jhs, required this.shs, required this.overall});
|
||||
factory DigitalizationData.fromJson(Map<String, dynamic> j) =>
|
||||
DigitalizationData(
|
||||
jhs: DigitalizationCategory.fromJson(j['jhs'] as Map<String, dynamic>? ?? {}),
|
||||
shs: DigitalizationCategory.fromJson(j['shs'] as Map<String, dynamic>? ?? {}),
|
||||
overall: DigitalizationCategory.fromJson(j['overall'] as Map<String, dynamic>? ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
class ActivityByDayEntry {
|
||||
final String day;
|
||||
final String action;
|
||||
final String entityType;
|
||||
final int count;
|
||||
const ActivityByDayEntry({
|
||||
required this.day,
|
||||
required this.action,
|
||||
required this.entityType,
|
||||
required this.count,
|
||||
});
|
||||
factory ActivityByDayEntry.fromJson(Map<String, dynamic> j) =>
|
||||
ActivityByDayEntry(
|
||||
day: j['day'] as String? ?? '',
|
||||
action: j['action'] as String? ?? '',
|
||||
entityType: j['entity_type'] as String? ?? '',
|
||||
count: (j['count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class StatusDistributionEntry {
|
||||
final String status;
|
||||
final int count;
|
||||
const StatusDistributionEntry({required this.status, required this.count});
|
||||
factory StatusDistributionEntry.fromJson(Map<String, dynamic> j) =>
|
||||
StatusDistributionEntry(
|
||||
status: j['status'] as String? ?? '',
|
||||
count: (j['count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class StudentDocCount {
|
||||
final int id;
|
||||
final String name;
|
||||
final int docCount;
|
||||
const StudentDocCount({required this.id, required this.name, required this.docCount});
|
||||
factory StudentDocCount.fromJson(Map<String, dynamic> j) =>
|
||||
StudentDocCount(
|
||||
id: (j['id'] as num?)?.toInt() ?? 0,
|
||||
name: j['name'] as String? ?? '',
|
||||
docCount: (j['doc_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class DocTypeEntry {
|
||||
final String name;
|
||||
final int count;
|
||||
const DocTypeEntry({required this.name, required this.count});
|
||||
factory DocTypeEntry.fromJson(Map<String, dynamic> j) =>
|
||||
DocTypeEntry(
|
||||
name: j['name'] as String? ?? '',
|
||||
count: (j['count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class UploadTrendEntry {
|
||||
final String day;
|
||||
final int count;
|
||||
const UploadTrendEntry({required this.day, required this.count});
|
||||
factory UploadTrendEntry.fromJson(Map<String, dynamic> j) =>
|
||||
UploadTrendEntry(
|
||||
day: j['day'] as String? ?? '',
|
||||
count: (j['count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class StorageTypeEntry {
|
||||
final String name;
|
||||
final int bytes;
|
||||
const StorageTypeEntry({required this.name, required this.bytes});
|
||||
factory StorageTypeEntry.fromJson(Map<String, dynamic> j) =>
|
||||
StorageTypeEntry(
|
||||
name: j['name'] as String? ?? '',
|
||||
bytes: (j['bytes'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class StorageAnalytics {
|
||||
final int totalBytes;
|
||||
final int totalFiles;
|
||||
final int filesWithSize;
|
||||
final List<StorageTypeEntry> byType;
|
||||
final int growthRate;
|
||||
final int recentBytes;
|
||||
const StorageAnalytics({
|
||||
required this.totalBytes,
|
||||
required this.totalFiles,
|
||||
required this.filesWithSize,
|
||||
required this.byType,
|
||||
required this.growthRate,
|
||||
required this.recentBytes,
|
||||
});
|
||||
factory StorageAnalytics.fromJson(Map<String, dynamic> j) =>
|
||||
StorageAnalytics(
|
||||
totalBytes: (j['totalBytes'] as num?)?.toInt() ?? 0,
|
||||
totalFiles: (j['totalFiles'] as num?)?.toInt() ?? 0,
|
||||
filesWithSize: (j['filesWithSize'] as num?)?.toInt() ?? 0,
|
||||
byType: (j['byType'] as List<dynamic>? ?? [])
|
||||
.map((e) => StorageTypeEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
growthRate: (j['growthRate'] as num?)?.toInt() ?? 0,
|
||||
recentBytes: (j['recentBytes'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class DashboardKpis {
|
||||
final DigitalizationData digitalization;
|
||||
final List<ActivityByDayEntry> activityByDay;
|
||||
final List<StatusDistributionEntry> statusDistribution;
|
||||
final List<StudentDocCount> topStudents;
|
||||
final List<StudentDocCount> bottomStudents;
|
||||
final List<DocTypeEntry> docTypeBreakdown;
|
||||
final List<UploadTrendEntry> uploadTrend;
|
||||
final StorageAnalytics storageAnalytics;
|
||||
|
||||
const DashboardKpis({
|
||||
required this.digitalization,
|
||||
required this.activityByDay,
|
||||
required this.statusDistribution,
|
||||
required this.topStudents,
|
||||
required this.bottomStudents,
|
||||
required this.docTypeBreakdown,
|
||||
required this.uploadTrend,
|
||||
required this.storageAnalytics,
|
||||
});
|
||||
|
||||
factory DashboardKpis.fromJson(Map<String, dynamic> j) => DashboardKpis(
|
||||
digitalization: DigitalizationData.fromJson(
|
||||
j['digitalization'] as Map<String, dynamic>? ?? {}),
|
||||
activityByDay: (j['activityByDay'] as List<dynamic>? ?? [])
|
||||
.map((e) => ActivityByDayEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
statusDistribution: (j['statusDistribution'] as List<dynamic>? ?? [])
|
||||
.map((e) => StatusDistributionEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
topStudents: (j['topStudents'] as List<dynamic>? ?? [])
|
||||
.map((e) => StudentDocCount.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
bottomStudents: (j['bottomStudents'] as List<dynamic>? ?? [])
|
||||
.map((e) => StudentDocCount.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
docTypeBreakdown: (j['docTypeBreakdown'] as List<dynamic>? ?? [])
|
||||
.map((e) => DocTypeEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
uploadTrend: (j['uploadTrend'] as List<dynamic>? ?? [])
|
||||
.map((e) => UploadTrendEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
storageAnalytics: StorageAnalytics.fromJson(
|
||||
j['storageAnalytics'] as Map<String, dynamic>? ?? {}),
|
||||
);
|
||||
}
|
||||
@@ -55,4 +55,14 @@ class DashboardRepository {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<DashboardKpis> getKpis() async {
|
||||
try {
|
||||
final options = await _getAuthOptions();
|
||||
final response = await _dio.get('/dashboard/kpis', options: options);
|
||||
return DashboardKpis.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw Exception(e.response?.data['message'] ?? 'Failed to fetch KPIs.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,12 @@ final dashboardDataProvider = FutureProvider<DashboardData>((ref) async {
|
||||
);
|
||||
});
|
||||
|
||||
// ── KPI chart data (all 7 chart datasets) ──────────────────────────────────
|
||||
final dashboardKpisProvider = FutureProvider<DashboardKpis>((ref) async {
|
||||
final repository = ref.read(dashboardRepositoryProvider);
|
||||
return repository.getKpis();
|
||||
});
|
||||
|
||||
// ── Full paginated activities (used by RecentActivitiesScreen) ─────────────
|
||||
class ActivityQueryParams {
|
||||
final int page;
|
||||
|
||||
@@ -25,6 +25,7 @@ import '../settings/teacher_management_screen.dart';
|
||||
import 'widgets/notification_dropdown.dart';
|
||||
import '../../shared/modals/view_activity_modal.dart';
|
||||
import '../../shared/modals/reset_requests_modal.dart';
|
||||
import 'widgets/dashboard_kpis.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
@@ -100,6 +101,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
_pollingTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
if (mounted && ref.read(activeTabProvider) == 'Dashboard') {
|
||||
ref.invalidate(dashboardDataProvider);
|
||||
ref.invalidate(dashboardKpisProvider);
|
||||
ref.read(notificationsProvider.notifier).refreshNotifications();
|
||||
}
|
||||
});
|
||||
@@ -112,6 +114,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
Future<void> _handleRefresh() async {
|
||||
if (!mounted) return;
|
||||
ref.invalidate(dashboardDataProvider);
|
||||
ref.invalidate(dashboardKpisProvider);
|
||||
if (!mounted) return;
|
||||
ref.read(notificationsProvider.notifier).refreshNotifications();
|
||||
if (!mounted) return;
|
||||
@@ -347,6 +350,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
const SizedBox(height: 24),
|
||||
_buildStatGrid(data.stats, user),
|
||||
const SizedBox(height: 32),
|
||||
const DashboardKpisSection(),
|
||||
const SizedBox(height: 32),
|
||||
_buildHistorySections(data, user),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, TargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -52,6 +53,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
|
||||
Timer? _debounce;
|
||||
bool _isGridView = false;
|
||||
late final TabController _tabController;
|
||||
bool _isDragOver = false;
|
||||
|
||||
// --- Windows Explorer State Variables ---
|
||||
int? _openedFolderStudentId;
|
||||
@@ -829,7 +831,9 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
|
||||
return GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Scaffold(
|
||||
child: _buildDragDropWrapper(
|
||||
context,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton:
|
||||
@@ -1012,6 +1016,92 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── DRAG AND DROP WRAPPER (Windows only) ──────────────────────────────────
|
||||
Widget _buildDragDropWrapper(BuildContext context, {required Widget child}) {
|
||||
final isWindows = defaultTargetPlatform == TargetPlatform.windows;
|
||||
if (!isWindows) return child;
|
||||
|
||||
return DragTarget<String>(
|
||||
onWillAcceptWithDetails: (details) {
|
||||
setState(() => _isDragOver = true);
|
||||
return true;
|
||||
},
|
||||
onLeave: (_) => setState(() => _isDragOver = false),
|
||||
onAcceptWithDetails: (details) {
|
||||
setState(() => _isDragOver = false);
|
||||
final path = details.data;
|
||||
final file = File(path);
|
||||
final ext = path.toLowerCase();
|
||||
if (ext.endsWith('.pdf') ||
|
||||
ext.endsWith('.jpg') ||
|
||||
ext.endsWith('.jpeg') ||
|
||||
ext.endsWith('.png')) {
|
||||
UploadOcrModal.show(
|
||||
context,
|
||||
prefilledStudentId:
|
||||
_openedFolderStudentId ?? widget.initialStudentId,
|
||||
preloadedFiles: [file],
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
if (_isDragOver)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryGreen.withValues(alpha: 0.08),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryGreen,
|
||||
width: 2.5,
|
||||
strokeAlign: BorderSide.strokeAlignInside,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_upload_outlined,
|
||||
size: 64,
|
||||
color: AppColors.primaryGreen.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Drop files to upload',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
AppColors.primaryGreen.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'PDF, JPG, PNG supported',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color:
|
||||
AppColors.primaryGreen.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,11 +52,19 @@ class UploadOcrModal extends ConsumerStatefulWidget {
|
||||
/// If provided, the modal will automatically fetch and fill this student's LRN
|
||||
final int? prefilledStudentId;
|
||||
|
||||
const UploadOcrModal({super.key, this.prefilledStudentId});
|
||||
/// Files pre-populated from drag-and-drop (Windows)
|
||||
final List<File>? preloadedFiles;
|
||||
|
||||
static void show(BuildContext context, {int? prefilledStudentId}) {
|
||||
const UploadOcrModal({super.key, this.prefilledStudentId, this.preloadedFiles});
|
||||
|
||||
static void show(
|
||||
BuildContext context, {
|
||||
int? prefilledStudentId,
|
||||
List<File>? preloadedFiles,
|
||||
}) {
|
||||
WoltModalSheet.show<void>(
|
||||
context: context,
|
||||
useSafeArea: false,
|
||||
pageListBuilder: (modalSheetContext) {
|
||||
return [
|
||||
WoltModalSheetPage(
|
||||
@@ -64,7 +72,10 @@ class UploadOcrModal extends ConsumerStatefulWidget {
|
||||
hasSabGradient: false,
|
||||
hasTopBarLayer: false,
|
||||
isTopBarLayerAlwaysVisible: false,
|
||||
child: UploadOcrModal(prefilledStudentId: prefilledStudentId),
|
||||
child: UploadOcrModal(
|
||||
prefilledStudentId: prefilledStudentId,
|
||||
preloadedFiles: preloadedFiles,
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
@@ -92,11 +103,28 @@ class _UploadOcrModalState extends ConsumerState<UploadOcrModal> {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.invalidate(documentRequirementsProvider);
|
||||
ref.read(documentRequirementsProvider.future);
|
||||
});
|
||||
if (widget.prefilledStudentId != null) {
|
||||
_matchedStudentId = widget.prefilledStudentId;
|
||||
_fetchPrefilledStudentLrn();
|
||||
}
|
||||
// Pre-populate entries from drag-and-drop files
|
||||
if (widget.preloadedFiles != null && widget.preloadedFiles!.isNotEmpty) {
|
||||
_entries = widget.preloadedFiles!.map((f) {
|
||||
final bytes = f.lengthSync();
|
||||
final kb = bytes / 1024;
|
||||
final size = kb >= 1024
|
||||
? '${(kb / 1024).toStringAsFixed(1)} MB'
|
||||
: '${kb.toStringAsFixed(0)} KB';
|
||||
return _UploadEntry(
|
||||
file: f,
|
||||
fileName: f.path.split(Platform.pathSeparator).last,
|
||||
fileSize: size,
|
||||
);
|
||||
}).toList();
|
||||
_currentStep = 1; // Jump to review step
|
||||
}
|
||||
_lrnController.addListener(_onLrnChanged);
|
||||
}
|
||||
|
||||
@@ -221,7 +249,7 @@ class _UploadOcrModalState extends ConsumerState<UploadOcrModal> {
|
||||
options: DocumentScannerOptions(
|
||||
documentFormats: const {DocumentFormat.pdf},
|
||||
mode: ScannerMode.full,
|
||||
isGalleryImport: false,
|
||||
isGalleryImport: true,
|
||||
pageLimit: 20,
|
||||
),
|
||||
);
|
||||
@@ -269,6 +297,7 @@ class _UploadOcrModalState extends ConsumerState<UploadOcrModal> {
|
||||
|
||||
void _applyAutoDetect(_UploadEntry entry, List<dynamic> requirements) {
|
||||
final fileNameLower = entry.fileName.toLowerCase();
|
||||
final cleanFileName = fileNameLower.replaceAll(RegExp(r'[^a-z0-9]'), '');
|
||||
|
||||
// Extract alphanumeric words from filename
|
||||
final fileWords = RegExp(r'[a-zA-Z0-9]+')
|
||||
@@ -280,12 +309,29 @@ class _UploadOcrModalState extends ConsumerState<UploadOcrModal> {
|
||||
int bestScore = 0;
|
||||
|
||||
for (final req in requirements) {
|
||||
final reqNameLower = (req.name as String).toLowerCase();
|
||||
final cleanReqName = reqNameLower.replaceAll(RegExp(r'[^a-z0-9]'), '');
|
||||
|
||||
int matches = 0;
|
||||
|
||||
// If the clean filename contains the clean requirement name, it's a very strong match
|
||||
if (cleanReqName.isNotEmpty && cleanFileName.contains(cleanReqName)) {
|
||||
matches += 100;
|
||||
}
|
||||
|
||||
// Special logic for SF forms
|
||||
if (reqNameLower.contains('sf9') && cleanFileName.contains('sf9')) {
|
||||
matches += 50;
|
||||
}
|
||||
if (reqNameLower.contains('sf10') && cleanFileName.contains('sf10')) {
|
||||
matches += 50;
|
||||
}
|
||||
|
||||
final reqWords = RegExp(r'[a-zA-Z0-9]+')
|
||||
.allMatches((req.name as String).toLowerCase())
|
||||
.allMatches(reqNameLower)
|
||||
.map((m) => m.group(0)!)
|
||||
.toList();
|
||||
|
||||
int matches = 0;
|
||||
for (final rw in reqWords) {
|
||||
if (rw.length < 2) continue; // Ignore single characters
|
||||
|
||||
@@ -302,7 +348,7 @@ class _UploadOcrModalState extends ConsumerState<UploadOcrModal> {
|
||||
}
|
||||
}
|
||||
|
||||
if (bestScore > 0 && best != null) {
|
||||
if (best != null && bestScore > 0) {
|
||||
entry.selectedRequirementId = best.id as int;
|
||||
entry.selectedDocumentType = best.name as String;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class _TeacherManagementModalState extends ConsumerState<TeacherManagementModal>
|
||||
|
||||
if (isAndroid) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundColor: AppColors.surfaceWhite,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
|
||||
Reference in new issue
Block a user