feat: Make Date of Birth optional and implement custom DobPicker

This commit is contained in:
ssbg04 committed 2026-07-24 23:36:05 +08:00
1 parent 9988eb8337
commit 1c105fd2ed
6 files changed
+208 -63

No files matched your search

+8 -5
View File
@@ -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
+33 -1
View File
@@ -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(),
),
),
],
),
);
}
}