add more controller
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user