feat: Add student profile enrollment modification and name formatting
This commit is contained in:
1 parent
7e528441ef
commit
b15e52d39c
9 files changed
+798
-52
No files matched your search
@@ -36,13 +36,33 @@ class StudentModel {
|
||||
}) : _latestGradeLevel = latestGradeLevel,
|
||||
_latestSection = latestSection;
|
||||
|
||||
/// Display name: "De La Cruz, Juan Jr. M."
|
||||
String get fullName {
|
||||
final ext = extension != null && extension!.isNotEmpty ? ' ${extension!}' : '';
|
||||
final mi = middleName != null && middleName!.isNotEmpty ? ' ${middleName![0]}.' : '';
|
||||
return '$lastName, $firstName$ext$mi'.trim();
|
||||
String _getValidExtension() {
|
||||
if (extension == null || extension!.trim().isEmpty) return '';
|
||||
// do not display the suffix with n/a regex
|
||||
final RegExp naRegex = RegExp(r'^n\/?a$', caseSensitive: false);
|
||||
if (naRegex.hasMatch(extension!.trim())) return '';
|
||||
return extension!.trim();
|
||||
}
|
||||
|
||||
/// Format: [Lastname, Firstname, suffix, middle name]
|
||||
String get listDisplayName {
|
||||
final extStr = _getValidExtension();
|
||||
final ext = extStr.isNotEmpty ? ' $extStr' : '';
|
||||
final mName = middleName != null && middleName!.isNotEmpty ? ' $middleName' : '';
|
||||
return '$lastName, $firstName$ext$mName'.trim();
|
||||
}
|
||||
|
||||
/// Format: [First Name, Middle name, last name, suffix]
|
||||
String get profileDisplayName {
|
||||
final extStr = _getValidExtension();
|
||||
final ext = extStr.isNotEmpty ? ' $extStr' : '';
|
||||
final mName = middleName != null && middleName!.isNotEmpty ? ' $middleName' : '';
|
||||
return '$firstName$mName $lastName$ext'.trim();
|
||||
}
|
||||
|
||||
/// Legacy fullName alias (maps to listDisplayName to avoid breaking existing code where not updated yet)
|
||||
String get fullName => listDisplayName;
|
||||
|
||||
/// Traverses the enrollments list and dynamically resolves parameters matching
|
||||
/// the highest value of academicYearId or the highest lexicographical string sequence in yearRange.
|
||||
EnrollmentModel? get latestEnrollment {
|
||||
|
||||
@@ -281,4 +281,71 @@ class StudentRepository {
|
||||
throw Exception(msg);
|
||||
}
|
||||
}
|
||||
// ----------------------------------------------------------------
|
||||
// Update Enrollment
|
||||
// ----------------------------------------------------------------
|
||||
Future<void> updateEnrollment({
|
||||
required int enrollmentId,
|
||||
required int academicYearId,
|
||||
required int gradeLevel,
|
||||
required int sectionId,
|
||||
String? trackStrand,
|
||||
}) async {
|
||||
try {
|
||||
final options = await _getAuthOptions();
|
||||
await _dio.put(
|
||||
'/students/enrollments/$enrollmentId',
|
||||
data: {
|
||||
'academicYearId': academicYearId,
|
||||
'gradeLevel': gradeLevel,
|
||||
'sectionId': sectionId,
|
||||
'trackStrand': trackStrand,
|
||||
},
|
||||
options: options,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
final msg = e.response?.data?['message'] ?? 'Failed to update enrollment.';
|
||||
throw Exception(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Delete Enrollment
|
||||
// ----------------------------------------------------------------
|
||||
Future<void> deleteEnrollment(int enrollmentId) async {
|
||||
try {
|
||||
final options = await _getAuthOptions();
|
||||
await _dio.delete('/students/enrollments/$enrollmentId', options: options);
|
||||
} on DioException catch (e) {
|
||||
final msg = e.response?.data?['message'] ?? 'Failed to delete enrollment.';
|
||||
throw Exception(msg);
|
||||
}
|
||||
}
|
||||
// ----------------------------------------------------------------
|
||||
// Add Enrollment
|
||||
// ----------------------------------------------------------------
|
||||
Future<void> addEnrollment({
|
||||
required int studentId,
|
||||
required int academicYearId,
|
||||
required int gradeLevel,
|
||||
required int sectionId,
|
||||
String? trackStrand,
|
||||
}) async {
|
||||
try {
|
||||
final options = await _getAuthOptions();
|
||||
await _dio.post(
|
||||
'/students/$studentId/enrollments',
|
||||
data: {
|
||||
'academicYearId': academicYearId,
|
||||
'gradeLevel': gradeLevel,
|
||||
'sectionId': sectionId,
|
||||
'trackStrand': trackStrand,
|
||||
},
|
||||
options: options,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
final msg = e.response?.data?['message'] ?? 'Failed to add enrollment.';
|
||||
throw Exception(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,4 +325,69 @@ class StudentMutationNotifier extends AsyncNotifier<void> {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
Future<void> updateEnrollment({
|
||||
required int studentId,
|
||||
required int enrollmentId,
|
||||
required int academicYearId,
|
||||
required int gradeLevel,
|
||||
required int sectionId,
|
||||
String? trackStrand,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final repo = ref.read(studentRepositoryProvider);
|
||||
await repo.updateEnrollment(
|
||||
enrollmentId: enrollmentId,
|
||||
academicYearId: academicYearId,
|
||||
gradeLevel: gradeLevel,
|
||||
sectionId: sectionId,
|
||||
trackStrand: trackStrand,
|
||||
);
|
||||
state = const AsyncData(null);
|
||||
ref.invalidate(studentDetailProvider(studentId));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteEnrollment({
|
||||
required int studentId,
|
||||
required int enrollmentId,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final repo = ref.read(studentRepositoryProvider);
|
||||
await repo.deleteEnrollment(enrollmentId);
|
||||
state = const AsyncData(null);
|
||||
ref.invalidate(studentDetailProvider(studentId));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
Future<void> addEnrollment({
|
||||
required int studentId,
|
||||
required int academicYearId,
|
||||
required int gradeLevel,
|
||||
required int sectionId,
|
||||
String? trackStrand,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final repo = ref.read(studentRepositoryProvider);
|
||||
await repo.addEnrollment(
|
||||
studentId: studentId,
|
||||
academicYearId: academicYearId,
|
||||
gradeLevel: gradeLevel,
|
||||
sectionId: sectionId,
|
||||
trackStrand: trackStrand,
|
||||
);
|
||||
state = const AsyncData(null);
|
||||
ref.invalidate(studentDetailProvider(studentId));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import '../../../../domain/repositories/document_repository.dart'
|
||||
show MissingRequirements;
|
||||
import '../../../providers/student_provider.dart';
|
||||
import '../../../providers/document_provider.dart';
|
||||
import '../../students/widgets/edit_enrollment_modal.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public helper – call this anywhere to show the modal
|
||||
@@ -148,11 +149,38 @@ class StudentProfileModalBody extends ConsumerWidget {
|
||||
_buildInfoCard(student),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
if (student.enrollments != null &&
|
||||
student.enrollments!.isNotEmpty) ...[
|
||||
const Text('Enrollments',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Enrollments',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
if (userRole == 'admin' || userRole == 'super_admin')
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.45),
|
||||
builder: (ctx) => EditEnrollmentModal(
|
||||
studentId: studentId,
|
||||
enrollment: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Add Enrollment'),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
if (student.enrollments != null && student.enrollments!.isNotEmpty) ...[
|
||||
|
||||
...(() {
|
||||
final sorted = List.from(student.enrollments!);
|
||||
sorted.sort((a, b) =>
|
||||
@@ -169,7 +197,7 @@ class StudentProfileModalBody extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return uniqueEnrollments
|
||||
.map<Widget>((e) => _buildEnrollmentCard(e))
|
||||
.map<Widget>((e) => _buildEnrollmentCard(context, ref, e))
|
||||
.toList();
|
||||
})(),
|
||||
const SizedBox(height: 20),
|
||||
@@ -233,7 +261,7 @@ class StudentProfileModalBody extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${student.firstName} ${student.middleName != null ? student.middleName! + ' ' : ''}${student.lastName}${student.extension?.isNotEmpty == true ? ', ${student.extension}' : ''}',
|
||||
student.profileDisplayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 17, fontWeight: FontWeight.bold),
|
||||
),
|
||||
@@ -353,7 +381,7 @@ class StudentProfileModalBody extends ConsumerWidget {
|
||||
}
|
||||
|
||||
// ── Enrollment card ──────────────────────────────────────
|
||||
Widget _buildEnrollmentCard(dynamic enrollment) {
|
||||
Widget _buildEnrollmentCard(BuildContext context, WidgetRef ref, dynamic enrollment) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -392,6 +420,53 @@ class StudentProfileModalBody extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (userRole == 'admin' || userRole == 'super_admin') ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 18, color: Colors.blue),
|
||||
tooltip: 'Edit Enrollment',
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.45),
|
||||
builder: (ctx) => EditEnrollmentModal(
|
||||
studentId: studentId,
|
||||
enrollment: enrollment,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 18, color: AppColors.error),
|
||||
tooltip: 'Delete Enrollment',
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete Enrollment'),
|
||||
content: const Text(
|
||||
'Are you sure you want to delete this enrollment? This action cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
ref.read(studentMutationProvider.notifier).deleteEnrollment(
|
||||
studentId: studentId,
|
||||
enrollmentId: enrollment.id,
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
||||
child: const Text('DELETE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -329,7 +329,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
children: [
|
||||
Text(
|
||||
'Are you sure you want to permanently delete '
|
||||
'"${student.fullName}"? This cannot be undone.',
|
||||
'"${student.listDisplayName}"? This cannot be undone.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Please enter your password to confirm:'),
|
||||
@@ -430,7 +430,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
Future.microtask(() {
|
||||
if (mounted) {
|
||||
ref.read(openedFolderProvider.notifier).setFolder(
|
||||
OpenedFolderData(id: student.id, name: student.fullName)
|
||||
OpenedFolderData(id: student.id, name: student.listDisplayName)
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1170,7 +1170,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
onTap: () => _viewProfile(student),
|
||||
),
|
||||
DataCell(
|
||||
Text(student.fullName),
|
||||
Text(student.listDisplayName),
|
||||
onTap: () => _viewProfile(student),
|
||||
),
|
||||
DataCell(
|
||||
@@ -1314,7 +1314,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.fullName,
|
||||
s.listDisplayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
|
||||
@@ -70,9 +70,11 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
|
||||
int? _selectedSectionId;
|
||||
String? _trackStrand;
|
||||
bool _isEnrollmentInitialized = false;
|
||||
// Cache of all enrollments loaded for the student being edited
|
||||
List<dynamic>? _loadedEnrollments;
|
||||
|
||||
static const _statuses = ['Enrolled', 'Graduated', 'Transferred', 'Dropped'];
|
||||
static const _extSuggestions = ['JR.', 'SR.', 'II', 'III', 'IV', 'V', 'VI'];
|
||||
static const _extSuggestions = ['N/A', 'JR.', 'SR.', 'II', 'III', 'IV', 'V', 'VI'];
|
||||
|
||||
bool _showOcrStep = false;
|
||||
String? _selectedOcrDocType;
|
||||
@@ -105,10 +107,21 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
|
||||
ref.invalidate(sectionsListProvider);
|
||||
}
|
||||
});
|
||||
// Also refresh when the Enrollment tab is opened
|
||||
_tabController.addListener(_onTabChanged);
|
||||
}
|
||||
|
||||
void _onTabChanged() {
|
||||
if (_tabController.index == 1 && mounted) {
|
||||
ref.invalidate(academicYearsListProvider);
|
||||
ref.invalidate(gradeLevelsListProvider);
|
||||
ref.invalidate(sectionsListProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.removeListener(_onTabChanged);
|
||||
_tabController.dispose();
|
||||
_lrnController.dispose();
|
||||
_firstNameController.dispose();
|
||||
@@ -492,6 +505,34 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate enrollment academic year downgrade restriction (edit only)
|
||||
// A student cannot be updated to a lower grade level within the same academic year.
|
||||
if (widget.student != null) {
|
||||
// Use _loadedEnrollments (fetched via studentDetailProvider) which is always
|
||||
// populated in edit mode, unlike widget.student!.enrollments which may be null.
|
||||
final enrollments = _loadedEnrollments;
|
||||
if (enrollments != null && enrollments.isNotEmpty) {
|
||||
final sameYearEnrollments = enrollments.where(
|
||||
(e) => e.academicYearId == _selectedAcademicYearId,
|
||||
).toList();
|
||||
if (sameYearEnrollments.isNotEmpty) {
|
||||
final existingMaxGrade = sameYearEnrollments
|
||||
.map((e) => e.gradeLevel as int)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
if (_selectedGradeLevel! < existingMaxGrade) {
|
||||
_tabController.animateTo(1);
|
||||
_showValidationDialog(
|
||||
'Cannot downgrade enrollment. This student is already '
|
||||
'in Grade $existingMaxGrade for this academic year. '
|
||||
'You cannot change it to Grade $_selectedGradeLevel '
|
||||
'in the same academic year.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final notifier = ref.read(studentMutationProvider.notifier);
|
||||
@@ -562,6 +603,12 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
|
||||
if (widget.student != null && !_isEnrollmentInitialized) {
|
||||
final detailsAsync = ref.watch(studentDetailProvider(widget.student!.id));
|
||||
detailsAsync.whenData((fullStudent) {
|
||||
// Cache the enrollment list for use in validation
|
||||
if (_loadedEnrollments == null && fullStudent.enrollments != null) {
|
||||
Future.microtask(() {
|
||||
if (mounted) setState(() => _loadedEnrollments = fullStudent.enrollments);
|
||||
});
|
||||
}
|
||||
if (fullStudent.enrollments != null &&
|
||||
fullStudent.enrollments!.isNotEmpty) {
|
||||
final latestEnrollment = fullStudent.enrollments!.reduce((a, b) {
|
||||
@@ -809,7 +856,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
|
||||
_SectionLabel(label: 'NAME'),
|
||||
const SizedBox(height: AppSizes.p8),
|
||||
|
||||
// Names & Ext
|
||||
// Names & Ext — order: First Name | Ext | Middle Name (row 1), Last Name (row 2)
|
||||
LayoutBuilder(
|
||||
builder: (ctx, c) {
|
||||
final wide = c.maxWidth > 480;
|
||||
@@ -817,8 +864,11 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// First Name
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextFormField(
|
||||
controller: _firstNameController,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
@@ -836,7 +886,18 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSizes.p12),
|
||||
// Extension (right after first name)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _ExtensionNameField(
|
||||
controller: _extController,
|
||||
suggestions: _extSuggestions,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSizes.p12),
|
||||
// Middle Name
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextFormField(
|
||||
controller: _middleNameController,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
@@ -852,32 +913,19 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSizes.p12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextFormField(
|
||||
controller: _lastNameController,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
inputFormatters: [_UpperCaseWordsFormatter()],
|
||||
validator: (v) =>
|
||||
_validateRequired(v, 'Last name'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'LAST NAME',
|
||||
prefixIcon: Icon(
|
||||
Icons.badge_outlined,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Last Name — full width
|
||||
TextFormField(
|
||||
controller: _lastNameController,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
inputFormatters: [_UpperCaseWordsFormatter()],
|
||||
validator: (v) =>
|
||||
_validateRequired(v, 'Last name'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'LAST NAME',
|
||||
prefixIcon: Icon(
|
||||
Icons.badge_outlined,
|
||||
),
|
||||
const SizedBox(width: AppSizes.p12),
|
||||
Expanded(
|
||||
child: _ExtensionNameField(
|
||||
controller: _extController,
|
||||
suggestions: _extSuggestions,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -895,6 +943,11 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSizes.p12),
|
||||
_ExtensionNameField(
|
||||
controller: _extController,
|
||||
suggestions: _extSuggestions,
|
||||
),
|
||||
const SizedBox(height: AppSizes.p12),
|
||||
TextFormField(
|
||||
controller: _middleNameController,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
@@ -914,11 +967,6 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
|
||||
labelText: 'LAST NAME',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSizes.p12),
|
||||
_ExtensionNameField(
|
||||
controller: _extController,
|
||||
suggestions: _extSuggestions,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1129,7 +1177,15 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
|
||||
final matches = filtered.where((s) => s.id == _selectedSectionId);
|
||||
final initialSectionName = matches.isNotEmpty ? matches.first.name : '';
|
||||
|
||||
// Key based on grade+year ensures Autocomplete fully rebuilds
|
||||
// when grade level or academic year changes — fixes the bug
|
||||
// where user had to type and erase before dropdown refreshed.
|
||||
final autocompleteKey = ValueKey(
|
||||
'section_${_selectedGradeLevel}_${_selectedAcademicYearId}',
|
||||
);
|
||||
|
||||
return Autocomplete<SectionModel>(
|
||||
key: autocompleteKey,
|
||||
initialValue: TextEditingValue(text: initialSectionName),
|
||||
displayStringForOption: (sec) => sec.name,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
@@ -1572,6 +1628,7 @@ class _SectionLabel extends StatelessWidget {
|
||||
|
||||
// ================================================================
|
||||
// EXTENSION NAME FIELD — text field with autocomplete suggestions
|
||||
// Selecting 'N/A' clears the field so the stored value is null.
|
||||
// ================================================================
|
||||
class _ExtensionNameField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
@@ -1610,7 +1667,7 @@ class _ExtensionNameField extends StatelessWidget {
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
inputFormatters: [_UpperCaseWordsFormatter()],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'EXTENSION NAME',
|
||||
labelText: 'SUFFIX (Optional)',
|
||||
hintText: 'Jr. / III',
|
||||
prefixIcon: Icon(Icons.text_format),
|
||||
),
|
||||
@@ -1618,7 +1675,12 @@ class _ExtensionNameField extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
onSelected: (selection) {
|
||||
controller.text = selection;
|
||||
// 'N/A' means no extension — clear so stored value is null/empty
|
||||
if (selection == 'N/A') {
|
||||
controller.clear();
|
||||
} else {
|
||||
controller.text = selection;
|
||||
}
|
||||
},
|
||||
optionsViewBuilder: (ctx, onSelected, options) {
|
||||
return Align(
|
||||
@@ -1635,6 +1697,7 @@ class _ExtensionNameField extends StatelessWidget {
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, index) {
|
||||
final opt = options.elementAt(index);
|
||||
final isNa = opt == 'N/A';
|
||||
return InkWell(
|
||||
onTap: () => onSelected(opt),
|
||||
child: Padding(
|
||||
@@ -1644,9 +1707,11 @@ class _ExtensionNameField extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
opt,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: isNa ? AppColors.textSecondary : null,
|
||||
fontStyle: isNa ? FontStyle.italic : FontStyle.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../shared/modals/custom_modal.dart';
|
||||
import '../../../shared/buttons/primary_button.dart';
|
||||
import '../../../providers/student_provider.dart';
|
||||
import '../../../../domain/entities/student_model.dart';
|
||||
import '../../../providers/setup_provider.dart';
|
||||
import '../../../../domain/entities/setup_models.dart';
|
||||
|
||||
class EditEnrollmentModal extends ConsumerStatefulWidget {
|
||||
final int studentId;
|
||||
final dynamic enrollment; // If null, it's an Add operation. If set, it's an Edit operation.
|
||||
|
||||
const EditEnrollmentModal({
|
||||
Key? key,
|
||||
required this.studentId,
|
||||
this.enrollment,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
ConsumerState<EditEnrollmentModal> createState() => _EditEnrollmentModalState();
|
||||
}
|
||||
|
||||
class _EditEnrollmentModalState extends ConsumerState<EditEnrollmentModal> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
int? _selectedAcademicYearId;
|
||||
int? _selectedGradeLevel;
|
||||
int? _selectedSectionId;
|
||||
String? _trackStrand;
|
||||
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.enrollment != null) {
|
||||
_selectedAcademicYearId = widget.enrollment.academicYearId;
|
||||
_selectedGradeLevel = widget.enrollment.gradeLevel;
|
||||
_selectedSectionId = widget.enrollment.sectionId;
|
||||
_trackStrand = widget.enrollment.trackStrand;
|
||||
}
|
||||
}
|
||||
|
||||
void _showValidationDialog(String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Invalid Update'),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('OK'),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleSave() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_selectedSectionId == null) {
|
||||
_showValidationDialog('Please select a valid section.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final notifier = ref.read(studentMutationProvider.notifier);
|
||||
if (widget.enrollment == null) {
|
||||
// Add
|
||||
await notifier.addEnrollment(
|
||||
studentId: widget.studentId,
|
||||
academicYearId: _selectedAcademicYearId!,
|
||||
gradeLevel: _selectedGradeLevel!,
|
||||
sectionId: _selectedSectionId!,
|
||||
trackStrand: _trackStrand,
|
||||
);
|
||||
} else {
|
||||
// Update
|
||||
await notifier.updateEnrollment(
|
||||
studentId: widget.studentId,
|
||||
enrollmentId: widget.enrollment.id,
|
||||
academicYearId: _selectedAcademicYearId!,
|
||||
gradeLevel: _selectedGradeLevel!,
|
||||
sectionId: _selectedSectionId!,
|
||||
trackStrand: _trackStrand,
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final raw = e.toString();
|
||||
final msg = raw.startsWith('Exception: ') ? raw.substring(11) : raw;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = msg;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final yearsAsync = ref.watch(academicYearsListProvider);
|
||||
final gradeLevelsAsync = ref.watch(gradeLevelsListProvider);
|
||||
final sectionsAsync = ref.watch(sectionsListProvider);
|
||||
|
||||
final viewInsets = MediaQuery.viewInsetsOf(context);
|
||||
final screenHeight = MediaQuery.sizeOf(context).height;
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final isMobile = screenWidth < 600;
|
||||
|
||||
double maxDialogHeight = isMobile ? (screenHeight * 0.8) : 480;
|
||||
double dialogHeight = maxDialogHeight.clamp(
|
||||
200.0,
|
||||
screenHeight - viewInsets.bottom - 24.0,
|
||||
);
|
||||
|
||||
return CustomModal(
|
||||
title: widget.enrollment == null ? 'Add Enrollment' : 'Edit Enrollment',
|
||||
icon: widget.enrollment == null ? Icons.add_box_outlined : Icons.edit_document,
|
||||
maxWidth: 450,
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
content: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: dialogHeight,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
// Academic Year
|
||||
yearsAsync.when(
|
||||
data: (years) {
|
||||
return DropdownButtonFormField<int>(
|
||||
key: ValueKey('academic_$_selectedAcademicYearId'),
|
||||
initialValue: _selectedAcademicYearId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Academic Year',
|
||||
prefixIcon: Icon(Icons.calendar_today),
|
||||
),
|
||||
items: years
|
||||
.map((y) => DropdownMenuItem<int>(
|
||||
value: y.id,
|
||||
child: Text(y.yearRange),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_selectedAcademicYearId = val;
|
||||
_selectedSectionId = null;
|
||||
});
|
||||
},
|
||||
validator: (v) => v == null ? 'Academic year is required.' : null,
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Text('Error: $err', style: const TextStyle(color: Colors.red)),
|
||||
),
|
||||
const SizedBox(height: AppSizes.p16),
|
||||
|
||||
// Grade Level
|
||||
gradeLevelsAsync.when(
|
||||
data: (grades) {
|
||||
return DropdownButtonFormField<int>(
|
||||
key: ValueKey('grade_$_selectedGradeLevel'),
|
||||
initialValue: _selectedGradeLevel,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Grade Level',
|
||||
prefixIcon: Icon(Icons.grade),
|
||||
),
|
||||
items: grades
|
||||
.map((g) => DropdownMenuItem<int>(
|
||||
value: g.level,
|
||||
child: Text(g.name),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_selectedGradeLevel = val;
|
||||
_selectedSectionId = null;
|
||||
});
|
||||
},
|
||||
validator: (v) => v == null ? 'Grade level is required.' : null,
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Text('Error: $err', style: const TextStyle(color: Colors.red)),
|
||||
),
|
||||
const SizedBox(height: AppSizes.p16),
|
||||
|
||||
// Section
|
||||
sectionsAsync.when(
|
||||
data: (sections) {
|
||||
final filtered = sections
|
||||
.where(
|
||||
(sec) =>
|
||||
sec.academicYearId == _selectedAcademicYearId &&
|
||||
sec.gradeLevel == _selectedGradeLevel,
|
||||
)
|
||||
.toList();
|
||||
|
||||
final matches = filtered.where((s) => s.id == _selectedSectionId);
|
||||
final initialSectionName = matches.isNotEmpty ? matches.first.name : '';
|
||||
|
||||
final autocompleteKey = ValueKey(
|
||||
'section_${_selectedGradeLevel}_${_selectedAcademicYearId}',
|
||||
);
|
||||
|
||||
return Autocomplete<SectionModel>(
|
||||
key: autocompleteKey,
|
||||
initialValue: TextEditingValue(text: initialSectionName),
|
||||
displayStringForOption: (sec) => sec.name,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
if (textEditingValue.text.isEmpty) {
|
||||
return filtered;
|
||||
}
|
||||
return filtered.where((sec) =>
|
||||
sec.name.toLowerCase().contains(textEditingValue.text.toLowerCase()));
|
||||
},
|
||||
onSelected: (sec) {
|
||||
setState(() => _selectedSectionId = sec.id);
|
||||
},
|
||||
fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Section (Type or Select)',
|
||||
prefixIcon: Icon(Icons.segment),
|
||||
suffixIcon: Icon(Icons.arrow_drop_down),
|
||||
),
|
||||
onChanged: (val) {
|
||||
if (val.isEmpty) {
|
||||
setState(() => _selectedSectionId = null);
|
||||
} else {
|
||||
final exactMatches =
|
||||
filtered.where((s) => s.name.toLowerCase() == val.toLowerCase());
|
||||
setState(() => _selectedSectionId =
|
||||
exactMatches.isNotEmpty ? exactMatches.first.id : null);
|
||||
}
|
||||
},
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty || _selectedSectionId == null) {
|
||||
return 'Please select a valid section.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Text('Error: $err', style: const TextStyle(color: Colors.red)),
|
||||
),
|
||||
|
||||
if (_selectedGradeLevel != null && _selectedGradeLevel! >= 11) ...[
|
||||
const SizedBox(height: AppSizes.p16),
|
||||
TextFormField(
|
||||
initialValue: _trackStrand,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Track & Strand (for SHS)',
|
||||
prefixIcon: Icon(Icons.school_outlined),
|
||||
),
|
||||
onChanged: (val) => _trackStrand = val.trim().isEmpty ? null : val.trim(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.08),
|
||||
border: Border.all(color: AppColors.error.withValues(alpha: 0.4)),
|
||||
borderRadius: BorderRadius.circular(AppSizes.radiusMedium),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: AppColors.error, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: AppColors.error, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.end,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: AppSizes.p12,
|
||||
runSpacing: AppSizes.p8,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text(
|
||||
'CANCEL',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: PrimaryButton(
|
||||
label: 'SAVE',
|
||||
isLoading: _isLoading,
|
||||
onPressed: _handleSave,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user