feat: implement dashboard KPIs, drag-and-drop upload for documents, and teacher management styling

This commit is contained in:
ssbg04 committed 2026-07-25 18:55:30 +08:00
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,