feat: Make Date of Birth optional and implement custom DobPicker
This commit is contained in:
1 parent
9988eb8337
commit
1c105fd2ed
6 files changed
+208
-63
No files matched your search
@@ -271,8 +271,7 @@ exports.createStudent = (req, res) => {
|
||||
if (!firstName || !firstName.trim()) errors.push('First name is required.');
|
||||
if (!lastName || !lastName.trim()) errors.push('Last name is required.');
|
||||
if (!sex || !['Male', 'Female'].includes(sex)) errors.push('Sex must be Male or Female.');
|
||||
if (!birthDate) errors.push('Date of birth is required.');
|
||||
else {
|
||||
if (birthDate) {
|
||||
const dob = new Date(birthDate);
|
||||
if (isNaN(dob.getTime())) errors.push('Invalid date of birth format.');
|
||||
else if (dob > new Date()) errors.push('Date of birth cannot be in the future.');
|
||||
@@ -300,7 +299,7 @@ exports.createStudent = (req, res) => {
|
||||
lastName.trim(),
|
||||
extension?.trim() || null,
|
||||
sex,
|
||||
birthDate,
|
||||
birthDate || null,
|
||||
is4ps ? 1 : 0
|
||||
);
|
||||
|
||||
@@ -430,7 +429,11 @@ exports.updateStudent = (req, res) => {
|
||||
if (!firstName || !firstName.trim()) errors.push('First name is required.');
|
||||
if (!lastName || !lastName.trim()) errors.push('Last name is required.');
|
||||
if (!sex || !['Male', 'Female'].includes(sex)) errors.push('Sex must be Male or Female.');
|
||||
if (!birthDate) errors.push('Date of birth is required.');
|
||||
if (birthDate) {
|
||||
const dob = new Date(birthDate);
|
||||
if (isNaN(dob.getTime())) errors.push('Invalid date of birth format.');
|
||||
else if (dob > new Date()) errors.push('Date of birth cannot be in the future.');
|
||||
}
|
||||
if (status && !['Enrolled', 'Graduated', 'Transferred', 'Dropped', 'Inactive'].includes(status)) {
|
||||
errors.push('Invalid status value.');
|
||||
}
|
||||
@@ -467,7 +470,7 @@ exports.updateStudent = (req, res) => {
|
||||
lastName.trim(),
|
||||
extension?.trim() || null,
|
||||
sex,
|
||||
birthDate,
|
||||
birthDate || null,
|
||||
status || 'Enrolled',
|
||||
is4ps ? 1 : 0,
|
||||
id
|
||||
|
||||
@@ -365,7 +365,7 @@ const initSchema = () => {
|
||||
last_name TEXT NOT NULL,
|
||||
extension TEXT,
|
||||
sex TEXT CHECK(sex IN ('Male', 'Female')) NOT NULL,
|
||||
birth_date DATE NOT NULL,
|
||||
birth_date DATE,
|
||||
status TEXT DEFAULT 'Enrolled', -- Enrolled, Graduated, Transferred, Dropped
|
||||
is_4ps INTEGER DEFAULT 0, -- 1 = 4Ps beneficiary, 0 = not
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
@@ -379,6 +379,38 @@ const initSchema = () => {
|
||||
console.log('Migration: added is_4ps column to students table');
|
||||
}
|
||||
|
||||
// Migration: make birth_date optional in students table
|
||||
const birthDateCol = studentCols.find(c => c.name === 'birth_date');
|
||||
if (birthDateCol && birthDateCol.notnull === 1) {
|
||||
console.log('Migration: making birth_date optional in students table');
|
||||
db.pragma('foreign_keys = OFF');
|
||||
db.transaction(() => {
|
||||
db.prepare(`
|
||||
CREATE TABLE students_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lrn TEXT UNIQUE NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
middle_name TEXT,
|
||||
last_name TEXT NOT NULL,
|
||||
extension TEXT,
|
||||
sex TEXT CHECK(sex IN ('Male', 'Female')) NOT NULL,
|
||||
birth_date DATE,
|
||||
status TEXT DEFAULT 'Enrolled',
|
||||
is_4ps INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
)
|
||||
`).run();
|
||||
db.prepare(`
|
||||
INSERT INTO students_new (id, lrn, first_name, middle_name, last_name, extension, sex, birth_date, status, is_4ps, created_at)
|
||||
SELECT id, lrn, first_name, middle_name, last_name, extension, sex, birth_date, status, is_4ps, created_at FROM students
|
||||
`).run();
|
||||
db.prepare('DROP TABLE students').run();
|
||||
db.prepare('ALTER TABLE students_new RENAME TO students').run();
|
||||
})();
|
||||
db.pragma('foreign_keys = ON');
|
||||
console.log('Migration: birth_date is now optional');
|
||||
}
|
||||
|
||||
// 4. Enrollments Table
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS enrollments (
|
||||
|
||||
@@ -6,7 +6,7 @@ class StudentModel {
|
||||
final String lastName;
|
||||
final String? extension;
|
||||
final String sex;
|
||||
final DateTime birthDate;
|
||||
final DateTime? birthDate;
|
||||
final String status; // 'Enrolled', 'Graduated', 'Transferred', 'Dropped'
|
||||
final bool is4ps; // 4Ps beneficiary
|
||||
final int missingDocumentsCount;
|
||||
@@ -24,7 +24,7 @@ class StudentModel {
|
||||
required this.lastName,
|
||||
this.extension,
|
||||
required this.sex,
|
||||
required this.birthDate,
|
||||
this.birthDate,
|
||||
this.status = 'Enrolled',
|
||||
this.is4ps = false,
|
||||
this.missingDocumentsCount = 0,
|
||||
@@ -142,7 +142,7 @@ class StudentModel {
|
||||
lastName: json['last_name'] as String,
|
||||
extension: json['extension'] as String?,
|
||||
sex: json['sex'] as String,
|
||||
birthDate: DateTime.parse(json['birth_date'] as String),
|
||||
birthDate: json['birth_date'] != null ? DateTime.parse(json['birth_date'] as String) : null,
|
||||
status: json['status'] as String? ?? 'Enrolled',
|
||||
is4ps: (json['is_4ps'] as num?)?.toInt() == 1,
|
||||
missingDocumentsCount:
|
||||
@@ -172,7 +172,7 @@ class StudentModel {
|
||||
'last_name': lastName,
|
||||
'extension': extension,
|
||||
'sex': sex,
|
||||
'birth_date': birthDate.toIso8601String().split('T').first,
|
||||
'birth_date': birthDate?.toIso8601String().split('T').first,
|
||||
'status': status,
|
||||
'is_4ps': is4ps ? 1 : 0,
|
||||
};
|
||||
@@ -187,7 +187,7 @@ class StudentModel {
|
||||
'lastName': lastName,
|
||||
'extension': extension,
|
||||
'sex': sex,
|
||||
'birthDate': birthDate.toIso8601String().split('T').first,
|
||||
'birthDate': birthDate?.toIso8601String().split('T').first,
|
||||
'status': status,
|
||||
'is4ps': is4ps,
|
||||
};
|
||||
@@ -201,6 +201,8 @@ class StudentModel {
|
||||
String? lastName,
|
||||
String? extension,
|
||||
String? sex,
|
||||
// For nullable properties, use a specific check if we want to overwrite with null
|
||||
// But copyWith usually doesn't clear nulls unless wrapped. For simplicity:
|
||||
DateTime? birthDate,
|
||||
String? status,
|
||||
bool? is4ps,
|
||||
|
||||
@@ -203,7 +203,7 @@ class StudentMutationNotifier extends AsyncNotifier<void> {
|
||||
required String lastName,
|
||||
String? extension,
|
||||
required String sex,
|
||||
required DateTime birthDate,
|
||||
required DateTime? birthDate,
|
||||
required int academicYearId,
|
||||
required int gradeLevel,
|
||||
required int sectionId,
|
||||
@@ -244,7 +244,7 @@ class StudentMutationNotifier extends AsyncNotifier<void> {
|
||||
required String lastName,
|
||||
String? extension,
|
||||
required String sex,
|
||||
required DateTime birthDate,
|
||||
required DateTime? birthDate,
|
||||
required int academicYearId,
|
||||
required int gradeLevel,
|
||||
required int sectionId,
|
||||
|
||||
@@ -318,7 +318,7 @@ class StudentProfileModalBody extends ConsumerWidget {
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildInfoItem('Sex', student.sex ?? 'N/A'),
|
||||
_buildInfoItem('Birth Date', _formatDate(student.birthDate)),
|
||||
_buildInfoItem('Birth Date', student.birthDate != null ? _formatDate(student.birthDate!) : 'N/A'),
|
||||
_build4psItem(student.is4ps),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -135,8 +135,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal> {
|
||||
_lrnController.text.isNotEmpty ||
|
||||
_firstNameController.text.isNotEmpty ||
|
||||
_middleNameController.text.isNotEmpty ||
|
||||
_lastNameController.text.isNotEmpty ||
|
||||
_selectedDob != null;
|
||||
_lastNameController.text.isNotEmpty;
|
||||
|
||||
Future<void> _confirmClose() async {
|
||||
// If editing, or nothing entered yet — close immediately
|
||||
@@ -403,20 +402,6 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal> {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _selectDate() async {
|
||||
final picked = await DatePicker.showSimpleDatePicker(
|
||||
context,
|
||||
initialDate: _selectedDob ?? DateTime(2010),
|
||||
firstDate: DateTime(1980),
|
||||
lastDate: DateTime.now(),
|
||||
dateFormat: "dd-MMMM-yyyy",
|
||||
locale: DateTimePickerLocale.en_us,
|
||||
looping: true,
|
||||
textColor: AppColors.textPrimary,
|
||||
);
|
||||
if (picked != null) setState(() => _selectedDob = picked);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// VALIDATION DIALOG
|
||||
// ----------------------------------------------------------------
|
||||
@@ -474,8 +459,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal> {
|
||||
|
||||
if (lrnErr != null ||
|
||||
fnErr != null ||
|
||||
lnErr != null ||
|
||||
_selectedDob == null) {
|
||||
lnErr != null) {
|
||||
setState(() => _currentStep = widget.student != null ? 0 : 1);
|
||||
_studentFormKey.currentState?.validate();
|
||||
// Build a descriptive message listing all missing fields
|
||||
@@ -483,7 +467,6 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal> {
|
||||
if (lrnErr != null) missing.add('LRN ($lrnErr)');
|
||||
if (fnErr != null) missing.add('First Name');
|
||||
if (lnErr != null) missing.add('Last Name');
|
||||
if (_selectedDob == null) missing.add('Date of Birth');
|
||||
_showValidationDialog(
|
||||
'Please fill in all required fields:\n\n${missing.map((e) => '• $e').join('\n')}',
|
||||
);
|
||||
@@ -560,7 +543,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal> {
|
||||
? null
|
||||
: _extController.text.trim(),
|
||||
sex: _selectedSex,
|
||||
birthDate: _selectedDob!,
|
||||
birthDate: _selectedDob,
|
||||
academicYearId: _selectedAcademicYearId!,
|
||||
gradeLevel: _selectedGradeLevel!,
|
||||
sectionId: _selectedSectionId!,
|
||||
@@ -580,7 +563,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal> {
|
||||
? null
|
||||
: _extController.text.trim(),
|
||||
sex: _selectedSex,
|
||||
birthDate: _selectedDob!,
|
||||
birthDate: _selectedDob,
|
||||
status: _selectedStatus,
|
||||
academicYearId: _selectedAcademicYearId!,
|
||||
gradeLevel: _selectedGradeLevel!,
|
||||
@@ -1026,7 +1009,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal> {
|
||||
_UpperCaseWordsFormatter(),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'MIDDLE NAME (optional)',
|
||||
labelText: 'MIDDLE NAME (Optional)',
|
||||
prefixIcon: Icon(Icons.badge_outlined),
|
||||
),
|
||||
),
|
||||
@@ -1072,7 +1055,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal> {
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
inputFormatters: [_UpperCaseWordsFormatter()],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'MIDDLE NAME',
|
||||
labelText: 'MIDDLE NAME (Optional)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSizes.p12),
|
||||
@@ -1115,31 +1098,13 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal> {
|
||||
const SizedBox(height: AppSizes.p12),
|
||||
|
||||
// DOB
|
||||
GestureDetector(
|
||||
onTap: _selectDate,
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'DATE OF BIRTH',
|
||||
prefixIcon: const Icon(
|
||||
Icons.calendar_today,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
errorText: null,
|
||||
),
|
||||
child: Text(
|
||||
_selectedDob == null
|
||||
? 'Select date…'
|
||||
: '${_selectedDob!.year}-'
|
||||
'${_selectedDob!.month.toString().padLeft(2, '0')}-'
|
||||
'${_selectedDob!.day.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
color: _selectedDob == null
|
||||
? AppColors.textMuted
|
||||
: AppColors.textPrimary,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
_DobPicker(
|
||||
initialDate: _selectedDob,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_selectedDob = val;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSizes.p16),
|
||||
|
||||
@@ -1694,7 +1659,6 @@ class _ExtensionNameField extends StatelessWidget {
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'SUFFIX (Optional)',
|
||||
hintText: 'Jr. / III',
|
||||
prefixIcon: Icon(Icons.text_format),
|
||||
),
|
||||
onFieldSubmitted: (_) => onSubmit(),
|
||||
);
|
||||
@@ -1750,3 +1714,147 @@ class _ExtensionNameField extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
class _DobPicker extends StatefulWidget {
|
||||
final DateTime? initialDate;
|
||||
final ValueChanged<DateTime?> onChanged;
|
||||
|
||||
const _DobPicker({Key? key, this.initialDate, required this.onChanged})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<_DobPicker> createState() => _DobPickerState();
|
||||
}
|
||||
|
||||
class _DobPickerState extends State<_DobPicker> {
|
||||
int? _month;
|
||||
int? _day;
|
||||
late final TextEditingController _yearController;
|
||||
|
||||
static const List<String> _months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_yearController = TextEditingController();
|
||||
if (widget.initialDate != null) {
|
||||
_month = widget.initialDate!.month;
|
||||
_day = widget.initialDate!.day;
|
||||
_yearController.text = widget.initialDate!.year.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_yearController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _getDaysInMonth(int? month, int? year) {
|
||||
if (month == null) return 31;
|
||||
if (month == 2) {
|
||||
if (year != null && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0))) {
|
||||
return 29;
|
||||
}
|
||||
return 28;
|
||||
}
|
||||
if ([4, 6, 9, 11].contains(month)) return 30;
|
||||
return 31;
|
||||
}
|
||||
|
||||
void _update() {
|
||||
int? year = int.tryParse(_yearController.text);
|
||||
int maxDays = _getDaysInMonth(_month, year);
|
||||
if (_day != null && _day! > maxDays) {
|
||||
_day = maxDays;
|
||||
}
|
||||
|
||||
if (_month != null && _day != null && year != null && _yearController.text.length == 4) {
|
||||
widget.onChanged(DateTime(year, _month!, _day!));
|
||||
} else {
|
||||
widget.onChanged(null);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
int? year = int.tryParse(_yearController.text);
|
||||
int maxDays = _getDaysInMonth(_month, year);
|
||||
|
||||
return InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'DATE OF BIRTH (Optional)',
|
||||
prefixIcon: Icon(Icons.calendar_today, color: AppColors.textSecondary),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
isExpanded: true,
|
||||
hint: const Text('Month', style: TextStyle(fontSize: 14)),
|
||||
value: _month,
|
||||
items: List.generate(12, (index) {
|
||||
return DropdownMenuItem(
|
||||
value: index + 1,
|
||||
child: Text(_months[index], style: const TextStyle(fontSize: 14)),
|
||||
);
|
||||
}),
|
||||
onChanged: (val) {
|
||||
_month = val;
|
||||
_update();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
isExpanded: true,
|
||||
hint: const Text('Day', style: TextStyle(fontSize: 14)),
|
||||
value: _day,
|
||||
items: List.generate(maxDays, (index) {
|
||||
return DropdownMenuItem(
|
||||
value: index + 1,
|
||||
child: Text('\', style: const TextStyle(fontSize: 14)),
|
||||
);
|
||||
}),
|
||||
onChanged: (val) {
|
||||
_day = val;
|
||||
_update();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
controller: _yearController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 4,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Year',
|
||||
counterText: '',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onChanged: (_) => _update(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user