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,34 @@
<?php
namespace App\Services\EmergencyContacts;
use App\Models\EmergencyContact;
use App\Services\PhoneFormatterService;
class EmergencyContactCrudService
{
public function __construct(
private PhoneFormatterService $phoneFormatter
) {
}
public function update(int $contactId, array $payload): array
{
$contact = EmergencyContact::query()->findOrFail($contactId);
$contact->update([
'emergency_contact_name' => $payload['name'],
'cellphone' => $this->phoneFormatter->formatPhoneNumber($payload['cellphone']),
'email' => $payload['email'] ?? null,
'relation' => $payload['relation'] ?? null,
]);
return $contact->toArray();
}
public function delete(int $contactId): void
{
$contact = EmergencyContact::query()->findOrFail($contactId);
$contact->delete();
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Services\EmergencyContacts;
use App\Models\EmergencyContact;
use App\Models\Student;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class EmergencyContactDirectoryService
{
public function groups(): array
{
$parentIds = EmergencyContact::query()
->distinct()
->pluck('parent_id')
->filter(static fn ($id) => (int) $id > 0)
->values()
->all();
$groups = [];
foreach ($parentIds as $parentId) {
$parentId = (int) $parentId;
$parent = User::query()->find($parentId);
$parentName = $parent
? trim((string) $parent->firstname . ' ' . (string) $parent->lastname)
: 'Unknown Parent';
if ($parentName === '') {
$parentName = 'Unknown Parent';
}
$parentPhone = $parent ? (string) ($parent->cellphone ?? '') : '';
$secondPhone = $this->secondParentPhone($parentId);
$students = Student::query()
->select(['id', 'firstname', 'lastname', 'school_id'])
->where('parent_id', $parentId)
->get()
->toArray();
$contacts = EmergencyContact::query()
->where('parent_id', $parentId)
->orderBy('updated_at', 'DESC')
->get()
->toArray();
$groups[] = [
'parent_id' => $parentId,
'parent_name' => $parentName,
'parent_phones' => array_values(array_filter([
$parentPhone,
$secondPhone,
], static fn ($value) => (string) $value !== '')),
'students' => $students,
'contacts' => $contacts,
];
}
return $groups;
}
private function secondParentPhone(int $parentId): string
{
try {
if (!Schema::hasTable('parents')) {
return '';
}
if (!Schema::hasColumn('parents', 'secondparent_phone')) {
return '';
}
$phone = DB::table('parents')
->where('firstparent_id', $parentId)
->orderByDesc('updated_at')
->value('secondparent_phone');
return $phone ? (string) $phone : '';
} catch (\Throwable $e) {
Log::debug('EmergencyContactDirectoryService: could not load second parent phone: ' . $e->getMessage());
return '';
}
}
}
@@ -0,0 +1,225 @@
<?php
namespace App\Services\Exams;
use App\Models\ClassSection;
use App\Models\ExamDraft;
use Illuminate\Support\Facades\DB;
class ExamDraftAdminService
{
public function __construct(
private ExamDraftConfigService $configService,
private ExamDraftFileService $fileService
) {
}
public function list(): array
{
$context = $this->configService->context();
$drafts = DB::table('exam_drafts as ed')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ed.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'ed.teacher_id')
->leftJoin('users as a', 'a.id', '=', 'ed.admin_id')
->select(
'ed.*',
'cs.class_section_name',
'u.firstname as teacher_first',
'u.lastname as teacher_last',
'a.firstname as admin_first',
'a.lastname as admin_last'
)
->orderByDesc('ed.created_at')
->get()
->map(fn ($r) => (array) $r)
->all();
$classSections = ClassSection::query()
->select('class_section_id', 'class_section_name')
->orderBy('class_section_name')
->get()
->toArray();
$legacyByClass = [];
if ($context['has_legacy'] ?? false) {
foreach ($drafts as $row) {
$isLegacy = !empty($row['is_legacy']);
if (!$isLegacy) {
continue;
}
$cid = (int) ($row['class_section_id'] ?? 0);
if (!isset($legacyByClass[$cid])) {
$legacyByClass[$cid] = [
'class_section_id' => $cid,
'class_section_name' => $row['class_section_name'] ?? 'Class ' . $cid,
'items' => [],
];
}
$legacyByClass[$cid]['items'][] = $row;
}
}
return [
'drafts' => $drafts,
'classSections' => $classSections,
'legacyByClass' => $legacyByClass,
'examTypes' => ExamDraftConfigService::EXAM_TYPES,
'statusOptions' => ExamDraftConfigService::STATUS_OPTIONS,
'schoolYear' => (string) ($context['school_year'] ?? ''),
'semester' => (string) ($context['semester'] ?? ''),
'allowedExtensions' => ExamDraftConfigService::ADMIN_ALLOWED_EXTENSIONS,
'maxUploadBytes' => ExamDraftConfigService::MAX_UPLOAD_BYTES,
];
}
public function uploadLegacy(int $adminId, array $payload, \Illuminate\Http\UploadedFile $file): array
{
$context = $this->configService->context();
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
$schoolYear = trim((string) ($payload['school_year'] ?? $context['school_year'] ?? ''));
$semester = trim((string) ($payload['semester'] ?? $context['semester'] ?? ''));
$examType = trim((string) ($payload['exam_type'] ?? ''));
if ($classSectionId <= 0 || $schoolYear === '' || $semester === '') {
return ['ok' => false, 'message' => 'Select class section, school year, and semester.'];
}
$stored = $this->fileService->storeUploadedFile(
$file,
ExamDraftConfigService::FINAL_UPLOAD_DIR,
ExamDraftConfigService::ADMIN_ALLOWED_EXTENSIONS,
ExamDraftConfigService::MAX_UPLOAD_BYTES
);
if ($stored === null) {
return ['ok' => false, 'message' => 'File type not allowed or upload failed.'];
}
$payload = [
'teacher_id' => $adminId,
'class_section_id' => $classSectionId,
'semester' => ucfirst(strtolower($semester)),
'school_year' => $schoolYear,
'exam_type' => $examType !== '' ? $examType : null,
'draft_title' => $examType !== '' ? $examType : 'Legacy Exam',
'final_file' => $stored['stored'],
'final_filename' => $stored['original'],
'status' => 'finalized',
'admin_id' => $adminId,
'reviewed_at' => now(),
'version' => 1,
];
if ($context['has_legacy'] ?? false) {
$payload['is_legacy'] = 1;
}
$pdfName = null;
if ($stored['extension'] === 'pdf') {
$pdfName = $stored['stored'];
} else {
$pdfName = $this->fileService->ensurePdfExists(
$stored['stored'],
$stored['extension'],
ExamDraftConfigService::FINAL_UPLOAD_DIR
);
}
if ($pdfName !== null && ($context['has_final_pdf'] ?? false)) {
$payload['final_pdf_file'] = $pdfName;
}
$draft = ExamDraft::query()->create($payload);
return ['ok' => true, 'draft' => $draft->toArray()];
}
public function review(int $adminId, array $payload, ?\Illuminate\Http\UploadedFile $file): array
{
$draftId = (int) ($payload['draft_id'] ?? 0);
if ($draftId <= 0) {
return ['ok' => false, 'message' => 'Invalid submission selected.'];
}
$draft = ExamDraft::query()->find($draftId);
if (!$draft) {
return ['ok' => false, 'message' => 'Submission not found.'];
}
$comments = trim((string) ($payload['admin_comments'] ?? ''));
$statusInput = trim((string) ($payload['review_status'] ?? ''));
$currentStatus = (string) ($draft->status ?? 'draft');
$status = $this->normalizeStatus($statusInput !== '' ? $statusInput : $currentStatus, $currentStatus);
if (!in_array($status, ExamDraftConfigService::STATUS_OPTIONS, true)) {
$status = 'reviewed';
}
$update = [
'status' => $status,
'admin_comments' => $comments !== '' ? $comments : null,
'admin_id' => $adminId,
'reviewed_at' => now(),
];
if ($file) {
$stored = $this->fileService->storeUploadedFile(
$file,
ExamDraftConfigService::FINAL_UPLOAD_DIR,
ExamDraftConfigService::ADMIN_ALLOWED_EXTENSIONS,
ExamDraftConfigService::MAX_UPLOAD_BYTES
);
if ($stored === null) {
return ['ok' => false, 'message' => 'Unable to store the final draft.'];
}
$update['final_file'] = $stored['stored'];
$update['final_filename'] = $stored['original'];
$pdfName = $this->fileService->ensurePdfExists(
$stored['stored'],
$stored['extension'],
ExamDraftConfigService::FINAL_UPLOAD_DIR
);
if ($pdfName !== null && ($this->configService->context()['has_final_pdf'] ?? false)) {
$update['final_pdf_file'] = $pdfName;
}
} elseif ($status === 'finalized' && !empty($draft->teacher_file)) {
$copied = $this->fileService->copyDraftToFinal(
(string) $draft->teacher_file,
ExamDraftConfigService::TEACHER_UPLOAD_DIR,
ExamDraftConfigService::FINAL_UPLOAD_DIR
);
if ($copied !== null) {
$update['final_file'] = $copied;
$update['final_filename'] = $draft->teacher_filename ?? $draft->teacher_file;
$pdfName = $this->fileService->ensurePdfExists(
$copied,
pathinfo($copied, PATHINFO_EXTENSION),
ExamDraftConfigService::FINAL_UPLOAD_DIR
);
if ($pdfName !== null && ($this->configService->context()['has_final_pdf'] ?? false)) {
$update['final_pdf_file'] = $pdfName;
}
}
}
if ($status === 'finalized' && ($this->configService->context()['has_legacy'] ?? false)) {
$update['is_legacy'] = 1;
}
$draft->update($update);
return ['ok' => true, 'draft' => $draft->fresh()->toArray()];
}
private function normalizeStatus(string $input, string $default): string
{
$input = strtolower(trim($input));
$aliases = [
'pending' => 'submitted',
'final' => 'finalized',
'approved' => 'finalized',
];
if (isset($aliases[$input])) {
$input = $aliases[$input];
}
return in_array($input, ExamDraftConfigService::STATUS_OPTIONS, true) ? $input : $default;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Services\Exams;
use App\Models\Configuration;
use Illuminate\Support\Facades\Schema;
class ExamDraftConfigService
{
public const TEACHER_UPLOAD_DIR = 'exams/drafts';
public const FINAL_UPLOAD_DIR = 'exams/finals';
public const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
public const ALLOWED_EXTENSIONS = ['doc', 'docx'];
public const ADMIN_ALLOWED_EXTENSIONS = ['doc', 'docx', 'pdf'];
public const EXAM_TYPES = [
'Final Exam',
'Midterm Exam',
'Quiz',
'Study Guide',
'Practice Exam',
'Other',
];
public const STATUS_OPTIONS = ['draft', 'submitted', 'reviewed', 'finalized', 'rejected'];
public function context(): array
{
return [
'school_year' => (string) (Configuration::getConfig('school_year') ?? ''),
'semester' => (string) (Configuration::getConfig('semester') ?? ''),
'has_final_pdf' => $this->hasColumn('exam_drafts', 'final_pdf_file'),
'has_legacy' => $this->hasColumn('exam_drafts', 'is_legacy'),
];
}
private function hasColumn(string $table, string $column): bool
{
try {
return Schema::hasColumn($table, $column);
} catch (\Throwable) {
return false;
}
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Services\Exams;
use Illuminate\Http\UploadedFile;
class ExamDraftFileService
{
public function storeUploadedFile(UploadedFile $file, string $subdir, array $allowedExtensions, int $maxBytes): ?array
{
$ext = strtolower($file->getClientOriginalExtension());
if (!in_array($ext, $allowedExtensions, true)) {
return null;
}
if ($file->getSize() > $maxBytes) {
return null;
}
$destination = storage_path('uploads/' . trim($subdir, '/'));
if (!is_dir($destination)) {
mkdir($destination, 0755, true);
}
$filename = uniqid('exam_', true) . '.' . $ext;
try {
$file->move($destination, $filename);
return [
'stored' => $filename,
'original' => $file->getClientOriginalName(),
'extension' => $ext,
];
} catch (\Throwable) {
return null;
}
}
public function ensurePdfExists(string $finalFilename, ?string $originalExt, string $subdir): ?string
{
$pdfNeighbor = $this->neighborPdfIfExists($finalFilename, $subdir);
if ($pdfNeighbor !== null) {
return $pdfNeighbor;
}
$ext = strtolower((string) $originalExt);
if ($ext === 'pdf') {
$path = $this->fullUploadPath($subdir, $finalFilename);
return is_file($path) ? $finalFilename : null;
}
return $this->convertDocToPdf($this->fullUploadPath($subdir, $finalFilename), $subdir);
}
public function copyDraftToFinal(string $draftFilename, string $sourceDir, string $targetDir): ?string
{
$source = $this->fullUploadPath($sourceDir, $draftFilename);
if (!is_file($source)) {
return null;
}
$destinationDir = storage_path('uploads/' . trim($targetDir, '/'));
if (!is_dir($destinationDir)) {
mkdir($destinationDir, 0755, true);
}
$ext = pathinfo($draftFilename, PATHINFO_EXTENSION);
$destName = uniqid('final_', true) . '.' . $ext;
$destPath = $destinationDir . '/' . $destName;
if (!@copy($source, $destPath)) {
return null;
}
return $destName;
}
private function convertDocToPdf(string $sourcePath, string $targetSubdir): ?string
{
$ext = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION));
if (!in_array($ext, ['doc', 'docx'], true)) {
return null;
}
$targetDir = storage_path('uploads/' . trim($targetSubdir, '/'));
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
$base = pathinfo($sourcePath, PATHINFO_FILENAME);
$targetPath = $targetDir . '/' . $base . '.pdf';
$cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
@exec($cmd);
return is_file($targetPath) ? basename($targetPath) : null;
}
private function neighborPdfIfExists(string $filename, string $subdir): ?string
{
if ($filename === '') {
return null;
}
$path = $this->fullUploadPath($subdir, $filename);
$base = pathinfo($path, PATHINFO_FILENAME);
$dir = pathinfo($path, PATHINFO_DIRNAME);
$pdfPath = $dir . '/' . $base . '.pdf';
return is_file($pdfPath) ? basename($pdfPath) : null;
}
private function fullUploadPath(string $subdir, string $filename): string
{
return storage_path('uploads/' . trim($subdir, '/') . '/' . $filename);
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Services\Exams;
use App\Models\ExamDraft;
use App\Models\TeacherClass;
use Illuminate\Support\Facades\DB;
class ExamDraftTeacherService
{
public function __construct(
private ExamDraftConfigService $configService,
private ExamDraftFileService $fileService
) {
}
public function listForTeacher(int $teacherId): array
{
$context = $this->configService->context();
$schoolYear = (string) ($context['school_year'] ?? '');
$assignments = TeacherClass::getClassAssignmentsByUserId($teacherId, $schoolYear);
$classSectionIds = array_map(static fn ($a) => (int) $a['class_section_id'], $assignments);
$drafts = ExamDraft::query()
->where('teacher_id', $teacherId)
->orderByDesc('created_at')
->get()
->toArray();
$legacyExams = [];
if (($context['has_legacy'] ?? false) && !empty($classSectionIds)) {
$legacyExams = DB::table('exam_drafts as ed')
->select('ed.*', 'cs.class_section_name')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ed.class_section_id')
->whereIn('ed.class_section_id', $classSectionIds)
->where('ed.is_legacy', 1)
->whereNotNull('ed.final_file')
->orderBy('cs.class_section_name')
->orderByDesc('ed.created_at')
->get()
->map(fn ($r) => (array) $r)
->all();
}
return [
'assignments' => $assignments,
'drafts' => $drafts,
'legacyExams' => $legacyExams,
'examTypes' => ExamDraftConfigService::EXAM_TYPES,
'statusOptions' => ExamDraftConfigService::STATUS_OPTIONS,
'schoolYear' => $schoolYear,
'semester' => (string) ($context['semester'] ?? ''),
];
}
public function store(int $teacherId, array $payload, ?\Illuminate\Http\UploadedFile $file): array
{
$context = $this->configService->context();
$schoolYear = (string) ($context['school_year'] ?? '');
$semester = (string) ($context['semester'] ?? '');
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
$examType = trim((string) ($payload['exam_type'] ?? ''));
$description = trim((string) ($payload['description'] ?? ''));
if ($classSectionId <= 0) {
return ['ok' => false, 'message' => 'Select a class section before submitting.'];
}
$assignment = TeacherClass::query()
->where('teacher_id', $teacherId)
->where('class_section_id', $classSectionId)
->first();
if (!$assignment) {
return ['ok' => false, 'message' => 'You are not assigned to the selected class section.'];
}
$teacherFile = null;
$teacherFilename = null;
if ($file) {
$stored = $this->fileService->storeUploadedFile(
$file,
ExamDraftConfigService::TEACHER_UPLOAD_DIR,
ExamDraftConfigService::ALLOWED_EXTENSIONS,
ExamDraftConfigService::MAX_UPLOAD_BYTES
);
if ($stored === null) {
return ['ok' => false, 'message' => 'Failed to store the uploaded file.'];
}
$teacherFile = $stored['stored'];
$teacherFilename = $stored['original'];
}
$existingDraft = ExamDraft::query()
->where('teacher_id', $teacherId)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->orderByDesc('version')
->first();
$nextVersion = 1;
$previousId = null;
if ($existingDraft) {
$nextVersion = ((int) ($existingDraft->version ?? 1)) + 1;
$previousId = (int) ($existingDraft->id ?? 0) ?: null;
}
$title = $examType !== '' ? $examType : 'Exam Draft';
$draft = ExamDraft::query()->create([
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'exam_type' => $examType !== '' ? $examType : null,
'draft_title' => $title,
'description' => $description !== '' ? $description : null,
'teacher_file' => $teacherFile,
'teacher_filename' => $teacherFilename,
'status' => 'submitted',
'version' => $nextVersion,
'previous_draft_id' => $previousId,
]);
return [
'ok' => true,
'draft' => $draft->toArray(),
];
}
}
@@ -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;
}
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Services\Reports;
use App\Models\Configuration;
use App\Models\User;
class SlipPrinterConfigService
{
public function context(): array
{
return [
'school_year' => Configuration::getConfig('school_year'),
'semester' => Configuration::getConfig('semester'),
];
}
public function currentAdminName(?int $userId): string
{
if ($userId && $userId > 0) {
$user = User::query()->select('firstname', 'lastname')->find($userId);
if ($user) {
$full = trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? ''));
if ($full !== '') {
return $full;
}
}
}
return '';
}
public function printerConfig(): array
{
$get = function ($keys, $default = null) {
$keys = is_array($keys) ? $keys : [$keys];
foreach ($keys as $key) {
$val = env($key);
if ($val !== null && $val !== '') {
return $val;
}
}
return $default;
};
return [
'chars_per_line' => (int) $get(['printer.chars_per_line', 'PRINTER_CHARS_PER_LINE'], 48),
'feed_lines' => (int) $get(['printer.feed_lines', 'PRINTER_FEED_LINES'], 3),
'paper' => (string) $get(['printer.paper', 'SLIP_PAPER'], 'card'),
];
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Services\Reports;
class SlipPrinterFormatterService
{
public function toDbDate(?string $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return $value;
}
if (preg_match('/^\d{1,2}\/\d{1,2}\/\d{4}$/', $value)) {
$dt = \DateTime::createFromFormat('!m/d/Y', $value);
return $dt ? $dt->format('Y-m-d') : null;
}
if (preg_match('/^\d{1,2}-\d{1,2}-\d{4}$/', $value)) {
$dt = \DateTime::createFromFormat('!m-d-Y', $value);
return $dt ? $dt->format('Y-m-d') : null;
}
$ts = strtotime($value);
return $ts ? date('Y-m-d', $ts) : null;
}
public function toDbTime(?string $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
$ts = strtotime($value);
return $ts ? date('H:i:s', $ts) : null;
}
public function formatDisplayDate(?string $value): string
{
$value = trim((string) $value);
if ($value === '' || $value === '0000-00-00') {
return '';
}
$ts = strtotime($value);
return $ts ? date('m/d/Y', $ts) : '';
}
public function formatDisplayTime(?string $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
$ts = strtotime($value);
if ($ts === false) {
$ts = strtotime('today ' . $value);
}
return $ts ? date('h:i A', $ts) : '';
}
public function fitLine(string $line, int $width): string
{
$line = (string) $line;
if (strlen($line) > $width) {
return substr($line, 0, $width);
}
return $line . str_repeat(' ', $width - strlen($line));
}
public function buildSlipLines(array $data, int $width): array
{
$lines = [];
$lines[] = $this->fitLine('Student Name: ' . (string) ($data['student_name'] ?? ''), $width);
$lines[] = $this->fitLine('Date: ' . (string) ($data['date'] ?? '') . ' Time In: ' . (string) ($data['time_in'] ?? ''), $width);
$lines[] = $this->fitLine('Grade: ' . (string) ($data['grade'] ?? ''), $width);
$lines[] = $this->fitLine('Reason: ' . (string) ($data['reason'] ?? ''), $width);
$lines[] = $this->fitLine('Admin: ' . (string) ($data['admin_name'] ?? ''), $width);
return $lines;
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Services\Reports;
use Dompdf\Dompdf;
use Dompdf\Options;
class SlipPrinterPdfService
{
public function __construct(private SlipPrinterConfigService $configService)
{
}
public function render(array $data, array $lines, array $options = []): array
{
$paper = strtolower((string) ($options['paper'] ?? $this->configService->printerConfig()['paper'] ?? 'card'));
$fontPt = (float) ($options['font_pt'] ?? env('SLIP_FONT_PT', 13.0));
$lineH = (float) ($options['line_h'] ?? env('SLIP_LINE_H', 2.0));
$linesCount = (int) ($options['rows'] ?? count($lines));
if ($linesCount < 1) {
$linesCount = count($lines);
}
$ptPerLine = $fontPt * $lineH;
$mmPerPt = 25.4 / 72.0;
$contentMm = $ptPerLine * $linesCount * $mmPerPt;
$pageMarginsMm = 6.0;
$fudgeMm = (float) ($options['fudge_mm'] ?? env('SLIP_FUDGE_MM', 12.0));
$dynHeightMm = $contentMm + $pageMarginsMm + $fudgeMm;
switch ($paper) {
case 'cardp':
case 'card-portrait':
$wMm = 53.98;
$hMm = max($dynHeightMm, 40.0);
break;
case 'receipt80':
$wMm = 72.0;
$hMm = 90.0;
break;
case 'receipt58':
$wMm = 58.0;
$hMm = 80.0;
break;
case 'card':
default:
$wMm = 85.60;
$hMm = max($dynHeightMm, 40.0);
}
$hOverride = $options['height_mm'] ?? $options['h'] ?? null;
if (is_numeric($hOverride)) {
$hMm = max(30.0, min(200.0, (float) $hOverride));
}
if ($hMm < $dynHeightMm) {
$hMm = $dynHeightMm;
}
$html = view('slips/slip_pdf', [
'entry' => $data,
'lines' => $lines,
'page' => ['w_mm' => $wMm, 'h_mm' => $hMm],
]);
$optionsObj = new Options();
$optionsObj->set('isRemoteEnabled', true);
$optionsObj->set('defaultFont', 'Courier');
$dompdf = new Dompdf($optionsObj);
$dompdf->loadHtml($html, 'UTF-8');
$mmToPt = 72 / 25.4;
$wPt = $wMm * $mmToPt;
$hPt = $hMm * $mmToPt;
$dompdf->setPaper([0, 0, $wPt, $hPt]);
$dompdf->render();
$filename = 'LateSlip_' . preg_replace('/[^A-Za-z0-9]+/', '_', (string) ($data['student_name'] ?? 'slip')) . '_' . date('Ymd_His') . '.pdf';
return [
'content' => $dompdf->output(),
'filename' => $filename,
'width_mm' => $wMm,
'height_mm' => $hMm,
];
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
namespace App\Services\Reports;
use App\Models\LateSlipLog;
class SlipPrinterService
{
public function __construct(
private SlipPrinterConfigService $configService,
private SlipPrinterFormatterService $formatter,
private SlipPrinterPdfService $pdfService
) {
}
public function print(array $input, ?int $userId): array
{
$data = $this->buildData($input, $userId);
if ($data['student_name'] === '') {
return ['ok' => false, 'message' => 'Student name is required.'];
}
$this->logSlip($data, $userId);
$lines = $this->formatter->buildSlipLines($data, $this->lineWidth());
$pdf = $this->pdfService->render($data, $lines, $this->paperOptions($input));
return [
'ok' => true,
'data' => $data,
'pdf' => $pdf['content'],
'filename' => $pdf['filename'],
];
}
public function preview(array $input, ?int $userId): array
{
$data = $this->buildData($input, $userId);
$width = $this->lineWidth();
$feedLines = $this->configService->printerConfig()['feed_lines'] ?? 3;
$header = "Al Rahma School at ISGL\nSchool Year: {$data['school_year']}\n\n";
$body = implode("\n", $this->formatter->buildSlipLines($data, $width));
$footer = str_repeat("\n", (int) $feedLines);
return [
'ok' => true,
'text' => $header . $body . $footer,
'width' => $width,
];
}
public function logs(?string $schoolYear = null, ?string $semester = null): array
{
$query = LateSlipLog::query();
if ($schoolYear !== null && trim($schoolYear) !== '') {
$query->where('school_year', trim($schoolYear));
}
$semester = strtolower(trim((string) $semester));
if ($semester !== '') {
if ($semester === 'fall') {
$query->whereIn('semester', ['fall', '1', 1]);
} elseif ($semester === 'spring') {
$query->whereIn('semester', ['spring', '2', 2]);
}
}
$rows = $query->orderByDesc('id')->limit(50)->get()->toArray();
$out = [];
foreach ($rows as $row) {
$dispDate = $this->formatter->formatDisplayDate($row['slip_date'] ?? '');
if ($dispDate === '') {
$dispDate = $this->formatter->formatDisplayDate($row['printed_at'] ?? '');
}
$dispTime = $this->formatter->formatDisplayTime($row['time_in'] ?? '');
$out[] = [
'id' => (int) ($row['id'] ?? 0),
'school_year' => (string) ($row['school_year'] ?? ''),
'semester' => (string) ($row['semester'] ?? ''),
'student_name' => (string) ($row['student_name'] ?? ''),
'date' => $dispDate,
'time_in' => $dispTime,
'grade' => (string) ($row['grade'] ?? ''),
'reason' => (string) ($row['reason'] ?? ''),
'admin_name' => (string) ($row['admin_name'] ?? ''),
'printed_at' => (string) ($row['printed_at'] ?? ''),
];
}
return $out;
}
public function reprint(int $id, ?int $userId): array
{
$row = LateSlipLog::query()->find($id);
if (!$row) {
return ['ok' => false, 'message' => 'Slip not found.'];
}
$data = [
'school_year' => (string) ($row->school_year ?? ''),
'student_name' => (string) ($row->student_name ?? ''),
'date' => $this->formatter->formatDisplayDate($row->slip_date ?? '') ?: date('m/d/Y'),
'time_in' => $this->formatter->formatDisplayTime($row->time_in ?? '') ?: date('h:i A'),
'grade' => (string) ($row->grade ?? ''),
'reason' => (string) ($row->reason ?? ''),
'admin_name' => (string) ($row->admin_name ?? ''),
];
$adminFromDb = $this->configService->currentAdminName($userId);
if ($adminFromDb !== '') {
$data['admin_name'] = $adminFromDb;
}
$lines = $this->formatter->buildSlipLines($data, $this->lineWidth());
$pdf = $this->pdfService->render($data, $lines, []);
$this->logSlip($data, $userId);
return [
'ok' => true,
'data' => $data,
'pdf' => $pdf['content'],
'filename' => $pdf['filename'],
];
}
private function buildData(array $input, ?int $userId): array
{
$context = $this->configService->context();
$schoolYear = trim((string) ($input['school_year'] ?? $context['school_year'] ?? ''));
$data = [
'school_year' => $schoolYear,
'student_name' => trim((string) ($input['student_name'] ?? '')),
'date' => trim((string) ($input['date'] ?? date('m/d/Y'))),
'time_in' => trim((string) ($input['time_in'] ?? date('h:i A'))),
'grade' => trim((string) ($input['grade'] ?? '')),
'reason' => trim((string) ($input['reason'] ?? '')),
'admin_name' => trim((string) ($input['admin_name'] ?? '')),
];
$adminFromDb = $this->configService->currentAdminName($userId);
if ($adminFromDb !== '') {
$data['admin_name'] = $adminFromDb;
}
return $data;
}
private function logSlip(array $data, ?int $userId): void
{
$context = $this->configService->context();
$logData = [
'school_year' => $data['school_year'],
'semester' => (string) ($context['semester'] ?? ''),
'student_name' => $data['student_name'],
'slip_date' => $this->formatter->toDbDate($data['date']),
'time_in' => $this->formatter->toDbTime($data['time_in']),
'grade' => $data['grade'],
'reason' => $data['reason'],
'admin_name' => $data['admin_name'],
];
LateSlipLog::logSlip($logData, $userId);
}
private function lineWidth(): int
{
$cfg = $this->configService->printerConfig();
$width = (int) ($cfg['chars_per_line'] ?? 48);
return $width > 0 ? $width : 48;
}
private function paperOptions(array $input): array
{
return [
'paper' => $input['paper'] ?? null,
'font_pt' => $input['font_pt'] ?? null,
'line_h' => $input['line_h'] ?? null,
'rows' => $input['rows'] ?? null,
'height_mm' => $input['height_mm'] ?? null,
'h' => $input['h'] ?? null,
'fudge_mm' => $input['fudge_mm'] ?? null,
];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Services\SchoolIds;
use App\Services\SchoolIdService;
class SchoolIdAssignmentService
{
public function __construct(private SchoolIdService $schoolIdService)
{
}
public function assignToUser(int $userId): ?string
{
return $this->schoolIdService->assignSchoolIdToUser($userId);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Services\SchoolIds;
use App\Services\SchoolIdService;
class SchoolIdGenerationService
{
public function __construct(private SchoolIdService $schoolIdService)
{
}
public function generateStudentId(): string
{
return $this->schoolIdService->generateStudentSchoolId();
}
public function generateUserId(): string
{
return $this->schoolIdService->generateUserSchoolId();
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Services\Settings;
use App\Models\Configuration;
class ConfigurationService
{
public function list(): array
{
return Configuration::query()
->orderBy('id')
->get()
->toArray();
}
public function store(array $payload): Configuration
{
return Configuration::query()->create([
'config_key' => (string) $payload['config_key'],
'config_value' => (string) $payload['config_value'],
]);
}
public function update(int $id, array $payload): ?Configuration
{
$config = Configuration::query()->find($id);
if (!$config) {
return null;
}
$config->update([
'config_key' => (string) $payload['config_key'],
'config_value' => (string) $payload['config_value'],
]);
return $config;
}
public function delete(int $id): bool
{
$config = Configuration::query()->find($id);
if (!$config) {
return false;
}
return (bool) $config->delete();
}
}
@@ -0,0 +1,316 @@
<?php
namespace App\Services\Students;
use App\Models\ClassSection;
use App\Models\Enrollment;
use App\Models\Student;
use App\Models\StudentClass;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class StudentAssignmentService
{
public function __construct(private StudentConfigService $configService)
{
}
public function assignClasses(int $studentId, array $classSectionIds, bool $isEventOnly, ?int $userId = null): array
{
$context = $this->configService->context();
$semester = (string) ($context['semester'] ?? '');
$schoolYear = (string) ($context['school_year'] ?? '');
$classSectionIds = $this->normalizeIds($classSectionIds);
if ($studentId <= 0 || empty($classSectionIds) || $schoolYear === '') {
return ['ok' => false, 'message' => 'Missing required data (student/class section).'];
}
$student = Student::query()->find($studentId);
if (!$student) {
return ['ok' => false, 'message' => 'Student not found.'];
}
$sections = ClassSection::query()
->whereIn('class_section_id', $classSectionIds)
->orWhereIn('id', $classSectionIds)
->get()
->toArray();
if (empty($sections)) {
return ['ok' => false, 'message' => 'Class/Section not found.'];
}
$sectionMap = [];
foreach ($sections as $section) {
$sectionMap[(int) ($section['class_section_id'] ?? 0)] = $section;
}
$missing = array_diff($classSectionIds, array_keys($sectionMap));
if (!empty($missing)) {
return ['ok' => false, 'message' => 'One or more selected classes do not exist.'];
}
$existing = StudentClass::query()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->get()
->keyBy(fn ($row) => (int) ($row->class_section_id ?? 0));
$primarySectionId = $classSectionIds[0];
$primarySection = $sectionMap[$primarySectionId] ?? reset($sectionMap);
$parentClassId = (int) ($primarySection['class_id'] ?? 0);
if ($parentClassId <= 0) {
$parentClassId = (int) (ClassSection::getClassId($primarySectionId) ?? 0);
}
$attendanceStats = [];
$scoreStats = [];
DB::transaction(function () use (
$studentId,
$schoolYear,
$classSectionIds,
$isEventOnly,
$userId,
$existing,
$semester,
$primarySectionId,
$parentClassId,
&$attendanceStats,
&$scoreStats
): void {
foreach ($classSectionIds as $classSectionId) {
$eventFlag = $isEventOnly ? 1 : 0;
if ($existing->has($classSectionId)) {
$eventFlag = (int) ($existing[$classSectionId]->is_event_only ?? 0);
}
$payload = [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'school_year' => $schoolYear,
'is_event_only' => $eventFlag,
'updated_by' => $userId,
'updated_at' => now(),
];
if ($existing->has($classSectionId)) {
$existing[$classSectionId]->fill($payload)->save();
} else {
StudentClass::query()->create($payload + [
'created_at' => now(),
]);
}
}
if (!$isEventOnly) {
$enrollment = Enrollment::query()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->when($semester !== '', fn ($q) => $q->where('semester', $semester))
->first();
if ($enrollment) {
$enrollment->update([
'class_section_id' => $primarySectionId,
'enrollment_status' => 'payment pending',
'admission_status' => 'accepted',
'updated_at' => now(),
]);
}
$attendanceStats = $this->updateAttendance(
$studentId,
$primarySectionId,
$parentClassId,
$semester,
$schoolYear,
$userId
);
$scoreStats = $this->updateScores(
$studentId,
$primarySectionId,
$semester,
$schoolYear,
$userId
);
}
});
$displayNames = [];
foreach ($classSectionIds as $classSectionId) {
$section = $sectionMap[$classSectionId] ?? null;
if (!$section) {
continue;
}
$name = (string) ($section['class_section_name'] ?? '');
if ($name === '') {
$name = 'Section #' . $classSectionId;
}
$displayNames[] = $name;
}
return [
'ok' => true,
'student_id' => $studentId,
'class_section_id' => $primarySectionId,
'class_section_ids' => $classSectionIds,
'class_section_names' => $displayNames,
'attendance_updates' => $attendanceStats,
'score_updates' => $scoreStats,
];
}
public function removeClass(int $studentId, int $classSectionId, ?int $userId = null): array
{
$context = $this->configService->context();
$semester = (string) ($context['semester'] ?? '');
$schoolYear = (string) ($context['school_year'] ?? '');
if ($studentId <= 0 || $classSectionId <= 0 || $schoolYear === '') {
return ['ok' => false, 'message' => 'Missing required data (student/class section).'];
}
$row = StudentClass::query()
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->first();
if (!$row) {
return ['ok' => false, 'message' => 'Assignment not found for this student/class.'];
}
$remainingIds = [];
$remainingNames = [];
DB::transaction(function () use (
$studentId,
$classSectionId,
$schoolYear,
$semester,
$userId,
&$remainingIds,
&$remainingNames
): void {
StudentClass::query()
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->delete();
$remainingIds = StudentClass::getClassSectionIdsByStudentId($studentId, $schoolYear);
$remainingNames = StudentClass::getClassSectionsByStudentId($studentId, $schoolYear, true);
$enrollment = Enrollment::query()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->when($semester !== '', fn ($q) => $q->where('semester', $semester))
->first();
if ($enrollment) {
$newClassId = !empty($remainingIds) ? $remainingIds[0] : null;
if ((int) $enrollment->class_section_id === $classSectionId || $newClassId !== null) {
$enrollment->update([
'class_section_id' => $newClassId,
'updated_at' => now(),
'updated_by' => $userId,
]);
}
}
});
return [
'ok' => true,
'student_id' => $studentId,
'removed_class_id' => $classSectionId,
'remaining_ids' => $remainingIds,
'remaining_names' => $remainingNames,
];
}
private function updateAttendance(
int $studentId,
int $classSectionId,
int $classId,
string $semester,
string $schoolYear,
?int $modifiedBy
): array {
$now = now()->toDateTimeString();
$attendanceUpdated = DB::table('attendance_data')
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->update([
'class_section_id' => $classSectionId,
'class_id' => $classId,
'updated_at' => $now,
'modified_by' => $modifiedBy,
]);
$recordUpdated = DB::table('attendance_record')
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->update([
'class_section_id' => $classSectionId,
'updated_at' => $now,
'modified_by' => $modifiedBy,
]);
return [
'attendance_data_updated' => $attendanceUpdated,
'attendance_record_updated' => $recordUpdated,
];
}
private function updateScores(
int $studentId,
int $classSectionId,
string $semester,
string $schoolYear,
?int $modifiedBy
): array {
$now = now()->toDateTimeString();
$tables = [
'homework',
'quiz',
'project',
'participation',
'midterm_exam',
'final_exam',
'final_score',
'semester_scores',
];
$results = [];
foreach ($tables as $table) {
$payload = [
'class_section_id' => $classSectionId,
'updated_at' => $now,
];
if ($modifiedBy !== null && Schema::hasColumn($table, 'updated_by')) {
$payload['updated_by'] = $modifiedBy;
}
$results[$table . '_updated'] = DB::table($table)
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->update($payload);
}
return $results;
}
private function normalizeIds(array $ids): array
{
$values = array_map('intval', $ids);
$values = array_values(array_unique(array_filter($values, static fn ($v) => $v > 0)));
return $values;
}
}
@@ -0,0 +1,256 @@
<?php
namespace App\Services\Students;
use App\Models\ClassSection;
use App\Models\PromotionQueue;
use App\Models\StudentClass;
use Illuminate\Support\Facades\DB;
class StudentAutoDistributionService
{
public function __construct(private StudentConfigService $configService)
{
}
public function promotionTotals(?string $schoolYear = null): array
{
$context = $this->configService->context();
$year = $schoolYear ?: (string) ($context['school_year'] ?? '');
$bases = ClassSection::query()
->whereRaw("class_section_name NOT LIKE '%-%'")
->orderBy('class_id')
->get()
->toArray();
$wanted = [];
foreach ($bases as $row) {
$nameRaw = (string) ($row['class_section_name'] ?? '');
$name = strtolower($nameRaw);
if ($name === 'kg' || $name === 'youth') {
$wanted[] = $row;
continue;
}
if (ctype_digit($name)) {
$num = (int) $name;
if ($num >= 1 && $num <= 9) {
$wanted[] = $row;
}
}
}
$rows = [];
foreach ($wanted as $row) {
$classId = (int) ($row['class_id'] ?? 0);
if ($classId <= 0) {
continue;
}
$cands = DB::table('promotion_queue as pq')
->select('pq.student_id')
->join('enrollments as e', function ($join) use ($year) {
$join->on('e.student_id', '=', 'pq.student_id')
->where('e.school_year', '=', $year);
})
->where('pq.to_class_id', $classId)
->where('pq.school_year_to', $year)
->whereIn('pq.status', ['queued', 'assigned', 'applied'])
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
->groupBy('pq.student_id')
->get();
$rows[] = [
'class_id' => $classId,
'class_section_id' => (int) ($row['class_section_id'] ?? 0),
'class_section_name' => (string) ($row['class_section_name'] ?? ''),
'total' => $cands->count(),
];
}
return [
'year' => $year,
'rows' => $rows,
];
}
public function autoDistribute(int $classId, int $studentsPerSection, ?string $schoolYear = null, ?int $classSectionId = null, ?int $userId = null): array
{
$context = $this->configService->context();
$year = $schoolYear ?: (string) ($context['school_year'] ?? '');
$semester = (string) ($context['semester'] ?? '');
if ($classId <= 0 && $classSectionId > 0) {
$classId = (int) (ClassSection::getClassId($classSectionId) ?? 0);
}
if ($classId <= 0 || $studentsPerSection <= 0) {
return ['ok' => false, 'message' => 'Invalid class_id or students_per_section.'];
}
$cands = DB::table('promotion_queue as pq')
->select('pq.*', 'students.gender')
->join('students', 'students.id', '=', 'pq.student_id')
->where('pq.to_class_id', $classId)
->where('pq.school_year_to', $year)
->whereIn('pq.status', ['queued', 'assigned'])
->get()
->map(fn ($r) => (array) $r)
->all();
if (empty($cands)) {
return ['ok' => false, 'message' => 'No students found in promotion queue for selected class/year.'];
}
$studentIds = array_map(static fn ($r) => (int) $r['student_id'], $cands);
$enrolledIds = DB::table('enrollments')
->select('student_id')
->whereIn('student_id', $studentIds)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->groupBy('student_id')
->pluck('student_id')
->map(fn ($v) => (int) $v)
->all();
$cands = array_values(array_filter($cands, static function ($row) use ($enrolledIds) {
return in_array((int) $row['student_id'], $enrolledIds, true);
}));
if (empty($cands)) {
return ['ok' => false, 'message' => 'No eligible enrolled students found to distribute.'];
}
$sectionsNeeded = (int) ceil(count($cands) / $studentsPerSection);
$letters = ClassSection::getLetterSectionsByClassId($classId);
if (empty($letters)) {
return ['ok' => false, 'message' => 'No lettered sections found for the selected class.'];
}
if (count($letters) < $sectionsNeeded) {
return ['ok' => false, 'message' => 'Not enough sections available.'];
}
$letters = array_slice($letters, 0, $sectionsNeeded);
$buckets = [];
foreach ($letters as $idx => $section) {
$buckets[$idx] = [
'class_section_id' => (int) ($section['class_section_id'] ?? 0),
'assigned' => [],
'male' => 0,
'female' => 0,
];
}
$males = [];
$females = [];
foreach ($cands as $row) {
$gender = (string) ($row['gender'] ?? '');
if (strcasecmp($gender, 'Female') === 0) {
$females[] = $row;
} else {
$males[] = $row;
}
}
$pickBucket = function (string $gender) use (&$buckets, $studentsPerSection): ?int {
$bestIdx = null;
$bestCnt = PHP_INT_MAX;
foreach ($buckets as $idx => $bucket) {
if (count($bucket['assigned']) >= $studentsPerSection) {
continue;
}
$cnt = $gender === 'female' ? $bucket['female'] : $bucket['male'];
if ($cnt < $bestCnt) {
$bestCnt = $cnt;
$bestIdx = $idx;
}
}
return $bestIdx;
};
foreach ($males as $row) {
$idx = $pickBucket('male');
if ($idx === null) {
break;
}
$buckets[$idx]['assigned'][] = (int) $row['student_id'];
$buckets[$idx]['male']++;
}
foreach ($females as $row) {
$idx = $pickBucket('female');
if ($idx === null) {
break;
}
$buckets[$idx]['assigned'][] = (int) $row['student_id'];
$buckets[$idx]['female']++;
}
$promoIdsByStudent = [];
foreach ($cands as $row) {
$promoIdsByStudent[(int) $row['student_id']] = (int) $row['id'];
}
DB::transaction(function () use ($buckets, $promoIdsByStudent, $year, $semester, $userId): void {
foreach ($buckets as $bucket) {
$sectionId = (int) $bucket['class_section_id'];
foreach ($bucket['assigned'] as $studentId) {
if (isset($promoIdsByStudent[$studentId])) {
PromotionQueue::query()->where('id', $promoIdsByStudent[$studentId])->update([
'to_class_section_id' => $sectionId,
'status' => 'assigned',
'updated_by' => $userId,
'updated_at' => now(),
]);
}
$exists = StudentClass::query()
->where('student_id', $studentId)
->where('school_year', $year)
->when($semester !== '', fn ($q) => $q->where('semester', $semester))
->first();
$payload = [
'student_id' => $studentId,
'class_section_id' => $sectionId,
'school_year' => $year,
'semester' => $semester,
'updated_by' => $userId,
'updated_at' => now(),
];
if ($exists) {
$exists->update($payload);
} else {
StudentClass::query()->create($payload + ['created_at' => now()]);
}
}
}
});
$nameById = [];
foreach ($letters as $row) {
$nameById[(int) $row['class_section_id']] = (string) ($row['class_section_name'] ?? '');
}
$summary = [];
foreach ($buckets as $bucket) {
$sectionId = (int) $bucket['class_section_id'];
$summary[] = [
'class_section_id' => $sectionId,
'class_section_name' => $nameById[$sectionId] ?? (string) $sectionId,
'total' => count($bucket['assigned']),
'male' => $bucket['male'],
'female' => $bucket['female'],
];
}
return [
'ok' => true,
'message' => 'Auto distribution completed.',
'sections' => $summary,
];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Services\Students;
use App\Models\Configuration;
class StudentConfigService
{
public function context(): array
{
return [
'school_year' => Configuration::getConfig('school_year'),
'semester' => Configuration::getConfig('semester'),
];
}
}
@@ -0,0 +1,164 @@
<?php
namespace App\Services\Students;
use App\Models\ClassSection;
use App\Models\EmergencyContact;
use App\Models\Student;
use App\Models\StudentClass;
use Illuminate\Support\Facades\DB;
class StudentDirectoryService
{
public function __construct(private StudentConfigService $configService)
{
}
public function assignmentData(?string $schoolYear = null): array
{
$context = $this->configService->context();
$schoolYear = $schoolYear ?: (string) ($context['school_year'] ?? '');
$semester = (string) ($context['semester'] ?? '');
$yearsRows = DB::table('enrollments')
->selectRaw('DISTINCT school_year')
->orderByDesc('school_year')
->get()
->map(fn ($r) => (array) $r)
->all();
$schoolYears = array_values(array_filter(array_map(static function ($r) {
return isset($r['school_year']) ? (string) $r['school_year'] : null;
}, $yearsRows)));
$students = Student::query()
->select('students.id', 'students.firstname', 'students.lastname', 'students.registration_date', 'students.is_new', 'students.age', 'students.parent_id', 'students.registration_grade')
->join('enrollments as e', 'e.student_id', '=', 'students.id')
->when($schoolYear !== '', fn ($q) => $q->where('e.school_year', $schoolYear))
->groupBy('students.id')
->orderBy('students.lastname')
->orderBy('students.firstname')
->get()
->toArray();
if (empty($students)) {
$students = Student::query()
->select('students.id', 'students.firstname', 'students.lastname', 'students.registration_date', 'students.is_new', 'students.age', 'students.parent_id', 'students.registration_grade')
->orderBy('students.lastname')
->orderBy('students.firstname')
->get()
->toArray();
}
$studentData = [];
foreach ($students as $student) {
$studentId = (int) ($student['id'] ?? 0);
$sectionNames = StudentClass::getClassSectionsByStudentIdWithFlags($studentId, $schoolYear, true);
$sectionIds = StudentClass::getClassSectionIdsByStudentId($studentId, $schoolYear);
$sectionDisplay = !empty($sectionNames) ? implode(', ', $sectionNames) : '';
$parentId = (int) ($student['parent_id'] ?? 0);
$emergencyInfo = $parentId > 0
? (EmergencyContact::getEmergencyContactByParentId($parentId) ?? [])
: [];
$studentData[] = [
'student_id' => $studentId,
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
'age' => $student['age'] ?? 'N/A',
'email' => $emergencyInfo['emergency_contact_name'] ?? '',
'phone' => $emergencyInfo['cellphone'] ?? '',
'registration_grade' => $student['registration_grade'] ?? 'N/A',
'class_section_name' => $sectionDisplay !== '' ? $sectionDisplay : 'No class assigned',
'class_section_names' => $sectionNames,
'class_section_ids' => $sectionIds,
'new_student' => ((int) ($student['is_new'] ?? 0) === 1) ? 'Yes' : 'No',
'registration_date' => $student['registration_date'] ?? '',
'school_year' => $schoolYear,
'semester' => $semester,
];
}
$classes = ClassSection::query()
->select('id', 'class_section_id', 'class_section_name', 'school_year')
->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear))
->orderBy('class_section_name')
->get()
->toArray();
if (empty($classes)) {
$classes = ClassSection::query()
->select('id', 'class_section_id', 'class_section_name')
->orderBy('class_section_name')
->get()
->toArray();
}
return [
'students' => $studentData,
'classes' => $classes,
'schoolYears' => $schoolYears,
'selectedYear' => $schoolYear,
'currentYear' => (string) ($context['school_year'] ?? ''),
'isCurrentYear' => $schoolYear !== '' && $schoolYear === (string) ($context['school_year'] ?? ''),
];
}
public function removedStudents(?string $schoolYear = null): array
{
$context = $this->configService->context();
$schoolYear = $schoolYear ?: (string) ($context['school_year'] ?? '');
$activeStudents = Student::query()
->select('id', 'school_id', 'firstname', 'lastname', 'gender', 'age')
->where('is_active', 1)
->orderBy('lastname')
->orderBy('firstname')
->get()
->toArray();
$removedStudents = Student::query()
->select('id', 'school_id', 'firstname', 'lastname', 'gender', 'age')
->where('is_active', 0)
->orderBy('lastname')
->orderBy('firstname')
->get()
->toArray();
$classRows = DB::table('student_class as sc')
->select('sc.student_id', 'cs.class_section_name')
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->when($schoolYear !== '', fn ($q) => $q->where('sc.school_year', $schoolYear))
->get()
->map(fn ($r) => (array) $r)
->all();
$classMap = [];
foreach ($classRows as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$name = trim((string) ($row['class_section_name'] ?? ''));
if ($studentId <= 0 || $name === '') {
continue;
}
$classMap[$studentId][] = $name;
}
$attachClasses = static function (array $students) use ($classMap): array {
foreach ($students as &$student) {
$studentId = (int) ($student['id'] ?? 0);
$names = $classMap[$studentId] ?? [];
$names = array_values(array_unique(array_filter($names)));
$student['class_sections'] = $names;
$student['class_section_name'] = !empty($names) ? implode(', ', $names) : 'No class assigned';
}
unset($student);
return $students;
};
return [
'active_students' => $attachClasses($activeStudents),
'removed_students' => $attachClasses($removedStudents),
'school_year' => $schoolYear,
];
}
}
@@ -0,0 +1,135 @@
<?php
namespace App\Services\Students;
use App\Models\Student;
use App\Models\StudentAllergy;
use App\Models\StudentMedicalCondition;
use Illuminate\Support\Facades\DB;
class StudentProfileService
{
public function updateStudent(int $studentId, array $payload): array
{
$student = Student::query()->find($studentId);
if (!$student) {
return ['ok' => false, 'message' => 'Student not found.'];
}
$studentData = array_filter([
'school_id' => $payload['school_id'] ?? null,
'firstname' => $payload['firstname'] ?? null,
'lastname' => $payload['lastname'] ?? null,
'dob' => $payload['dob'] ?? null,
'age' => $payload['age'] ?? null,
'gender' => $payload['gender'] ?? null,
'registration_grade' => $payload['registration_grade'] ?? null,
'photo_consent' => $payload['photo_consent'] ?? null,
'parent_id' => $payload['parent_id'] ?? null,
'registration_date' => $payload['registration_date'] ?? null,
'tuition_paid' => $payload['tuition_paid'] ?? null,
'year_of_registration' => $payload['year_of_registration'] ?? null,
'school_year' => $payload['school_year'] ?? null,
'rfid_tag' => $payload['rfid_tag'] ?? null,
'semester' => $payload['semester'] ?? null,
'is_new' => $payload['is_new'] ?? null,
'is_active' => $payload['is_active'] ?? null,
], static fn ($value) => $value !== null);
$conditionsInput = (string) ($payload['medical_conditions'] ?? '');
$allergiesInput = (string) ($payload['allergies'] ?? '');
$conditions = $this->normalizeHealthList($conditionsInput);
$allergies = $this->normalizeHealthList($allergiesInput);
$shouldSyncConditions = array_key_exists('medical_conditions', $payload);
$shouldSyncAllergies = array_key_exists('allergies', $payload);
DB::transaction(function () use ($student, $studentData, $conditions, $allergies): void {
if (!empty($studentData)) {
$student->update($studentData);
}
if ($shouldSyncConditions) {
$this->syncHealthList($student->id, StudentMedicalCondition::class, 'condition_name', $conditions);
}
if ($shouldSyncAllergies) {
$this->syncHealthList($student->id, StudentAllergy::class, 'allergy', $allergies);
}
});
return ['ok' => true, 'message' => 'Student updated successfully.'];
}
private function normalizeHealthList(string $value, int $maxLen = 100): array
{
$value = trim($value);
if ($value === '') {
return [];
}
$parts = preg_split('/[,\n;]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$out = [];
foreach ($parts as $part) {
$item = trim(preg_replace('/\s+/u', ' ', $part));
if ($item === '') {
continue;
}
if (preg_match('/^(none|n\/a|na|null|nil|no)$/i', $item)) {
continue;
}
$out[mb_strtolower($item, 'UTF-8')] = mb_substr($item, 0, $maxLen, 'UTF-8');
}
return array_values($out);
}
private function syncHealthList(int $studentId, string $modelClass, string $field, array $newValues): void
{
$model = new $modelClass();
$rows = $model->newQuery()->where('student_id', $studentId)->get(['id', $field])->toArray();
$current = [];
$byId = [];
foreach ($rows as $row) {
$val = (string) ($row[$field] ?? '');
$key = mb_strtolower(trim($val), 'UTF-8');
if ($key === '') {
continue;
}
$current[$key] = $val;
$byId[$key] = (int) ($row['id'] ?? 0);
}
$incoming = [];
foreach ($newValues as $value) {
$key = mb_strtolower(trim($value), 'UTF-8');
if ($key === '') {
continue;
}
$incoming[$key] = $value;
}
$toDeleteKeys = array_diff(array_keys($current), array_keys($incoming));
$toInsertKeys = array_diff(array_keys($incoming), array_keys($current));
if (!empty($toDeleteKeys)) {
$ids = array_map(fn ($key) => $byId[$key], $toDeleteKeys);
if (!empty($ids)) {
$model->newQuery()->whereIn('id', $ids)->delete();
}
}
if (!empty($toInsertKeys)) {
$batch = [];
foreach ($toInsertKeys as $key) {
$batch[] = [
'student_id' => $studentId,
$field => $incoming[$key],
];
}
if (!empty($batch)) {
$model->newQuery()->insert($batch);
}
}
}
}
@@ -0,0 +1,113 @@
<?php
namespace App\Services\Students;
use App\Models\Student;
use Illuminate\Support\Facades\DB;
class StudentScoreCardService
{
public function scoreCard(int $studentId): array
{
$student = Student::query()
->select('id', 'firstname', 'lastname', 'school_id')
->where('id', $studentId)
->first();
if (!$student) {
return ['ok' => false, 'message' => 'Student not found.'];
}
$rows = DB::table('semester_scores as ss')
->select([
'ss.school_year',
'ss.semester',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
'ss.quiz_avg',
'ss.test_avg',
'ss.attendance_score',
'ss.ptap_score',
'ss.midterm_exam_score',
'ss.semester_score',
'cs.class_section_name',
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('ss.student_id', $studentId)
->get()
->map(fn ($r) => (array) $r)
->all();
usort($rows, static function (array $a, array $b): int {
$ay = (string) ($a['school_year'] ?? '');
$by = (string) ($b['school_year'] ?? '');
if ($ay !== $by) {
return strcmp($by, $ay);
}
return strcmp((string) ($a['semester'] ?? ''), (string) ($b['semester'] ?? ''));
});
$yearScoreMap = [];
foreach ($rows as $row) {
$year = (string) ($row['school_year'] ?? '');
$score = $row['semester_score'] ?? null;
if ($year === '' || !is_numeric($score)) {
continue;
}
$yearScoreMap[$year][] = (float) $score;
}
$yearAvg = [];
foreach ($yearScoreMap as $year => $scores) {
$yearAvg[$year] = round(array_sum($scores) / count($scores), 1);
}
$commentRows = DB::table('score_comments')
->select('school_year', 'semester', 'score_type', 'comment', 'comment_review')
->where('student_id', $studentId)
->orderByDesc('created_at')
->get()
->map(fn ($r) => (array) $r)
->all();
$commentMap = [];
foreach ($commentRows as $row) {
$year = (string) ($row['school_year'] ?? '');
$semester = strtolower(trim((string) ($row['semester'] ?? '')));
$type = strtolower(trim((string) ($row['score_type'] ?? '')));
if ($year === '' || $type === '') {
continue;
}
if ($type === 'attendance_comment') {
$type = 'attendance';
}
$commentVal = trim((string) ($row['comment'] ?? ''));
$reviewVal = trim((string) ($row['comment_review'] ?? ''));
if (in_array($type, ['midterm', 'final', 'ptap'], true)) {
$commentVal = $reviewVal !== '' ? $reviewVal : $commentVal;
} elseif ($type === 'attendance' && $commentVal === '' && $reviewVal !== '') {
$commentVal = $reviewVal;
}
if ($commentVal === '') {
continue;
}
$key = $year . '|' . $semester;
$commentMap[$key][$type] = $commentVal;
}
foreach ($rows as &$row) {
$year = (string) ($row['school_year'] ?? '');
$semester = strtolower(trim((string) ($row['semester'] ?? '')));
$row['comments'] = $commentMap[$year . '|' . $semester] ?? [];
$row['year_score'] = $yearAvg[$year] ?? null;
}
unset($row);
return [
'ok' => true,
'student' => $student->toArray(),
'rows' => $rows,
];
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Services\Students;
use App\Models\ClassSection;
use App\Models\Student;
use App\Models\StudentClass;
class StudentStatusService
{
public function __construct(private StudentConfigService $configService)
{
}
public function setActive(int $studentId, bool $isActive, ?int $userId = null): array
{
$context = $this->configService->context();
$semester = (string) ($context['semester'] ?? '');
$schoolYear = (string) ($context['school_year'] ?? '');
$student = Student::query()->find($studentId);
if (!$student) {
return ['ok' => false, 'message' => 'Student not found.'];
}
$student->update([
'is_active' => $isActive ? 1 : 0,
]);
$message = $isActive ? 'Student restored successfully.' : 'Student removed successfully.';
if ($isActive) {
$hasCurrentClass = StudentClass::query()
->where('student_id', $studentId)
->when($semester !== '', fn ($q) => $q->where('semester', $semester))
->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear))
->first();
if (!$hasCurrentClass) {
$lastClass = StudentClass::query()
->where('student_id', $studentId)
->orderByDesc('updated_at')
->orderByDesc('created_at')
->orderByDesc('id')
->first();
$restoreClassId = (int) ($lastClass?->class_section_id ?? 0);
if ($restoreClassId > 0) {
StudentClass::query()->create([
'student_id' => $studentId,
'class_section_id' => $restoreClassId,
'semester' => $semester,
'school_year' => $schoolYear,
'description' => $lastClass?->description,
'updated_by' => $userId,
'updated_at' => now(),
'created_at' => now(),
]);
$classLabel = ClassSection::getClassSectionNameBySectionId($restoreClassId);
if ($classLabel) {
$message .= ' Class assignment restored to ' . $classLabel . '.';
}
} else {
$message .= ' No class assignment found for the current term.';
}
}
}
return [
'ok' => true,
'message' => $message,
'is_active' => $isActive,
];
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Services\Subjects;
use App\Models\SchoolClass;
use App\Models\SubjectCurriculum;
use Illuminate\Support\Facades\DB;
class SubjectCurriculumService
{
public const SUBJECT_OPTIONS = [
'islamic' => 'Islamic Studies',
'quran' => 'Quran/Arabic',
];
public function list(): array
{
$classes = SchoolClass::query()
->orderBy('class_name')
->get()
->toArray();
$entries = DB::table('subject_curriculum_items as sci')
->leftJoin('classes', 'classes.id', '=', 'sci.class_id')
->select('sci.*', 'classes.class_name')
->orderBy('classes.class_name')
->orderBy('sci.subject')
->orderBy('sci.unit_number')
->orderBy('sci.chapter_name')
->get()
->map(fn ($r) => (array) $r)
->all();
return [
'classes' => $classes,
'entries' => $entries,
'subject_labels' => self::SUBJECT_OPTIONS,
];
}
public function store(array $payload): SubjectCurriculum
{
return SubjectCurriculum::query()->create([
'class_id' => (int) $payload['class_id'],
'subject' => (string) $payload['subject'],
'unit_number' => $payload['unit_number'] !== null ? (int) $payload['unit_number'] : null,
'unit_title' => $payload['unit_title'] !== '' ? $payload['unit_title'] : null,
'chapter_name' => (string) $payload['chapter_name'],
]);
}
public function update(int $id, array $payload): ?SubjectCurriculum
{
$entry = SubjectCurriculum::query()->find($id);
if (!$entry) {
return null;
}
$entry->update([
'class_id' => (int) $payload['class_id'],
'subject' => (string) $payload['subject'],
'unit_number' => $payload['unit_number'] !== null ? (int) $payload['unit_number'] : null,
'unit_title' => $payload['unit_title'] !== '' ? $payload['unit_title'] : null,
'chapter_name' => (string) $payload['chapter_name'],
]);
return $entry;
}
public function delete(int $id): bool
{
$entry = SubjectCurriculum::query()->find($id);
if (!$entry) {
return false;
}
return (bool) $entry->delete();
}
}
@@ -0,0 +1,290 @@
<?php
namespace App\Services\Teachers;
use App\Libraries\StaffTimeOffLinkService;
use App\Models\StaffAttendance;
use App\Models\TeacherClass;
use App\Models\User;
use App\Services\SemesterRangeService;
use Carbon\Carbon;
use Illuminate\Support\Facades\Mail;
class TeacherAbsenceService
{
public function __construct(
private TeacherConfigService $configService,
private SemesterRangeService $semesterRangeService,
private StaffTimeOffLinkService $staffTimeOffLinkService
) {
}
public function formData(int $userId): array
{
$context = $this->configService->context();
$semester = (string) ($context['semester'] ?? '');
$schoolYear = (string) ($context['school_year'] ?? '');
$teacher = User::query()->find($userId);
$displayName = $teacher
? trim(($teacher->firstname ?? '') . ' ' . ($teacher->lastname ?? ''))
: 'Teacher';
$existing = StaffAttendance::query()
->where('user_id', $userId)
->when($semester !== '', fn ($q) => $q->where('semester', $semester))
->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear))
->orderByDesc('date')
->get()
->toArray();
return [
'teacher_name' => $displayName,
'semester' => $semester,
'school_year' => $schoolYear,
'existing' => $existing,
'available_dates' => $this->allowedAbsenceDates($schoolYear),
];
}
public function submit(int $userId, array $payload): array
{
$context = $this->configService->context();
$semester = (string) ($context['semester'] ?? '');
$schoolYear = (string) ($context['school_year'] ?? '');
if ($schoolYear === '') {
return [
'ok' => false,
'message' => 'Semester or school year not configured.',
'status' => 422,
];
}
$dates = (array) ($payload['dates'] ?? []);
$reasonType = trim((string) ($payload['reason_type'] ?? ''));
$reasonText = trim((string) ($payload['reason'] ?? ''));
if ($reasonText === '') {
return [
'ok' => false,
'message' => 'Reason is required.',
'status' => 422,
];
}
$reasonBase = $reasonType !== '' ? strtolower($reasonType) : '';
$reason = $reasonBase !== '' ? ($reasonBase . ': ' . $reasonText) : $reasonText;
$allowedSet = array_fill_keys($this->allowedAbsenceDates($schoolYear), true);
$roleName = User::getUserRoleName($userId) ?: null;
$saved = 0;
$invalid = [];
$savedDates = [];
$dates = array_values(array_unique(array_map('strval', $dates)));
foreach ($dates as $date) {
$date = trim($date);
if ($date === '') {
continue;
}
try {
$parsed = Carbon::createFromFormat('Y-m-d', $date);
if ($parsed->format('Y-m-d') !== $date || empty($allowedSet[$date])) {
$invalid[] = $date;
continue;
}
} catch (\Throwable) {
$invalid[] = $date;
continue;
}
$semesterForDate = $this->semesterRangeService->getSemesterForDate($date);
if ($semesterForDate === '') {
$semesterForDate = $semester;
}
$record = StaffAttendance::upsertOne(
userId: $userId,
roleName: $roleName,
date: $date,
semester: $semesterForDate,
schoolYear: $schoolYear,
status: StaffAttendance::STATUS_ABSENT,
reason: $reason,
editorId: $userId
);
if ($record) {
$saved++;
$savedDates[] = $date;
}
}
if (!empty($invalid)) {
return [
'ok' => false,
'message' => 'Invalid dates: ' . implode(', ', $invalid),
'status' => 422,
];
}
$this->sendPrincipalNotification(
userId: $userId,
roleName: $roleName,
semester: $semester,
schoolYear: $schoolYear,
reasonType: $reasonType,
reasonText: $reasonText,
dates: $dates,
savedDates: $savedDates
);
return [
'ok' => true,
'message' => $saved . ' day(s) saved as absent.',
'saved' => $saved,
'dates' => $savedDates,
'status' => 200,
];
}
private function allowedAbsenceDates(string $schoolYear): array
{
$today = Carbon::today();
$startYear = null;
$endYear = null;
if (preg_match('/^(\d{4})\D+(\d{4})$/', $schoolYear, $m)) {
$startYear = (int) $m[1];
$endYear = (int) $m[2];
} else {
$cy = (int) now()->format('Y');
$cm = (int) now()->format('n');
if ($cm >= 9) {
$startYear = $cy;
$endYear = $cy + 1;
} else {
$startYear = $cy - 1;
$endYear = $cy;
}
}
try {
$start = Carbon::create($startYear, 9, 1)->startOfDay();
$end = Carbon::create($endYear, 5, 31)->startOfDay();
} catch (\Throwable) {
return [];
}
if ($start->lt($today)) {
$start = $today->copy();
}
$dates = [];
$cursor = $start->copy();
while ($cursor->lte($end)) {
if ($cursor->dayOfWeek === Carbon::SUNDAY) {
$dates[] = $cursor->format('Y-m-d');
}
$cursor->addDay();
}
return $dates;
}
private function sendPrincipalNotification(
int $userId,
?string $roleName,
string $semester,
string $schoolYear,
string $reasonType,
string $reasonText,
array $dates,
array $savedDates
): void {
try {
$user = User::query()->find($userId);
$fullName = trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? '')) ?: 'Teacher';
$userEmail = $user->email ?? '';
$role = $roleName ?: 'teacher';
$dateList = !empty($savedDates) ? implode(', ', $savedDates) : implode(', ', $dates);
$subject = sprintf(
'TimeOff Request: %s (%s) — %s',
$fullName,
ucfirst((string) $role),
$dateList ?: 'No dates'
);
$submittedAt = now()->toDateTimeString();
$assignedText = $this->formatAssignedClasses($userId, $schoolYear, $semester);
$body = '<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">'
. '<h3 style="margin:0 0 8px;">New TimeOff Request</h3>'
. '<p style="margin:0 0 12px;">A staff time-off request was submitted from the teacher portal.</p>'
. '<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;">'
. '<tr><td><strong>Name</strong></td><td>' . e($fullName) . '</td></tr>'
. '<tr><td><strong>Email</strong></td><td>' . e($userEmail) . '</td></tr>'
. '<tr><td><strong>Role</strong></td><td>' . e((string) $role) . '</td></tr>'
. '<tr><td><strong>Semester</strong></td><td>' . e($semester) . '</td></tr>'
. '<tr><td><strong>School Year</strong></td><td>' . e($schoolYear) . '</td></tr>'
. '<tr><td><strong>Reason Type</strong></td><td>' . e($reasonType ?: '-') . '</td></tr>'
. '<tr><td><strong>Reason</strong></td><td>' . e($reasonText) . '</td></tr>'
. '<tr><td><strong>Dates</strong></td><td>' . e($dateList ?: '-') . '</td></tr>'
. '<tr><td><strong>Assigned Class(es)</strong></td><td>' . e($assignedText) . '</td></tr>'
. '<tr><td><strong>Submitted At</strong></td><td>' . e($submittedAt) . '</td></tr>'
. '</table>'
. '</div>';
$token = $this->staffTimeOffLinkService->createToken([
'uid' => $userId,
'email' => $userEmail,
'name' => $fullName,
'role' => $role,
'dates' => $dateList ?: '-',
'reason' => $reasonText,
'reason_type' => $reasonType,
'submitted_at' => $submittedAt,
'origin' => 'teacher portal',
]);
$notifyUrl = url('/timeoff/notify/' . rawurlencode($token));
$body .= '<p style="margin-top:16px;">Click <a href="' . e($notifyUrl) . '">Send confirmation email to '
. e($fullName)
. '</a> so the staff member is notified automatically. This link expires in 14 days.</p>';
$principalEmail = env('PRINCIPAL_EMAIL', 'principal@alrahmaisgl.org');
Mail::html($body, function ($message) use ($principalEmail, $subject) {
$message->to($principalEmail)
->subject($subject);
});
} catch (\Throwable) {
// Email failures should not block absence submissions.
}
}
private function formatAssignedClasses(int $userId, string $schoolYear, string $semester): string
{
$assignments = TeacherClass::getClassAssignmentsByUserId($userId, $schoolYear, $semester);
if (empty($assignments)) {
return 'No assigned class';
}
$parts = [];
foreach ($assignments as $assignment) {
$name = (string) ($assignment['class_section_name'] ?? '');
$role = (string) ($assignment['teacher_role'] ?? '');
if ($name !== '') {
$parts[] = $role !== '' ? ($name . ' (' . $role . ')') : $name;
}
}
return !empty($parts) ? implode(', ', $parts) : 'No assigned class';
}
}
@@ -0,0 +1,186 @@
<?php
namespace App\Services\Teachers;
use App\Models\ClassSection;
use App\Models\TeacherClass;
use App\Models\User;
class TeacherAssignmentService
{
public function __construct(private TeacherConfigService $configService)
{
}
public function listAssignments(?string $schoolYear = null): array
{
$context = $this->configService->context();
$schoolYear = $schoolYear ?: (string) ($context['school_year'] ?? '');
$teachers = User::getTeachersAndTAs();
$classSections = ClassSection::query()
->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear))
->orderBy('class_section_name')
->get()
->toArray();
if (empty($classSections)) {
$classSections = ClassSection::query()->orderBy('class_section_name')->get()->toArray();
}
$classNames = [];
foreach ($classSections as $section) {
$sectionId = (int) ($section['class_section_id'] ?? $section['id'] ?? 0);
if ($sectionId <= 0) {
continue;
}
$classNames[$sectionId] = $section['class_section_name'] ?? 'N/A';
}
$assignments = TeacherClass::query()
->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear))
->get()
->toArray();
$assignmentMap = [];
foreach ($assignments as $assign) {
$teacherId = (int) ($assign['teacher_id'] ?? 0);
$sectionId = (int) ($assign['class_section_id'] ?? 0);
if ($teacherId <= 0 || $sectionId <= 0) {
continue;
}
$role = strtolower((string) ($assign['position'] ?? ''));
if (!in_array($role, ['main', 'ta'], true)) {
continue;
}
$assignmentMap[$teacherId][$role][] = [
'section_id' => $sectionId,
'class_section_name' => $classNames[$sectionId] ?? 'N/A',
'role' => $role,
'semester' => (string) ($assign['semester'] ?? ''),
'school_year' => (string) ($assign['school_year'] ?? $schoolYear),
];
}
$teacherData = [];
foreach ($teachers as $teacher) {
$tid = (int) ($teacher['id'] ?? 0);
if ($tid <= 0) {
continue;
}
$assigned = $assignmentMap[$tid] ?? ['main' => [], 'ta' => []];
$name = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
if ($name === '') {
$name = 'User#' . $tid;
}
$teacherData[] = [
'teacher_id' => $tid,
'firstname' => $teacher['firstname'] ?? '',
'lastname' => $teacher['lastname'] ?? '',
'name' => $name,
'email' => $teacher['email'] ?? '',
'cellphone' => $teacher['cellphone'] ?? '',
'role' => $teacher['role'] ?? '',
'school_year' => $schoolYear,
'main_assignments' => $assigned['main'] ?? [],
'ta_assignments' => $assigned['ta'] ?? [],
];
}
$classesPayload = [];
foreach ($classSections as $section) {
$sectionId = (int) ($section['class_section_id'] ?? $section['id'] ?? 0);
if ($sectionId <= 0) {
continue;
}
$classesPayload[] = [
'class_section_id' => $sectionId,
'class_section_name' => $section['class_section_name'] ?? 'N/A',
];
}
return [
'school_year' => $schoolYear,
'teachers' => $teacherData,
'classes' => $classesPayload,
];
}
public function assign(array $input): array
{
$context = $this->configService->context();
$schoolYear = (string) ($input['school_year'] ?? $context['school_year'] ?? '');
$teacherId = (int) ($input['teacher_id'] ?? 0);
$classSectionId = (int) ($input['class_section_id'] ?? 0);
$teacherRole = (string) ($input['teacher_role'] ?? '');
$loggedInUserId = (int) ($input['updated_by'] ?? 0);
if ($teacherId <= 0 || $classSectionId <= 0 || $schoolYear === '') {
return ['ok' => false, 'message' => 'Missing required parameters for assignment.'];
}
if ($teacherRole === '') {
$teacherRole = (string) (User::getUserRoleName($teacherId) ?? '');
}
$isAssistant = strtolower($teacherRole) === 'teacher_assistant';
$position = $isAssistant ? 'ta' : 'main';
$alreadyAssigned = TeacherClass::query()
->where('teacher_id', $teacherId)
->where('class_section_id', $classSectionId)
->where('position', $position)
->where('school_year', $schoolYear)
->first();
if ($alreadyAssigned) {
return ['ok' => false, 'message' => 'This teacher is already assigned to this class with the same role.'];
}
TeacherClass::query()->create([
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'position' => $position,
'school_year' => $schoolYear,
'created_at' => now(),
'updated_at' => now(),
'updated_by' => $loggedInUserId ?: null,
]);
return [
'ok' => true,
'message' => 'Class assigned successfully.',
'position' => $position,
];
}
public function delete(array $input): array
{
$context = $this->configService->context();
$teacherId = (int) ($input['teacher_id'] ?? 0);
$classSectionId = (int) ($input['class_section_id'] ?? 0);
$position = strtolower((string) ($input['position'] ?? ''));
$schoolYear = trim((string) ($input['school_year'] ?? $context['school_year'] ?? ''));
if ($teacherId <= 0 || $classSectionId <= 0 || !in_array($position, ['main', 'ta'], true) || $schoolYear === '') {
return ['ok' => false, 'message' => 'Missing or invalid data for deletion.'];
}
$assignment = TeacherClass::query()
->where('teacher_id', $teacherId)
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('position', $position)
->first();
if (!$assignment) {
return ['ok' => false, 'message' => 'Assignment not found.'];
}
$assignment->delete();
return ['ok' => true, 'message' => ucfirst($position) . ' assignment removed successfully.'];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Services\Teachers;
use App\Models\Configuration;
class TeacherConfigService
{
public function context(): array
{
return [
'school_year' => Configuration::getConfig('school_year'),
'semester' => Configuration::getConfig('semester'),
];
}
}
@@ -0,0 +1,130 @@
<?php
namespace App\Services\Teachers;
use App\Models\StudentAllergy;
use App\Models\StudentClass;
use App\Models\StudentMedicalCondition;
use App\Models\TeacherClass;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class TeacherDashboardService
{
public function __construct(private TeacherConfigService $configService)
{
}
public function classView(int $userId, ?int $requestedClassSectionId = null): array
{
$context = $this->configService->context();
$schoolYear = (string) ($context['school_year'] ?? '');
$semester = (string) ($context['semester'] ?? '');
$roles = DB::table('user_roles as ur')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->where('ur.user_id', $userId)
->whereNull('ur.deleted_at')
->pluck('r.name')
->map(fn ($r) => strtolower((string) $r))
->all();
if (!array_intersect($roles, ['teacher', 'teacher_assistant'])) {
throw new \RuntimeException('Access denied.');
}
$classes = TeacherClass::getClassAssignmentsByUserId($userId, $schoolYear, $semester);
if (empty($classes)) {
return [
'teacher' => null,
'classes' => [],
'students' => [],
'studentsBySection' => [],
'active_class_section_id' => null,
'assignedNames' => [],
];
}
$classSectionIds = array_values(array_unique(array_map('intval', array_column($classes, 'class_section_id'))));
$activeClassSectionId = null;
if ($requestedClassSectionId && in_array($requestedClassSectionId, $classSectionIds, true)) {
$activeClassSectionId = $requestedClassSectionId;
} elseif (!empty($classSectionIds)) {
$activeClassSectionId = $classSectionIds[0];
}
$studentsBySection = [];
if (!empty($classSectionIds)) {
$students = StudentClass::getStudentsByClassSectionIds($classSectionIds);
if (!empty($students)) {
$studentIds = array_values(array_unique(array_map(
static fn (array $row) => (int) ($row['student_id'] ?? 0),
$students
)));
$studentIds = array_values(array_filter($studentIds));
$conditionsByStudent = !empty($studentIds)
? StudentMedicalCondition::getMedicalByStudentIds($studentIds)
: [];
$allergiesByStudent = !empty($studentIds)
? StudentAllergy::getAllergiesByStudentIds($studentIds)
: [];
foreach ($students as $student) {
$sid = (int) ($student['student_id'] ?? 0);
$cid = (int) ($student['class_section_id'] ?? 0);
$medList = array_column($conditionsByStudent[$sid] ?? [], 'condition_name');
$algList = array_column($allergiesByStudent[$sid] ?? [], 'allergy');
$student['medical_conditions'] = $medList;
$student['allergies'] = $algList;
$student['medical_conditions_text'] = implode(', ', $medList);
$student['allergies_text'] = implode(', ', $algList);
$studentsBySection[$cid][] = $student;
}
}
}
$assignedNames = [];
if (!empty($classSectionIds)) {
$rows = DB::table('teacher_class as tc')
->select('tc.class_section_id', 'tc.position', 'u.firstname', 'u.lastname')
->join('users as u', 'u.id', '=', 'tc.teacher_id')
->whereIn('tc.class_section_id', $classSectionIds)
->where('tc.school_year', $schoolYear)
->orderBy('tc.class_section_id')
->orderByRaw("FIELD(tc.position,'main','ta')")
->orderBy('u.firstname')
->get();
foreach ($rows as $row) {
$sid = (int) ($row->class_section_id ?? 0);
$pos = strtolower((string) ($row->position ?? ''));
$name = trim(($row->firstname ?? '') . ' ' . ($row->lastname ?? ''));
if ($sid === 0 || $name === '') {
continue;
}
if (in_array($pos, ['main', 'teacher'], true)) {
$assignedNames[$sid]['mains'][] = $name;
} elseif (in_array($pos, ['ta', 'assistant', 'teacher_assistant'], true)) {
$assignedNames[$sid]['tas'][] = $name;
} else {
$assignedNames[$sid]['others'][] = $name;
}
}
}
$teacher = User::query()->find($userId)?->toArray();
return [
'teacher' => $teacher,
'classes' => $classes,
'students' => $activeClassSectionId ? ($studentsBySection[$activeClassSectionId] ?? []) : [],
'studentsBySection' => $studentsBySection,
'active_class_section_id' => $activeClassSectionId,
'assignedNames' => $assignedNames,
];
}
}