feat: dynamic file upload limit per document requirement (frontend & backend)

This commit is contained in:
ssbg04 committed 2026-09-10 17:29:01 +08:00
1 parent 01d636ea19
commit d8dd86ba04
8 files changed
+168 -52

No files matched your search

@@ -8,6 +8,7 @@ class DocumentRequirementModel {
final DateTime? dueDate;
final String acceptedFileTypes;
final String schoolLevels;
final int maxFiles;
final DateTime? createdAt;
final DateTime? updatedAt;
@@ -21,11 +22,15 @@ class DocumentRequirementModel {
this.dueDate,
this.acceptedFileTypes = 'pdf,jpg,jpeg,png',
this.schoolLevels = 'JHS,SHS',
this.maxFiles = 1,
this.createdAt,
this.updatedAt,
});
factory DocumentRequirementModel.fromJson(Map<String, dynamic> json) {
final rawMax = json['max_files'] ?? json['maxFiles'];
final parsedMax = rawMax != null ? (rawMax as num).toInt() : 1;
return DocumentRequirementModel(
id: json['id'] as int,
name: json['name'] as String,
@@ -39,6 +44,7 @@ class DocumentRequirementModel {
: null,
acceptedFileTypes: json['accepted_file_types'] ?? 'pdf,jpg,jpeg,png',
schoolLevels: json['school_levels'] ?? 'JHS,SHS',
maxFiles: parsedMax >= 1 ? parsedMax : 1,
createdAt:
(json['created_at'] != null &&
json['created_at'].toString().isNotEmpty)
@@ -63,6 +69,8 @@ class DocumentRequirementModel {
'dueDate': dueDate?.toIso8601String(),
'acceptedFileTypes': acceptedFileTypes,
'schoolLevels': schoolLevels,
'maxFiles': maxFiles,
'max_files': maxFiles,
};
}
@@ -76,6 +84,7 @@ class DocumentRequirementModel {
DateTime? dueDate,
String? acceptedFileTypes,
String? schoolLevels,
int? maxFiles,
}) {
return DocumentRequirementModel(
id: id ?? this.id,
@@ -87,6 +96,7 @@ class DocumentRequirementModel {
dueDate: dueDate ?? this.dueDate,
acceptedFileTypes: acceptedFileTypes ?? this.acceptedFileTypes,
schoolLevels: schoolLevels ?? this.schoolLevels,
maxFiles: maxFiles ?? this.maxFiles,
createdAt: createdAt,
updatedAt: updatedAt,
);
@@ -162,8 +162,8 @@ class _RequirementsSettingsModalState
mainAxisSize: MainAxisSize.min,
children: [
Switch(
value: req.isEnabled ?? true,
activeColor: AppColors.primaryGreen,
value: req.isEnabled,
activeThumbColor: AppColors.primaryGreen,
onChanged: (val) {
_toggleRequirement(req, val);
},
@@ -193,17 +193,7 @@ class _RequirementsSettingsModalState
void _toggleRequirement(DocumentRequirementModel req, bool isEnabled) async {
try {
final updatedReq = DocumentRequirementModel(
id: req.id,
name: req.name,
description: req.description,
category: req.category,
isMandatory: req.isMandatory,
isEnabled: isEnabled,
dueDate: req.dueDate,
acceptedFileTypes: req.acceptedFileTypes,
schoolLevels: req.schoolLevels,
);
final updatedReq = req.copyWith(isEnabled: isEnabled);
await ref
.read(requirementMutationProvider.notifier)
.updateRequirement(updatedReq);
@@ -18,6 +18,7 @@ import '../../../shared/inputs/custom_text_field.dart';
import '../../../shared/buttons/primary_button.dart';
import '../../../shared/dialogs/error_dialog.dart';
import '../../../../domain/entities/student_model.dart';
import '../../../../domain/entities/document_requirement_model.dart';
import 'package:wolt_modal_sheet/wolt_modal_sheet.dart';
import 'upload_modal_header.dart';
@@ -727,23 +728,32 @@ class _UploadOcrModalState extends ConsumerState<UploadOcrModal> {
return;
}
// Check for duplicate document types within the same upload batch
final docTypeCounts = <String, int>{};
// Check against max allowed files per requirement within the same upload batch
final requirements = ref.read(documentRequirementsProvider).value ?? [];
final reqMap = <int, dynamic>{for (final r in requirements) r.id: r};
final reqCounts = <int, int>{};
for (final e in pending) {
final dt = e.selectedDocumentType;
if (dt != null && dt.isNotEmpty) {
docTypeCounts[dt] = (docTypeCounts[dt] ?? 0) + 1;
if (e.selectedRequirementId != null) {
reqCounts[e.selectedRequirementId!] = (reqCounts[e.selectedRequirementId!] ?? 0) + 1;
}
}
final duplicatesInBatch = docTypeCounts.entries
.where((entry) => entry.value > 1)
.map((entry) => entry.key)
.toList();
if (duplicatesInBatch.isNotEmpty) {
final exceededInBatch = <String>[];
for (final entry in reqCounts.entries) {
final req = reqMap[entry.key];
final maxAllowed = (req is DocumentRequirementModel) ? req.maxFiles : 1;
if (entry.value > maxAllowed) {
final reqName = req != null ? req.name : 'Selected Requirement';
exceededInBatch.add('• $reqName (${entry.value} files selected, max allowed: $maxAllowed)');
}
}
if (exceededInBatch.isNotEmpty) {
showErrorDialog(
context,
'Duplicate Document Types',
'Multiple files have the same document type (${duplicatesInBatch.join(", ")}).\n\nOnly one file per document type is allowed per student. Please assign distinct document types.',
'Upload Limit Exceeded',
'The following document requirement(s) exceed their allowed file limit:\n\n${exceededInBatch.join('\n')}\n\nPlease adjust your selected files or document types.',
);
return;
}
@@ -1353,9 +1363,11 @@ class _UploadOcrModalState extends ConsumerState<UploadOcrModal> {
),
));
for (final r in jhs) {
final maxF = (r is DocumentRequirementModel) ? r.maxFiles : 1;
final limitHint = maxF > 1 ? ' (Max $maxF files)' : '';
entries.add(DropdownMenuEntry<int>(
value: r.id as int,
label: '${r.name}${r.isMandatory ? " *" : ""}',
label: '${r.name}${r.isMandatory ? " *" : ""}$limitHint',
));
}
}
@@ -1371,9 +1383,11 @@ class _UploadOcrModalState extends ConsumerState<UploadOcrModal> {
),
));
for (final r in shs) {
final maxF = (r is DocumentRequirementModel) ? r.maxFiles : 1;
final limitHint = maxF > 1 ? ' (Max $maxF files)' : '';
entries.add(DropdownMenuEntry<int>(
value: r.id as int,
label: '${r.name}${r.isMandatory ? " *" : ""}',
label: '${r.name}${r.isMandatory ? " *" : ""}$limitHint',
));
}
}
@@ -885,6 +885,39 @@ class _RequirementsModalState extends ConsumerState<RequirementsModal> {
),
),
// Max Files Tag
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (isDark ? AppColors.darkSurface2 : const Color(0xFFF1F3F5)),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isDark ? AppColors.darkBorder : Colors.grey.shade300,
width: 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.file_copy_outlined,
size: 10,
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
),
const SizedBox(width: 3),
Text(
req.maxFiles == 1 ? '1 file' : 'Max: ${req.maxFiles} files',
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
),
),
],
),
),
// Inactive Tag
if (!req.isEnabled) ...[
const SizedBox(width: 6),
@@ -1030,6 +1063,7 @@ class _RequirementFormModalState extends ConsumerState<RequirementFormModal> {
late bool _isMandatory;
late bool _isEnabled;
late String _acceptedFileTypes;
late int _maxFiles;
bool _isLoading = false;
@@ -1057,6 +1091,7 @@ class _RequirementFormModalState extends ConsumerState<RequirementFormModal> {
_isMandatory = req?.isMandatory ?? true;
_isEnabled = req?.isEnabled ?? true;
_maxFiles = req?.maxFiles ?? 1;
String savedTypes = (req?.acceptedFileTypes ?? 'pdf,jpg,jpeg,png').replaceAll(' ', '');
const validItems = [
@@ -1241,6 +1276,26 @@ class _RequirementFormModalState extends ConsumerState<RequirementFormModal> {
const SizedBox(height: 12),
// Maximum Files Allowed Dropdown
DropdownButtonFormField<int>(
initialValue: _maxFiles,
decoration: const InputDecoration(
labelText: 'Maximum Files Allowed',
helperText: 'Number of files that can be uploaded for this requirement',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
items: [1, 2, 3, 4, 5, 10, 15, 20].map((n) {
return DropdownMenuItem<int>(
value: n,
child: Text(n == 1 ? '1 file (Single upload)' : '$n files'),
);
}).toList(),
onChanged: (v) => setState(() => _maxFiles = v ?? 1),
),
const SizedBox(height: 12),
// Mandatory Switch
SwitchListTile(
title: const Text('Mandatory Requirement', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13.5)),
@@ -1328,6 +1383,7 @@ class _RequirementFormModalState extends ConsumerState<RequirementFormModal> {
dueDate: dueDate,
acceptedFileTypes: _acceptedFileTypes,
schoolLevels: 'JHS,SHS',
maxFiles: _maxFiles,
);
if (widget.requirement != null && categories.length == 1) {