add more controller

This commit is contained in:
root
2026-03-10 00:48:32 -04:00
parent 5eeaec0257
commit 20ee70d153
151 changed files with 9481 additions and 8407 deletions
@@ -0,0 +1,127 @@
<?php
namespace App\Services\Parents;
use Illuminate\Support\Facades\DB;
class ParentAttendanceReportCalendarService
{
public function __construct(private ParentConfigService $configService)
{
}
/**
* @return array{0: array<int, array{value:string,label:string}>, 1: string}
*/
public function computeSundays(int $weeksBefore = 4, int $weeksAfter = 26): array
{
$today = new \DateTime('today');
$dow = (int) $today->format('w');
$thisSunday = clone $today;
if ($dow !== 0) {
$thisSunday->modify('last sunday');
}
$default = clone $today;
if ($dow !== 0) {
$default->modify('next sunday');
}
$schoolYear = (string) ($this->configService->context()['school_year'] ?? '');
$cap = $this->firstSundayOfJune($schoolYear);
$dates = [];
for ($i = $weeksBefore; $i >= 1; $i--) {
$dates[] = (clone $thisSunday)->modify('-' . $i . ' week');
}
$cursor = clone $thisSunday;
while ($cursor <= $cap) {
$dates[] = clone $cursor;
$cursor->modify('+1 week');
}
$rangeStart = reset($dates);
$rangeEnd = end($dates);
$rangeStartStr = $rangeStart instanceof \DateTime ? $rangeStart->format('Y-m-d') : null;
$rangeEndStr = $rangeEnd instanceof \DateTime ? $rangeEnd->format('Y-m-d') : null;
$noSchool = [];
if ($rangeStartStr && $rangeEndStr) {
$rows = DB::table('calendar_events')
->select('date')
->where('no_school', 1)
->groupStart()
->where('school_year', $schoolYear)
->orWhere('school_year IS NULL', null, false)
->groupEnd()
->where('date >=', $rangeStartStr)
->where('date <=', $rangeEndStr)
->get()
->toArray();
foreach ($rows as $row) {
$d = substr((string) ($row->date ?? ''), 0, 10);
if ($d !== '') {
$noSchool[$d] = true;
}
}
}
$filtered = [];
foreach ($dates as $d) {
$ymd = $d->format('Y-m-d');
if (!isset($noSchool[$ymd]) && $d >= $today && $d <= $cap) {
$filtered[] = $d;
}
}
$defaultYmd = $default->format('Y-m-d');
if (!empty($filtered)) {
$defaultYmd = $filtered[0]->format('Y-m-d');
}
$out = [];
foreach ($filtered as $d) {
$out[] = [
'value' => $d->format('Y-m-d'),
'label' => $d->format('m-d-Y'),
];
}
return [$out, $defaultYmd];
}
public function normalizeCutoffTime(string $raw, string $default = '09:00'): string
{
$raw = trim($raw);
if ($raw === '') {
return $default;
}
if (preg_match('/^(\\d{1,2}):(\\d{2})$/', $raw, $m)) {
$hh = str_pad((string) ((int) $m[1]), 2, '0', STR_PAD_LEFT);
return $hh . ':' . $m[2];
}
return $default;
}
public function firstSundayOfJune(string $schoolYear): \DateTime
{
$today = new \DateTime('today');
$endYear = null;
if (preg_match('/^(\\d{4})\\D(\\d{4})$/', $schoolYear, $m)) {
$endYear = (int) $m[2];
}
if ($endYear === null) {
$y = (int) $today->format('Y');
$endYear = ((int) $today->format('n') > 6) ? ($y + 1) : $y;
}
$juneFirst = new \DateTime(sprintf('%04d-06-01', $endYear));
if ((int) $juneFirst->format('w') !== 0) {
$juneFirst->modify('next sunday');
}
return $juneFirst;
}
}
@@ -0,0 +1,509 @@
<?php
namespace App\Services\Parents;
use App\Models\AttendanceData;
use App\Models\AttendanceRecord;
use App\Models\ParentAttendanceReport;
use App\Models\Student;
use App\Models\ClassSection;
use App\Services\SemesterRangeService;
use Illuminate\Support\Facades\DB;
class ParentAttendanceReportService
{
public function __construct(
private ParentConfigService $configService,
private ParentAttendanceReportCalendarService $calendarService,
private SemesterRangeService $semesterRangeService
) {
}
public function formData(int $parentId): array
{
$context = $this->configService->context();
$students = Student::query()
->where('parent_id', $parentId)
->orderBy('firstname')
->orderBy('lastname')
->get()
->toArray();
[$sundays, $defaultDate] = $this->calendarService->computeSundays();
$today = now()->toDateString();
$schoolYear = (string) ($context['school_year'] ?? '');
$previewRows = DB::table('parent_attendance_reports as par')
->select('par.*', 's.firstname', 's.lastname')
->leftJoin('students as s', 's.id', '=', 'par.student_id')
->where('par.parent_id', $parentId)
->where('par.school_year', $schoolYear)
->where('par.report_date', '>=', $today)
->orderBy('par.report_date')
->orderBy('s.firstname')
->orderBy('s.lastname')
->get()
->map(fn ($row) => (array) $row)
->all();
$cutoffThreshold = $this->calendarService->normalizeCutoffTime(
(string) ($context['parent_report_cutoff'] ?? ''),
'09:00'
);
return [
'students' => $students,
'today' => $today,
'sundays' => $sundays,
'defaultDate' => $defaultDate,
'myReports' => $previewRows,
'cutoffThreshold' => $cutoffThreshold,
];
}
public function submit(int $parentId, array $payload): array
{
$context = $this->configService->context();
$schoolYear = (string) ($context['school_year'] ?? '');
$semester = (string) ($context['semester'] ?? '');
$studentIds = array_values(array_filter(array_map('intval', (array) ($payload['student_ids'] ?? []))));
if (empty($studentIds)) {
throw new \RuntimeException('At least select one student.');
}
$datesInput = $payload['dates'] ?? ($payload['date'] ?? null);
$rawDates = [];
if (is_array($datesInput)) {
$rawDates = $datesInput;
} elseif ($datesInput !== null) {
$rawDates = [$datesInput];
}
$rawDates = array_values(array_unique(array_filter(array_map(static function ($val) {
return substr((string) $val, 0, 10);
}, $rawDates))));
if (empty($rawDates)) {
throw new \RuntimeException('Please select at least one Sunday date.');
}
$type = (string) ($payload['type'] ?? '');
if (!in_array($type, ['absent', 'late', 'early_dismissal'], true)) {
throw new \RuntimeException('Invalid report type.');
}
$arrivalTime = $payload['arrival_time'] ?? null;
$dismissTime = $payload['dismiss_time'] ?? null;
$reason = isset($payload['reason']) ? trim((string) $payload['reason']) : null;
$todayCheck = new \DateTime('today');
$cap = $this->calendarService->firstSundayOfJune($schoolYear);
$validDates = [];
foreach ($rawDates as $entry) {
$dt = \DateTime::createFromFormat('Y-m-d', $entry);
if (!$dt) {
throw new \RuntimeException('Invalid date selected: ' . $entry);
}
if ((int) $dt->format('w') !== 0) {
throw new \RuntimeException('Please select Sunday dates only.');
}
if ($dt < $todayCheck) {
throw new \RuntimeException('Past dates are not allowed. Please select today or a future Sunday.');
}
if ($dt > $cap) {
throw new \RuntimeException('The last available date is the first Sunday of June.');
}
$validDates[$entry] = $dt;
}
if ($type === 'late' && (!is_string($arrivalTime) || $arrivalTime === '')) {
throw new \RuntimeException('Please provide an expected arrival time for late reporting.');
}
if ($type === 'early_dismissal' && (!is_string($dismissTime) || $dismissTime === '')) {
throw new \RuntimeException('Please provide a dismissal time for early dismissal.');
}
if ($type !== 'early_dismissal') {
if ($reason === null || $reason === '') {
throw new \RuntimeException('Please provide a reason for absence or late arrival.');
}
}
$inserted = 0;
$blocked = [];
$successDetails = [];
foreach ($studentIds as $sid) {
if ($sid <= 0) {
continue;
}
$sectionRow = DB::table('student_class')
->select('class_section_id')
->where('student_id', $sid)
->where('school_year', $schoolYear)
->orderByDesc('updated_at')
->first();
$classSectionId = $sectionRow->class_section_id ?? null;
$student = Student::query()->select('firstname', 'lastname')->find($sid);
$studentName = $student ? trim($student->firstname . ' ' . $student->lastname) : ('Student #' . $sid);
$classLabel = $this->resolveClassSectionLabel($classSectionId);
foreach ($validDates as $reportDate => $_dt) {
$semesterForDate = $this->semesterRangeService->getSemesterForDate($reportDate) ?: $semester;
$saved = false;
$qb = ParentAttendanceReport::query()
->where('student_id', $sid)
->where('report_date', $reportDate);
if ($type === 'early_dismissal') {
$existingAL = (clone $qb)
->whereIn('type', ['absent', 'late'])
->first();
if ($existingAL) {
$blocked[] = $studentName . ' (' . $reportDate . ')';
} else {
$existingED = (clone $qb)->where('type', 'early_dismissal')->first();
if ($existingED) {
$existingED->update([
'dismiss_time' => $dismissTime ?: $existingED->dismiss_time,
'status' => 'new',
]);
$saved = true;
$inserted++;
} else {
ParentAttendanceReport::query()->create([
'parent_id' => $parentId,
'student_id' => $sid,
'class_section_id' => $classSectionId,
'report_date' => $reportDate,
'type' => 'early_dismissal',
'dismiss_time' => $dismissTime ?: null,
'reason' => $reason ?: null,
'semester' => $semesterForDate,
'school_year' => $schoolYear,
'status' => 'new',
]);
$saved = true;
$inserted++;
}
}
} else {
$existingAny = (clone $qb)
->whereIn('type', ['absent', 'late', 'early_dismissal'])
->first();
if ($existingAny) {
$blocked[] = $studentName . ' (' . $reportDate . ')';
} else {
ParentAttendanceReport::query()->create([
'parent_id' => $parentId,
'student_id' => $sid,
'class_section_id' => $classSectionId,
'report_date' => $reportDate,
'type' => $type,
'arrival_time' => ($type === 'late') ? ($arrivalTime ?: null) : null,
'reason' => $reason ?: null,
'semester' => $semesterForDate,
'school_year' => $schoolYear,
'status' => 'new',
]);
$saved = true;
$inserted++;
}
}
if ($saved && in_array($type, ['absent', 'late'], true)) {
$this->reflectToAttendanceData(
studentId: $sid,
reportDate: $reportDate,
type: $type,
semester: $semesterForDate,
schoolYear: $schoolYear,
classSectionId: $classSectionId,
reason: $reason,
arrivalTime: $arrivalTime
);
}
if ($saved) {
$successDetails[] = [
'student_id' => $sid,
'name' => $studentName,
'class_label' => $classLabel,
'type' => $type,
'arrival_time' => $arrivalTime,
'dismiss_time' => $dismissTime,
'date' => $reportDate,
];
}
}
}
if ($inserted <= 0) {
$err = !empty($blocked)
? ('Already submitted and cannot be changed for: ' . implode(', ', array_slice($blocked, 0, 5)) . (count($blocked) > 5 ? '…' : ''))
: 'Could not save your report. Please try again.';
throw new \RuntimeException($err);
}
return [
'inserted' => $inserted,
'blocked' => $blocked,
'students' => $successDetails,
'dates' => array_keys($validDates),
];
}
public function listReports(string $start, ?string $end, ?string $schoolYear, ?string $semester): array
{
return ParentAttendanceReport::listForDateRange($start, $end, $schoolYear, $semester);
}
public function checkExisting(array $payload): array
{
$datesPost = $payload['dates'] ?? null;
$dateSingle = $payload['date'] ?? null;
$dateValues = [];
if (is_array($datesPost)) {
foreach ($datesPost as $d) {
$dateValues[] = substr((string) $d, 0, 10);
}
} elseif ($dateSingle !== null) {
$dateValues[] = substr((string) $dateSingle, 0, 10);
}
$dateValues = array_values(array_unique(array_filter($dateValues, static function ($d) {
return preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $d);
})));
$type = (string) ($payload['type'] ?? '');
if (empty($dateValues) || !in_array($type, ['absent', 'late', 'early_dismissal'], true)) {
throw new \RuntimeException('Invalid date or type.');
}
$studentIds = array_values(array_unique(array_map('intval', (array) ($payload['student_ids'] ?? []))));
$studentIds = array_filter($studentIds, static fn ($v) => $v > 0);
if (empty($studentIds)) {
return [];
}
$rows = DB::table('parent_attendance_reports as par')
->select('par.student_id', 'par.type', 'par.report_date', 's.firstname', 's.lastname')
->leftJoin('students as s', 's.id', '=', 'par.student_id')
->whereIn('par.report_date', $dateValues)
->whereIn('par.student_id', $studentIds)
->get()
->map(fn ($row) => (array) $row)
->all();
$map = [];
foreach ($rows as $row) {
$sid = (int) ($row['student_id'] ?? 0);
if ($sid <= 0) {
continue;
}
if (!isset($map[$sid])) {
$map[$sid] = [
'student_id' => $sid,
'firstname' => (string) ($row['firstname'] ?? ''),
'lastname' => (string) ($row['lastname'] ?? ''),
'dates' => [],
];
}
$dVal = substr((string) ($row['report_date'] ?? ''), 0, 10);
if ($dVal === '') {
continue;
}
$map[$sid]['dates'][$dVal] = $map[$sid]['dates'][$dVal] ?? [];
$t = (string) ($row['type'] ?? '');
if ($t !== '' && !in_array($t, $map[$sid]['dates'][$dVal], true)) {
$map[$sid]['dates'][$dVal][] = $t;
}
}
return array_values($map);
}
public function updateReport(int $parentId, int $reportId, array $payload): void
{
$row = ParentAttendanceReport::query()->find($reportId);
if (!$row || (int) $row->parent_id !== $parentId) {
throw new \RuntimeException('Report not found.');
}
if ((string) ($row->status ?? '') !== 'new') {
throw new \RuntimeException('This report can no longer be edited.');
}
$tzName = (string) (config('School')->attendance['timezone'] ?? 'UTC');
$tz = new \DateTimeZone($tzName ?: 'UTC');
$cutoff = new \DateTime($row->report_date . ' 09:00:00', $tz);
$now = new \DateTime('now', $tz);
if ($now >= $cutoff) {
throw new \RuntimeException('Editing window closed at 9:00 AM on the report date.');
}
$type = (string) $row->type;
$reason = trim((string) ($payload['reason'] ?? ''));
$arrival = (string) ($payload['arrival_time'] ?? '');
$dismiss = (string) ($payload['dismiss_time'] ?? '');
$update = [
'reason' => $reason !== '' ? $reason : null,
];
if ($type === 'late' && $arrival !== '') {
if (!preg_match('/^\\d{2}:\\d{2}(:\\d{2})?$/', $arrival)) {
throw new \RuntimeException('Invalid arrival time format.');
}
$update['arrival_time'] = $arrival;
}
if ($type === 'early_dismissal' && $dismiss !== '') {
if (!preg_match('/^\\d{2}:\\d{2}(:\\d{2})?$/', $dismiss)) {
throw new \RuntimeException('Invalid dismissal time format.');
}
$update['dismiss_time'] = $dismiss;
}
if (empty($update)) {
throw new \RuntimeException('Nothing to update.');
}
$row->update($update);
}
private function resolveClassSectionLabel(?int $classSectionId): string
{
$cid = (int) ($classSectionId ?? 0);
if ($cid <= 0) {
return 'Not Assigned';
}
$name = ClassSection::query()
->where('class_section_id', $cid)
->value('class_section_name');
return $name ?: 'Not Assigned';
}
private function reflectToAttendanceData(
int $studentId,
string $reportDate,
string $type,
string $semester,
string $schoolYear,
?int $classSectionId,
?string $reason = null,
?string $arrivalTime = null
): void {
$status = ($type === 'late') ? 'late' : (($type === 'absent') ? 'absent' : null);
if ($status === null) {
return;
}
if (!$classSectionId) {
$row = DB::table('student_class')
->select('class_section_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderByDesc('updated_at')
->first();
$classSectionId = (int) ($row->class_section_id ?? 0);
}
if ($classSectionId <= 0) {
return;
}
$existing = AttendanceData::query()
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('date', $reportDate)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$reasonParts = ['Parent-reported'];
if ($type === 'late' && $arrivalTime) {
$reasonParts[] = 'ETA ' . substr($arrivalTime, 0, 5);
}
if (!empty($reason)) {
$reasonParts[] = trim($reason);
}
$reasonText = implode(' — ', $reasonParts);
if (!$existing) {
$classId = ClassSection::query()
->where('class_section_id', $classSectionId)
->value('class_id');
$schoolId = Student::query()->whereKey($studentId)->value('school_id');
if (!$classId || !$schoolId) {
return;
}
AttendanceData::query()->create([
'class_id' => $classId,
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $schoolId,
'date' => $reportDate,
'status' => $status,
'reason' => $reasonText,
'semester' => $semester,
'school_year' => $schoolYear,
]);
$this->bumpAttendanceRecord($studentId, $classSectionId, $schoolId, $status, $semester, $schoolYear);
return;
}
$existingStatus = strtolower((string) ($existing->status ?? ''));
if ($existingStatus === 'present' || $existingStatus === '') {
$existing->update([
'status' => $status,
'reason' => $existing->reason ?: $reasonText,
]);
}
}
private function bumpAttendanceRecord(
int $studentId,
int $classSectionId,
string $schoolId,
string $status,
string $semester,
string $schoolYear
): void {
$record = AttendanceRecord::query()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if ($record) {
$updates = [
'total_presence' => (int) ($record->total_presence ?? 0),
'total_absence' => (int) ($record->total_absence ?? 0),
'total_late' => (int) ($record->total_late ?? 0),
'total_attendance' => (int) ($record->total_attendance ?? 0) + 1,
];
if ($status === 'present') {
$updates['total_presence']++;
} elseif ($status === 'absent') {
$updates['total_absence']++;
} elseif ($status === 'late') {
$updates['total_late']++;
}
$record->update($updates);
return;
}
AttendanceRecord::query()->create([
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $schoolId,
'semester' => $semester,
'school_year' => $schoolYear,
'total_presence' => ($status === 'present') ? 1 : 0,
'total_absence' => ($status === 'absent') ? 1 : 0,
'total_late' => ($status === 'late') ? 1 : 0,
'total_attendance' => 1,
]);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Services\Parents;
use Illuminate\Support\Facades\DB;
class ParentAttendanceService
{
public function __construct(private ParentConfigService $configService)
{
}
public function listAttendance(int $parentId, ?string $schoolYear = null): array
{
$context = $this->configService->context();
$selectedYear = $schoolYear ?: $context['school_year'];
$rows = DB::table('attendance_data')
->select('students.firstname', 'students.lastname', 'attendance_data.date', 'attendance_data.status', 'attendance_data.reason')
->join('students', 'students.id', '=', 'attendance_data.student_id')
->where('students.parent_id', $parentId)
->where('attendance_data.school_year', $selectedYear)
->orderBy('attendance_data.date', 'desc')
->get()
->map(fn ($row) => (array) $row)
->all();
$schoolYears = DB::table('attendance_data')
->select('school_year')
->distinct()
->orderBy('school_year', 'desc')
->get()
->map(fn ($row) => (array) $row)
->all();
return [
'attendance' => $rows,
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Services\Parents;
use App\Models\Configuration;
class ParentConfigService
{
public function context(): array
{
return [
'date_age_reference' => Configuration::getConfig('date_age_reference'),
'enrollment_deadline' => Configuration::getConfig('enrollment_deadline'),
'fall_semester_start' => Configuration::getConfig('fall_semester_start'),
'refund_deadline' => Configuration::getConfig('refund_deadline'),
'school_year' => Configuration::getConfig('school_year'),
'semester' => Configuration::getConfig('semester'),
'max_kids' => (int) (Configuration::getConfig('max_kids') ?? 0),
'max_emergency' => (int) (Configuration::getConfig('max_emergency') ?? 0),
];
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Services\Parents;
use App\Models\EmergencyContact;
use App\Services\PhoneFormatterService;
class ParentEmergencyContactService
{
public function __construct(
private ParentConfigService $configService,
private PhoneFormatterService $phoneFormatter
) {
}
public function list(int $parentId): array
{
return EmergencyContact::query()
->where('parent_id', $parentId)
->orderBy('emergency_contact_name')
->get()
->toArray();
}
public function store(int $parentId, array $payload): array
{
$context = $this->configService->context();
$contact = EmergencyContact::query()->create([
'parent_id' => $parentId,
'emergency_contact_name' => $payload['name'],
'cellphone' => $this->phoneFormatter->formatPhoneNumber($payload['cellphone']),
'email' => $payload['email'] ?? null,
'relation' => $payload['relation'] ?? null,
'semester' => $context['semester'],
'school_year' => $context['school_year'],
]);
return $contact->toArray();
}
public function update(int $parentId, int $contactId, array $payload): array
{
$contact = EmergencyContact::query()
->where('id', $contactId)
->where('parent_id', $parentId)
->firstOrFail();
$contact->update([
'emergency_contact_name' => $payload['name'],
'cellphone' => $this->phoneFormatter->formatPhoneNumber($payload['cellphone']),
'email' => $payload['email'] ?? null,
'relation' => $payload['relation'] ?? null,
]);
return $contact->toArray();
}
}
@@ -0,0 +1,206 @@
<?php
namespace App\Services\Parents;
use App\Models\Enrollment;
use App\Models\Refund;
use App\Models\Student;
use App\Models\StudentClass;
use Illuminate\Support\Facades\DB;
class ParentEnrollmentService
{
public function __construct(private ParentConfigService $configService)
{
}
public function overview(int $parentId, ?string $schoolYear = null): array
{
$context = $this->configService->context();
$selectedYear = $schoolYear ?: $context['school_year'];
$students = Student::query()
->where('parent_id', $parentId)
->orderBy('lastname')
->orderBy('firstname')
->get()
->map(function ($student) use ($selectedYear) {
$classSections = StudentClass::getClassSectionsByStudentId($student->id, $selectedYear, true);
$student->class_section = !empty($classSections)
? implode(', ', $classSections)
: 'Class not Assigned';
$isArabicClass = !empty($classSections) && array_reduce(
$classSections,
static fn ($carry, $name) => $carry || (is_string($name) && stripos($name, 'arabic') === 0),
false
);
$enrollment = Enrollment::query()
->select('enrollment_status', 'admission_status')
->where('student_id', $student->id)
->where('school_year', $selectedYear)
->first();
$statusMap = [
'admission under review' => 'admission under review',
'payment pending' => 'payment pending',
'enrolled' => 'enrolled',
'withdraw under review' => 'withdraw under review',
'refund pending' => 'refund pending',
'withdrawn' => 'withdrawn',
'denied' => 'denied',
'waitlist' => 'waitlist',
];
if ($enrollment && isset($enrollment->admission_status)) {
$student->admission_status = $enrollment->admission_status;
if ($enrollment->admission_status === 'denied') {
$student->enrollment_status = 'denied';
} else {
$student->enrollment_status = $statusMap[$enrollment->enrollment_status] ?? 'not enrolled';
}
} else {
$student->admission_status = null;
$student->enrollment_status = 'not enrolled';
}
if ($student->enrollment_status === 'not enrolled' && $isArabicClass) {
$student->enrollment_status = 'enrolled';
}
$student->disable_enroll = in_array(
$student->enrollment_status,
['admission under review', 'payment pending', 'enrolled', 'withdraw under review', 'denied'],
true
);
return $student->toArray();
})
->all();
$schoolYears = DB::table('enrollments')
->select('school_year')
->distinct()
->orderBy('school_year', 'desc')
->get()
->map(fn ($row) => (array) $row)
->all();
if (empty($schoolYears)) {
$schoolYears[] = ['school_year' => $selectedYear];
}
return [
'students' => $students,
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
'withdrawalDeadline' => $context['refund_deadline'],
'lastDayOfRegistration' => $context['enrollment_deadline'],
'schoolStartDate' => $context['fall_semester_start'],
];
}
public function updateEnrollment(int $parentId, array $enrollIds, array $withdrawIds): array
{
$context = $this->configService->context();
$schoolYear = $context['school_year'];
$semester = $context['semester'];
$enrolled = [];
$withdrawn = [];
foreach ($enrollIds as $studentId) {
$existing = Enrollment::query()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->first();
if ($existing) {
if ((int) $existing->is_withdrawn === 1) {
$existing->update([
'is_withdrawn' => 0,
'withdrawal_date' => null,
'enrollment_status' => 'payment pending',
'updated_at' => now(),
]);
}
} else {
Enrollment::query()->create([
'student_id' => $studentId,
'parent_id' => $parentId,
'school_year' => $schoolYear,
'semester' => $semester,
'enrollment_date' => now()->toDateString(),
'is_withdrawn' => 0,
'enrollment_status' => 'admission under review',
'admission_status' => 'pending',
'created_at' => now(),
]);
}
$enrolled[] = (int) $studentId;
}
foreach ($withdrawIds as $studentId) {
$enrollment = Enrollment::query()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where('is_withdrawn', 0)
->first();
if (!$enrollment) {
continue;
}
$enrollment->update([
'withdrawal_date' => now()->toDateString(),
'enrollment_status' => 'withdraw under review',
'updated_at' => now(),
]);
$invoice = DB::table('invoices')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderByDesc('created_at')
->first();
if ($invoice) {
$refund = Refund::query()
->where('parent_id', $parentId)
->where('invoice_id', $invoice->id)
->where('school_year', $schoolYear)
->first();
if ($refund) {
$refund->update([
'reason' => 'Withdrawal under review for student ID ' . $studentId,
'note' => null,
'updated_by' => $parentId,
]);
} else {
Refund::query()->create([
'parent_id' => $parentId,
'invoice_id' => $invoice->id,
'requested_at' => now(),
'school_year' => $schoolYear,
'status' => Refund::STATUS_PENDING,
'reason' => 'Withdrawal under review for student ID ' . $studentId,
'request' => 'new',
'semester' => $semester,
'refund_paid_amount' => 0.0,
]);
}
}
$withdrawn[] = (int) $studentId;
}
return [
'enrolled' => $enrolled,
'withdrawn' => $withdrawn,
];
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Services\Parents;
use App\Models\Event;
use App\Models\EventCharges;
use App\Models\Enrollment;
use App\Services\Invoices\InvoiceGenerationService;
class ParentEventParticipationService
{
public function __construct(
private ParentConfigService $configService,
private InvoiceGenerationService $invoiceGeneration
) {
}
public function overview(int $parentId): array
{
$context = $this->configService->context();
$schoolYear = $context['school_year'];
$semester = $context['semester'];
$activeEvents = Event::getActiveEvents($schoolYear, $semester) ?? [];
$chargesList = EventCharges::getChargesWithEventInfo($parentId, $schoolYear) ?? [];
$charges = [];
foreach ($chargesList as $charge) {
$key = $charge['student_id'] . ':' . $charge['event_id'];
$charges[$key] = [
'participation' => $charge['participation'],
'date' => $charge['updated_at'] ?? $charge['created_at'],
];
}
$students = Enrollment::getEnrolledStudents($parentId, $schoolYear);
return [
'activeEvents' => $activeEvents,
'charges' => $charges,
'yourStudents' => $students,
];
}
public function updateParticipation(int $parentId, array $participations): void
{
$context = $this->configService->context();
$schoolYear = $context['school_year'];
$semester = $context['semester'];
foreach ($participations as $key => $value) {
[$studentId, $eventId] = array_map('intval', explode(':', (string) $key));
$existing = EventCharges::query()
->where('parent_id', $parentId)
->where('student_id', $studentId)
->where('event_id', $eventId)
->first();
if ($value === 'no') {
if ($existing) {
$existing->delete();
}
continue;
}
if ($existing) {
$existing->update(['participation' => $value]);
} else {
$event = Event::getEvent($eventId, $schoolYear);
EventCharges::query()->create([
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'participation' => $value,
'charged' => $event['amount'] ?? 0,
'school_year' => $schoolYear,
'semester' => $semester,
'updated_by' => $parentId,
]);
}
}
$this->invoiceGeneration->generateInvoice($parentId, $schoolYear);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Services\Parents;
use App\Models\Invoice;
class ParentInvoiceService
{
public function listInvoices(int $parentId, ?string $schoolYear = null): array
{
$rows = Invoice::query()
->where('parent_id', $parentId)
->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))
->orderByDesc('created_at')
->get();
return $rows->toArray();
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Services\Parents;
use App\Models\User;
class ParentProfileService
{
public function getProfile(int $parentId): ?array
{
$user = User::query()->find($parentId);
return $user ? $user->toArray() : null;
}
public function updateProfile(int $parentId, array $payload): array
{
$user = User::query()->findOrFail($parentId);
$user->update([
'firstname' => $payload['firstname'],
'lastname' => $payload['lastname'],
'cellphone' => $payload['cellphone'],
'address_street' => $payload['address_street'],
'city' => $payload['city'],
'state' => $payload['state'],
'zip' => $payload['zip'],
]);
return $user->toArray();
}
}
@@ -0,0 +1,228 @@
<?php
namespace App\Services\Parents;
use App\Models\EmergencyContact;
use App\Models\Student;
use App\Models\StudentAllergy;
use App\Models\StudentMedicalCondition;
use App\Models\User;
use App\Services\SchoolIdService;
use Illuminate\Support\Facades\DB;
class ParentRegistrationService
{
public function __construct(
private ParentConfigService $configService,
private SchoolIdService $schoolIdService
) {
}
public function overview(int $parentId): array
{
$context = $this->configService->context();
$parent = User::query()->find($parentId);
$kids = Student::query()->where('parent_id', $parentId)->get();
$kidIds = $kids->pluck('id')->all();
$allergies = StudentAllergy::query()
->whereIn('student_id', $kidIds)
->get()
->groupBy('student_id');
$conditions = StudentMedicalCondition::query()
->whereIn('student_id', $kidIds)
->get()
->groupBy('student_id');
$kids = $kids->map(function ($kid) use ($allergies, $conditions) {
$kid->allergies = ($allergies[$kid->id] ?? collect())->pluck('allergy')->all();
$kid->medical_conditions = ($conditions[$kid->id] ?? collect())->pluck('condition_name')->all();
return $kid->toArray();
})->all();
$emergencies = EmergencyContact::query()->where('parent_id', $parentId)->get()->toArray();
return [
'parent' => $parent ? $parent->toArray() : null,
'existingKids' => $kids,
'emergencies' => $emergencies,
'maxChilds' => $context['max_kids'],
'maxEmergency' => $context['max_emergency'],
'lastDayOfRegistration' => $context['enrollment_deadline'],
'registrationAgeDeadline' => $context['date_age_reference'],
];
}
public function register(int $parentId, array $students, array $emergencyContacts): array
{
$context = $this->configService->context();
$schoolYear = $context['school_year'];
$semester = $context['semester'];
$ageReference = $context['date_age_reference'] ?: now()->toDateString();
$existingKidsCount = Student::query()->where('parent_id', $parentId)->count();
$existingECCount = EmergencyContact::query()->where('parent_id', $parentId)->count();
if ($existingKidsCount + count($students) > $context['max_kids']) {
throw new \RuntimeException('Student limit exceeded.');
}
if ($existingECCount + count($emergencyContacts) > $context['max_emergency']) {
throw new \RuntimeException('Emergency contact limit exceeded.');
}
$createdStudentIds = [];
DB::transaction(function () use (
$parentId,
$students,
$emergencyContacts,
$schoolYear,
$semester,
&$createdStudentIds
) {
foreach ($students as $student) {
$exists = Student::query()
->where('school_year', $schoolYear)
->where('dob', $student['dob'])
->where('firstname', $student['firstname'])
->where('lastname', $student['lastname'])
->first();
if ($exists) {
continue;
}
$row = Student::query()->create([
'firstname' => $student['firstname'],
'lastname' => $student['lastname'],
'dob' => $student['dob'],
'age' => $this->calculateAge($student['dob'], $ageReference),
'gender' => $student['gender'],
'registration_grade' => $student['registration_grade'] ?? null,
'photo_consent' => !empty($student['photo_consent']) ? 1 : 0,
'parent_id' => $parentId,
'year_of_registration' => (string) date('Y'),
'school_year' => $schoolYear,
'semester' => $semester,
'is_new' => isset($student['is_new']) ? (int) (bool) $student['is_new'] : null,
'registration_date' => now(),
'tuition_paid' => 0,
'school_id' => $this->schoolIdService->generateStudentSchoolId(),
]);
$createdStudentIds[] = (int) $row->id;
foreach (($student['medical_conditions'] ?? []) as $condition) {
$condition = trim((string) $condition);
if ($condition === '') {
continue;
}
StudentMedicalCondition::query()->create([
'student_id' => $row->id,
'condition_name' => $condition,
]);
}
foreach (($student['allergies'] ?? []) as $allergy) {
$allergy = trim((string) $allergy);
if ($allergy === '') {
continue;
}
StudentAllergy::query()->create([
'student_id' => $row->id,
'allergy' => $allergy,
]);
}
}
foreach ($emergencyContacts as $contact) {
EmergencyContact::query()->create([
'parent_id' => $parentId,
'emergency_contact_name' => $contact['name'],
'cellphone' => $contact['cellphone'],
'email' => $contact['email'] ?? null,
'relation' => $contact['relation'] ?? null,
'semester' => $semester,
'school_year' => $schoolYear,
]);
}
});
return [
'created_student_ids' => $createdStudentIds,
];
}
public function updateStudent(int $parentId, int $studentId, array $payload): void
{
$student = Student::query()
->where('id', $studentId)
->where('parent_id', $parentId)
->firstOrFail();
$ageReference = $this->configService->context()['date_age_reference'] ?: now()->toDateString();
$student->update([
'firstname' => $payload['firstname'],
'lastname' => $payload['lastname'],
'dob' => $payload['dob'],
'age' => $this->calculateAge($payload['dob'], $ageReference),
'gender' => $payload['gender'],
'registration_grade' => $payload['registration_grade'] ?? null,
'photo_consent' => !empty($payload['photo_consent']) ? 1 : 0,
]);
StudentMedicalCondition::query()->where('student_id', $studentId)->delete();
foreach (($payload['medical_conditions'] ?? []) as $condition) {
$condition = trim((string) $condition);
if ($condition === '') {
continue;
}
StudentMedicalCondition::query()->create([
'student_id' => $studentId,
'condition_name' => $condition,
]);
}
StudentAllergy::query()->where('student_id', $studentId)->delete();
foreach (($payload['allergies'] ?? []) as $allergy) {
$allergy = trim((string) $allergy);
if ($allergy === '') {
continue;
}
StudentAllergy::query()->create([
'student_id' => $studentId,
'allergy' => $allergy,
]);
}
}
public function deleteStudent(int $parentId, int $studentId): void
{
$student = Student::query()
->where('id', $studentId)
->where('parent_id', $parentId)
->firstOrFail();
$student->delete();
$remaining = Student::query()->where('parent_id', $parentId)->count();
if ($remaining === 0) {
EmergencyContact::query()->where('parent_id', $parentId)->delete();
}
}
private function calculateAge(string $dob, string $referenceDate): int
{
try {
$dobObj = new \DateTimeImmutable($dob);
$refObj = new \DateTimeImmutable($referenceDate);
return (int) $dobObj->diff($refObj)->y;
} catch (\Throwable $e) {
return 0;
}
}
}