add controllers, servoices
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
namespace Config;
|
namespace Config;
|
||||||
|
|
||||||
use App\Services\EmailService;
|
use App\Services\EmailService;
|
||||||
use App\Services\SemesterScoreService;
|
use App\Services\Scores\SemesterScoreService;
|
||||||
|
|
||||||
class Services
|
class Services
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ use App\Http\Requests\AttendanceTracking\ParentsInfoRequest;
|
|||||||
use App\Http\Requests\AttendanceTracking\RecordAttendanceTrackingRequest;
|
use App\Http\Requests\AttendanceTracking\RecordAttendanceTrackingRequest;
|
||||||
use App\Http\Requests\AttendanceTracking\SaveAttendanceNotificationNoteRequest;
|
use App\Http\Requests\AttendanceTracking\SaveAttendanceNotificationNoteRequest;
|
||||||
use App\Http\Requests\AttendanceTracking\SendAttendanceManualEmailRequest;
|
use App\Http\Requests\AttendanceTracking\SendAttendanceManualEmailRequest;
|
||||||
use App\Services\AttendanceTrackingService;
|
use App\Services\AttendanceTracking\AttendanceTrackingService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Auth\RegisterRequest;
|
||||||
|
use App\Http\Resources\Auth\RegisterResource;
|
||||||
|
use App\Services\Auth\RegistrationCaptchaService;
|
||||||
|
use App\Services\Auth\RegistrationService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class RegisterController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RegistrationService $service,
|
||||||
|
private RegistrationCaptchaService $captchaService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function captcha(): JsonResponse
|
||||||
|
{
|
||||||
|
$captcha = $this->captchaService->generate();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'captcha' => $captcha,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(RegisterRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
if (app()->runningUnitTests() && $request->header('X-Debug-Request') === '1') {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'debug' => [
|
||||||
|
'content_type' => $request->header('Content-Type'),
|
||||||
|
'content_length' => strlen((string) $request->getContent()),
|
||||||
|
'request_all' => $request->all(),
|
||||||
|
'json_all' => $request->json()->all(),
|
||||||
|
'payload_data' => $this->payloadData(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->service->register($request->validated());
|
||||||
|
|
||||||
|
if (empty($result['ok'])) {
|
||||||
|
$code = $result['code'] ?? 'registration_failed';
|
||||||
|
$status = $code === 'pending_activation' || $code === 'email_exists' ? 409 : 422;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $result['message'] ?? 'Registration failed.',
|
||||||
|
'code' => $code,
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'registration' => new RegisterResource($result),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\ClassPrep;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Services\ClassPrep\ClassRosterService;
|
||||||
|
use App\Services\ClassPrep\StickerCountService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ClassPrepController extends BaseApiController
|
||||||
|
{
|
||||||
|
private StickerCountService $stickerCounts;
|
||||||
|
private ClassRosterService $roster;
|
||||||
|
|
||||||
|
public function __construct(StickerCountService $stickerCounts, ClassRosterService $roster)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$this->stickerCounts = $stickerCounts;
|
||||||
|
$this->roster = $roster;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stickerCounts(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$schoolYear = (string) ($request->query('school_year') ?? '');
|
||||||
|
$semester = (string) ($request->query('semester') ?? '');
|
||||||
|
$classSectionId = $request->query('class_section_id') ?? $request->query('class_id');
|
||||||
|
$classSectionId = is_numeric($classSectionId) ? (int) $classSectionId : null;
|
||||||
|
|
||||||
|
if ($classSectionId !== null && $classSectionId > 0) {
|
||||||
|
$payload = $this->stickerCounts->listForClass($schoolYear, $semester, $classSectionId);
|
||||||
|
} else {
|
||||||
|
$payload = $this->stickerCounts->listAll($schoolYear, $semester);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $payload,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function studentsByClass(Request $request, int $classSectionId): JsonResponse
|
||||||
|
{
|
||||||
|
$schoolYear = (string) ($request->query('school_year') ?? '');
|
||||||
|
|
||||||
|
$students = $this->roster->listStudentsByClass($classSectionId, $schoolYear !== '' ? $schoolYear : null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => $students,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\ClassPreparation;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Services\ClassPreparation\ClassPreparationService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ClassPreparationController extends BaseApiController
|
||||||
|
{
|
||||||
|
private ClassPreparationService $service;
|
||||||
|
|
||||||
|
public function __construct(ClassPreparationService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$this->service = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$schoolYear = (string) ($request->query('school_year') ?? '');
|
||||||
|
$semester = (string) ($request->query('semester') ?? '');
|
||||||
|
|
||||||
|
$payload = $this->service->listPrep(
|
||||||
|
$schoolYear !== '' ? $schoolYear : null,
|
||||||
|
$semester !== '' ? $semester : null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'schoolYear' => $payload['schoolYear'],
|
||||||
|
'semester' => $payload['semester'],
|
||||||
|
'results' => $payload['results'],
|
||||||
|
'totals' => $payload['totals'],
|
||||||
|
'shortages' => $payload['shortages'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markPrinted(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$schoolYear = (string) ($request->input('school_year') ?? '');
|
||||||
|
$semester = (string) ($request->input('semester') ?? '');
|
||||||
|
$ids = $request->input('class_section_ids', []);
|
||||||
|
|
||||||
|
if ($schoolYear === '' || !is_array($ids)) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'school_year and class_section_ids are required.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = $this->service->markPrinted($schoolYear, $semester, $ids);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'updated' => $count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Communication;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Services\Communication\CommunicationFamilyService;
|
||||||
|
use App\Services\Communication\CommunicationPreviewService;
|
||||||
|
use App\Services\Communication\CommunicationSendService;
|
||||||
|
use App\Services\Communication\CommunicationStudentService;
|
||||||
|
use App\Services\Communication\CommunicationTemplateService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CommunicationController extends BaseApiController
|
||||||
|
{
|
||||||
|
private CommunicationStudentService $students;
|
||||||
|
private CommunicationTemplateService $templates;
|
||||||
|
private CommunicationFamilyService $families;
|
||||||
|
private CommunicationPreviewService $previewService;
|
||||||
|
private CommunicationSendService $sendService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
CommunicationStudentService $students,
|
||||||
|
CommunicationTemplateService $templates,
|
||||||
|
CommunicationFamilyService $families,
|
||||||
|
CommunicationPreviewService $previewService,
|
||||||
|
CommunicationSendService $sendService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
$this->students = $students;
|
||||||
|
$this->templates = $templates;
|
||||||
|
$this->families = $families;
|
||||||
|
$this->previewService = $previewService;
|
||||||
|
$this->sendService = $sendService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function options(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => $this->students->listStudents(),
|
||||||
|
'templates' => $this->templates->listActiveTemplates(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function families(int $studentId): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->families->familiesForStudent($studentId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function guardians(int $familyId): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->families->guardiansForFamily($familyId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preview(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$templateKey = (string) $request->input('template_key', '');
|
||||||
|
$studentId = (int) $request->input('student_id', 0);
|
||||||
|
$familyId = (int) $request->input('family_id', 0);
|
||||||
|
$vars = $this->normalizeArray($request->input('vars', []));
|
||||||
|
|
||||||
|
if ($templateKey === '' || $studentId <= 0 || $familyId <= 0) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'template_key, student_id, and family_id are required.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$teacherName = $this->resolveTeacherName();
|
||||||
|
$result = $this->previewService->buildPreview($templateKey, $studentId, $familyId, $vars, $teacherName);
|
||||||
|
|
||||||
|
if (!$result['ok']) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $result['message'] ?? 'Preview failed.',
|
||||||
|
], $result['status'] ?? 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'subject' => $result['subject'],
|
||||||
|
'html' => $result['html'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$studentId = (int) $request->input('student_id', 0);
|
||||||
|
$familyId = (int) $request->input('family_id', 0);
|
||||||
|
$templateKey = (string) $request->input('template_key', '');
|
||||||
|
$subject = (string) $request->input('subject', '');
|
||||||
|
$body = (string) $request->input('body', '');
|
||||||
|
$recipients = $this->normalizeArray($request->input('recipients', []));
|
||||||
|
$cc = $this->normalizeArray($request->input('cc', []));
|
||||||
|
$bcc = $this->normalizeArray($request->input('bcc', []));
|
||||||
|
|
||||||
|
if ($studentId <= 0 || $familyId <= 0 || $templateKey === '' || $subject === '' || $body === '') {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'student_id, family_id, template_key, subject, and body are required.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($recipients)) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'At least one recipient is required.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$student = $this->students->getStudentBasic($studentId);
|
||||||
|
if (!$student) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Student not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->sendService->send([
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'family_id' => $familyId,
|
||||||
|
'template_key' => $templateKey,
|
||||||
|
'subject' => $subject,
|
||||||
|
'body' => $body,
|
||||||
|
'recipients' => $recipients,
|
||||||
|
'cc' => $cc,
|
||||||
|
'bcc' => $bcc,
|
||||||
|
'student_name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
||||||
|
'sent_by' => (int) (auth()->id() ?? 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$result['ok']) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Failed to send email.',
|
||||||
|
'error' => $result['error'],
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Email sent successfully.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeArray($value): array
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTeacherName(): string
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if ($user) {
|
||||||
|
$name = trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? ''));
|
||||||
|
return $name !== '' ? $name : 'Teacher';
|
||||||
|
}
|
||||||
|
return 'Teacher';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\CompetitionScores;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Models\Competition;
|
||||||
|
use App\Services\CompetitionScores\CompetitionScoresContextService;
|
||||||
|
use App\Services\CompetitionScores\CompetitionScoresQueryService;
|
||||||
|
use App\Services\CompetitionScores\CompetitionScoresRosterService;
|
||||||
|
use App\Services\CompetitionScores\CompetitionScoresSaveService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CompetitionScoresController extends BaseApiController
|
||||||
|
{
|
||||||
|
private CompetitionScoresContextService $contextService;
|
||||||
|
private CompetitionScoresQueryService $queryService;
|
||||||
|
private CompetitionScoresRosterService $rosterService;
|
||||||
|
private CompetitionScoresSaveService $saveService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
CompetitionScoresContextService $contextService,
|
||||||
|
CompetitionScoresQueryService $queryService,
|
||||||
|
CompetitionScoresRosterService $rosterService,
|
||||||
|
CompetitionScoresSaveService $saveService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
$this->contextService = $contextService;
|
||||||
|
$this->queryService = $queryService;
|
||||||
|
$this->rosterService = $rosterService;
|
||||||
|
$this->saveService = $saveService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($userId);
|
||||||
|
|
||||||
|
$activeClassId = $this->contextService->resolveActiveClassId(
|
||||||
|
$assignments,
|
||||||
|
(int) $request->query('class_section_id', 0)
|
||||||
|
);
|
||||||
|
$activeClassName = $this->contextService->getActiveClassName($assignments, $activeClassId);
|
||||||
|
|
||||||
|
if (empty($assignments) || $activeClassId <= 0) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'competitions' => [],
|
||||||
|
'activeClassId' => $activeClassId,
|
||||||
|
'activeClassName' => $activeClassName,
|
||||||
|
'questionCounts' => [],
|
||||||
|
'scoreCounts' => [],
|
||||||
|
'studentTotal' => 0,
|
||||||
|
'sectionMap' => $this->contextService->getClassSectionMap(),
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'semester' => $semester,
|
||||||
|
'hasClasses' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$competitions = $this->queryService->getCompetitionsForClass($activeClassId, $schoolYear, $semester);
|
||||||
|
$competitionIds = array_values(array_filter(array_map(static function ($row) {
|
||||||
|
return (int) ($row['id'] ?? 0);
|
||||||
|
}, $competitions)));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'competitions' => $competitions,
|
||||||
|
'activeClassId' => $activeClassId,
|
||||||
|
'activeClassName' => $activeClassName,
|
||||||
|
'questionCounts' => $this->queryService->getQuestionCounts($competitionIds, $activeClassId),
|
||||||
|
'scoreCounts' => $this->queryService->getScoreCounts($competitionIds, $activeClassId),
|
||||||
|
'studentTotal' => $this->rosterService->getClassStudentCount($activeClassId, $schoolYear),
|
||||||
|
'sectionMap' => $this->contextService->getClassSectionMap(),
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'semester' => $semester,
|
||||||
|
'hasClasses' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($userId);
|
||||||
|
$allowedClassIds = $this->contextService->getAllowedClassIds($assignments);
|
||||||
|
|
||||||
|
if (empty($allowedClassIds)) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'No class assignments found for your account.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$competition = Competition::query()->find($id);
|
||||||
|
if (!$competition) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Competition not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isLocked = (bool) $competition->is_locked;
|
||||||
|
$lockedClassId = (int) ($competition->class_section_id ?? 0);
|
||||||
|
$requestedClassId = (int) $request->query('class_section_id', 0);
|
||||||
|
$activeClassId = $this->contextService->resolveActiveClassId($assignments, $requestedClassId);
|
||||||
|
$classSectionId = $lockedClassId > 0 ? $lockedClassId : $activeClassId;
|
||||||
|
|
||||||
|
if ($classSectionId <= 0) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Select a class section before entering scores.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($classSectionId, $allowedClassIds, true)) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'You are not assigned to that class.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$competitionArray = $competition->toArray();
|
||||||
|
$students = $this->rosterService->getStudentsForCompetition($competitionArray, $classSectionId);
|
||||||
|
$scoreMap = $this->saveService->getScoreMap($id, $classSectionId);
|
||||||
|
$questionCount = $this->queryService->getQuestionCountForCompetition($id, $classSectionId);
|
||||||
|
$classStudentCount = $this->rosterService->getClassStudentCount(
|
||||||
|
$classSectionId,
|
||||||
|
$competitionArray['school_year'] ?? $schoolYear
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'competition' => $competitionArray,
|
||||||
|
'students' => $students,
|
||||||
|
'scoreMap' => $scoreMap,
|
||||||
|
'classSectionId' => $classSectionId,
|
||||||
|
'classSectionName' => $this->contextService->getActiveClassName($assignments, $classSectionId),
|
||||||
|
'classStudentCount' => $classStudentCount,
|
||||||
|
'questionCount' => $questionCount,
|
||||||
|
'classSelectionLocked' => $lockedClassId > 0,
|
||||||
|
'isLocked' => $isLocked,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'semester' => $semester,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
[$assignments] = $this->contextService->getTeacherContext($userId);
|
||||||
|
$allowedClassIds = $this->contextService->getAllowedClassIds($assignments);
|
||||||
|
|
||||||
|
if (empty($allowedClassIds)) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'No class assignments found for your account.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$competition = Competition::query()->find($id);
|
||||||
|
if (!$competition) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Competition not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((bool) $competition->is_locked) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Competition is locked. You cannot update scores.',
|
||||||
|
], 423);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockedClassId = (int) ($competition->class_section_id ?? 0);
|
||||||
|
$classSectionId = $lockedClassId > 0 ? $lockedClassId : (int) $request->input('class_section_id', 0);
|
||||||
|
|
||||||
|
if ($classSectionId <= 0) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Select a class section before saving scores.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($classSectionId, $allowedClassIds, true)) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'You are not assigned to that class.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scores = $request->input('scores', []);
|
||||||
|
$scores = is_array($scores) ? $scores : [];
|
||||||
|
[$cleanScores, $invalidScores] = $this->saveService->filterScores($scores);
|
||||||
|
|
||||||
|
if (!empty($invalidScores)) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Scores must be whole numbers (no decimals).',
|
||||||
|
'invalidScores' => $invalidScores,
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->saveService->saveScores($id, $classSectionId, $cleanScores);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Scores saved.',
|
||||||
|
'savedCount' => count($cleanScores),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Discounts;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Discounts\ApplyDiscountVoucherRequest;
|
||||||
|
use App\Http\Requests\Discounts\StoreDiscountVoucherRequest;
|
||||||
|
use App\Http\Requests\Discounts\UpdateDiscountVoucherRequest;
|
||||||
|
use App\Http\Resources\Discounts\DiscountParentResource;
|
||||||
|
use App\Http\Resources\Discounts\DiscountVoucherResource;
|
||||||
|
use App\Services\Discounts\DiscountApplyService;
|
||||||
|
use App\Services\Discounts\DiscountContextService;
|
||||||
|
use App\Services\Discounts\DiscountParentService;
|
||||||
|
use App\Services\Discounts\DiscountVoucherService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class DiscountController extends BaseApiController
|
||||||
|
{
|
||||||
|
private DiscountContextService $context;
|
||||||
|
private DiscountVoucherService $voucherService;
|
||||||
|
private DiscountParentService $parentService;
|
||||||
|
private DiscountApplyService $applyService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
DiscountContextService $context,
|
||||||
|
DiscountVoucherService $voucherService,
|
||||||
|
DiscountParentService $parentService,
|
||||||
|
DiscountApplyService $applyService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
$this->context = $context;
|
||||||
|
$this->voucherService = $voucherService;
|
||||||
|
$this->parentService = $parentService;
|
||||||
|
$this->applyService = $applyService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function options(): JsonResponse
|
||||||
|
{
|
||||||
|
$schoolYear = $this->context->getSchoolYear();
|
||||||
|
$vouchers = $this->voucherService->listActive();
|
||||||
|
$parents = $this->parentService->listParentsWithDiscounts($schoolYear);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'vouchers' => DiscountVoucherResource::collection($vouchers),
|
||||||
|
'parents' => DiscountParentResource::collection($parents),
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(ApplyDiscountVoucherRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->applyService->applyVoucher(
|
||||||
|
(int) $payload['voucher_id'],
|
||||||
|
$payload['parent_ids'],
|
||||||
|
(bool) ($payload['allow_additional'] ?? false),
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($result, $result['ok'] ? 200 : 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listVouchers(): JsonResponse
|
||||||
|
{
|
||||||
|
$vouchers = $this->voucherService->listAll();
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'vouchers' => DiscountVoucherResource::collection($vouchers),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeVoucher(StoreDiscountVoucherRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$voucher = $this->voucherService->create($request->validated());
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'voucher' => new DiscountVoucherResource($voucher),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateVoucher(UpdateDiscountVoucherRequest $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$voucher = $this->voucherService->update($id, $request->validated());
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'voucher' => new DiscountVoucherResource($voucher),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showVoucher(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$voucher = $this->voucherService->find($id);
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'voucher' => new DiscountVoucherResource($voucher),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteVoucher(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$this->voucherService->delete($id);
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Email;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Services\BroadcastEmail\BroadcastEmailComposerService;
|
||||||
|
use App\Services\BroadcastEmail\BroadcastEmailDispatchService;
|
||||||
|
use App\Services\BroadcastEmail\BroadcastEmailImageService;
|
||||||
|
use App\Services\BroadcastEmail\BroadcastEmailRecipientService;
|
||||||
|
use App\Services\BroadcastEmail\BroadcastEmailSenderOptionsService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class BroadcastEmailController extends BaseApiController
|
||||||
|
{
|
||||||
|
private BroadcastEmailSenderOptionsService $senderOptions;
|
||||||
|
private BroadcastEmailRecipientService $recipients;
|
||||||
|
private BroadcastEmailComposerService $composer;
|
||||||
|
private BroadcastEmailDispatchService $dispatch;
|
||||||
|
private BroadcastEmailImageService $imageService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
BroadcastEmailSenderOptionsService $senderOptions,
|
||||||
|
BroadcastEmailRecipientService $recipients,
|
||||||
|
BroadcastEmailComposerService $composer,
|
||||||
|
BroadcastEmailDispatchService $dispatch,
|
||||||
|
BroadcastEmailImageService $imageService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
$this->senderOptions = $senderOptions;
|
||||||
|
$this->recipients = $recipients;
|
||||||
|
$this->composer = $composer;
|
||||||
|
$this->dispatch = $dispatch;
|
||||||
|
$this->imageService = $imageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function options(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'parents' => $this->recipients->parentsWithEmails(),
|
||||||
|
'fromOptions' => $this->senderOptions->listOptions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$mode = (string) ($request->input('mode') ?? 'standard');
|
||||||
|
$subject = trim((string) ($request->input('subject') ?? ''));
|
||||||
|
$fromKey = trim((string) ($request->input('from_key') ?? 'general'));
|
||||||
|
$body = (string) ($request->input('body_html') ?? '');
|
||||||
|
$wrapLayout = (bool) $request->boolean('wrap_layout');
|
||||||
|
$preheader = (string) ($request->input('preheader') ?? '');
|
||||||
|
$ctaText = (string) ($request->input('cta_text') ?? '');
|
||||||
|
$ctaUrl = (string) ($request->input('cta_url') ?? '');
|
||||||
|
$testEmail = trim((string) ($request->input('test_email') ?? ''));
|
||||||
|
$isTestOnly = (bool) $request->boolean('send_test_only');
|
||||||
|
|
||||||
|
$body = $this->composer->sanitizeHtml($body);
|
||||||
|
|
||||||
|
if ($subject === '' || $body === '') {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Subject and Body are required.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$personalize = $mode === 'personalized';
|
||||||
|
|
||||||
|
if ($isTestOnly) {
|
||||||
|
if ($testEmail === '') {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Provide a test email address.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->dispatch->sendTest([
|
||||||
|
'wrap_layout' => $wrapLayout,
|
||||||
|
'subject' => $subject,
|
||||||
|
'body_html' => $body,
|
||||||
|
'recipient_name' => 'Parent',
|
||||||
|
'preheader' => $preheader,
|
||||||
|
'cta_text' => $ctaText,
|
||||||
|
'cta_url' => $ctaUrl,
|
||||||
|
'personalize' => $personalize,
|
||||||
|
'test_email' => $testEmail,
|
||||||
|
'from_key' => $fromKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($result, $result['ok'] ? 200 : 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawIds = $request->input('parent_ids', []);
|
||||||
|
$ids = $this->normalizeIds($rawIds);
|
||||||
|
|
||||||
|
if (empty($ids)) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Please select at least one parent.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipients = $this->recipients->recipientsByIds($ids);
|
||||||
|
|
||||||
|
if (empty($recipients)) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'No valid parent emails found.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = $this->dispatch->sendBroadcast([
|
||||||
|
'wrap_layout' => $wrapLayout,
|
||||||
|
'subject' => $subject,
|
||||||
|
'body_html' => $body,
|
||||||
|
'preheader' => $preheader,
|
||||||
|
'cta_text' => $ctaText,
|
||||||
|
'cta_url' => $ctaUrl,
|
||||||
|
'personalize' => $personalize,
|
||||||
|
'from_key' => $fromKey,
|
||||||
|
'mode' => $mode,
|
||||||
|
], $recipients);
|
||||||
|
|
||||||
|
$message = sprintf(
|
||||||
|
'Broadcast finished. Mode: %s. Sent: %d/%d. Failures: %d.',
|
||||||
|
$stats['mode'],
|
||||||
|
$stats['sent'],
|
||||||
|
$stats['attempted'],
|
||||||
|
$stats['failed']
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => $stats['failed'] === 0,
|
||||||
|
'stats' => $stats,
|
||||||
|
'message' => $message,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadImage(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$file = $request->file('image');
|
||||||
|
if (!$file || !$file->isValid()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'No image uploaded',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->imageService->store($file);
|
||||||
|
if (!$result['ok']) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $result['error'] ?? 'Upload failed',
|
||||||
|
], $result['status'] ?? 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newHash = function_exists('csrf_hash') ? csrf_hash() : csrf_token();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'url' => $result['url'],
|
||||||
|
'csrf_hash' => $newHash,
|
||||||
|
])->header('X-CSRF-HASH', (string) $newHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeIds($rawIds): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
$raw = is_array($rawIds) ? $rawIds : [$rawIds];
|
||||||
|
|
||||||
|
foreach ($raw as $value) {
|
||||||
|
if (is_string($value) && strpos($value, ',') !== false) {
|
||||||
|
foreach (explode(',', $value) as $piece) {
|
||||||
|
$ids[] = (int) $piece;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ids[] = (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter($ids)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Expenses;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Expenses\StoreExpenseRequest;
|
||||||
|
use App\Http\Requests\Expenses\UpdateExpenseRequest;
|
||||||
|
use App\Http\Requests\Expenses\UpdateExpenseStatusRequest;
|
||||||
|
use App\Http\Resources\Expenses\ExpenseResource;
|
||||||
|
use App\Http\Resources\Expenses\ExpenseStaffResource;
|
||||||
|
use App\Models\Expense;
|
||||||
|
use App\Services\Expenses\ExpenseContextService;
|
||||||
|
use App\Services\Expenses\ExpenseQueryService;
|
||||||
|
use App\Services\Expenses\ExpenseReceiptService;
|
||||||
|
use App\Services\Expenses\ExpenseRetailorService;
|
||||||
|
use App\Services\Expenses\ExpenseStaffService;
|
||||||
|
use App\Services\Expenses\ExpenseStatusService;
|
||||||
|
use App\Services\Expenses\ExpenseWriteService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ExpenseController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ExpenseContextService $context,
|
||||||
|
private ExpenseRetailorService $retailors,
|
||||||
|
private ExpenseStaffService $staff,
|
||||||
|
private ExpenseQueryService $queryService,
|
||||||
|
private ExpenseReceiptService $receiptService,
|
||||||
|
private ExpenseWriteService $writeService,
|
||||||
|
private ExpenseStatusService $statusService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function options(): JsonResponse
|
||||||
|
{
|
||||||
|
$schoolYear = $this->context->getSchoolYear();
|
||||||
|
$semester = $this->context->getSemester();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'semester' => $semester,
|
||||||
|
'retailors' => $this->retailors->listRetailors(),
|
||||||
|
'staff' => ExpenseStaffResource::collection($this->staff->listStaffUsers()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$rows = $this->queryService->listAll();
|
||||||
|
$rows = array_map(function ($row) {
|
||||||
|
$row['receipt_url'] = $this->receiptService->receiptUrl($row['receipt_path'] ?? null);
|
||||||
|
return $row;
|
||||||
|
}, $rows);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'expenses' => ExpenseResource::collection($rows),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$row = $this->queryService->findById($id);
|
||||||
|
if (!$row) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['receipt_url'] = $this->receiptService->receiptUrl($row['receipt_path'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'expense' => new ExpenseResource($row),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreExpenseRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
|
||||||
|
$receiptName = null;
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
$receiptName = $this->receiptService->storeReceipt($request->file('receipt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$isDonation = $payload['category'] === 'Donation';
|
||||||
|
|
||||||
|
$expense = $this->writeService->store([
|
||||||
|
'category' => $payload['category'],
|
||||||
|
'amount' => $payload['amount'],
|
||||||
|
'receipt_path' => $receiptName,
|
||||||
|
'description' => $payload['description'] ?? null,
|
||||||
|
'retailor' => $payload['retailor'] ?? null,
|
||||||
|
'date_of_purchase' => $payload['date_of_purchase'],
|
||||||
|
'purchased_by' => (int) $payload['purchased_by'],
|
||||||
|
'added_by' => $userId,
|
||||||
|
'status' => $isDonation ? 'approved' : 'pending',
|
||||||
|
'status_reason' => $isDonation ? 'Marked as Donation (non-reimbursable).' : null,
|
||||||
|
'approved_by' => $isDonation ? $userId : null,
|
||||||
|
'school_year' => $payload['school_year'] ?? null,
|
||||||
|
'semester' => $payload['semester'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'expense' => new ExpenseResource($expense->toArray()),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateExpenseRequest $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$expense = Expense::query()->find($id);
|
||||||
|
if (!$expense) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $request->validated();
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
|
||||||
|
$receiptName = $expense->receipt_path;
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
$receiptName = $this->receiptService->storeReceipt($request->file('receipt'));
|
||||||
|
}
|
||||||
|
if (!empty($payload['remove_receipt'])) {
|
||||||
|
$receiptName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = $payload['category'];
|
||||||
|
$isDonation = $category === 'Donation';
|
||||||
|
|
||||||
|
$updateData = [
|
||||||
|
'category' => $category,
|
||||||
|
'amount' => $payload['amount'],
|
||||||
|
'description' => $payload['description'] ?? null,
|
||||||
|
'retailor' => $payload['retailor'] ?? null,
|
||||||
|
'date_of_purchase' => $payload['date_of_purchase'],
|
||||||
|
'purchased_by' => (int) $payload['purchased_by'],
|
||||||
|
'receipt_path' => $receiptName,
|
||||||
|
'updated_by' => $userId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($isDonation) {
|
||||||
|
$updateData['status'] = 'approved';
|
||||||
|
$updateData['status_reason'] = 'Marked as Donation (non-reimbursable).';
|
||||||
|
$updateData['approved_by'] = $userId ?: null;
|
||||||
|
$updateData['reimbursement_id'] = null;
|
||||||
|
} elseif (($expense->category ?? '') === 'Donation') {
|
||||||
|
$updateData['status_reason'] = null;
|
||||||
|
$updateData['approved_by'] = $expense->approved_by;
|
||||||
|
$updateData['status'] = $expense->status ?? 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $this->writeService->update($expense, $updateData);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'expense' => new ExpenseResource($updated->toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateStatus(UpdateExpenseStatusRequest $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$expense = Expense::query()->find($id);
|
||||||
|
if (!$expense) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $request->validated();
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
|
||||||
|
$updated = $this->statusService->updateStatus($expense, $payload['status'], $payload['reason'] ?? '', $userId);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'expense' => new ExpenseResource($updated->toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\ExtraCharges;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\ExtraCharges\StoreExtraChargeRequest;
|
||||||
|
use App\Http\Requests\ExtraCharges\UpdateExtraChargeRequest;
|
||||||
|
use App\Http\Resources\ExtraCharges\ExtraChargeInvoiceOptionResource;
|
||||||
|
use App\Http\Resources\ExtraCharges\ExtraChargeParentOptionResource;
|
||||||
|
use App\Http\Resources\ExtraCharges\ExtraChargeResource;
|
||||||
|
use App\Models\AdditionalCharge;
|
||||||
|
use App\Services\ExtraCharges\ExtraChargesChargeService;
|
||||||
|
use App\Services\ExtraCharges\ExtraChargesContextService;
|
||||||
|
use App\Services\ExtraCharges\ExtraChargesInvoiceService;
|
||||||
|
use App\Services\ExtraCharges\ExtraChargesListService;
|
||||||
|
use App\Services\ExtraCharges\ExtraChargesMetaService;
|
||||||
|
use App\Services\ExtraCharges\ExtraChargesParentService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ExtraChargesController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ExtraChargesContextService $context,
|
||||||
|
private ExtraChargesMetaService $meta,
|
||||||
|
private ExtraChargesParentService $parents,
|
||||||
|
private ExtraChargesInvoiceService $invoices,
|
||||||
|
private ExtraChargesListService $listService,
|
||||||
|
private ExtraChargesChargeService $chargeService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$year = (string) ($request->query('school_year') ?? $this->context->getSchoolYear());
|
||||||
|
$sem = (string) ($request->query('semester') ?? $this->context->getSemester());
|
||||||
|
$status = $request->query('status') ?: null;
|
||||||
|
$q = trim((string) ($request->query('q') ?? '')) ?: null;
|
||||||
|
$per = (int) ($request->query('per_page') ?? 50);
|
||||||
|
|
||||||
|
[$rows, $meta] = $this->listService->listForTerm($year, $sem, $status, $q, $per);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'school_year' => $year,
|
||||||
|
'semester' => $sem,
|
||||||
|
'rows' => ExtraChargeResource::collection($rows),
|
||||||
|
'pager' => $meta,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function options(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$q = trim((string) ($request->query('q') ?? ''));
|
||||||
|
$parentOptions = $this->parents->searchParents($q);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'schoolYear' => $this->context->getSchoolYear(),
|
||||||
|
'semester' => $this->context->getSemester(),
|
||||||
|
'schoolYears' => $this->meta->getSchoolYears($this->context->getSchoolYear()),
|
||||||
|
'parents' => ExtraChargeParentOptionResource::collection($parentOptions),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parentOptions(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$q = trim((string) ($request->query('q') ?? ''));
|
||||||
|
$parentOptions = $this->parents->searchParents($q);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'results' => ExtraChargeParentOptionResource::collection($parentOptions),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoicesForParent(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$parentId = (int) ($request->query('parent_id') ?? 0);
|
||||||
|
$schoolYear = (string) ($request->query('school_year') ?? $this->context->getSchoolYear());
|
||||||
|
$rows = $this->invoices->listInvoicesForParent($parentId, $schoolYear);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'results' => ExtraChargeInvoiceOptionResource::collection($rows),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreExtraChargeRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$payload['created_by'] = (int) (auth()->id() ?? 0);
|
||||||
|
|
||||||
|
$result = $this->chargeService->createCharge($payload);
|
||||||
|
|
||||||
|
return response()->json($result, $result['ok'] ? 201 : 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateExtraChargeRequest $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$charge = AdditionalCharge::query()->find($id);
|
||||||
|
if (!$charge) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $request->validated();
|
||||||
|
$payload['updated_by'] = (int) (auth()->id() ?? 0);
|
||||||
|
|
||||||
|
$ok = $this->chargeService->updateCharge($charge, $payload);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => $ok,
|
||||||
|
'id' => $id,
|
||||||
|
], $ok ? 200 : 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function void(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$charge = AdditionalCharge::query()->find($id);
|
||||||
|
if (!$charge) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $this->chargeService->voidCharge($charge);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => $ok,
|
||||||
|
], $ok ? 200 : 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reverse(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$charge = AdditionalCharge::query()->find($id);
|
||||||
|
if (!$charge) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $this->chargeService->reverseCharge($charge);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => $ok,
|
||||||
|
], $ok ? 200 : 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Finance\FinancialReportRequest;
|
||||||
|
use App\Http\Requests\Finance\FinancialSummaryRequest;
|
||||||
|
use App\Http\Requests\Finance\FinancialUnpaidParentsRequest;
|
||||||
|
use App\Http\Resources\Finance\FinancialReportResource;
|
||||||
|
use App\Http\Resources\Finance\FinancialSummaryResource;
|
||||||
|
use App\Http\Resources\Finance\FinancialUnpaidParentResource;
|
||||||
|
use App\Services\Finance\FinancialPdfReportService;
|
||||||
|
use App\Services\Finance\FinancialReportService;
|
||||||
|
use App\Services\Finance\FinancialSchoolYearService;
|
||||||
|
use App\Services\Finance\FinancialSummaryService;
|
||||||
|
use App\Services\Finance\FinancialUnpaidParentsService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class FinancialController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private FinancialReportService $reportService,
|
||||||
|
private FinancialSummaryService $summaryService,
|
||||||
|
private FinancialUnpaidParentsService $unpaidParentsService,
|
||||||
|
private FinancialSchoolYearService $schoolYears,
|
||||||
|
private FinancialPdfReportService $pdfService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function report(FinancialReportRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$report = $this->reportService->getReport(
|
||||||
|
$payload['date_from'] ?? null,
|
||||||
|
$payload['date_to'] ?? null,
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'report' => new FinancialReportResource($report),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function summary(FinancialSummaryRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$summary = $this->summaryService->getSummary(
|
||||||
|
$payload['date_from'] ?? null,
|
||||||
|
$payload['date_to'] ?? null,
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
$schoolYears = $this->schoolYears->listYears($summary['schoolYear'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'summary' => new FinancialSummaryResource($summary),
|
||||||
|
'schoolYears' => $schoolYears,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unpaidParents(FinancialUnpaidParentsRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->unpaidParentsService->getUnpaidParents($payload['school_year'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'schoolYear' => $data['schoolYear'] ?? null,
|
||||||
|
'schoolYears' => $data['schoolYears'] ?? [],
|
||||||
|
'results' => FinancialUnpaidParentResource::collection($data['results'] ?? []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadCsv(FinancialReportRequest $request): StreamedResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$report = $this->reportService->getReport(
|
||||||
|
$payload['date_from'] ?? null,
|
||||||
|
$payload['date_to'] ?? null,
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
$paymentsMap = [];
|
||||||
|
foreach ($report['payments'] as $payment) {
|
||||||
|
$invoiceId = (int) ($payment['invoice_id'] ?? 0);
|
||||||
|
$paymentsMap[$invoiceId] = (float) ($payment['paid_amount'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$refundsMap = [];
|
||||||
|
foreach ($report['refunds'] as $refund) {
|
||||||
|
$invoiceId = (int) ($refund['invoice_id'] ?? 0);
|
||||||
|
$refundsMap[$invoiceId] = (float) ($refund['total_refunded'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$discountsMap = [];
|
||||||
|
foreach ($report['discounts'] as $discount) {
|
||||||
|
$invoiceId = (int) ($discount['invoice_id'] ?? 0);
|
||||||
|
$discountsMap[$invoiceId] = (float) ($discount['discount_amount'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$breakdown = $report['paymentBreakdown'] ?? [];
|
||||||
|
|
||||||
|
$filename = 'financial_report_' . date('Ymd_His') . '.csv';
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($report, $paymentsMap, $refundsMap, $discountsMap, $breakdown) {
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
fputcsv($out, ['Invoice #', 'Parent', 'School Year', 'Total', 'Paid', 'Cash', 'Credit', 'Check', 'Balance', 'Refund', 'Discount', 'Status']);
|
||||||
|
$sumTotal = 0.0;
|
||||||
|
$sumPaid = 0.0;
|
||||||
|
$sumCash = 0.0;
|
||||||
|
$sumCredit = 0.0;
|
||||||
|
$sumCheck = 0.0;
|
||||||
|
$sumBalance = 0.0;
|
||||||
|
$sumRefund = 0.0;
|
||||||
|
$sumDiscount = 0.0;
|
||||||
|
|
||||||
|
foreach ($report['invoices'] as $inv) {
|
||||||
|
$invoiceId = (int) ($inv['id'] ?? 0);
|
||||||
|
$paid = (float) ($paymentsMap[$invoiceId] ?? 0);
|
||||||
|
$bd = $breakdown[$invoiceId] ?? [];
|
||||||
|
$cash = (float) ($bd['cash'] ?? 0);
|
||||||
|
$credit = (float) ($bd['credit'] ?? 0);
|
||||||
|
$check = (float) ($bd['check'] ?? 0);
|
||||||
|
$refunded = (float) ($refundsMap[$invoiceId] ?? 0);
|
||||||
|
$discount = (float) ($discountsMap[$invoiceId] ?? 0);
|
||||||
|
$total = (float) ($inv['total_amount'] ?? 0);
|
||||||
|
$balance = $total - $paid - $discount - $refunded;
|
||||||
|
if ($balance < 0) {
|
||||||
|
$balance = 0.0;
|
||||||
|
}
|
||||||
|
$status = $balance === 0.0 ? 'Paid' : 'Unpaid';
|
||||||
|
|
||||||
|
fputcsv($out, [
|
||||||
|
$inv['invoice_number'] ?? '',
|
||||||
|
$inv['parent_name'] ?? '',
|
||||||
|
$inv['school_year'] ?? '',
|
||||||
|
$total,
|
||||||
|
$paid,
|
||||||
|
$cash,
|
||||||
|
$credit,
|
||||||
|
$check,
|
||||||
|
$balance,
|
||||||
|
$refunded,
|
||||||
|
$discount,
|
||||||
|
$status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sumTotal += $total;
|
||||||
|
$sumPaid += $paid;
|
||||||
|
$sumCash += $cash;
|
||||||
|
$sumCredit += $credit;
|
||||||
|
$sumCheck += $check;
|
||||||
|
$sumBalance += $balance;
|
||||||
|
$sumRefund += $refunded;
|
||||||
|
$sumDiscount += $discount;
|
||||||
|
}
|
||||||
|
|
||||||
|
fputcsv($out, [
|
||||||
|
'TOTALS',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
$sumTotal,
|
||||||
|
$sumPaid,
|
||||||
|
$sumCash,
|
||||||
|
$sumCredit,
|
||||||
|
$sumCheck,
|
||||||
|
$sumBalance,
|
||||||
|
$sumRefund,
|
||||||
|
$sumDiscount,
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
|
||||||
|
fputcsv($out, []);
|
||||||
|
fputcsv($out, ['Expenses Summary']);
|
||||||
|
fputcsv($out, ['Category', 'Total Amount']);
|
||||||
|
foreach ($report['expenses'] as $exp) {
|
||||||
|
fputcsv($out, [
|
||||||
|
$exp['category'] ?? '',
|
||||||
|
$exp['total_amount'] ?? 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fputcsv($out, []);
|
||||||
|
fputcsv($out, ['Reimbursements Summary']);
|
||||||
|
fputcsv($out, ['Status', 'Total Amount']);
|
||||||
|
foreach ($report['reimbursements'] as $row) {
|
||||||
|
fputcsv($out, [
|
||||||
|
$row['status'] ?? '',
|
||||||
|
$row['total_amount'] ?? 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($out);
|
||||||
|
}, $filename, ['Content-Type' => 'text/csv']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadPdf(FinancialReportRequest $request): StreamedResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$summary = $this->summaryService->getSummary(
|
||||||
|
$payload['date_from'] ?? null,
|
||||||
|
$payload['date_to'] ?? null,
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
$pdfBytes = $this->pdfService->buildPdf($summary);
|
||||||
|
$schoolYear = $summary['schoolYear'] ?? 'report';
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($pdfBytes) {
|
||||||
|
echo $pdfBytes;
|
||||||
|
}, 'Financial_Report_' . $schoolYear . '.pdf', ['Content-Type' => 'application/pdf']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Invoices\InvoiceGenerateRequest;
|
||||||
|
use App\Http\Requests\Invoices\InvoiceManagementRequest;
|
||||||
|
use App\Http\Requests\Invoices\InvoiceParentRequest;
|
||||||
|
use App\Http\Requests\Invoices\InvoiceStatusRequest;
|
||||||
|
use App\Http\Resources\Invoices\InvoiceManagementParentResource;
|
||||||
|
use App\Http\Resources\Invoices\InvoiceResource;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Services\Invoices\InvoiceGenerationService;
|
||||||
|
use App\Services\Invoices\InvoiceManagementService;
|
||||||
|
use App\Services\Invoices\InvoicePaymentService;
|
||||||
|
use App\Services\Invoices\InvoicePdfService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class InvoiceController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private InvoiceManagementService $management,
|
||||||
|
private InvoiceGenerationService $generation,
|
||||||
|
private InvoicePaymentService $paymentService,
|
||||||
|
private InvoicePdfService $pdfService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function management(InvoiceManagementRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$schoolYear = $request->validated()['school_year'] ?? null;
|
||||||
|
$schoolYear = $schoolYear ?: $request->input('schoolYear');
|
||||||
|
$schoolYear = $schoolYear ?: $request->input('year');
|
||||||
|
|
||||||
|
$data = $this->management->getManagementData($schoolYear);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'schoolYear' => $data['schoolYear'],
|
||||||
|
'schoolYears' => $data['schoolYears'],
|
||||||
|
'invoices' => InvoiceManagementParentResource::collection($data['invoices']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate(InvoiceGenerateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->generation->generateInvoice((int) $payload['parent_id'], $payload['school_year'] ?? null);
|
||||||
|
|
||||||
|
if (empty($result['ok'])) {
|
||||||
|
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to generate invoice.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'updated' => $result['updated'] ?? false,
|
||||||
|
'updated_ids' => $result['updated_ids'] ?? [],
|
||||||
|
'insert_id' => $result['insert_id'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function byParent(InvoiceParentRequest $request, int $parentId): JsonResponse
|
||||||
|
{
|
||||||
|
$schoolYear = $request->validated()['school_year'] ?? null;
|
||||||
|
$invoices = Invoice::getInvoicesByParentId($parentId, $schoolYear);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'invoices' => InvoiceResource::collection($invoices),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parentPayment(InvoiceParentRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$parentId = (int) (auth()->id() ?? 0);
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$schoolYear = $request->validated()['school_year'] ?? null;
|
||||||
|
$data = $this->paymentService->getParentInvoiceSummary($parentId, $schoolYear);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'invoices' => InvoiceResource::collection($data['invoices']),
|
||||||
|
'schoolYears' => $data['schoolYears'],
|
||||||
|
'selectedYear' => $data['selectedYear'],
|
||||||
|
'currentSchoolYear' => $data['currentSchoolYear'],
|
||||||
|
'dueDate' => $data['dueDate'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateStatus(InvoiceStatusRequest $request, int $invoiceId): JsonResponse
|
||||||
|
{
|
||||||
|
$status = $request->validated()['status'];
|
||||||
|
$updated = Invoice::updateInvoiceStatus($invoiceId, $status);
|
||||||
|
|
||||||
|
if (!$updated) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Invoice not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'status' => $status]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unpaid(): JsonResponse
|
||||||
|
{
|
||||||
|
$rows = Invoice::getUnpaidInvoices();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'invoices' => InvoiceResource::collection($rows),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pdf(int $invoiceId): StreamedResponse
|
||||||
|
{
|
||||||
|
$pdfBytes = $this->pdfService->buildPdf($invoiceId);
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($pdfBytes) {
|
||||||
|
echo $pdfBytes;
|
||||||
|
}, 'invoice_' . $invoiceId . '.pdf', ['Content-Type' => 'application/pdf']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Payments\PaymentByParentRequest;
|
||||||
|
use App\Http\Requests\Payments\PaymentCreateRequest;
|
||||||
|
use App\Http\Requests\Payments\PaymentUpdateBalanceRequest;
|
||||||
|
use App\Http\Resources\Payments\PaymentResource;
|
||||||
|
use App\Services\Payments\PaymentBalanceService;
|
||||||
|
use App\Services\Payments\PaymentLookupService;
|
||||||
|
use App\Services\Payments\PaymentPlanService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PaymentController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PaymentPlanService $planService,
|
||||||
|
private PaymentLookupService $lookupService,
|
||||||
|
private PaymentBalanceService $balanceService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(PaymentCreateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$payment = $this->planService->createPlan($payload);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'payment' => new PaymentResource($payment),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function byParent(PaymentByParentRequest $request, int $parentId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$payments = $this->lookupService->getByParent($parentId, $payload['school_year'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'payments' => PaymentResource::collection($payments),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateBalance(PaymentUpdateBalanceRequest $request, int $paymentId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$updated = $this->balanceService->updateBalance($paymentId, (float) $payload['paid_amount']);
|
||||||
|
|
||||||
|
if (!$updated) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Payment not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Payments\PaymentEventChargesListRequest;
|
||||||
|
use App\Http\Requests\Payments\PaymentEventChargesStoreRequest;
|
||||||
|
use App\Services\Payments\PaymentEventChargesService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PaymentEventChargesController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private PaymentEventChargesService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(PaymentEventChargesListRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->service->listCharges($payload['school_year'] ?? null, $payload['semester'] ?? null);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true] + $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(PaymentEventChargesStoreRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
|
||||||
|
$count = $this->service->addCharges($payload, $userId);
|
||||||
|
if ($count === 0) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'No student selected.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'inserted' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enrolledStudents(PaymentEventChargesListRequest $request, int $parentId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$students = $this->service->getEnrolledStudents($parentId, $payload['school_year'] ?? null);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'students' => $students]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Payments\PaymentManualEditRequest;
|
||||||
|
use App\Http\Requests\Payments\PaymentManualSearchRequest;
|
||||||
|
use App\Http\Requests\Payments\PaymentManualSuggestRequest;
|
||||||
|
use App\Http\Requests\Payments\PaymentManualUpdateRequest;
|
||||||
|
use App\Services\Payments\PaymentManualService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PaymentManualController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private PaymentManualService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(PaymentManualSearchRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->service->search(
|
||||||
|
(string) ($payload['search_term'] ?? ''),
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true] + $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function suggest(PaymentManualSuggestRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$items = $this->service->suggest((string) ($payload['q'] ?? ''));
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'items' => $items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function record(PaymentManualUpdateRequest $request, int $invoiceId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
|
||||||
|
$result = $this->service->recordPayment(
|
||||||
|
$invoiceId,
|
||||||
|
(float) $payload['amount'],
|
||||||
|
(string) $payload['payment_method'],
|
||||||
|
$payload['payment_date'] ?? null,
|
||||||
|
$payload['check_number'] ?? null,
|
||||||
|
$payload['payment_type'] ?? null,
|
||||||
|
$request->file('payment_file'),
|
||||||
|
$payload['school_year'] ?? null,
|
||||||
|
$payload['semester'] ?? null,
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true] + $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(PaymentManualEditRequest $request, int $paymentId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
|
||||||
|
$this->service->editPayment(
|
||||||
|
$paymentId,
|
||||||
|
(float) $payload['paid_amount'],
|
||||||
|
(string) $payload['payment_method'],
|
||||||
|
$payload['check_number'] ?? null,
|
||||||
|
$request->file('payment_file'),
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function file(Request $request, string $filename)
|
||||||
|
{
|
||||||
|
$mode = (string) ($request->query('mode') ?? 'download');
|
||||||
|
return $this->service->serveCheckFile($filename, $mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Payments\PaymentNotificationListRequest;
|
||||||
|
use App\Http\Requests\Payments\PaymentNotificationSendRequest;
|
||||||
|
use App\Http\Resources\Payments\PaymentNotificationLogResource;
|
||||||
|
use App\Services\Payments\PaymentNotificationService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PaymentNotificationController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private PaymentNotificationService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(PaymentNotificationListRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$logs = $this->service->listLogs(
|
||||||
|
$payload['from'] ?? null,
|
||||||
|
$payload['to'] ?? null,
|
||||||
|
$payload['type'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'logs' => PaymentNotificationLogResource::collection($logs),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(PaymentNotificationSendRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->service->send($payload);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true] + $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Payments\PaymentTransactionCreateRequest;
|
||||||
|
use App\Http\Requests\Payments\PaymentTransactionStatusRequest;
|
||||||
|
use App\Http\Resources\Payments\PaymentTransactionResource;
|
||||||
|
use App\Services\Payments\PaymentTransactionService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PaymentTransactionController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private PaymentTransactionService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(PaymentTransactionCreateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$transaction = $this->service->create($payload);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'transaction' => new PaymentTransactionResource($transaction->toArray()),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function byPayment(int $paymentId): JsonResponse
|
||||||
|
{
|
||||||
|
$transactions = $this->service->getByPayment($paymentId);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'transactions' => PaymentTransactionResource::collection($transactions),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateStatus(PaymentTransactionStatusRequest $request, string $transactionId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$updated = $this->service->updateStatus($transactionId, $payload['status']);
|
||||||
|
|
||||||
|
if (!$updated) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Transaction not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Payments\PaypalExecuteRequest;
|
||||||
|
use App\Services\Payments\PaypalPaymentService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PaypalPaymentController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private PaypalPaymentService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function redirect(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'redirect_url' => $this->service->getRedirectUrl(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(int $paymentId): JsonResponse
|
||||||
|
{
|
||||||
|
$result = $this->service->createPayment($paymentId);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true] + $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(PaypalExecuteRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->service->executePayment(
|
||||||
|
(string) $payload['payer_id'],
|
||||||
|
$payload['paypal_payment_id'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(): JsonResponse
|
||||||
|
{
|
||||||
|
$this->service->cancelPayment();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Payment was canceled.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Payments\PaypalTransactionsListRequest;
|
||||||
|
use App\Http\Resources\Payments\PaypalTransactionResource;
|
||||||
|
use App\Services\Payments\PaypalTransactionsService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class PaypalTransactionsController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private PaypalTransactionsService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(PaypalTransactionsListRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$perPage = (int) ($payload['per_page'] ?? 10);
|
||||||
|
$rows = $this->service->list($payload['q'] ?? null, $perPage);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'transactions' => PaypalTransactionResource::collection($rows->items()),
|
||||||
|
'pagination' => [
|
||||||
|
'current_page' => $rows->currentPage(),
|
||||||
|
'per_page' => $rows->perPage(),
|
||||||
|
'total' => $rows->total(),
|
||||||
|
'last_page' => $rows->lastPage(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadCsv(PaypalTransactionsListRequest $request): StreamedResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$rows = $this->service->listAll($payload['q'] ?? null);
|
||||||
|
|
||||||
|
$filename = 'paypal_transactions_' . date('Ymd_His') . '.csv';
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($rows) {
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
fputcsv($out, [
|
||||||
|
'ID', 'Transaction ID', 'Order ID', 'Parent School ID',
|
||||||
|
'Email', 'Amount', 'Net Amount', 'Currency',
|
||||||
|
'Status', 'Event Type', 'Created At',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
fputcsv($out, [
|
||||||
|
$row['id'] ?? null,
|
||||||
|
$row['transaction_id'] ?? null,
|
||||||
|
$row['order_id'] ?? null,
|
||||||
|
$row['parent_school_id'] ?? null,
|
||||||
|
$row['payer_email'] ?? null,
|
||||||
|
$row['amount'] ?? null,
|
||||||
|
$row['net_amount'] ?? null,
|
||||||
|
$row['currency'] ?? null,
|
||||||
|
$row['status'] ?? null,
|
||||||
|
$row['event_type'] ?? null,
|
||||||
|
$row['created_at'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($out);
|
||||||
|
}, $filename, ['Content-Type' => 'text/csv']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Files\FileNameRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementBatchAdminFileRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementBatchAssignmentRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementBatchCreateRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementBatchEmailRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementBatchLockRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementExportBatchRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementExportRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementIndexRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementMarkDonationRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementProcessRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementStoreRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementUnderProcessingRequest;
|
||||||
|
use App\Http\Requests\Reimbursements\ReimbursementUpdateRequest;
|
||||||
|
use App\Http\Resources\Reimbursements\ReimbursementBatchResource;
|
||||||
|
use App\Http\Resources\Reimbursements\ReimbursementExpenseResource;
|
||||||
|
use App\Http\Resources\Reimbursements\ReimbursementRecipientResource;
|
||||||
|
use App\Http\Resources\Reimbursements\ReimbursementResource;
|
||||||
|
use App\Http\Resources\Reimbursements\ReimbursementUnderProcessingItemResource;
|
||||||
|
use App\Models\Reimbursement;
|
||||||
|
use App\Models\ReimbursementBatch;
|
||||||
|
use App\Services\Files\FileServeService;
|
||||||
|
use App\Services\Reimbursements\ReimbursementBatchAssignmentService;
|
||||||
|
use App\Services\Reimbursements\ReimbursementBatchService;
|
||||||
|
use App\Services\Reimbursements\ReimbursementContextService;
|
||||||
|
use App\Services\Reimbursements\ReimbursementCrudService;
|
||||||
|
use App\Services\Reimbursements\ReimbursementDonationService;
|
||||||
|
use App\Services\Reimbursements\ReimbursementEmailService;
|
||||||
|
use App\Services\Reimbursements\ReimbursementExportService;
|
||||||
|
use App\Services\Reimbursements\ReimbursementFileService;
|
||||||
|
use App\Services\Reimbursements\ReimbursementQueryService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ReimbursementController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ReimbursementContextService $context,
|
||||||
|
private ReimbursementQueryService $queryService,
|
||||||
|
private ReimbursementDonationService $donationService,
|
||||||
|
private ReimbursementBatchService $batchService,
|
||||||
|
private ReimbursementBatchAssignmentService $assignmentService,
|
||||||
|
private ReimbursementFileService $fileService,
|
||||||
|
private ReimbursementExportService $exportService,
|
||||||
|
private ReimbursementEmailService $emailService,
|
||||||
|
private ReimbursementCrudService $crudService,
|
||||||
|
private FileServeService $fileServeService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function underProcessing(ReimbursementUnderProcessingRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $this->queryService->underProcessing();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'pendingItems' => ReimbursementUnderProcessingItemResource::collection($data['pendingItems'] ?? []),
|
||||||
|
'existingBatches' => ReimbursementBatchResource::collection($data['existingBatches'] ?? []),
|
||||||
|
'adminUsers' => ReimbursementRecipientResource::collection($data['adminUsers'] ?? []),
|
||||||
|
'itemsPayload' => ReimbursementUnderProcessingItemResource::collection($data['itemsPayload'] ?? []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markDonation(ReimbursementMarkDonationRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->donationService->markDonation((int) $payload['expense_id'], $userId);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
$message = $e->getMessage();
|
||||||
|
$status = str_contains($message, 'not found') ? 404 : (str_contains($message, 'already') ? 409 : 422);
|
||||||
|
return response()->json(['ok' => false, 'message' => $message], $status);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unable to mark donation right now.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createBatch(ReimbursementBatchCreateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
$schoolYear = $this->context->getSchoolYear();
|
||||||
|
$semester = $this->context->getSemester();
|
||||||
|
|
||||||
|
$batch = $this->batchService->createBatch($payload['title'] ?? null, $userId, $schoolYear, $semester);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'batch' => $batch,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateBatchAssignment(ReimbursementBatchAssignmentRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
|
||||||
|
$batchId = (int) ($payload['batch_id'] ?? 0);
|
||||||
|
$batchNumber = $payload['batch_number'] ?? null;
|
||||||
|
if ($batchId <= 0 && $batchNumber !== null && trim((string) $batchNumber) !== '') {
|
||||||
|
$batchId = (int) $batchNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminIdRaw = $payload['admin_id'] ?? null;
|
||||||
|
$adminId = ($adminIdRaw === '' || $adminIdRaw === null) ? null : (int) $adminIdRaw;
|
||||||
|
$reimbursementId = !empty($payload['reimbursement_id']) ? (int) $payload['reimbursement_id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->assignmentService->updateAssignment(
|
||||||
|
(int) $payload['expense_id'],
|
||||||
|
$batchId,
|
||||||
|
$adminId,
|
||||||
|
$reimbursementId,
|
||||||
|
$this->context->getSchoolYear(),
|
||||||
|
$this->context->getSemester()
|
||||||
|
);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'assignment' => $result,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lockBatch(ReimbursementBatchLockRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->batchService->lockBatch(
|
||||||
|
(int) $payload['batch_id'],
|
||||||
|
$userId,
|
||||||
|
$this->context->getSchoolYear(),
|
||||||
|
$this->context->getSemester()
|
||||||
|
);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
$message = $e->getMessage();
|
||||||
|
$status = str_contains($message, 'check file') ? 409 : 404;
|
||||||
|
return response()->json(['ok' => false, 'message' => $message], $status);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unable to lock batch right now.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'status' => 'closed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadBatchAdminFile(ReimbursementBatchAdminFileRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$batchId = (int) $payload['batch_id'];
|
||||||
|
$adminId = (int) $payload['admin_id'];
|
||||||
|
|
||||||
|
$batch = ReimbursementBatch::query()->find($batchId);
|
||||||
|
if (!$batch) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Batch not found.'], 404);
|
||||||
|
}
|
||||||
|
if (strtolower((string) ($batch->status ?? '')) !== ReimbursementBatch::STATUS_OPEN) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Cannot upload files for a locked batch.'], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$file = $this->fileService->storeAdminFile($batchId, $adminId, $request->file('check_file'), (int) (auth()->id() ?? 0));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unable to store the uploaded file.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'file' => $file,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serveAdminCheckFile(FileNameRequest $request, string $name, string $mode = 'inline'): Response|JsonResponse
|
||||||
|
{
|
||||||
|
$allowedExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
||||||
|
$meta = $this->fileServeService->meta(storage_path('uploads/reimbursements'), $name, $allowedExtensions, null);
|
||||||
|
$disposition = strtolower(trim($mode)) === 'download' ? 'attachment' : 'inline';
|
||||||
|
|
||||||
|
return response(file_get_contents($meta['path']), 200, [
|
||||||
|
'Content-Type' => $meta['mime'],
|
||||||
|
'Content-Disposition' => $disposition . '; filename="' . $meta['download_name'] . '"',
|
||||||
|
'Content-Length' => (string) $meta['size'],
|
||||||
|
'ETag' => $meta['etag'],
|
||||||
|
'Last-Modified' => $meta['last_modified'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(ReimbursementIndexRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$filters = [
|
||||||
|
'school_year' => $payload['school_year'] ?? null,
|
||||||
|
'semester' => $payload['semester'] ?? null,
|
||||||
|
'status' => $payload['status'] ?? null,
|
||||||
|
'user_id' => $payload['user_id'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$data = $this->queryService->index($filters);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'expenses' => ReimbursementExpenseResource::collection($data['expenses'] ?? []),
|
||||||
|
'users' => ReimbursementRecipientResource::collection($data['users'] ?? []),
|
||||||
|
'schoolYears' => $data['schoolYears'] ?? [],
|
||||||
|
'batchSummaries' => $data['batchSummaries'] ?? [],
|
||||||
|
'batchDetails' => $data['batchDetails'] ?? [],
|
||||||
|
'batchAttachments' => $data['batchAttachments'] ?? [],
|
||||||
|
'donationBatch' => $data['donationBatch'] ?? null,
|
||||||
|
'donationDetails' => $data['donationDetails'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendBatchEmail(ReimbursementBatchEmailRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$receiptIds = $payload['receipts'] ?? [];
|
||||||
|
$checkIds = $payload['checks'] ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ok = $this->emailService->sendBatchEmail(
|
||||||
|
(int) $payload['batch_id'],
|
||||||
|
$payload['recipient_email'],
|
||||||
|
(string) ($payload['message'] ?? ''),
|
||||||
|
$receiptIds,
|
||||||
|
$checkIds
|
||||||
|
);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return response()->json(['ok' => false, 'message' => $e->getMessage()], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$ok) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Failed to send email. Please try again.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'message' => 'Email sent successfully.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export(ReimbursementExportRequest $request): Response
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$type = $payload['type'] ?? 'processed';
|
||||||
|
|
||||||
|
if ($type === 'under_processing') {
|
||||||
|
$csv = $this->exportService->buildUnderProcessingCsv();
|
||||||
|
} else {
|
||||||
|
$csv = $this->exportService->buildProcessedCsv($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($csv) {
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||||
|
foreach ($csv['rows'] as $row) {
|
||||||
|
fputcsv($out, $row);
|
||||||
|
}
|
||||||
|
fclose($out);
|
||||||
|
}, $csv['filename'], [
|
||||||
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportBatch(ReimbursementExportBatchRequest $request): Response
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$csv = $this->exportService->buildBatchCsv((int) $payload['batch_id']);
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($csv) {
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||||
|
foreach ($csv['rows'] as $row) {
|
||||||
|
fputcsv($out, $row);
|
||||||
|
}
|
||||||
|
fclose($out);
|
||||||
|
}, $csv['filename'], [
|
||||||
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ReimbursementStoreRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
$receiptName = null;
|
||||||
|
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
$receiptName = $this->fileService->storeReceipt($request->file('receipt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reimb = $this->crudService->store(
|
||||||
|
$payload,
|
||||||
|
$userId,
|
||||||
|
$this->context->getSchoolYear(),
|
||||||
|
$this->context->getSemester(),
|
||||||
|
$receiptName
|
||||||
|
);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $reimb->toArray();
|
||||||
|
$data['receipt_url'] = $this->fileService->receiptUrl($reimb->receipt_path ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'reimbursement' => new ReimbursementResource($data),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(ReimbursementProcessRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
$receiptName = null;
|
||||||
|
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
$receiptName = $this->fileService->storeReceipt($request->file('receipt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reimb = $this->crudService->process(
|
||||||
|
$payload,
|
||||||
|
$userId,
|
||||||
|
$this->context->getSchoolYear(),
|
||||||
|
$this->context->getSemester(),
|
||||||
|
$receiptName
|
||||||
|
);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $reimb->toArray();
|
||||||
|
$data['receipt_url'] = $this->fileService->receiptUrl($reimb->receipt_path ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'reimbursement' => new ReimbursementResource($data),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reimbursedExpenses(): JsonResponse
|
||||||
|
{
|
||||||
|
$expenses = $this->queryService->reimbursedExpenses();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'expenses' => $expenses,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ReimbursementUpdateRequest $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$reimb = Reimbursement::query()->find($id);
|
||||||
|
if (!$reimb) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $request->validated();
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
|
||||||
|
$receiptName = $reimb->receipt_path;
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
$receiptName = $this->fileService->storeReceipt($request->file('receipt'));
|
||||||
|
}
|
||||||
|
if (!empty($payload['remove_receipt'])) {
|
||||||
|
$receiptName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $this->crudService->update($reimb, $payload, $userId, $receiptName);
|
||||||
|
$data = $updated->toArray();
|
||||||
|
$data['receipt_url'] = $this->fileService->receiptUrl($updated->receipt_path ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'reimbursement' => new ReimbursementResource($data),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Grading;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Grading\BelowSixtyEmailRequest;
|
||||||
|
use App\Http\Requests\Grading\BelowSixtyMeetingRequest;
|
||||||
|
use App\Http\Requests\Grading\BelowSixtyMeetingSaveRequest;
|
||||||
|
use App\Http\Requests\Grading\BelowSixtySendRequest;
|
||||||
|
use App\Http\Requests\Grading\BelowSixtyStatusRequest;
|
||||||
|
use App\Http\Requests\Grading\BelowSixtyListRequest;
|
||||||
|
use App\Http\Requests\Grading\GradingLockAllRequest;
|
||||||
|
use App\Http\Requests\Grading\GradingLockRequest;
|
||||||
|
use App\Http\Requests\Grading\GradingOverviewRequest;
|
||||||
|
use App\Http\Requests\Grading\GradingRefreshRequest;
|
||||||
|
use App\Http\Requests\Grading\GradingScoreShowRequest;
|
||||||
|
use App\Http\Requests\Grading\GradingScoreUpdateRequest;
|
||||||
|
use App\Http\Requests\Grading\PlacementBatchRequest;
|
||||||
|
use App\Http\Requests\Grading\PlacementBatchUpdateRequest;
|
||||||
|
use App\Http\Requests\Grading\PlacementLevelsRequest;
|
||||||
|
use App\Http\Requests\Grading\PlacementLevelRequest;
|
||||||
|
use App\Http\Requests\Grading\PlacementRequest;
|
||||||
|
use App\Http\Resources\Grading\BelowSixtyStudentResource;
|
||||||
|
use App\Http\Resources\Grading\GradingScoreResource;
|
||||||
|
use App\Services\Grading\GradingBelowSixtyService;
|
||||||
|
use App\Services\Grading\GradingLockService;
|
||||||
|
use App\Services\Grading\GradingOverviewService;
|
||||||
|
use App\Services\Grading\GradingPlacementService;
|
||||||
|
use App\Services\Grading\GradingRefreshService;
|
||||||
|
use App\Services\Grading\GradingScoreService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class GradingController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GradingOverviewService $overviewService,
|
||||||
|
private GradingScoreService $scoreService,
|
||||||
|
private GradingLockService $lockService,
|
||||||
|
private GradingRefreshService $refreshService,
|
||||||
|
private GradingPlacementService $placementService,
|
||||||
|
private GradingBelowSixtyService $belowSixtyService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function overview(GradingOverviewRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->overviewService->overview(
|
||||||
|
isset($payload['class_id']) ? (int) $payload['class_id'] : null,
|
||||||
|
$payload['semester'] ?? null,
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true] + $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(GradingScoreShowRequest $request, string $type, int $classSectionId, int $studentId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->scoreService->show(
|
||||||
|
$type,
|
||||||
|
$classSectionId,
|
||||||
|
$studentId,
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year']
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'student' => $data['student'],
|
||||||
|
'scores' => GradingScoreResource::collection($data['scores']),
|
||||||
|
'type' => $data['type'],
|
||||||
|
'class_section_id' => $data['class_section_id'],
|
||||||
|
'semester' => $data['semester'],
|
||||||
|
'scores_locked' => $data['scores_locked'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(GradingScoreUpdateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->scoreService->update($payload, (int) (auth()->id() ?? 0));
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleLock(GradingLockRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$locked = $this->lockService->toggle(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'locked' => $locked]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lockAll(GradingLockAllRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$count = $this->lockService->lockAll(
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'locked_count' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refresh(GradingRefreshRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->refreshService->refreshSemesterScores(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year']
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function placement(PlacementRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->placementService->placementContext(
|
||||||
|
isset($payload['class_section_id']) ? (int) $payload['class_section_id'] : null,
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['placement_test'] ?? null,
|
||||||
|
$payload['open'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true] + $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePlacementLevel(PlacementLevelRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->placementService->updatePlacementLevel(
|
||||||
|
(int) $payload['student_id'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['placement_level'] ?? null,
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePlacementLevels(PlacementLevelsRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->placementService->updatePlacementLevels(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['placement_level'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPlacementBatch(PlacementBatchRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$batchId = $this->placementService->createPlacementBatch(
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
(string) $payload['placement_test'],
|
||||||
|
$payload['placement_level'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'batch_id' => $batchId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showPlacementBatch(int $batchId): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $this->placementService->getPlacementBatch($batchId);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true] + $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePlacementBatch(PlacementBatchUpdateRequest $request, int $batchId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->placementService->updatePlacementBatch(
|
||||||
|
$batchId,
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['placement_level'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function belowSixty(BelowSixtyListRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$rows = $this->belowSixtyService->listRows(
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
(string) $payload['semester']
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'rows' => BelowSixtyStudentResource::collection($rows),
|
||||||
|
'semester' => $payload['semester'],
|
||||||
|
'school_year' => $payload['school_year'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function belowSixtyEmail(BelowSixtyEmailRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->belowSixtyService->emailContext(
|
||||||
|
(int) $payload['student_id'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
(string) $payload['semester']
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true] + $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendBelowSixtyEmail(BelowSixtySendRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->belowSixtyService->emailContext(
|
||||||
|
(int) $payload['student_id'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
(string) $payload['semester']
|
||||||
|
);
|
||||||
|
|
||||||
|
$subject = trim((string) ($payload['subject'] ?? ''));
|
||||||
|
if ($subject !== '') {
|
||||||
|
$data['subject'] = $subject;
|
||||||
|
}
|
||||||
|
if (isset($payload['html']) && trim((string) $payload['html']) !== '') {
|
||||||
|
$data['html'] = (string) $payload['html'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->belowSixtyService->sendEmail($data);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateBelowSixtyStatus(BelowSixtyStatusRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->belowSixtyService->updateStatus(
|
||||||
|
(int) $payload['student_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
(string) $payload['status'],
|
||||||
|
(string) ($payload['note'] ?? ''),
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function belowSixtyMeeting(BelowSixtyMeetingRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->belowSixtyService->meetingContext(
|
||||||
|
(int) $payload['student_id'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
(string) $payload['semester']
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true] + $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveBelowSixtyMeeting(BelowSixtyMeetingSaveRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->belowSixtyService->saveMeeting($payload, (int) (auth()->id() ?? 0));
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Grading;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Grading\HomeworkTrackingRequest;
|
||||||
|
use App\Http\Resources\Grading\HomeworkTrackingTeacherResource;
|
||||||
|
use App\Services\Grading\HomeworkTrackingService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class HomeworkTrackingController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private HomeworkTrackingService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(HomeworkTrackingRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->service->report(
|
||||||
|
$payload['semester'] ?? null,
|
||||||
|
$payload['school_year'] ?? null,
|
||||||
|
isset($payload['page']) ? (int) $payload['page'] : 1
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'semester' => $data['semester'],
|
||||||
|
'school_year' => $data['school_year'],
|
||||||
|
'sundays' => $data['sundays'],
|
||||||
|
'event_days' => $data['event_days'],
|
||||||
|
'date_to_index' => $data['date_to_index'],
|
||||||
|
'teachers' => HomeworkTrackingTeacherResource::collection($data['teachers']),
|
||||||
|
'has_homework' => $data['has_homework'],
|
||||||
|
'hw_entered_at' => $data['hw_entered_at'],
|
||||||
|
'has_homework_by_date' => $data['has_homework_by_date'],
|
||||||
|
'hw_entered_at_by_date' => $data['hw_entered_at_by_date'],
|
||||||
|
'page' => $data['page'],
|
||||||
|
'total_pages' => $data['total_pages'],
|
||||||
|
'per_page' => $data['per_page'],
|
||||||
|
'total_rows' => $data['total_rows'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Incidents;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Incidents\IncidentCancelRequest;
|
||||||
|
use App\Http\Requests\Incidents\IncidentCloseRequest;
|
||||||
|
use App\Http\Requests\Incidents\IncidentListRequest;
|
||||||
|
use App\Http\Requests\Incidents\IncidentStateRequest;
|
||||||
|
use App\Http\Requests\Incidents\IncidentStoreRequest;
|
||||||
|
use App\Http\Resources\Incidents\IncidentAnalysisStudentResource;
|
||||||
|
use App\Http\Resources\Incidents\IncidentGradeResource;
|
||||||
|
use App\Http\Resources\Incidents\IncidentResource;
|
||||||
|
use App\Http\Resources\Incidents\IncidentStudentOptionResource;
|
||||||
|
use App\Services\Incidents\CurrentIncidentService;
|
||||||
|
use App\Services\Incidents\IncidentAnalysisService;
|
||||||
|
use App\Services\Incidents\IncidentHistoryService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class IncidentController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CurrentIncidentService $currentService,
|
||||||
|
private IncidentHistoryService $historyService,
|
||||||
|
private IncidentAnalysisService $analysisService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function current(): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $this->currentService->listCurrent();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'incidents' => IncidentResource::collection($data['incidents']),
|
||||||
|
'grades' => IncidentGradeResource::collection($data['grades']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function history(IncidentListRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$incidents = $this->historyService->history($payload['school_year'] ?? null, $payload['semester'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'incidents' => IncidentResource::collection($incidents),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processed(IncidentListRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$incidents = $this->historyService->processed($payload['school_year'] ?? null, $payload['semester'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'incidents' => IncidentResource::collection($incidents),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function analysis(IncidentListRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$students = $this->analysisService->analyze($payload['school_year'] ?? null, $payload['semester'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => IncidentAnalysisStudentResource::collection($students),
|
||||||
|
'school_year' => $payload['school_year'] ?? null,
|
||||||
|
'semester' => $payload['semester'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function studentsByGrade(int $gradeId): JsonResponse
|
||||||
|
{
|
||||||
|
$students = $this->currentService->studentsByGrade($gradeId);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => IncidentStudentOptionResource::collection($students),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(IncidentStoreRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->currentService->addIncident($payload, (int) (auth()->id() ?? 0));
|
||||||
|
|
||||||
|
if (empty($result['ok'])) {
|
||||||
|
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to add incident.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'created' => $result['created'],
|
||||||
|
'incident_id' => $result['incident_id'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateState(IncidentStateRequest $request, int $incidentId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$updated = $this->currentService->updateState($incidentId, (string) $payload['incident_state']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => $updated,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(IncidentCloseRequest $request, int $incidentId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->currentService->closeIncident(
|
||||||
|
$incidentId,
|
||||||
|
(string) $payload['state_description'],
|
||||||
|
$payload['action_taken'] ?? null,
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (empty($result['ok'])) {
|
||||||
|
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to close incident.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'incident_id' => $result['incident_id'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(IncidentCancelRequest $request, int $incidentId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->currentService->cancelIncident(
|
||||||
|
$incidentId,
|
||||||
|
$payload['state_description'] ?? null,
|
||||||
|
$payload['action_taken'] ?? null,
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (empty($result['ok'])) {
|
||||||
|
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to cancel incident.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'incident_id' => $result['incident_id'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Reports;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Files\FileNameRequest;
|
||||||
|
use App\Http\Resources\Files\FileMetaResource;
|
||||||
|
use App\Services\Files\ExamDraftDownloadNameService;
|
||||||
|
use App\Services\Files\FileServeService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class FilesController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private FileServeService $fileService,
|
||||||
|
private ExamDraftDownloadNameService $draftNameService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function receipt(FileNameRequest $request, string $name): Response|JsonResponse
|
||||||
|
{
|
||||||
|
return $this->serveFile(
|
||||||
|
$request,
|
||||||
|
storage_path('uploads/receipts'),
|
||||||
|
$name,
|
||||||
|
['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'],
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reimb(FileNameRequest $request, string $name): Response|JsonResponse
|
||||||
|
{
|
||||||
|
return $this->serveFile(
|
||||||
|
$request,
|
||||||
|
storage_path('uploads/reimbursements'),
|
||||||
|
$name,
|
||||||
|
['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'],
|
||||||
|
null,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function earlyDismissalSignature(FileNameRequest $request, string $name): Response|JsonResponse
|
||||||
|
{
|
||||||
|
return $this->serveFile(
|
||||||
|
$request,
|
||||||
|
storage_path('uploads/early_dismissal_signatures'),
|
||||||
|
$name,
|
||||||
|
['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'],
|
||||||
|
null,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function examDraftTeacher(FileNameRequest $request, string $name): Response|JsonResponse
|
||||||
|
{
|
||||||
|
$downloadName = $this->draftNameService->build($name, 'drafts');
|
||||||
|
|
||||||
|
return $this->serveFile(
|
||||||
|
$request,
|
||||||
|
storage_path('uploads/exams/drafts'),
|
||||||
|
$name,
|
||||||
|
['doc', 'docx', 'pdf'],
|
||||||
|
$downloadName,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function examDraftFinal(FileNameRequest $request, string $name): Response|JsonResponse
|
||||||
|
{
|
||||||
|
$downloadName = $this->draftNameService->build($name, 'finals');
|
||||||
|
|
||||||
|
return $this->serveFile(
|
||||||
|
$request,
|
||||||
|
storage_path('uploads/exams/finals'),
|
||||||
|
$name,
|
||||||
|
['doc', 'docx', 'pdf'],
|
||||||
|
$downloadName,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serveFile(
|
||||||
|
FileNameRequest $request,
|
||||||
|
string $baseDir,
|
||||||
|
string $name,
|
||||||
|
array $allowedExtensions,
|
||||||
|
?string $downloadName,
|
||||||
|
bool $nosniff
|
||||||
|
): Response|JsonResponse {
|
||||||
|
if ($request->boolean('meta')) {
|
||||||
|
$meta = $this->fileService->meta($baseDir, $name, $allowedExtensions, $downloadName);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'file' => new FileMetaResource($meta),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fileService->serveInline($baseDir, $name, $allowedExtensions, $request, $downloadName, $nosniff);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Scores;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Scores\ScoreClassRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreUpdateRequest;
|
||||||
|
use App\Http\Resources\Scores\ScoreStudentResource;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Services\Scores\ExamScoreService;
|
||||||
|
use App\Services\Scores\SemesterScoreService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class FinalController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ExamScoreService $service,
|
||||||
|
private SemesterScoreService $semesterScoreService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(ScoreClassRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->service->list('final_exam', (int) $payload['class_section_id'], $payload['semester'] ?? null, $payload['school_year'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => ScoreStudentResource::collection($data['students']),
|
||||||
|
'semester' => $data['semester'],
|
||||||
|
'school_year' => $data['schoolYear'],
|
||||||
|
'scores_locked' => $data['scoresLocked'],
|
||||||
|
'missing_ok_map' => $data['missingOkMap'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ScoreUpdateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$count = $this->service->update(
|
||||||
|
'final_exam',
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['scores'],
|
||||||
|
$payload['missing_ok'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'updated' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void
|
||||||
|
{
|
||||||
|
$studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0));
|
||||||
|
if (empty($studentInfo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Scores;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Scores\ScoreAddColumnRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreClassRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreUpdateRequest;
|
||||||
|
use App\Http\Resources\Scores\ScoreStudentResource;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Services\Scores\HomeworkScoreService;
|
||||||
|
use App\Services\Scores\SemesterScoreService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class HomeworkController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private HomeworkScoreService $service,
|
||||||
|
private SemesterScoreService $semesterScoreService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(ScoreClassRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->service->list(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
$payload['semester'] ?? null,
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => ScoreStudentResource::collection($data['students']),
|
||||||
|
'headers' => $data['headers'],
|
||||||
|
'semester' => $data['semester'],
|
||||||
|
'school_year' => $data['schoolYear'],
|
||||||
|
'scores_locked' => $data['scoresLocked'],
|
||||||
|
'missing_ok_map' => $data['missingOkMap'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ScoreUpdateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$count = $this->service->update(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['scores'],
|
||||||
|
$payload['missing_ok'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'updated' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addColumn(ScoreAddColumnRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$nextIndex = $this->service->addColumn(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'homework_index' => $nextIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void
|
||||||
|
{
|
||||||
|
$studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0));
|
||||||
|
if (empty($studentInfo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Scores;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Scores\ScoreClassRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreUpdateRequest;
|
||||||
|
use App\Http\Resources\Scores\ScoreStudentResource;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Services\Scores\ExamScoreService;
|
||||||
|
use App\Services\Scores\SemesterScoreService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class MidtermController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ExamScoreService $service,
|
||||||
|
private SemesterScoreService $semesterScoreService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(ScoreClassRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->service->list('midterm_exam', (int) $payload['class_section_id'], $payload['semester'] ?? null, $payload['school_year'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => ScoreStudentResource::collection($data['students']),
|
||||||
|
'semester' => $data['semester'],
|
||||||
|
'school_year' => $data['schoolYear'],
|
||||||
|
'scores_locked' => $data['scoresLocked'],
|
||||||
|
'missing_ok_map' => $data['missingOkMap'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ScoreUpdateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$count = $this->service->update(
|
||||||
|
'midterm_exam',
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['scores'],
|
||||||
|
$payload['missing_ok'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'updated' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void
|
||||||
|
{
|
||||||
|
$studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0));
|
||||||
|
if (empty($studentInfo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Scores;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Scores\ScoreClassRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreUpdateRequest;
|
||||||
|
use App\Http\Resources\Scores\ScoreStudentResource;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Services\Scores\ParticipationScoreService;
|
||||||
|
use App\Services\Scores\SemesterScoreService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ParticipationController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ParticipationScoreService $service,
|
||||||
|
private SemesterScoreService $semesterScoreService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(ScoreClassRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->service->list(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
$payload['semester'] ?? null,
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => ScoreStudentResource::collection($data['students']),
|
||||||
|
'semester' => $data['semester'],
|
||||||
|
'school_year' => $data['schoolYear'],
|
||||||
|
'scores_locked' => $data['scoresLocked'],
|
||||||
|
'missing_ok_map' => $data['missingOkMap'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ScoreUpdateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$count = $this->service->update(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['scores'],
|
||||||
|
$payload['missing_ok'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'updated' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void
|
||||||
|
{
|
||||||
|
$studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0));
|
||||||
|
if (empty($studentInfo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Scores;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Scores\ScoreAddColumnRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreClassRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreUpdateRequest;
|
||||||
|
use App\Http\Resources\Scores\ScoreStudentResource;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Services\Scores\ProjectScoreService;
|
||||||
|
use App\Services\Scores\SemesterScoreService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ProjectController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ProjectScoreService $service,
|
||||||
|
private SemesterScoreService $semesterScoreService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(ScoreClassRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->service->list(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
$payload['semester'] ?? null,
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => ScoreStudentResource::collection($data['students']),
|
||||||
|
'headers' => $data['headers'],
|
||||||
|
'semester' => $data['semester'],
|
||||||
|
'school_year' => $data['schoolYear'],
|
||||||
|
'scores_locked' => $data['scoresLocked'],
|
||||||
|
'missing_ok_map' => $data['missingOkMap'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ScoreUpdateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$count = $this->service->update(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['scores'],
|
||||||
|
$payload['missing_ok'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'updated' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addColumn(ScoreAddColumnRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$nextIndex = $this->service->addColumn(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'project_index' => $nextIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void
|
||||||
|
{
|
||||||
|
$studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0));
|
||||||
|
if (empty($studentInfo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// swallow errors to keep API response stable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Scores;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Scores\ScoreAddColumnRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreClassRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreUpdateRequest;
|
||||||
|
use App\Http\Resources\Scores\ScoreStudentResource;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Services\Scores\QuizScoreService;
|
||||||
|
use App\Services\Scores\SemesterScoreService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class QuizController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private QuizScoreService $service,
|
||||||
|
private SemesterScoreService $semesterScoreService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(ScoreClassRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->service->list(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
$payload['semester'] ?? null,
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => ScoreStudentResource::collection($data['students']),
|
||||||
|
'headers' => $data['headers'],
|
||||||
|
'semester' => $data['semester'],
|
||||||
|
'school_year' => $data['schoolYear'],
|
||||||
|
'scores_locked' => $data['scoresLocked'],
|
||||||
|
'missing_ok_map' => $data['missingOkMap'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ScoreUpdateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$count = $this->service->update(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['scores'],
|
||||||
|
$payload['missing_ok'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'updated' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addColumn(ScoreAddColumnRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$nextIndex = $this->service->addColumn(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'quiz_index' => $nextIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void
|
||||||
|
{
|
||||||
|
$studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0));
|
||||||
|
if (empty($studentInfo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Scores;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Scores\ScoreCommentListRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreCommentSaveRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreCommentUpdateRequest;
|
||||||
|
use App\Http\Resources\Scores\ScoreCommentResource;
|
||||||
|
use App\Services\Scores\ScoreCommentService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ScoreCommentController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private ScoreCommentService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(ScoreCommentListRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->service->list(
|
||||||
|
$payload['class_section_id'] ?? null,
|
||||||
|
$payload['semester'] ?? null,
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
$studentRows = [];
|
||||||
|
foreach ($data['students'] as $student) {
|
||||||
|
$studentId = (int) ($student['student_id'] ?? 0);
|
||||||
|
$studentRows[] = [
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'firstname' => $student['firstname'] ?? null,
|
||||||
|
'lastname' => $student['lastname'] ?? null,
|
||||||
|
'school_id' => $student['school_id'] ?? null,
|
||||||
|
'comments' => $data['comments'][$studentId] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => ScoreCommentResource::collection($studentRows),
|
||||||
|
'semester' => $data['semester'],
|
||||||
|
'school_year' => $data['schoolYear'],
|
||||||
|
'class_section_id' => $data['classSectionId'],
|
||||||
|
'scores_locked' => $data['scoresLocked'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ScoreCommentSaveRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->service->save(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['comments'],
|
||||||
|
$payload['missing_ok'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!empty($result['errors'])) {
|
||||||
|
return response()->json(['ok' => false, 'errors' => $result['errors']], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ScoreCommentUpdateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->service->update(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['comments'] ?? [],
|
||||||
|
$payload['reviews'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!empty($result['errors'])) {
|
||||||
|
return response()->json(['ok' => false, 'errors' => $result['errors']], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Scores;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Scores\ScoreLockRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreOverviewRequest;
|
||||||
|
use App\Http\Requests\Scores\ScoreStudentRequest;
|
||||||
|
use App\Http\Resources\Scores\ScoreStudentResource;
|
||||||
|
use App\Services\Scores\ScoreDashboardService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ScoreController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private ScoreDashboardService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function overview(ScoreOverviewRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$teacherId = (int) (auth()->id() ?? 0);
|
||||||
|
|
||||||
|
$data = $this->service->overview(
|
||||||
|
$teacherId,
|
||||||
|
isset($payload['class_section_id']) ? (int) $payload['class_section_id'] : null,
|
||||||
|
$payload['semester'] ?? null,
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => ScoreStudentResource::collection($data['students'] ?? []),
|
||||||
|
'class_section_id' => $data['class_section_id'] ?? null,
|
||||||
|
'class_section_name' => $data['class_section_name'] ?? null,
|
||||||
|
'semester' => $data['semester'] ?? null,
|
||||||
|
'school_year' => $data['school_year'] ?? null,
|
||||||
|
'scores_locked' => $data['scoresLocked'] ?? false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lock(ScoreLockRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->service->submitScoresLock(
|
||||||
|
(int) $payload['class_section_id'],
|
||||||
|
(string) $payload['semester'],
|
||||||
|
(string) $payload['school_year'],
|
||||||
|
$payload['missing_ok'] ?? [],
|
||||||
|
(int) (auth()->id() ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function studentScores(ScoreStudentRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$parentId = (int) (auth()->id() ?? 0);
|
||||||
|
|
||||||
|
$data = $this->service->viewStudentScores($parentId, (string) $payload['school_year']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true] + $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Scores;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Scores\ScorePredictorRequest;
|
||||||
|
use App\Http\Resources\Scores\ScorePredictorStudentResource;
|
||||||
|
use App\Services\Scores\ScorePredictorService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ScorePredictorController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private ScorePredictorService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(ScorePredictorRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$data = $this->service->report(
|
||||||
|
$payload['school_year'] ?? null,
|
||||||
|
isset($payload['class_section_id']) ? (int) $payload['class_section_id'] : null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => ScorePredictorStudentResource::collection($data['students'] ?? []),
|
||||||
|
'school_year' => $data['school_year'] ?? null,
|
||||||
|
'class_sections' => $data['class_sections'] ?? [],
|
||||||
|
'selected_class_section_id' => $data['selected_class_section_id'] ?? null,
|
||||||
|
'semester' => $data['semester'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\System;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\BaseApiController;
|
||||||
|
use App\Http\Requests\Semesters\SchoolYearRangeRequest;
|
||||||
|
use App\Http\Requests\Semesters\SemesterNormalizeRequest;
|
||||||
|
use App\Http\Requests\Semesters\SemesterRangeRequest;
|
||||||
|
use App\Http\Requests\Semesters\SemesterResolveRequest;
|
||||||
|
use App\Http\Resources\Semesters\SemesterRangeResource;
|
||||||
|
use App\Http\Resources\Semesters\SemesterResolveResource;
|
||||||
|
use App\Services\SemesterRangeService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class SemesterRangeController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private SemesterRangeService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function schoolYearRange(SchoolYearRangeRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
[$start, $end] = $this->service->getSchoolYearRange($payload['school_year']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'school_year' => $payload['school_year'],
|
||||||
|
'range' => new SemesterRangeResource([
|
||||||
|
'start_date' => $start,
|
||||||
|
'end_date' => $end,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function semesterRange(SemesterRangeRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$range = $this->service->getSemesterRange($payload['school_year'], $payload['semester']);
|
||||||
|
|
||||||
|
if ($range === null) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Invalid semester range.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'school_year' => $payload['school_year'],
|
||||||
|
'semester' => $this->service->normalizeSemester($payload['semester']),
|
||||||
|
'range' => new SemesterRangeResource([
|
||||||
|
'start_date' => $range[0],
|
||||||
|
'end_date' => $range[1],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalize(SemesterNormalizeRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'normalized' => $this->service->normalizeSemester($payload['semester']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve(SemesterResolveRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$semester = $this->service->getSemesterForDate($payload['date'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'resolution' => new SemesterResolveResource([
|
||||||
|
'date' => $payload['date'] ?? null,
|
||||||
|
'semester' => $semester,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Requests\Users\LoginActivityRequest;
|
||||||
|
use App\Http\Requests\Users\UserListRequest;
|
||||||
|
use App\Http\Requests\Users\UserStoreRequest;
|
||||||
|
use App\Http\Requests\Users\UserUpdateRequest;
|
||||||
|
use App\Http\Resources\Users\LoginActivityResource;
|
||||||
|
use App\Http\Resources\Users\UserWithRolesResource;
|
||||||
|
use App\Services\Users\LoginActivityService;
|
||||||
|
use App\Services\Users\UserListService;
|
||||||
|
use App\Services\Users\UserManagementService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class UserController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UserListService $listService,
|
||||||
|
private UserManagementService $managementService,
|
||||||
|
private LoginActivityService $loginActivityService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(UserListRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$users = $this->listService->list($payload['sort'] ?? null, $payload['order'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'users' => UserWithRolesResource::collection($users),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(UserStoreRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->managementService->create($payload, (int) (auth()->id() ?? 0));
|
||||||
|
|
||||||
|
if (empty($result['ok'])) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $result['message'] ?? 'Failed to create user.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'user_id' => $result['user']->id ?? null,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UserUpdateRequest $request, int $userId): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->managementService->update($userId, $payload);
|
||||||
|
|
||||||
|
if (empty($result['ok'])) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $result['message'] ?? 'Failed to update user.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(int $userId): JsonResponse
|
||||||
|
{
|
||||||
|
$result = $this->managementService->delete($userId);
|
||||||
|
|
||||||
|
if (empty($result['ok'])) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $result['message'] ?? 'Failed to delete user.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loginActivity(LoginActivityRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->loginActivityService->list(
|
||||||
|
(int) ($payload['per_page'] ?? 25),
|
||||||
|
(int) ($payload['page'] ?? 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'activities' => LoginActivityResource::collection($result['activities']),
|
||||||
|
'pagination' => $result['pagination'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use CodeIgniter\HTTP\CURLRequest;
|
||||||
|
use Config\Api as ApiConfig;
|
||||||
|
|
||||||
|
class ApiClient
|
||||||
|
{
|
||||||
|
protected CURLRequest $http;
|
||||||
|
protected ApiConfig $config;
|
||||||
|
|
||||||
|
public function __construct(CURLRequest $http, ApiConfig $config)
|
||||||
|
{
|
||||||
|
$this->http = $http;
|
||||||
|
$this->config = $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $uri, array $query = [], array $headers = []): array
|
||||||
|
{
|
||||||
|
return $this->request('get', $uri, [
|
||||||
|
'headers' => $this->mergeHeaders($headers),
|
||||||
|
'query' => $query,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $uri, array $data = [], array $headers = []): array
|
||||||
|
{
|
||||||
|
return $this->request('post', $uri, [
|
||||||
|
'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
|
||||||
|
'json' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $uri, array $data = [], array $headers = []): array
|
||||||
|
{
|
||||||
|
return $this->request('put', $uri, [
|
||||||
|
'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
|
||||||
|
'json' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $uri, array $data = [], array $headers = []): array
|
||||||
|
{
|
||||||
|
return $this->request('delete', $uri, [
|
||||||
|
'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
|
||||||
|
'json' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level access for uncommon HTTP methods or options
|
||||||
|
*/
|
||||||
|
public function request(string $method, string $uri, array $options = []): array
|
||||||
|
{
|
||||||
|
$url = $this->fullUrl($uri);
|
||||||
|
$options['timeout'] = $options['timeout'] ?? $this->config->timeout;
|
||||||
|
|
||||||
|
$response = $this->http->request(strtoupper($method), $url, $options);
|
||||||
|
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
$body = (string) $response->getBody();
|
||||||
|
|
||||||
|
$decoded = null;
|
||||||
|
if ($this->isJson($body)) {
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => $status >= 200 && $status < 300,
|
||||||
|
'status' => $status,
|
||||||
|
'headers' => $response->getHeaderLine('Content-Type'),
|
||||||
|
'data' => $decoded,
|
||||||
|
'raw' => $decoded === null ? $body : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function fullUrl(string $uri): string
|
||||||
|
{
|
||||||
|
if (preg_match('#^https?://#i', $uri)) {
|
||||||
|
return $uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = rtrim($this->config->baseURL ?? '', '/');
|
||||||
|
if ($base === '') {
|
||||||
|
return '/' . ltrim($uri, '/');
|
||||||
|
}
|
||||||
|
return $base . '/' . ltrim($uri, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mergeHeaders(array $override = [], array $extra = []): array
|
||||||
|
{
|
||||||
|
$base = $this->config->defaultHeaders ?? [];
|
||||||
|
return array_filter(array_merge($base, $extra, $override), static function ($v) {
|
||||||
|
return $v !== null && $v !== '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isJson(string $string): bool
|
||||||
|
{
|
||||||
|
json_decode($string);
|
||||||
|
return json_last_error() === JSON_ERROR_NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use App\Services\EmailService;
|
|
||||||
|
|
||||||
class BroadcastEmailController extends BaseController
|
|
||||||
{
|
|
||||||
protected UserModel $userModel;
|
|
||||||
protected EmailService $mailer;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->mailer = new EmailService(); // use your existing mailer unmodified
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
helper(['form']);
|
|
||||||
|
|
||||||
// Parents via your role-based function
|
|
||||||
$parents = $this->userModel->getParents();
|
|
||||||
$parents = array_values(array_filter($parents, static fn($p) => !empty($p['email'])));
|
|
||||||
|
|
||||||
// Sender list from MAIL_SENDERS directly (no change to EmailService)
|
|
||||||
$fromOptions = $this->senderOptionsFromEnv();
|
|
||||||
|
|
||||||
return view('administrator/broadcast_email', [
|
|
||||||
'parents' => $parents,
|
|
||||||
'fromOptions' => $fromOptions, // [['key'=>'general','label'=>'Al Rahma Office <office@...>'], ...]
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function send()
|
|
||||||
{
|
|
||||||
helper(['form']);
|
|
||||||
|
|
||||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
|
||||||
return redirect()->to(site_url('admin/broadcast-email'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$isTestOnly = $this->request->getPost('send_test_only') !== null;
|
|
||||||
|
|
||||||
$mode = (string) $this->request->getPost('mode'); // 'personalized' | 'standard'
|
|
||||||
$subject = trim((string) $this->request->getPost('subject'));
|
|
||||||
$fromKey = trim((string) $this->request->getPost('from_key') ?: 'general');
|
|
||||||
$body = (string) $this->request->getPost('body_html');
|
|
||||||
$body = $this->sanitizeEmailHtml($body);
|
|
||||||
|
|
||||||
|
|
||||||
// Layout options
|
|
||||||
$wrapLayout = (bool) $this->request->getPost('wrap_layout');
|
|
||||||
$preheader = (string) ($this->request->getPost('preheader') ?? '');
|
|
||||||
$ctaText = (string) ($this->request->getPost('cta_text') ?? '');
|
|
||||||
$ctaUrl = (string) ($this->request->getPost('cta_url') ?? '');
|
|
||||||
|
|
||||||
$testEmail = trim((string) $this->request->getPost('test_email'));
|
|
||||||
|
|
||||||
if ($subject === '' || $body === '') {
|
|
||||||
return redirect()->back()->withInput()->with('error', 'Subject and Body are required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$isPersonalized = ($mode === 'personalized');
|
|
||||||
|
|
||||||
// --- TEST ONLY ---
|
|
||||||
if ($isTestOnly) {
|
|
||||||
if ($testEmail === '') {
|
|
||||||
return redirect()->back()->withInput()->with('error', 'Provide a test email address.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$recipientName = 'Parent';
|
|
||||||
$html = $this->composeEmailHtml(
|
|
||||||
$wrapLayout,
|
|
||||||
$subject,
|
|
||||||
$body,
|
|
||||||
$recipientName,
|
|
||||||
$preheader,
|
|
||||||
$ctaText,
|
|
||||||
$ctaUrl,
|
|
||||||
$isPersonalized
|
|
||||||
);
|
|
||||||
|
|
||||||
$ok = $this->mailer->send($testEmail, '[TEST] ' . $subject, $html, $fromKey);
|
|
||||||
return redirect()->back()->with(
|
|
||||||
$ok ? 'message' : 'error',
|
|
||||||
$ok ? "Test email sent to {$testEmail}." : "Test email failed (mailer->send() returned false). Check logs."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- BROADCAST ---
|
|
||||||
$rawIds = (array) ($this->request->getPost('parent_ids') ?? []);
|
|
||||||
$ids = [];
|
|
||||||
foreach ($rawIds as $v) {
|
|
||||||
if (is_string($v) && strpos($v, ',') !== false) {
|
|
||||||
$ids = array_merge($ids, array_map('intval', explode(',', $v)));
|
|
||||||
} else {
|
|
||||||
$ids[] = (int) $v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$ids = array_values(array_unique(array_filter($ids)));
|
|
||||||
|
|
||||||
if (empty($ids)) {
|
|
||||||
return redirect()->back()->withInput()->with('error', 'Please select at least one parent.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = model(\App\Models\UserModel::class)
|
|
||||||
->select('users.id, users.email, CONCAT(users.firstname, " ", users.lastname) AS name')
|
|
||||||
->whereIn('users.id', $ids)
|
|
||||||
->where('users.email IS NOT NULL AND users.email != ""')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
if (empty($rows)) {
|
|
||||||
return redirect()->back()->withInput()->with('error', 'No valid parent emails found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stats = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'mode' => $mode];
|
|
||||||
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$stats['attempted']++;
|
|
||||||
$recipientName = $r['name'] ?: 'Parent';
|
|
||||||
|
|
||||||
$html = $this->composeEmailHtml(
|
|
||||||
$wrapLayout,
|
|
||||||
$subject,
|
|
||||||
$body,
|
|
||||||
$recipientName,
|
|
||||||
$preheader,
|
|
||||||
$ctaText,
|
|
||||||
$ctaUrl,
|
|
||||||
$isPersonalized
|
|
||||||
);
|
|
||||||
|
|
||||||
$ok = $this->mailer->send($r['email'], $subject, $html, $fromKey);
|
|
||||||
$ok ? $stats['sent']++ : $stats['failed']++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$msg = "Broadcast finished. Mode: {$stats['mode']}. Sent: {$stats['sent']}/{$stats['attempted']}. Failures: {$stats['failed']}.";
|
|
||||||
return redirect()->to(site_url('admin/broadcast-email'))
|
|
||||||
->with($stats['failed'] > 0 ? 'error' : 'message', $msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build HTML: optionally wrap $body inside your view('layout/email_layout', ...).
|
|
||||||
*/
|
|
||||||
private function composeEmailHtml(
|
|
||||||
bool $wrap,
|
|
||||||
string $subject,
|
|
||||||
string $body,
|
|
||||||
string $recipientName,
|
|
||||||
string $preheader = '',
|
|
||||||
string $ctaText = '',
|
|
||||||
string $ctaUrl = '',
|
|
||||||
bool $doPersonalize = true
|
|
||||||
): string {
|
|
||||||
$content = $doPersonalize ? str_replace('{{name}}', $recipientName, $body) : $body;
|
|
||||||
|
|
||||||
if (!$wrap) {
|
|
||||||
return $content; // raw body
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a CHILD view that defines the section your layout expects
|
|
||||||
return view('emails/broadcast_wrapper', [
|
|
||||||
'subject' => $subject,
|
|
||||||
'content' => $content,
|
|
||||||
// pass more if you later wire them in your layout
|
|
||||||
'preheader' => $preheader,
|
|
||||||
'cta_text' => $ctaText,
|
|
||||||
'cta_url' => $ctaUrl,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private function sanitizeEmailHtml(string $html): string
|
|
||||||
{
|
|
||||||
// Remove <script> and <iframe> blocks (cheap and effective for admin inputs)
|
|
||||||
$html = preg_replace('#<(script|iframe)[^>]*>.*?</\1>#is', '', $html);
|
|
||||||
// Optionally strip on* event handlers (onclick, etc.)
|
|
||||||
$html = preg_replace('/\son\w+="[^"]*"/i', '', $html);
|
|
||||||
$html = preg_replace("/\son\w+='[^']*'/i", '', $html);
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read MAIL_SENDERS from .env so we don’t need changes in EmailService.
|
|
||||||
* Returns [['key' => 'general', 'label' => 'Al Rahma Office <office@...>'], ...]
|
|
||||||
*/
|
|
||||||
private function senderOptionsFromEnv(): array
|
|
||||||
{
|
|
||||||
$json = env('MAIL_SENDERS', '{}');
|
|
||||||
$arr = json_decode($json, true);
|
|
||||||
if (!is_array($arr) || empty($arr)) {
|
|
||||||
// Fallback to SMTP_USER as a single "general" option
|
|
||||||
$smtpUser = getenv('SMTP_USER') ?: '';
|
|
||||||
$name = 'Al Rahma Sunday School';
|
|
||||||
return [['key' => 'general', 'label' => $name . ($smtpUser ? " <{$smtpUser}>" : '')]];
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
foreach ($arr as $key => $info) {
|
|
||||||
$nm = $info['name'] ?? 'Sender';
|
|
||||||
$em = $info['email'] ?? '';
|
|
||||||
$out[] = ['key' => (string)$key, 'label' => trim($nm . ($em ? " <{$em}>" : ''))];
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function uploadImage()
|
|
||||||
{
|
|
||||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
|
||||||
return $this->response->setStatusCode(405)->setJSON(['success' => false, 'error' => 'Method not allowed']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $this->request->getFile('image');
|
|
||||||
if (!$file || !$file->isValid()) {
|
|
||||||
return $this->response->setStatusCode(400)->setJSON(['success' => false, 'error' => 'No image uploaded']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$mime = strtolower((string) $file->getMimeType());
|
|
||||||
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
|
|
||||||
if (!isset($allowed[$mime])) {
|
|
||||||
return $this->response->setStatusCode(415)->setJSON(['success' => false, 'error' => 'Unsupported image type']);
|
|
||||||
}
|
|
||||||
if ($file->getSize() > 5 * 1024 * 1024) {
|
|
||||||
return $this->response->setStatusCode(413)->setJSON(['success' => false, 'error' => 'Image too large (max 5MB)']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$targetDir = rtrim(FCPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'email';
|
|
||||||
if (!is_dir($targetDir)) {
|
|
||||||
@mkdir($targetDir, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$ext = $allowed[$mime];
|
|
||||||
$newName = uniqid('em_', true) . '.' . $ext;
|
|
||||||
$file->move($targetDir, $newName, true);
|
|
||||||
|
|
||||||
helper('url');
|
|
||||||
$url = base_url('uploads/email/' . $newName);
|
|
||||||
|
|
||||||
// 👈 send the rotated token back both in JSON and a response header
|
|
||||||
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
|
|
||||||
return $this->response
|
|
||||||
->setHeader('X-CSRF-HASH', (string) $newHash)
|
|
||||||
->setJSON([
|
|
||||||
'success' => true,
|
|
||||||
'url' => $url,
|
|
||||||
'csrf_hash' => $newHash,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
class ClassPrepController extends PrintablesBaseController
|
|
||||||
{
|
|
||||||
// Compute per-student sticker counts for entire schoolYear (optionally semester).
|
|
||||||
protected function computeStickerCountsAll(\CodeIgniter\Database\BaseConnection $db, string $schoolYear, ?string $semester = null): array
|
|
||||||
{
|
|
||||||
return $this->computeStickerCounts($schoolYear, null, $semester);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute per-student sticker counts for one class section.
|
|
||||||
protected function computeStickerCountsForClass(\CodeIgniter\Database\BaseConnection $db, string $schoolYear, int $classSectionId, ?string $semester = null): array
|
|
||||||
{
|
|
||||||
return $this->computeStickerCounts($schoolYear, $classSectionId, $semester);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function computeStickerCounts(
|
|
||||||
string $schoolYear,
|
|
||||||
?int $classSectionId,
|
|
||||||
?string $semester
|
|
||||||
): array {
|
|
||||||
$specialMap = ['KG' => 1, '1-A' => 3, '1-B' => 4, '2-A' => 5, '2-B' => 5, '9' => 2];
|
|
||||||
$isUpperBlock = static fn(string $g) => (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
|
|
||||||
$isGrade5 = static fn(string $g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
|
||||||
|
|
||||||
$b = $this->db->table('student_class sc')
|
|
||||||
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
|
|
||||||
->join('students s', 's.id = sc.student_id', 'inner')
|
|
||||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
|
|
||||||
->join('classes c', 'c.id = cs.class_id', 'inner')
|
|
||||||
->where('sc.school_year', $schoolYear);
|
|
||||||
|
|
||||||
if (!empty($classSectionId)) {
|
|
||||||
$b->where('sc.class_section_id', $classSectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $b->get()->getResultArray();
|
|
||||||
|
|
||||||
// Exclude Youth and KG (case-insensitive; also ignore variants like youth-*, KG-*)
|
|
||||||
$rows = array_values(array_filter($rows, static function ($r) {
|
|
||||||
$g = trim((string) $r['grade_label']);
|
|
||||||
return $g !== ''
|
|
||||||
&& !preg_match('/^youth(?:\b|-)/i', $g)
|
|
||||||
&& !preg_match('/^kg(?:\b|-)/i', $g);
|
|
||||||
}));
|
|
||||||
|
|
||||||
$students = [];
|
|
||||||
$total = 0;
|
|
||||||
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$g = trim((string) $r['grade_label']);
|
|
||||||
$isNew = ((int) $r['is_new']) === 1;
|
|
||||||
|
|
||||||
// Primary-only rules
|
|
||||||
if (array_key_exists($g, $specialMap)) {
|
|
||||||
$p = $specialMap[$g];
|
|
||||||
} elseif ($isUpperBlock($g)) {
|
|
||||||
$p = $isNew ? 4 : ($isGrade5($g) ? 3 : 2);
|
|
||||||
} else {
|
|
||||||
$p = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
$total += $p;
|
|
||||||
|
|
||||||
$students[] = [
|
|
||||||
'student_id' => (int) $r['student_id'],
|
|
||||||
'firstname' => trim($r['firstname']),
|
|
||||||
'lastname' => trim($r['lastname']),
|
|
||||||
'grade_label' => $g,
|
|
||||||
'primary_count' => $p,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'students' => $students,
|
|
||||||
'totals' => [
|
|
||||||
'stickers' => $total,
|
|
||||||
'students' => count($students),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute sticker counts per student (no file I/O).
|
|
||||||
*
|
|
||||||
* @param BaseConnection $db
|
|
||||||
* @param string $schoolYear e.g. "2025-2026"
|
|
||||||
* @param string|null $semester e.g. "Fall" (optional filter)
|
|
||||||
* @return array{
|
|
||||||
* students: array<int, array{
|
|
||||||
* student_id:int,
|
|
||||||
* firstname:string,
|
|
||||||
* lastname:string,
|
|
||||||
* grade_label:string,
|
|
||||||
* primary_count:int,
|
|
||||||
* secondary_count:int
|
|
||||||
* }>,
|
|
||||||
* totals: array{primary:int, secondary:int, students:int}
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function getStickerCountsPerStudent(string $schoolYear, ?string $semester = null): array
|
|
||||||
{
|
|
||||||
// --- Sticker rules helpers ----------------------------------------------
|
|
||||||
$specialMap = [
|
|
||||||
'KG' => 1,
|
|
||||||
'1-A' => 3,
|
|
||||||
'1-B' => 4,
|
|
||||||
'2-A' => 5,
|
|
||||||
'2-B' => 5,
|
|
||||||
'9' => 2,
|
|
||||||
];
|
|
||||||
$isUpperBlock = static function (string $g): bool {
|
|
||||||
// 3-A, 3-B, 4-A, 4-B, 5-A, 5-B, 6, 7, 8 (also allow plain 3/4/5/6/7/8)
|
|
||||||
return (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
|
|
||||||
};
|
|
||||||
$isGrade5 = static function (string $g): bool {
|
|
||||||
// "5" OR "5-A"/"5-B"
|
|
||||||
return (bool) preg_match('/^5(\-|$)/', trim($g));
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Pull roster: student_class -> classSection -> classes -> students ---
|
|
||||||
$builder = $this->db->table('student_class sc')
|
|
||||||
->select([
|
|
||||||
'sc.student_id',
|
|
||||||
's.firstname',
|
|
||||||
's.lastname',
|
|
||||||
's.is_new',
|
|
||||||
'cs.class_section_name AS grade_label',
|
|
||||||
])
|
|
||||||
->join('students s', 's.id = sc.student_id')
|
|
||||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
|
||||||
->join('classes c', 'c.id = cs.class_id')
|
|
||||||
->where('sc.school_year', $schoolYear);
|
|
||||||
|
|
||||||
|
|
||||||
$rows = $builder->get()->getResultArray();
|
|
||||||
|
|
||||||
// Filter out Youth (any case; includes youth-*), and empty labels
|
|
||||||
$rows = array_values(array_filter($rows, static function ($r) {
|
|
||||||
$g = trim((string) $r['grade_label']);
|
|
||||||
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
|
|
||||||
}));
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
$totalPrimary = 0;
|
|
||||||
$totalSecondary = 0;
|
|
||||||
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$first = trim((string) $r['firstname']);
|
|
||||||
$last = trim((string) $r['lastname']);
|
|
||||||
$grade = trim((string) $r['grade_label']);
|
|
||||||
$isNew = ((int) $r['is_new']) === 1;
|
|
||||||
|
|
||||||
$primary = 0;
|
|
||||||
$secondary = 0;
|
|
||||||
|
|
||||||
// 1) Special fixed grades
|
|
||||||
if (array_key_exists($grade, $specialMap)) {
|
|
||||||
$primary = (int) $specialMap[$grade];
|
|
||||||
$secondary = 0;
|
|
||||||
}
|
|
||||||
// 2) Upper block 3..8
|
|
||||||
elseif ($isUpperBlock($grade)) {
|
|
||||||
if ($isNew) {
|
|
||||||
$primary = 4;
|
|
||||||
$secondary = 0;
|
|
||||||
} else {
|
|
||||||
$primary = $isGrade5($grade) ? 3 : 2;
|
|
||||||
$secondary = max(0, 4 - $primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 3) Fallback for other non-Youth labels (e.g., "1", "2", "10", "11")
|
|
||||||
else {
|
|
||||||
$primary = 4;
|
|
||||||
$secondary = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalPrimary += $primary;
|
|
||||||
$totalSecondary += $secondary;
|
|
||||||
|
|
||||||
$out[] = [
|
|
||||||
'student_id' => (int) $r['student_id'],
|
|
||||||
'firstname' => $first,
|
|
||||||
'lastname' => $last,
|
|
||||||
'grade_label' => $grade,
|
|
||||||
'primary_count' => $primary,
|
|
||||||
'secondary_count' => $secondary,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'students' => $out,
|
|
||||||
'totals' => [
|
|
||||||
'primary' => $totalPrimary,
|
|
||||||
'secondary' => $totalSecondary,
|
|
||||||
'students' => count($out),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getStickerCountsPerStudentForClass(
|
|
||||||
string $schoolYear,
|
|
||||||
int $classSectionId,
|
|
||||||
?string $semester = null
|
|
||||||
): array {
|
|
||||||
// Reuse exact helpers/rules from getStickerCountsPerStudent()
|
|
||||||
$specialMap = [
|
|
||||||
'1-A' => 3,
|
|
||||||
'1-B' => 4,
|
|
||||||
'2-A' => 5,
|
|
||||||
'2-B' => 5,
|
|
||||||
'9' => 2,
|
|
||||||
];
|
|
||||||
$isUpperBlock = static fn($g) => (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
|
|
||||||
$isGrade5 = static fn($g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
|
||||||
|
|
||||||
$b = $this->db->table('student_class sc')
|
|
||||||
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
|
|
||||||
->join('students s', 's.id = sc.student_id')
|
|
||||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
|
||||||
->join('classes c', 'c.id = cs.class_id')
|
|
||||||
->where('sc.school_year', $schoolYear)
|
|
||||||
->where('sc.class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
|
|
||||||
$rows = $b->get()->getResultArray();
|
|
||||||
|
|
||||||
$rows = array_values(array_filter($rows, static function ($r) {
|
|
||||||
$g = trim((string) $r['grade_label']);
|
|
||||||
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
|
|
||||||
}));
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
$tp = 0;
|
|
||||||
$ts = 0;
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$g = trim((string) $r['grade_label']);
|
|
||||||
$isNew = ((int) $r['is_new']) === 1;
|
|
||||||
$p = 0;
|
|
||||||
$s = 0;
|
|
||||||
|
|
||||||
if (isset($specialMap[$g])) {
|
|
||||||
$p = $specialMap[$g];
|
|
||||||
} elseif ($isUpperBlock($g)) {
|
|
||||||
if ($isNew) {
|
|
||||||
$p = 4;
|
|
||||||
} else {
|
|
||||||
$p = $isGrade5($g) ? 3 : 2;
|
|
||||||
$s = max(0, 4 - $p);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$p = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tp += $p;
|
|
||||||
$ts += $s;
|
|
||||||
|
|
||||||
$out[] = [
|
|
||||||
'student_id' => (int) $r['student_id'],
|
|
||||||
'firstname' => trim($r['firstname']),
|
|
||||||
'lastname' => trim($r['lastname']),
|
|
||||||
'grade_label' => $g,
|
|
||||||
'primary_count' => $p,
|
|
||||||
'secondary_count' => $s,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['students' => $out, 'totals' => ['primary' => $tp, 'secondary' => $ts, 'students' => count($out)]];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function previewStickerCounts()
|
|
||||||
{
|
|
||||||
// Optional: allow class_id filter; if present, preview only that class
|
|
||||||
$classId = (int) ($this->request->getGet('class_id') ?? 0);
|
|
||||||
|
|
||||||
if ($classId > 0) {
|
|
||||||
// same join as getStickerCountsPerStudent but add class filter
|
|
||||||
$result = $this->getStickerCountsPerStudentForClass($this->schoolYear, $classId, $this->semester);
|
|
||||||
} else {
|
|
||||||
// whole school year
|
|
||||||
$result = $this->getStickerCountsPerStudent($this->schoolYear, $this->semester);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'status' => 'ok',
|
|
||||||
'data' => $result
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function studentsByClass(int $classSectionId)
|
|
||||||
{
|
|
||||||
$rows = $this->studentModel
|
|
||||||
->select('students.id, students.firstname, students.lastname, students.gender, cs.class_section_name AS registration_grade')
|
|
||||||
->join('student_class sc', 'sc.student_id = students.id', 'inner')
|
|
||||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
|
|
||||||
->where('sc.school_year', $this->schoolYear)
|
|
||||||
->where('sc.class_section_id', $classSectionId)
|
|
||||||
->groupBy('students.id, students.firstname, students.lastname, students.gender, cs.class_section_name')
|
|
||||||
->orderBy('students.firstname', 'ASC')
|
|
||||||
->orderBy('students.lastname', 'ASC')
|
|
||||||
->findAll(1000);
|
|
||||||
|
|
||||||
return $this->response->setJSON($rows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,616 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\StudentClassModel;
|
|
||||||
use App\Models\ClassPreparationLogModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Models\ClassSectionModel;
|
|
||||||
use App\Models\ClassPrepAdjustmentModel;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
|
|
||||||
class ClassPreparationController extends BaseController
|
|
||||||
{
|
|
||||||
protected $studentClassModel;
|
|
||||||
protected $prepLogModel;
|
|
||||||
protected $classSectionModel;
|
|
||||||
protected $configModel;
|
|
||||||
protected $userModel;
|
|
||||||
protected $db;
|
|
||||||
protected $schoolYear;
|
|
||||||
protected $semester;
|
|
||||||
protected $adjustmentModel;
|
|
||||||
/** cache for roster presence per term */
|
|
||||||
private array $rosterPresenceCache = [];
|
|
||||||
// Inside ClassPreparationController (class scope, not inside a method)
|
|
||||||
private array $allowedPrepCategories = [
|
|
||||||
'Grade Box',
|
|
||||||
'Large Table',
|
|
||||||
'Regular Chair',
|
|
||||||
'Small Chair',
|
|
||||||
'Small Table',
|
|
||||||
'Teacher Chair',
|
|
||||||
'Trash Bin',
|
|
||||||
'White Board',
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->studentClassModel = new StudentClassModel();
|
|
||||||
$this->prepLogModel = new ClassPreparationLogModel();
|
|
||||||
$this->classSectionModel = new ClassSectionModel();
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->adjustmentModel = new ClassPrepAdjustmentModel();
|
|
||||||
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
|
||||||
$semParam = $this->request->getGet('semester');
|
|
||||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
|
||||||
$allowed = $this->allowedPrepCategories;
|
|
||||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
|
||||||
|
|
||||||
// 1) Get student count per class-section (distinct students, correct semester)
|
|
||||||
$scQ = $this->db->table('student_class sc')
|
|
||||||
->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false)
|
|
||||||
->join('students s', 's.id = sc.student_id', 'inner')
|
|
||||||
->where('s.is_active', 1)
|
|
||||||
->where('sc.school_year', $schoolYear);
|
|
||||||
if ($limitToSemester && $semester !== '') {
|
|
||||||
$scQ->where('sc.semester', $semester);
|
|
||||||
}
|
|
||||||
$classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray();
|
|
||||||
|
|
||||||
// 2) Inventory availability — prefer good_qty when present, else condition='good' quantity.
|
|
||||||
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
|
|
||||||
|
|
||||||
// Seed totals with allowed categories for stable ordering
|
|
||||||
$requiredTotals = array_fill_keys($allowed, 0);
|
|
||||||
$prepResults = [];
|
|
||||||
|
|
||||||
// 3) Build prep per section (once!)
|
|
||||||
foreach ($classSections as $section) {
|
|
||||||
$classSectionId = $section['class_section_id'];
|
|
||||||
$studentCount = (int)$section['student_count'];
|
|
||||||
$classLevel = $this->getClassLevelBySection($classSectionId);
|
|
||||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
|
||||||
|
|
||||||
// Calculate required items (whitelist inside)
|
|
||||||
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
|
||||||
|
|
||||||
// --- Apply adjustments (only Small/Large Table), clamp >= 0 ---
|
|
||||||
$rawAdjustments = $this->adjustmentModel
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('adjustable', 1)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
|
||||||
|
|
||||||
foreach ($rawAdjustments as $a) {
|
|
||||||
$item = $a['item_name'];
|
|
||||||
$delta = (int)$a['adjustment'];
|
|
||||||
|
|
||||||
if (array_key_exists($item, $adjMap)) {
|
|
||||||
$adjMap[$item] = $delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($baseItems[$item])) {
|
|
||||||
$baseItems[$item] = max(0, (int)$baseItems[$item] + $delta);
|
|
||||||
} elseif (in_array($item, $allowed, true)) {
|
|
||||||
$baseItems[$item] = max(0, $delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Accumulate global totals (allowed only)
|
|
||||||
foreach ($allowed as $cat) {
|
|
||||||
$requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Compare with last snapshot; do not save here — only on print or explicit API
|
|
||||||
$oldSnap = $this->prepLogModel
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->orderBy('created_at', 'DESC')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
|
|
||||||
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
|
|
||||||
|
|
||||||
// 6) Push exactly ONCE
|
|
||||||
$prepResults[] = [
|
|
||||||
'class_section' => $className,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'student_count' => $studentCount,
|
|
||||||
'class_level' => $classLevel,
|
|
||||||
'prep_items' => $baseItems,
|
|
||||||
'needs_print' => $hasChanged,
|
|
||||||
'last_printed_at' => $oldSnap['created_at'] ?? null,
|
|
||||||
'adjustments' => $adjMap,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7) Shortages (compare totals vs inventory)
|
|
||||||
$shortages = [];
|
|
||||||
foreach ($requiredTotals as $item => $reqQty) {
|
|
||||||
$have = (int)($inventoryMap[$item] ?? 0);
|
|
||||||
if ($have < (int)$reqQty) {
|
|
||||||
$shortages[$item] = (int)$reqQty - $have;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('class_prep/list', [
|
|
||||||
'prepResults' => $prepResults,
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
'semester' => $semester,
|
|
||||||
'shortages' => $shortages,
|
|
||||||
'totalNeeded' => $requiredTotals,
|
|
||||||
'available' => $inventoryMap,
|
|
||||||
// For temporary debugging, you can pass these too:
|
|
||||||
// 'joinGood' => $joinGood, 'nameGood' => $nameGood, 'joinCond' => $joinCond, 'nameCond' => $nameCond,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTeacherCountForSection(string $classSectionId, ?string $schoolYear = null): int
|
|
||||||
{
|
|
||||||
$schoolYear = $schoolYear ?? $this->schoolYear;
|
|
||||||
|
|
||||||
// positions: 'main' and 'ta' (per your new schema)
|
|
||||||
$row = $this->db->table('teacher_class')
|
|
||||||
->select('COUNT(*) AS cnt')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->whereIn('position', ['main', 'ta'])
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->get()->getRowArray();
|
|
||||||
|
|
||||||
return (int)($row['cnt'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array
|
|
||||||
{
|
|
||||||
// 1) Stable keys: your whitelist, all zero to start
|
|
||||||
$allowed = $this->allowedPrepCategories;
|
|
||||||
$items = array_fill_keys($allowed, 0);
|
|
||||||
|
|
||||||
// 2) Fetch only the classroom categories you care about (optionally with grade bounds)
|
|
||||||
$categories = $this->db->table('inventory_categories')
|
|
||||||
->select('name, grade_min, grade_max')
|
|
||||||
->where('type', 'classroom')
|
|
||||||
->whereIn('name', $allowed)
|
|
||||||
->orderBy('name')
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
// 3) Rules
|
|
||||||
$isLowerGrades = in_array($classLevel, [1, 2], true);
|
|
||||||
$teacherCount = $this->getTeacherCountForSection($classSectionId, $this->schoolYear);
|
|
||||||
|
|
||||||
foreach ($categories as $cat) {
|
|
||||||
$name = $cat['name']; // e.g., 'Small Table'
|
|
||||||
$gradeMin = $cat['grade_min']; // may be null
|
|
||||||
$gradeMax = $cat['grade_max']; // may be null
|
|
||||||
|
|
||||||
// Respect optional bounds
|
|
||||||
if ($gradeMin !== null && $classLevel < (int)$gradeMin) {
|
|
||||||
$items[$name] = 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($gradeMax !== null && $classLevel > (int)$gradeMax) {
|
|
||||||
$items[$name] = 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$qty = 0;
|
|
||||||
switch (strtolower($name)) {
|
|
||||||
case 'small table':
|
|
||||||
$qty = $isLowerGrades ? (int)ceil($students / 3) : 0;
|
|
||||||
break;
|
|
||||||
case 'large table':
|
|
||||||
$qty = $isLowerGrades ? 0 : (int)ceil($students / 4);
|
|
||||||
break;
|
|
||||||
case 'small chair':
|
|
||||||
$qty = $isLowerGrades ? $students : 0;
|
|
||||||
break;
|
|
||||||
case 'regular chair':
|
|
||||||
$qty = $isLowerGrades ? 0 : $students;
|
|
||||||
break;
|
|
||||||
case 'teacher chair':
|
|
||||||
$qty = (int)$teacherCount;
|
|
||||||
break;
|
|
||||||
case 'trash bin':
|
|
||||||
case 'white board':
|
|
||||||
case 'grade box':
|
|
||||||
$qty = 1;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
$qty = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only set for allowed keys (guards against unexpected DB rows)
|
|
||||||
if (array_key_exists($name, $items)) {
|
|
||||||
$items[$name] = $qty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $items;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function saveAdjustment()
|
|
||||||
{
|
|
||||||
$data = $this->request->getPost();
|
|
||||||
$sectionId = $data['class_section_id'];
|
|
||||||
$schoolYear = $data['school_year'];
|
|
||||||
$adjustments = $data['adjustments'] ?? [];
|
|
||||||
|
|
||||||
foreach ($adjustments as $itemName => $adjustment) {
|
|
||||||
$row = $this->adjustmentModel
|
|
||||||
->where('class_section_id', $sectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('item_name', $itemName)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($row) {
|
|
||||||
// Update
|
|
||||||
$this->adjustmentModel->update($row['id'], ['adjustment' => (int)$adjustment, 'adjustable' => 1]);
|
|
||||||
} else {
|
|
||||||
// Insert
|
|
||||||
$this->adjustmentModel->insert([
|
|
||||||
'class_section_id' => $sectionId,
|
|
||||||
'item_name' => $itemName,
|
|
||||||
'adjustment' => (int)$adjustment,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'adjustable' => 1,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->to(site_url('class-prep?school_year=' . urlencode($schoolYear)))
|
|
||||||
->with('success', 'Adjustments saved successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ---------- helpers ---------- */
|
|
||||||
|
|
||||||
private function hasPrepChanged(array $new, array $old): bool
|
|
||||||
{
|
|
||||||
ksort($new);
|
|
||||||
ksort($old);
|
|
||||||
|
|
||||||
return json_encode($new) !== json_encode($old);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function print($classSectionId, $schoolYear)
|
|
||||||
{
|
|
||||||
$semParam = $this->request->getGet('semester');
|
|
||||||
$semester = (is_string($semParam) && $semParam !== '') ? (string) $semParam : (string) $this->semester;
|
|
||||||
$limitToSemester = $this->hasRosterForSemester((string) $schoolYear, $semester);
|
|
||||||
|
|
||||||
// Friendly label (if you have this helper)
|
|
||||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
|
||||||
|
|
||||||
// Distinct student count for this term
|
|
||||||
$studentQ = $this->db->table('student_class sc')
|
|
||||||
->select('COUNT(DISTINCT sc.student_id) AS cnt', false)
|
|
||||||
->join('students s', 's.id = sc.student_id', 'inner')
|
|
||||||
->where('s.is_active', 1)
|
|
||||||
->where('sc.class_section_id', $classSectionId)
|
|
||||||
->where('sc.school_year', $schoolYear);
|
|
||||||
if ($limitToSemester && $semester !== '') {
|
|
||||||
$studentQ->where('sc.semester', $semester);
|
|
||||||
}
|
|
||||||
$studentRow = $studentQ->get()->getRowArray();
|
|
||||||
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
|
||||||
|
|
||||||
// Live calc + adjustments
|
|
||||||
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
|
|
||||||
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
|
|
||||||
|
|
||||||
$rawAdjustments = $this->adjustmentModel
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('adjustable', 1)
|
|
||||||
->findAll();
|
|
||||||
foreach ($rawAdjustments as $a) {
|
|
||||||
$item = $a['item_name'];
|
|
||||||
$delta = (int)$a['adjustment'];
|
|
||||||
if (isset($items[$item])) $items[$item] = max(0, (int)$items[$item] + $delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record a snapshot at print time as the new baseline
|
|
||||||
$this->prepLogModel->insert([
|
|
||||||
'class_section_id' => (string)$classSectionId,
|
|
||||||
'class_section' => $className,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'prep_data' => json_encode($items),
|
|
||||||
'created_at' => utc_now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Return PRINT view (HTML) that auto-opens the print dialog
|
|
||||||
return view('class_prep/print', [
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'classSection' => $className,
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
'prepItems' => $items,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API: Return class prep data and change flags for all sections in a year.
|
|
||||||
* GET: school_year
|
|
||||||
* Response: { ok, schoolYear, results:[{ class_section_id, class_section, student_count, class_level, prep_items, needs_print, last_printed_at }], totals, shortages }
|
|
||||||
*/
|
|
||||||
public function apiList()
|
|
||||||
{
|
|
||||||
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
|
||||||
$semParam = $this->request->getGet('semester');
|
|
||||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
|
||||||
$allowed = $this->allowedPrepCategories;
|
|
||||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
|
||||||
|
|
||||||
// Student counts
|
|
||||||
$scQ = $this->db->table('student_class sc')
|
|
||||||
->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false)
|
|
||||||
->join('students s', 's.id = sc.student_id', 'inner')
|
|
||||||
->where('s.is_active', 1)
|
|
||||||
->where('sc.school_year', $schoolYear);
|
|
||||||
if ($limitToSemester && $semester !== '') {
|
|
||||||
$scQ->where('sc.semester', $semester);
|
|
||||||
}
|
|
||||||
$classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray();
|
|
||||||
|
|
||||||
// Build inventory availability maps
|
|
||||||
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
|
|
||||||
|
|
||||||
$requiredTotals = array_fill_keys($allowed, 0);
|
|
||||||
$results = [];
|
|
||||||
|
|
||||||
foreach ($classSections as $row) {
|
|
||||||
$classSectionId = (string)$row['class_section_id'];
|
|
||||||
$studentCount = (int)$row['student_count'];
|
|
||||||
$classLevel = $this->getClassLevelBySection($classSectionId);
|
|
||||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
|
||||||
|
|
||||||
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
|
||||||
$rawAdjustments = $this->adjustmentModel
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('adjustable', 1)
|
|
||||||
->findAll();
|
|
||||||
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
|
||||||
foreach ($rawAdjustments as $a) {
|
|
||||||
$item = $a['item_name'];
|
|
||||||
$delta = (int)$a['adjustment'];
|
|
||||||
if (array_key_exists($item, $adjMap)) $adjMap[$item] = $delta;
|
|
||||||
if (isset($baseItems[$item])) $baseItems[$item] = max(0, (int)$baseItems[$item] + $delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($allowed as $cat) {
|
|
||||||
$requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
$oldSnap = $this->prepLogModel
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->orderBy('created_at', 'DESC')
|
|
||||||
->first();
|
|
||||||
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
|
|
||||||
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
|
|
||||||
|
|
||||||
$results[] = [
|
|
||||||
'class_section' => $className,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'student_count' => $studentCount,
|
|
||||||
'class_level' => $classLevel,
|
|
||||||
'prep_items' => $baseItems,
|
|
||||||
'needs_print' => $hasChanged,
|
|
||||||
'last_printed_at' => $oldSnap['created_at'] ?? null,
|
|
||||||
'adjustments' => $adjMap,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shortages
|
|
||||||
$shortages = [];
|
|
||||||
foreach ($requiredTotals as $item => $reqQty) {
|
|
||||||
$have = (int)($inventoryMap[$item] ?? 0);
|
|
||||||
if ($have < (int)$reqQty) $shortages[$item] = (int)$reqQty - $have;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'ok' => true,
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
'results' => $results,
|
|
||||||
'totals' => $requiredTotals,
|
|
||||||
'shortages' => $shortages,
|
|
||||||
'csrf_token' => csrf_token(),
|
|
||||||
'csrf_hash' => csrf_hash(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API: Mark selected classes as printed (save baseline snapshot).
|
|
||||||
* POST: school_year, class_section_ids[]
|
|
||||||
*/
|
|
||||||
public function apiMarkPrinted()
|
|
||||||
{
|
|
||||||
$schoolYear = (string)($this->request->getPost('school_year') ?? $this->schoolYear);
|
|
||||||
$semParam = $this->request->getPost('semester');
|
|
||||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
|
||||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
|
||||||
$ids = $this->request->getPost('class_section_ids') ?? [];
|
|
||||||
if (!is_array($ids)) $ids = $ids ? [$ids] : [];
|
|
||||||
$ids = array_values(array_unique(array_filter(array_map('strval', $ids))));
|
|
||||||
|
|
||||||
$now = utc_now();
|
|
||||||
$count = 0;
|
|
||||||
foreach ($ids as $classSectionId) {
|
|
||||||
$studentQ = $this->db->table('student_class sc')
|
|
||||||
->select('COUNT(DISTINCT sc.student_id) AS cnt', false)
|
|
||||||
->join('students s', 's.id = sc.student_id', 'inner')
|
|
||||||
->where('s.is_active', 1)
|
|
||||||
->where('sc.class_section_id', $classSectionId)
|
|
||||||
->where('sc.school_year', $schoolYear);
|
|
||||||
if ($limitToSemester && $semester !== '') {
|
|
||||||
$studentQ->where('sc.semester', $semester);
|
|
||||||
}
|
|
||||||
$studentRow = $studentQ->get()->getRowArray();
|
|
||||||
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
|
||||||
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
|
|
||||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
|
||||||
|
|
||||||
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
|
|
||||||
|
|
||||||
$rawAdjustments = $this->adjustmentModel
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('adjustable', 1)
|
|
||||||
->findAll();
|
|
||||||
foreach ($rawAdjustments as $a) {
|
|
||||||
$item = $a['item_name'];
|
|
||||||
$delta = (int)$a['adjustment'];
|
|
||||||
if (isset($items[$item])) $items[$item] = max(0, (int)$items[$item] + $delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->prepLogModel->insert([
|
|
||||||
'class_section_id' => (string)$classSectionId,
|
|
||||||
'class_section' => $className,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'prep_data' => json_encode($items),
|
|
||||||
'created_at' => $now,
|
|
||||||
]);
|
|
||||||
$count++;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
// ignore and continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'ok' => true,
|
|
||||||
'updated' => $count,
|
|
||||||
'csrf_token' => csrf_token(),
|
|
||||||
'csrf_hash' => csrf_hash(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private function getClassLevelBySection(string $classSectionId): int
|
|
||||||
{
|
|
||||||
// Prefer the human-readable section name for grade parsing.
|
|
||||||
$sectionName = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
|
||||||
$label = $sectionName !== null && $sectionName !== '' ? (string) $sectionName : $classSectionId;
|
|
||||||
|
|
||||||
// If it's clearly Kindergarten (KG/K)
|
|
||||||
if (preg_match('/^(kg|k)(\b|[^a-z0-9])/i', $label)) {
|
|
||||||
return 1; // treat Kindergarten as lower grade
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove non-digits, then infer by leading digit
|
|
||||||
$numValue = (int) preg_replace('/\D/', '', $label);
|
|
||||||
|
|
||||||
if ($numValue > 0 && $numValue < 30) {
|
|
||||||
$firstDigit = (int) substr((string) $numValue, 0, 1);
|
|
||||||
return $firstDigit === 1 ? 1 : ($firstDigit === 2 ? 2 : 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to upper grades (3+)
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if student_class has any rows for the given school year.
|
|
||||||
* The semester argument is ignored because assignments are tracked per year.
|
|
||||||
*/
|
|
||||||
private function hasRosterForTerm(string $schoolYear, string $semester): bool
|
|
||||||
{
|
|
||||||
$year = trim((string)$schoolYear);
|
|
||||||
if ($year === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists($year, $this->rosterPresenceCache)) {
|
|
||||||
return $this->rosterPresenceCache[$year];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cnt = $this->db->table('student_class')
|
|
||||||
->where('school_year', $year)
|
|
||||||
->countAllResults();
|
|
||||||
|
|
||||||
$this->rosterPresenceCache[$year] = $cnt > 0;
|
|
||||||
return $this->rosterPresenceCache[$year];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasRosterForSemester(string $schoolYear, string $semester): bool
|
|
||||||
{
|
|
||||||
$year = trim((string)$schoolYear);
|
|
||||||
$sem = trim((string)$semester);
|
|
||||||
if ($year === '' || $sem === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$key = sprintf('sem:%s:%s', $year, $sem);
|
|
||||||
if (array_key_exists($key, $this->rosterPresenceCache)) {
|
|
||||||
return $this->rosterPresenceCache[$key];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cnt = $this->db->table('student_class')
|
|
||||||
->where('school_year', $year)
|
|
||||||
->where('semester', $sem)
|
|
||||||
->countAllResults();
|
|
||||||
|
|
||||||
$this->rosterPresenceCache[$key] = $cnt > 0;
|
|
||||||
return $this->rosterPresenceCache[$key];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildInventoryAvailability(string $schoolYear, string $semester, bool $limitToSemester, array $allowed): array
|
|
||||||
{
|
|
||||||
$inventoryMap = array_fill_keys($allowed, 0);
|
|
||||||
|
|
||||||
$joinRows = $this->db->table('inventory_items ii')
|
|
||||||
->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available')
|
|
||||||
->join('inventory_categories ic', 'ic.id = ii.category_id', 'left')
|
|
||||||
->where('ii.type', 'classroom')
|
|
||||||
->where('ii.school_year', $schoolYear)
|
|
||||||
->whereIn('ic.name', $allowed);
|
|
||||||
if ($limitToSemester && $semester !== '') {
|
|
||||||
$joinRows->where('ii.semester', $semester);
|
|
||||||
}
|
|
||||||
$joinRows = $joinRows->groupBy('ic.name')->get()->getResultArray();
|
|
||||||
|
|
||||||
foreach ($joinRows as $r) {
|
|
||||||
$name = (string)($r['item_name'] ?? '');
|
|
||||||
if ($name !== '' && isset($inventoryMap[$name])) {
|
|
||||||
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$nameRows = $this->db->table('inventory_items')
|
|
||||||
->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available')
|
|
||||||
->where('type', 'classroom')
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->whereIn('name', $allowed);
|
|
||||||
if ($limitToSemester && $semester !== '') {
|
|
||||||
$nameRows->where('semester', $semester);
|
|
||||||
}
|
|
||||||
$nameRows = $nameRows->groupBy('name')->get()->getResultArray();
|
|
||||||
|
|
||||||
foreach ($nameRows as $r) {
|
|
||||||
$name = (string)($r['item_name'] ?? '');
|
|
||||||
if ($name !== '' && isset($inventoryMap[$name])) {
|
|
||||||
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $inventoryMap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\StudentModel;
|
|
||||||
use App\Models\FamilyModel;
|
|
||||||
use App\Models\FamilyStudentModel;
|
|
||||||
use App\Models\FamilyGuardianModel;
|
|
||||||
use App\Models\FamilyCommPrefModel; // optional
|
|
||||||
use App\Models\EmailTemplateModel;
|
|
||||||
use App\Models\CommunicationLogModel;
|
|
||||||
|
|
||||||
class CommunicationController extends BaseController
|
|
||||||
{
|
|
||||||
protected StudentModel $studentModel;
|
|
||||||
protected FamilyModel $familyModel;
|
|
||||||
protected FamilyStudentModel $fsModel;
|
|
||||||
protected FamilyGuardianModel $fgModel;
|
|
||||||
protected ?FamilyCommPrefModel $fcpModel = null;
|
|
||||||
protected EmailTemplateModel $templateModel;
|
|
||||||
protected CommunicationLogModel $logModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->studentModel = new StudentModel();
|
|
||||||
$this->familyModel = new FamilyModel();
|
|
||||||
$this->fsModel = new FamilyStudentModel();
|
|
||||||
$this->fgModel = new FamilyGuardianModel();
|
|
||||||
if (class_exists(FamilyCommPrefModel::class)) {
|
|
||||||
$this->fcpModel = new FamilyCommPrefModel();
|
|
||||||
}
|
|
||||||
$this->templateModel = new EmailTemplateModel();
|
|
||||||
$this->logModel = new CommunicationLogModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
$students = $this->studentModel->orderBy('lastname, firstname', 'asc')->findAll();
|
|
||||||
$templates = $this->templateModel->getActiveTemplates();
|
|
||||||
return view('communications/index', compact('students','templates'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// AJAX: families for a student
|
|
||||||
public function families(int $studentId)
|
|
||||||
{
|
|
||||||
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
|
|
||||||
$families = $this->fsModel->getFamiliesForStudent($studentId);
|
|
||||||
return $this->response->setJSON(['data' => $families]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AJAX: guardians for a family
|
|
||||||
public function guardians(int $familyId)
|
|
||||||
{
|
|
||||||
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
$rows = $db->query("SELECT u.id as user_id, u.firstname, u.lastname, u.email, fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms\n FROM family_guardians fg\n JOIN users u ON u.id = fg.user_id\n WHERE fg.family_id = ?", [$familyId])->getResultArray();
|
|
||||||
return $this->response->setJSON(['data' => $rows]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function preview()
|
|
||||||
{
|
|
||||||
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
|
|
||||||
|
|
||||||
$templateKey = (string) $this->request->getPost('template_key');
|
|
||||||
$studentId = (int) $this->request->getPost('student_id');
|
|
||||||
$familyId = (int) $this->request->getPost('family_id');
|
|
||||||
$varsPost = $this->request->getPost('vars');
|
|
||||||
$vars = is_array($varsPost) ? $varsPost : json_decode((string)$varsPost, true) ?? [];
|
|
||||||
|
|
||||||
$template = $this->templateModel->findByKey($templateKey);
|
|
||||||
if (!$template) return $this->response->setStatusCode(404)->setJSON(['error' => 'Template not found']);
|
|
||||||
|
|
||||||
$student = $this->studentModel->getStudentBasic($studentId);
|
|
||||||
if (!$student) return $this->response->setStatusCode(404)->setJSON(['error' => 'Student not found']);
|
|
||||||
|
|
||||||
// Build salutation from guardians in the chosen family
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
$gs = $db->query("SELECT u.firstname, u.lastname\n FROM family_guardians fg\n JOIN users u ON u.id = fg.user_id\n WHERE fg.family_id = ? AND fg.receive_emails = 1\n ORDER BY fg.is_primary DESC, u.lastname, u.firstname", [$familyId])->getResultArray();
|
|
||||||
$sal = 'Parent/Guardian';
|
|
||||||
if ($gs) {
|
|
||||||
$names = array_map(fn($r)=> trim(($r['firstname']??'').' '.($r['lastname']??'')), $gs);
|
|
||||||
$sal = implode(' & ', $names);
|
|
||||||
}
|
|
||||||
|
|
||||||
$autoVars = [
|
|
||||||
'student_fullname' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
|
|
||||||
'student_grade' => $student['grade'] ?? '',
|
|
||||||
'parent_salutation'=> $sal,
|
|
||||||
'date' => local_date(utc_now(), 'Y-m-d'),
|
|
||||||
'school_name' => 'Al Rahma Sunday School',
|
|
||||||
'teacher_name' => (string)(session('display_name') ?? 'Teacher'),
|
|
||||||
];
|
|
||||||
$all = array_merge($autoVars, $vars);
|
|
||||||
|
|
||||||
$subject = $this->renderTwig($template['subject'], $all);
|
|
||||||
$body = nl2br($this->renderTwig($template['body'], $all));
|
|
||||||
|
|
||||||
return $this->response->setJSON(['subject' => $subject, 'html' => $body]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function send()
|
|
||||||
{
|
|
||||||
$rules = [
|
|
||||||
'student_id' => 'required|integer',
|
|
||||||
'family_id' => 'required|integer',
|
|
||||||
'template_key' => 'required|string',
|
|
||||||
'subject' => 'required|string',
|
|
||||||
'body' => 'required|string',
|
|
||||||
'recipients' => 'required|string' // JSON array
|
|
||||||
];
|
|
||||||
if (!$this->validate($rules)) {
|
|
||||||
return redirect()->back()->with('error', 'Invalid form submission.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$studentId = (int) $this->request->getPost('student_id');
|
|
||||||
$familyId = (int) $this->request->getPost('family_id');
|
|
||||||
$templateKey = (string) $this->request->getPost('template_key');
|
|
||||||
$subject = (string) $this->request->getPost('subject');
|
|
||||||
$bodyHtml = (string) $this->request->getPost('body');
|
|
||||||
$recipients = json_decode((string)$this->request->getPost('recipients'), true) ?? [];
|
|
||||||
$cc = json_decode((string)($this->request->getPost('cc') ?? '[]'), true) ?? [];
|
|
||||||
$bcc = json_decode((string)($this->request->getPost('bcc') ?? '[]'), true) ?? [];
|
|
||||||
|
|
||||||
$student = $this->studentModel->getStudentBasic($studentId);
|
|
||||||
if (!$student) return redirect()->back()->with('error', 'Student not found');
|
|
||||||
|
|
||||||
// Send email (PHPMailer via service('mailer'))
|
|
||||||
$sendOk = false; $error = null;
|
|
||||||
try {
|
|
||||||
$mailer = service('mailer');
|
|
||||||
foreach (array_unique($recipients) as $to) { if ($to) $mailer->addAddress($to); }
|
|
||||||
foreach (array_unique($cc) as $c) { if ($c) $mailer->addCC($c); }
|
|
||||||
foreach (array_unique($bcc) as $b){ if ($b) $mailer->addBCC($b); }
|
|
||||||
$mailer->Subject = $subject;
|
|
||||||
$mailer->isHTML(true);
|
|
||||||
$mailer->Body = $bodyHtml;
|
|
||||||
$mailer->AltBody = strip_tags($bodyHtml);
|
|
||||||
$sendOk = $mailer->send();
|
|
||||||
} catch (\Throwable $t) {
|
|
||||||
$error = $t->getMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log
|
|
||||||
$this->logModel->insert([
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'family_id' => $familyId,
|
|
||||||
'student_name' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
|
|
||||||
'template_key' => $templateKey,
|
|
||||||
'subject' => $subject,
|
|
||||||
'body' => $bodyHtml,
|
|
||||||
'recipients' => json_encode(array_values($recipients)),
|
|
||||||
'cc' => json_encode(array_values($cc)),
|
|
||||||
'bcc' => json_encode(array_values($bcc)),
|
|
||||||
'status' => $sendOk ? 'sent' : 'failed',
|
|
||||||
'error_message'=> $error,
|
|
||||||
'sent_by' => (int)(session('user_id') ?? 0),
|
|
||||||
'metadata' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $sendOk
|
|
||||||
? redirect()->to('/communications')->with('success', 'Email sent successfully.')
|
|
||||||
: redirect()->back()->with('error', 'Failed to send email: '.($error ?? 'Unknown error'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function renderTwig(string $template, array $vars): string
|
|
||||||
{
|
|
||||||
return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}/', function($m) use ($vars) {
|
|
||||||
$key = $m[1];
|
|
||||||
return htmlspecialchars((string)($vars[$key] ?? ''), ENT_QUOTES, 'UTF-8');
|
|
||||||
}, $template);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\ClassSectionModel;
|
|
||||||
use App\Models\CompetitionClassWinnerModel;
|
|
||||||
use App\Models\CompetitionModel;
|
|
||||||
use App\Models\CompetitionScoreModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Models\TeacherClassModel;
|
|
||||||
|
|
||||||
class CompetitionScoresController extends BaseController
|
|
||||||
{
|
|
||||||
protected $db;
|
|
||||||
protected TeacherClassModel $teacherClassModel;
|
|
||||||
protected ConfigurationModel $configModel;
|
|
||||||
protected ClassSectionModel $classSectionModel;
|
|
||||||
protected CompetitionClassWinnerModel $classWinnerModel;
|
|
||||||
protected string $classStudentTable;
|
|
||||||
protected bool $hasQuestionCount = false;
|
|
||||||
protected bool $hasClassWinnerTable = false;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
$this->teacherClassModel = new TeacherClassModel();
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->classSectionModel = new ClassSectionModel();
|
|
||||||
$this->classWinnerModel = new CompetitionClassWinnerModel();
|
|
||||||
|
|
||||||
if ($this->db->tableExists('class_student')) {
|
|
||||||
$this->classStudentTable = 'class_student';
|
|
||||||
} elseif ($this->db->tableExists('student_class')) {
|
|
||||||
$this->classStudentTable = 'student_class';
|
|
||||||
} else {
|
|
||||||
$this->classStudentTable = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->hasClassWinnerTable = $this->db->tableExists('competition_class_winners');
|
|
||||||
$this->hasQuestionCount = $this->hasClassWinnerTable
|
|
||||||
&& $this->db->fieldExists('question_count', 'competition_class_winners');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
[$assignments, $schoolYear, $semester] = $this->getTeacherContext();
|
|
||||||
$activeClassId = $this->resolveActiveClassId($assignments);
|
|
||||||
$activeClassName = $this->getActiveClassName($assignments, $activeClassId);
|
|
||||||
|
|
||||||
if (empty($assignments) || $activeClassId <= 0) {
|
|
||||||
return view('teacher/competition_scores/index', [
|
|
||||||
'competitions' => [],
|
|
||||||
'activeClassId' => $activeClassId,
|
|
||||||
'activeClassName' => $activeClassName,
|
|
||||||
'questionCounts' => [],
|
|
||||||
'scoreCounts' => [],
|
|
||||||
'studentTotal' => 0,
|
|
||||||
'sectionMap' => $this->getClassSectionMap(),
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
'semester' => $semester,
|
|
||||||
'hasClasses' => false,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$competitions = $this->getCompetitionsForClass($activeClassId, $schoolYear, $semester);
|
|
||||||
$competitionIds = array_values(array_filter(array_map(static function ($row) {
|
|
||||||
return (int) ($row['id'] ?? 0);
|
|
||||||
}, $competitions)));
|
|
||||||
|
|
||||||
$questionCounts = $this->getQuestionCounts($competitionIds, $activeClassId);
|
|
||||||
$scoreCounts = $this->getScoreCounts($competitionIds, $activeClassId);
|
|
||||||
$studentTotal = $this->getClassStudentCount($activeClassId, $schoolYear);
|
|
||||||
|
|
||||||
return view('teacher/competition_scores/index', [
|
|
||||||
'competitions' => $competitions,
|
|
||||||
'activeClassId' => $activeClassId,
|
|
||||||
'activeClassName' => $activeClassName,
|
|
||||||
'questionCounts' => $questionCounts,
|
|
||||||
'scoreCounts' => $scoreCounts,
|
|
||||||
'studentTotal' => $studentTotal,
|
|
||||||
'sectionMap' => $this->getClassSectionMap(),
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
'semester' => $semester,
|
|
||||||
'hasClasses' => true,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function edit($id)
|
|
||||||
{
|
|
||||||
[$assignments, $schoolYear, $semester] = $this->getTeacherContext();
|
|
||||||
$allowedClassIds = $this->getAllowedClassIds($assignments);
|
|
||||||
if (empty($allowedClassIds)) {
|
|
||||||
return redirect()->to('/teacher/competition-scores')
|
|
||||||
->with('error', 'No class assignments found for your account.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$competitionModel = new CompetitionModel();
|
|
||||||
$scoreModel = new CompetitionScoreModel();
|
|
||||||
$competition = $competitionModel->find($id);
|
|
||||||
if (!$competition) {
|
|
||||||
return redirect()->to('/teacher/competition-scores')
|
|
||||||
->with('error', 'Competition not found.');
|
|
||||||
}
|
|
||||||
$isLocked = !empty($competition['is_locked']);
|
|
||||||
|
|
||||||
$activeClassId = $this->resolveActiveClassId($assignments);
|
|
||||||
$lockedClassId = (int) ($competition['class_section_id'] ?? 0);
|
|
||||||
$classSectionId = $lockedClassId > 0 ? $lockedClassId : $activeClassId;
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->to('/teacher/competition-scores')
|
|
||||||
->with('error', 'Select a class section before entering scores.');
|
|
||||||
}
|
|
||||||
if (!in_array($classSectionId, $allowedClassIds, true)) {
|
|
||||||
return redirect()->to('/teacher/competition-scores')
|
|
||||||
->with('error', 'You are not assigned to that class.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$students = $this->getStudentsForCompetition($competition, $classSectionId);
|
|
||||||
$existingScores = $scoreModel
|
|
||||||
->where('competition_id', $id)
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$scoreMap = [];
|
|
||||||
foreach ($existingScores as $row) {
|
|
||||||
$scoreMap[$row['student_id']] = $row['score'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$questionCount = null;
|
|
||||||
if ($this->hasQuestionCount && $this->hasClassWinnerTable) {
|
|
||||||
$row = $this->classWinnerModel
|
|
||||||
->select('question_count')
|
|
||||||
->where('competition_id', $id)
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->first();
|
|
||||||
if ($row && $row['question_count'] !== null) {
|
|
||||||
$questionCount = (int) $row['question_count'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$classStudentCount = $this->getClassStudentCount(
|
|
||||||
$classSectionId,
|
|
||||||
$competition['school_year'] ?? $schoolYear
|
|
||||||
);
|
|
||||||
|
|
||||||
$activeClassName = $this->getActiveClassName($assignments, $classSectionId);
|
|
||||||
|
|
||||||
return view('teacher/competition_scores/scores', [
|
|
||||||
'competition' => $competition,
|
|
||||||
'students' => $students,
|
|
||||||
'scoreMap' => $scoreMap,
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'classSectionName' => $activeClassName,
|
|
||||||
'classStudentCount' => $classStudentCount,
|
|
||||||
'questionCount' => $questionCount,
|
|
||||||
'classSelectionLocked' => $lockedClassId > 0,
|
|
||||||
'isLocked' => $isLocked,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save($id)
|
|
||||||
{
|
|
||||||
[$assignments] = $this->getTeacherContext();
|
|
||||||
$allowedClassIds = $this->getAllowedClassIds($assignments);
|
|
||||||
if (empty($allowedClassIds)) {
|
|
||||||
return redirect()->to('/teacher/competition-scores')
|
|
||||||
->with('error', 'No class assignments found for your account.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$competitionModel = new CompetitionModel();
|
|
||||||
$scoreModel = new CompetitionScoreModel();
|
|
||||||
$competition = $competitionModel->find($id);
|
|
||||||
if (!$competition) {
|
|
||||||
return redirect()->to('/teacher/competition-scores')
|
|
||||||
->with('error', 'Competition not found.');
|
|
||||||
}
|
|
||||||
if (!empty($competition['is_locked'])) {
|
|
||||||
return redirect()->to("/teacher/competition-scores/{$id}")
|
|
||||||
->with('error', 'Competition is locked. You cannot update scores.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$lockedClassId = (int) ($competition['class_section_id'] ?? 0);
|
|
||||||
$classSectionId = $lockedClassId > 0
|
|
||||||
? $lockedClassId
|
|
||||||
: (int) $this->request->getPost('class_section_id');
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->to('/teacher/competition-scores')
|
|
||||||
->with('error', 'Select a class section before saving scores.');
|
|
||||||
}
|
|
||||||
if (!in_array($classSectionId, $allowedClassIds, true)) {
|
|
||||||
return redirect()->to('/teacher/competition-scores')
|
|
||||||
->with('error', 'You are not assigned to that class.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$scores = (array) $this->request->getPost('scores');
|
|
||||||
$cleanScores = [];
|
|
||||||
$invalidScores = [];
|
|
||||||
|
|
||||||
foreach ($scores as $studentId => $scoreValue) {
|
|
||||||
$scoreValue = trim((string) $scoreValue);
|
|
||||||
if ($scoreValue === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!preg_match('/^\d+$/', $scoreValue)) {
|
|
||||||
$invalidScores[] = $studentId;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$cleanScores[(int) $studentId] = (int) $scoreValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($invalidScores)) {
|
|
||||||
return redirect()->to("/teacher/competition-scores/{$id}")
|
|
||||||
->with('error', 'Scores must be whole numbers (no decimals).');
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($cleanScores as $studentId => $scoreValue) {
|
|
||||||
$existing = $scoreModel
|
|
||||||
->where('competition_id', $id)
|
|
||||||
->where('student_id', (int) $studentId)
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$scoreModel->update($existing['id'], [
|
|
||||||
'score' => $scoreValue,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
$scoreModel->insert([
|
|
||||||
'competition_id' => (int) $id,
|
|
||||||
'student_id' => (int) $studentId,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'score' => $scoreValue,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->to("/teacher/competition-scores/{$id}")
|
|
||||||
->with('success', 'Scores saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTeacherContext(): array
|
|
||||||
{
|
|
||||||
$userId = (int) (session()->get('user_id') ?? 0);
|
|
||||||
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
|
||||||
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
|
||||||
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
|
||||||
$userId,
|
|
||||||
$schoolYear,
|
|
||||||
$semester
|
|
||||||
);
|
|
||||||
|
|
||||||
return [$assignments, $schoolYear, $semester];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getAllowedClassIds(array $assignments): array
|
|
||||||
{
|
|
||||||
$ids = array_map(static function ($row) {
|
|
||||||
return (int) ($row['class_section_id'] ?? 0);
|
|
||||||
}, $assignments);
|
|
||||||
|
|
||||||
$ids = array_values(array_filter(array_unique($ids)));
|
|
||||||
return $ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveActiveClassId(array $assignments): int
|
|
||||||
{
|
|
||||||
$allowed = $this->getAllowedClassIds($assignments);
|
|
||||||
$active = (int) (session()->get('class_section_id') ?? 0);
|
|
||||||
if ($active > 0 && in_array($active, $allowed, true)) {
|
|
||||||
return $active;
|
|
||||||
}
|
|
||||||
|
|
||||||
$active = $allowed[0] ?? 0;
|
|
||||||
if ($active > 0) {
|
|
||||||
session()->set('class_section_id', $active);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $active;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getActiveClassName(array $assignments, int $classSectionId): ?string
|
|
||||||
{
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($assignments as $row) {
|
|
||||||
if ((int) ($row['class_section_id'] ?? 0) === $classSectionId) {
|
|
||||||
return $row['class_section_name'] ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $this->classSectionModel
|
|
||||||
->select('class_section_name')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return $row['class_section_name'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getCompetitionsForClass(int $classSectionId, string $schoolYear, string $semester): array
|
|
||||||
{
|
|
||||||
$competitionModel = new CompetitionModel();
|
|
||||||
$builder = $competitionModel->orderBy('id', 'DESC');
|
|
||||||
|
|
||||||
if ($classSectionId > 0) {
|
|
||||||
$builder->groupStart()
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->orWhere('class_section_id', 0)
|
|
||||||
->orWhere('class_section_id IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($schoolYear !== '') {
|
|
||||||
$builder->groupStart()
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->orWhere('school_year', '')
|
|
||||||
->orWhere('school_year IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($semester !== '') {
|
|
||||||
$builder->groupStart()
|
|
||||||
->where('semester', $semester)
|
|
||||||
->orWhere('semester', '')
|
|
||||||
->orWhere('semester IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $builder->findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getQuestionCounts(array $competitionIds, int $classSectionId): array
|
|
||||||
{
|
|
||||||
if (!$this->hasQuestionCount || !$this->hasClassWinnerTable || empty($competitionIds)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $this->classWinnerModel
|
|
||||||
->select('competition_id, question_count')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->whereIn('competition_id', $competitionIds)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$compId = (int) ($row['competition_id'] ?? 0);
|
|
||||||
if ($compId > 0 && $row['question_count'] !== null) {
|
|
||||||
$out[$compId] = (int) $row['question_count'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getScoreCounts(array $competitionIds, int $classSectionId): array
|
|
||||||
{
|
|
||||||
if (empty($competitionIds)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $this->db->table('competition_scores')
|
|
||||||
->select('competition_id, COUNT(*) AS total')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->whereIn('competition_id', $competitionIds)
|
|
||||||
->groupBy('competition_id')
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$compId = (int) ($row['competition_id'] ?? 0);
|
|
||||||
if ($compId > 0) {
|
|
||||||
$out[$compId] = (int) ($row['total'] ?? 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getClassStudentCount(int $classSectionId, ?string $schoolYear): int
|
|
||||||
{
|
|
||||||
if ($classSectionId <= 0 || $this->classStudentTable === '') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$builder = $this->db->table($this->classStudentTable)
|
|
||||||
->select('COUNT(*) AS total')
|
|
||||||
->where('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
if ($schoolYear && $this->db->fieldExists('school_year', $this->classStudentTable)) {
|
|
||||||
$builder->where('school_year', $schoolYear);
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $builder->get()->getRowArray();
|
|
||||||
return (int) ($row['total'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getStudentsForCompetition(array $competition, int $classSectionId): array
|
|
||||||
{
|
|
||||||
if ($classSectionId <= 0 || $this->classStudentTable === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$builder = $this->db->table('students s')
|
|
||||||
->select('s.id, s.school_id, s.firstname, s.lastname')
|
|
||||||
->join($this->classStudentTable . ' cs', 'cs.student_id = s.id', 'inner')
|
|
||||||
->where('cs.class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
$hasSchoolYear = $this->db->fieldExists('school_year', $this->classStudentTable);
|
|
||||||
if ($hasSchoolYear && !empty($competition['school_year'])) {
|
|
||||||
$builder->where('cs.school_year', $competition['school_year']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$builder->distinct();
|
|
||||||
$builder->orderBy('s.lastname', 'ASC')
|
|
||||||
->orderBy('s.firstname', 'ASC');
|
|
||||||
|
|
||||||
return $builder->get()->getResultArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getClassSectionMap(): array
|
|
||||||
{
|
|
||||||
$sections = $this->classSectionModel
|
|
||||||
->select('class_section_id, class_section_name')
|
|
||||||
->orderBy('class_section_name', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$map = [];
|
|
||||||
foreach ($sections as $section) {
|
|
||||||
$id = (int) ($section['class_section_id'] ?? 0);
|
|
||||||
if ($id > 0) {
|
|
||||||
$map[$id] = $section['class_section_name'] ?? (string) $id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $map;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,906 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\DiscountVoucherModel;
|
|
||||||
use App\Models\DiscountUsageModel;
|
|
||||||
use App\Models\InvoiceModel;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Models\PaymentModel;
|
|
||||||
use App\Models\EnrollmentModel;
|
|
||||||
use App\Models\EventChargesModel;
|
|
||||||
use App\Models\AdditionalChargeModel;
|
|
||||||
use App\Models\ClassSectionModel;
|
|
||||||
use CodeIgniter\Events\Events;
|
|
||||||
|
|
||||||
class DiscountController extends BaseController
|
|
||||||
{
|
|
||||||
protected $configModel;
|
|
||||||
protected $voucherModel;
|
|
||||||
protected $invoiceModel;
|
|
||||||
protected $userModel;
|
|
||||||
protected $db;
|
|
||||||
protected $schoolYear;
|
|
||||||
protected $semester;
|
|
||||||
protected $paymentModel;
|
|
||||||
protected $enrollmentModel;
|
|
||||||
protected $eventChargesModel;
|
|
||||||
protected $additionalChargeModel;
|
|
||||||
protected $classSectionModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->voucherModel = new DiscountVoucherModel();
|
|
||||||
$this->invoiceModel = new InvoiceModel();
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
$this->paymentModel = new PaymentModel();
|
|
||||||
$this->enrollmentModel = new EnrollmentModel();
|
|
||||||
$this->eventChargesModel = new EventChargesModel();
|
|
||||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
|
||||||
$this->classSectionModel = new ClassSectionModel();
|
|
||||||
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function applyVoucher()
|
|
||||||
{
|
|
||||||
if (strtolower($this->request->getMethod()) === 'post') {
|
|
||||||
$voucherId = $this->request->getPost('voucher_id');
|
|
||||||
$parentIds = $this->request->getPost('parent_ids') ?? [];
|
|
||||||
$allowAdditional = (bool) $this->request->getPost('allow_additional');
|
|
||||||
|
|
||||||
if (empty($parentIds)) {
|
|
||||||
return redirect()->back()->with('error', 'Please select at least one parent.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$voucher = $this->voucherModel->find($voucherId);
|
|
||||||
if (!$voucher) {
|
|
||||||
return redirect()->back()->with('error', 'Voucher not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize description from voucher (optional)
|
|
||||||
$voucherDescription = trim((string) ($voucher['description'] ?? ''));
|
|
||||||
$voucherDescription = preg_replace(
|
|
||||||
'/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i',
|
|
||||||
'',
|
|
||||||
$voucherDescription
|
|
||||||
);
|
|
||||||
|
|
||||||
$maxUsesRaw = $voucher['max_uses'] ?? null;
|
|
||||||
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
|
|
||||||
$timesUsed = (int) ($voucher['times_used'] ?? 0);
|
|
||||||
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
|
|
||||||
if ($remainingUses <= 0) {
|
|
||||||
return redirect()->back()->with('error', 'This voucher has reached its maximum allowed uses.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not allowing additional discounts, filter out parents who already have discounts this school year.
|
|
||||||
if (!$allowAdditional) {
|
|
||||||
$rows = $this->db->table('discount_usages du')
|
|
||||||
->select('i.parent_id')
|
|
||||||
->join('invoices i', 'i.id = du.invoice_id')
|
|
||||||
->whereIn('i.parent_id', $parentIds)
|
|
||||||
->where('i.school_year', $this->schoolYear)
|
|
||||||
->groupBy('i.parent_id')
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
$blockedParentIds = array_map(static fn($r) => (int) $r['parent_id'], $rows);
|
|
||||||
if (!empty($blockedParentIds)) {
|
|
||||||
$parentIds = array_values(array_diff(
|
|
||||||
array_map('intval', $parentIds),
|
|
||||||
$blockedParentIds
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (empty($parentIds)) {
|
|
||||||
return redirect()->back()->with('error', 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$paymentDate = utc_now();
|
|
||||||
|
|
||||||
// Collect invoice IDs that end up fully covered by the voucher in this run
|
|
||||||
$fullyCoveredInvoiceIds = [];
|
|
||||||
$touchedInvoiceIds = [];
|
|
||||||
$appliedCount = 0;
|
|
||||||
|
|
||||||
$this->db->transStart();
|
|
||||||
|
|
||||||
foreach ($parentIds as $parentId) {
|
|
||||||
// Fetch invoices for this parent & school year
|
|
||||||
$invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear);
|
|
||||||
if (empty($invoices)) {
|
|
||||||
// Do NOT early-return inside a transaction; just continue
|
|
||||||
session()->setFlashdata('error', 'A selected parent has no invoice in record.');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($invoices as $invoice) {
|
|
||||||
if ($remainingUses <= 0) break 2; // out of parentIds loop too
|
|
||||||
|
|
||||||
// Snapshot current balance BEFORE applying
|
|
||||||
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
|
||||||
if ($initialPreBalance <= 0) {
|
|
||||||
log_message(
|
|
||||||
'error',
|
|
||||||
'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}',
|
|
||||||
[
|
|
||||||
'vid' => (int)$voucherId,
|
|
||||||
'pid' => (int)$parentId,
|
|
||||||
'iid' => (int)$invoice['id'],
|
|
||||||
'inum' => (string)($invoice['invoice_number'] ?? ''),
|
|
||||||
'bal' => $initialPreBalance,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already used this voucher on this invoice?
|
|
||||||
if (!$allowAdditional) {
|
|
||||||
$exists = $this->db->table('discount_usages du')
|
|
||||||
->join('invoices i', 'du.invoice_id = i.id')
|
|
||||||
->where('du.voucher_id', $voucherId)
|
|
||||||
->where('du.invoice_id', $invoice['id'])
|
|
||||||
->where('i.school_year', $this->schoolYear)
|
|
||||||
->countAllResults();
|
|
||||||
if ($exists) {
|
|
||||||
log_message(
|
|
||||||
'error',
|
|
||||||
'applyVoucher skip: voucher already used | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum}',
|
|
||||||
[
|
|
||||||
'vid' => (int)$voucherId,
|
|
||||||
'pid' => (int)$parentId,
|
|
||||||
'iid' => (int)$invoice['id'],
|
|
||||||
'inum' => (string)($invoice['invoice_number'] ?? ''),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate discount
|
|
||||||
$rawDiscount = ($voucher['discount_type'] === 'percent')
|
|
||||||
? round(((float)$invoice['total_amount'] * (float)$voucher['discount_value']) / 100, 2)
|
|
||||||
: (float) $voucher['discount_value'];
|
|
||||||
|
|
||||||
// Cap by CURRENT invoice balance snapshot
|
|
||||||
$discount = min($rawDiscount, $initialPreBalance);
|
|
||||||
|
|
||||||
// Nothing to do if no discount
|
|
||||||
if ($discount <= 0) {
|
|
||||||
log_message(
|
|
||||||
'error',
|
|
||||||
'applyVoucher skip: discount <= 0 | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} raw_discount={raw} balance={bal}',
|
|
||||||
[
|
|
||||||
'vid' => (int)$voucherId,
|
|
||||||
'pid' => (int)$parentId,
|
|
||||||
'iid' => (int)$invoice['id'],
|
|
||||||
'inum' => (string)($invoice['invoice_number'] ?? ''),
|
|
||||||
'raw' => $rawDiscount,
|
|
||||||
'bal' => $initialPreBalance,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert discount usage
|
|
||||||
$now = utc_now();
|
|
||||||
$this->db->table('discount_usages')->insert([
|
|
||||||
'voucher_id' => $voucherId,
|
|
||||||
'invoice_id' => $invoice['id'],
|
|
||||||
'parent_id' => $parentId,
|
|
||||||
'discount_amount' => $discount,
|
|
||||||
'description' => $voucherDescription,
|
|
||||||
'school_year' => $this->schoolYear,
|
|
||||||
'updated_by' => session()->get('user_id'),
|
|
||||||
'used_at' => $now,
|
|
||||||
'created_at' => $now,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Update invoice balance based on pre-discount snapshot (supports multiple discounts)
|
|
||||||
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
|
|
||||||
$this->db->table('invoices')
|
|
||||||
->where('id', $invoice['id'])
|
|
||||||
->update([
|
|
||||||
'balance' => $newBalance,
|
|
||||||
'has_discount' => 1,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Compute post-balance based on pre-snapshot (more stable than $invoice['balance'])
|
|
||||||
$postBalance = round($initialPreBalance - $discount, 2);
|
|
||||||
if ($postBalance < 0) $postBalance = 0.0;
|
|
||||||
|
|
||||||
// (Optional) current balance re-check (in case of concurrent writes)
|
|
||||||
$currentBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
|
||||||
|
|
||||||
// Increment voucher usage
|
|
||||||
$this->db->table('discount_vouchers')
|
|
||||||
->where('id', $voucherId)
|
|
||||||
->set('times_used', 'COALESCE(times_used,0) + 1', false)
|
|
||||||
->update();
|
|
||||||
|
|
||||||
// Prepare and trigger payment event
|
|
||||||
[$eventData, $studentData] = $this->buildPaymentEventData(
|
|
||||||
$invoice['id'],
|
|
||||||
$voucherId,
|
|
||||||
$discount,
|
|
||||||
'discount',
|
|
||||||
$paymentDate,
|
|
||||||
'',
|
|
||||||
0,
|
|
||||||
$initialPreBalance, // pre-payment snapshot
|
|
||||||
$postBalance // computed post-payment
|
|
||||||
);
|
|
||||||
Events::trigger('paymentReceived', $eventData, $studentData);
|
|
||||||
|
|
||||||
$touchedInvoiceIds[] = (int) $invoice['id'];
|
|
||||||
$appliedCount++;
|
|
||||||
|
|
||||||
// If voucher covered the entire current balance, mark to update enrollments
|
|
||||||
// Use a small epsilon to tolerate cents rounding.
|
|
||||||
$epsilon = 0.01;
|
|
||||||
$fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon)
|
|
||||||
|| ($postBalance <= $epsilon)
|
|
||||||
|| ($currentBalance <= $epsilon);
|
|
||||||
|
|
||||||
if ($fullyCovered) {
|
|
||||||
$fullyCoveredInvoiceIds[] = (int) $invoice['id'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$remainingUses--;
|
|
||||||
|
|
||||||
// Deactivate if we just hit the cap
|
|
||||||
if ($remainingUses <= 0 && $maxUses !== null) {
|
|
||||||
$this->db->table('discount_vouchers')
|
|
||||||
->where('id', $voucherId)
|
|
||||||
->update([
|
|
||||||
'is_active' => 0,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
break 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->db->transComplete();
|
|
||||||
|
|
||||||
if ($this->db->transStatus() === false) {
|
|
||||||
return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($appliedCount === 0) {
|
|
||||||
return redirect()->back()->with('error', 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Ensure voucher deactivates when max_uses reached (handles edge cases)
|
|
||||||
if ($maxUses !== null) {
|
|
||||||
$row = $this->db->table('discount_vouchers')
|
|
||||||
->select('times_used')
|
|
||||||
->where('id', $voucherId)
|
|
||||||
->get()
|
|
||||||
->getRowArray();
|
|
||||||
$usedNow = (int) ($row['times_used'] ?? 0);
|
|
||||||
if ($usedNow >= $maxUses) {
|
|
||||||
$this->db->table('discount_vouchers')
|
|
||||||
->where('id', $voucherId)
|
|
||||||
->update([
|
|
||||||
'is_active' => 0,
|
|
||||||
'updated_at' => utc_now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund
|
|
||||||
foreach (array_unique($touchedInvoiceIds) as $iid) {
|
|
||||||
try {
|
|
||||||
$this->recalculateInvoice($iid, $this->schoolYear);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'recalculateInvoice failed for invoice {iid}: {err}', [
|
|
||||||
'iid' => $iid,
|
|
||||||
'err' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ AFTER COMMIT: update enrollments for fully-covered invoices
|
|
||||||
// Avoid nested transactions by doing this post-commit.
|
|
||||||
$updatedTotal = 0;
|
|
||||||
foreach (array_unique($fullyCoveredInvoiceIds) as $iid) {
|
|
||||||
try {
|
|
||||||
$updatedTotal += (int) $this->updateEnrollmentStatusIfPaid($iid);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [
|
|
||||||
'iid' => $iid,
|
|
||||||
'err' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($updatedTotal > 0) {
|
|
||||||
return redirect()->back()->with('success', 'Voucher applied successfully and enrollments updated.');
|
|
||||||
}
|
|
||||||
return redirect()->back()->with('success', 'Voucher applied successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: load page
|
|
||||||
$parents = $this->userModel->getParents();
|
|
||||||
|
|
||||||
foreach ($parents as &$parent) {
|
|
||||||
$discount = $this->db->table('discount_usages')
|
|
||||||
->select('COALESCE(SUM(discount_amount),0) AS total_discount')
|
|
||||||
->where('parent_id', $parent['id'])
|
|
||||||
->where('school_year', $this->schoolYear)
|
|
||||||
->get()
|
|
||||||
->getRowArray();
|
|
||||||
|
|
||||||
$parent['total_discount'] = $discount['total_discount'] ?? 0;
|
|
||||||
$parent['has_discount'] = ($parent['total_discount'] > 0) ? 1 : 0;
|
|
||||||
}
|
|
||||||
unset($parent);
|
|
||||||
|
|
||||||
return view('discounts/apply_voucher', [
|
|
||||||
'vouchers' => $this->voucherModel->where('is_active', 1)->findAll(),
|
|
||||||
'parents' => $parents,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function updateEnrollmentStatusIfPaid(int $invoiceId): int
|
|
||||||
{
|
|
||||||
// 1) Fetch invoice
|
|
||||||
$invoice = $this->invoiceModel->find($invoiceId);
|
|
||||||
if (!$invoice) {
|
|
||||||
log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Payment check (any payment recorded: balance < total)
|
|
||||||
$total = (float) ($invoice['total_amount'] ?? 0);
|
|
||||||
$balance = (float) ($invoice['balance'] ?? 0);
|
|
||||||
if (!($total > 0 && $balance < $total)) {
|
|
||||||
log_message('info', 'No payment yet. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
|
||||||
$schoolYear = (string) $this->schoolYear;
|
|
||||||
if ($parentId <= 0 || $schoolYear === '') {
|
|
||||||
log_message('warning', 'updateEnrollmentStatusIfPaid: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Try to limit to enrollments actually present on the invoice (optional)
|
|
||||||
// If invoice_items.enrollment_id doesn't exist, this silently falls back to parent/year scope.
|
|
||||||
$paidEnrollmentIds = [];
|
|
||||||
try {
|
|
||||||
$rows = $this->db->table('invoice_items')
|
|
||||||
->select('enrollment_id')
|
|
||||||
->where('invoice_id', $invoiceId)
|
|
||||||
->where('enrollment_id IS NOT NULL', null, false)
|
|
||||||
->get()->getResultArray();
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$eid = (int) ($r['enrollment_id'] ?? 0);
|
|
||||||
if ($eid > 0) $paidEnrollmentIds[] = $eid;
|
|
||||||
}
|
|
||||||
$paidEnrollmentIds = array_values(array_unique($paidEnrollmentIds));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
// No-op: table/column might not exist in your schema
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = $this->db;
|
|
||||||
$db->transBegin();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 4) Preview IDs that will be updated (good for debugging “partials”)
|
|
||||||
$previewBuilder = $db->table('enrollments')
|
|
||||||
->select('id')
|
|
||||||
->where('parent_id', $parentId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->groupStart()
|
|
||||||
// tolerant match for 'payment pending' (case/space/nbsp)
|
|
||||||
->where('enrollment_status', 'payment pending')
|
|
||||||
->orWhere('enrollment_status', 'Payment pending')
|
|
||||||
->orWhere('enrollment_status', 'Payment Pending')
|
|
||||||
->orWhere('enrollment_status', 'PAYMENT PENDING')
|
|
||||||
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false)
|
|
||||||
->groupEnd();
|
|
||||||
|
|
||||||
if (!empty($paidEnrollmentIds)) {
|
|
||||||
$previewBuilder->whereIn('id', $paidEnrollmentIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
$toUpdateIds = array_map(
|
|
||||||
static fn($r) => (int)$r['id'],
|
|
||||||
$previewBuilder->get()->getResultArray()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (empty($toUpdateIds)) {
|
|
||||||
$db->transCommit();
|
|
||||||
log_message('info', 'No enrollments matched pending status for parent {p}, year {y}, sem {s}.', [
|
|
||||||
'p' => $parentId,
|
|
||||||
'y' => $schoolYear,
|
|
||||||
's' => 'ALL'
|
|
||||||
]);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Perform update with same filters
|
|
||||||
$builder = $db->table('enrollments');
|
|
||||||
$builder->set('enrollment_status', 'enrolled')
|
|
||||||
// Use DB date if you prefer: ->set('enrollment_date', 'CURRENT_DATE()', false)
|
|
||||||
->set('enrollment_date', local_date(utc_now(), 'Y-m-d'))
|
|
||||||
->whereIn('id', $toUpdateIds);
|
|
||||||
|
|
||||||
if ($builder->update() === false) {
|
|
||||||
$err = $db->error();
|
|
||||||
throw new \RuntimeException('Enrollments update failed: ' . ($err['message'] ?? 'unknown DB error'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$affected = $db->affectedRows();
|
|
||||||
$db->transCommit();
|
|
||||||
|
|
||||||
log_message(
|
|
||||||
'info',
|
|
||||||
'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]',
|
|
||||||
[
|
|
||||||
'n' => $affected,
|
|
||||||
'p' => $parentId,
|
|
||||||
'y' => $schoolYear,
|
|
||||||
's' => 'ALL',
|
|
||||||
'ids' => implode(',', $toUpdateIds),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return $affected;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$db->transRollback();
|
|
||||||
log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function listVouchers()
|
|
||||||
{
|
|
||||||
|
|
||||||
$vouchers = $this->voucherModel->findAll();
|
|
||||||
return view('discounts/list', ['vouchers' => $vouchers]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createVoucher()
|
|
||||||
{
|
|
||||||
if (strtolower($this->request->getMethod()) === 'post') {
|
|
||||||
|
|
||||||
// -------- Gather & normalize inputs --------
|
|
||||||
$rawCode = (string) $this->request->getPost('code');
|
|
||||||
// Keep A–Z, 0–9 and dashes; uppercase for consistency
|
|
||||||
$code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim($rawCode)));
|
|
||||||
|
|
||||||
$discountType = strtolower((string) $this->request->getPost('discount_type')); // 'percent' | 'fixed'
|
|
||||||
$discountValue = (float) ($this->request->getPost('discount_value') ?? 0);
|
|
||||||
$maxUsesRaw = $this->request->getPost('max_uses');
|
|
||||||
$maxUses = ($maxUsesRaw === '' || $maxUsesRaw === null) ? null : max(0, (int) $maxUsesRaw);
|
|
||||||
|
|
||||||
$validFrom = trim((string) ($this->request->getPost('valid_from') ?? ''));
|
|
||||||
$validUntil = trim((string) ($this->request->getPost('valid_until') ?? ''));
|
|
||||||
$isActive = $this->request->getPost('is_active') ? 1 : 0;
|
|
||||||
|
|
||||||
// NEW: Description / Reason
|
|
||||||
$description = trim((string) ($this->request->getPost('description') ?? ''));
|
|
||||||
|
|
||||||
// -------- Basic validations (before model rules) --------
|
|
||||||
$errors = [];
|
|
||||||
|
|
||||||
if ($code === '') {
|
|
||||||
$errors[] = 'Voucher code is required.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!in_array($discountType, ['percent', 'fixed'], true)) {
|
|
||||||
$errors[] = 'Discount type must be "percent" or "fixed".';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($discountType === 'percent') {
|
|
||||||
if ($discountValue <= 0 || $discountValue > 100) {
|
|
||||||
$errors[] = 'Percentage discount must be between 0 and 100.';
|
|
||||||
}
|
|
||||||
} else { // fixed
|
|
||||||
if ($discountValue < 0) {
|
|
||||||
$errors[] = 'Fixed discount must be 0 or greater.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dates come from <input type="date"> as YYYY-MM-DD
|
|
||||||
$validFrom = $validFrom !== '' ? $validFrom : null;
|
|
||||||
$validUntil = $validUntil !== '' ? $validUntil : null;
|
|
||||||
|
|
||||||
// Ensure date format is plausible
|
|
||||||
$isDate = static function (?string $d): bool {
|
|
||||||
if ($d === null) return true;
|
|
||||||
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
|
|
||||||
};
|
|
||||||
if (!$isDate($validFrom)) $errors[] = 'Valid From must be a date (YYYY-MM-DD).';
|
|
||||||
if (!$isDate($validUntil)) $errors[] = 'Valid Until must be a date (YYYY-MM-DD).';
|
|
||||||
|
|
||||||
if ($validFrom && $validUntil && $validFrom > $validUntil) {
|
|
||||||
$errors[] = 'Valid Until must be the same as or after Valid From.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: require a reason when auto-generated codes are used
|
|
||||||
// if ($description === '' && strlen($code) === 10) {
|
|
||||||
// $errors[] = 'Please add a description/reason for this voucher.';
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (!empty($errors)) {
|
|
||||||
return redirect()->back()->withInput()->with('error', implode(' ', $errors));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Prepare payload for model --------
|
|
||||||
$data = [
|
|
||||||
'code' => $code,
|
|
||||||
'discount_type' => $discountType,
|
|
||||||
'discount_value' => $discountValue,
|
|
||||||
'max_uses' => $maxUses,
|
|
||||||
'valid_from' => $validFrom,
|
|
||||||
'valid_until' => $validUntil,
|
|
||||||
'is_active' => $isActive,
|
|
||||||
'description' => $description, // <-- NEW
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($this->voucherModel->save($data)) {
|
|
||||||
return redirect()->to('/discounts/list')->with('success', 'Voucher created successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model-level errors (unique code, etc.)
|
|
||||||
return redirect()->back()
|
|
||||||
->withInput()
|
|
||||||
->with('error', 'Failed to save voucher: ' . implode('; ', (array) $this->voucherModel->errors()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('discounts/create');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function editVoucher($id)
|
|
||||||
{
|
|
||||||
$voucher = $this->voucherModel->find($id);
|
|
||||||
|
|
||||||
if (!$voucher) {
|
|
||||||
return redirect()->to('discounts/list')->with('error', 'Voucher not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strtolower($this->request->getMethod()) === 'post') {
|
|
||||||
$data = [
|
|
||||||
'id' => $id,
|
|
||||||
'code' => $this->request->getPost('code'),
|
|
||||||
'discount_type' => $this->request->getPost('discount_type'),
|
|
||||||
'discount_value' => $this->request->getPost('discount_value'),
|
|
||||||
'max_uses' => $this->request->getPost('max_uses'),
|
|
||||||
'valid_from' => $this->request->getPost('valid_from') ?: null,
|
|
||||||
'valid_until' => $this->request->getPost('valid_until') ?: null,
|
|
||||||
'is_active' => $this->request->getPost('is_active') ? 1 : 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->voucherModel->save($data);
|
|
||||||
return redirect()->to('discounts/list')->with('success', 'Voucher updated successfully');
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('discounts/edit', ['voucher' => $voucher]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🔄 Helper: Current invoice balance (school-year scoped) = total - payments - discounts - refundsPaid
|
|
||||||
*/
|
|
||||||
private function getCurrentInvoiceBalance($invoiceId, $schoolYear)
|
|
||||||
{
|
|
||||||
$invoice = $this->invoiceModel->find($invoiceId);
|
|
||||||
if (!$invoice) return 0.0;
|
|
||||||
|
|
||||||
// Payments (exclude void/refund/failed, honor year)
|
|
||||||
$qb = $this->paymentModel
|
|
||||||
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
|
|
||||||
->where('invoice_id', $invoiceId)
|
|
||||||
->where('school_year', $schoolYear);
|
|
||||||
|
|
||||||
$table = $this->paymentModel->table;
|
|
||||||
$hasStatus = $this->db->fieldExists('status', $table);
|
|
||||||
$hasVoid = $this->db->fieldExists('is_void', $table);
|
|
||||||
if ($hasStatus) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
|
|
||||||
->orWhere('status IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
if ($hasVoid) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->where('is_void', 0)
|
|
||||||
->orWhere('is_void IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
$rowPaid = $qb->first();
|
|
||||||
$totalPaid = (float)($rowPaid['total_paid'] ?? 0);
|
|
||||||
|
|
||||||
// Discounts for this invoice in this year
|
|
||||||
$rowDisc = $this->db->table('discount_usages du')
|
|
||||||
->select('COALESCE(SUM(du.discount_amount),0) AS total_disc')
|
|
||||||
->join('invoices i', 'i.id = du.invoice_id')
|
|
||||||
->where('du.invoice_id', $invoiceId)
|
|
||||||
->where('i.school_year', $schoolYear)
|
|
||||||
->get()->getRowArray();
|
|
||||||
$totalDisc = (float)($rowDisc['total_disc'] ?? 0);
|
|
||||||
|
|
||||||
// Refunds PAID for this invoice in this year
|
|
||||||
$rowRefund = $this->db->table('refunds')
|
|
||||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
|
||||||
->where('invoice_id', $invoiceId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->whereIn('status', ['Partial', 'Paid'])
|
|
||||||
->get()->getRowArray();
|
|
||||||
$totalRefundPaid = (float)($rowRefund['total_refund_paid'] ?? 0);
|
|
||||||
|
|
||||||
$total = (float)($invoice['total_amount'] ?? 0);
|
|
||||||
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🔄 Recalculate invoice totals and status based on all payments for current school year
|
|
||||||
*/
|
|
||||||
private function recalculateInvoice($invoiceId, $schoolYear): void
|
|
||||||
{
|
|
||||||
$invoice = $this->invoiceModel->find($invoiceId);
|
|
||||||
if (!$invoice) return;
|
|
||||||
|
|
||||||
$parentId = (int)($invoice['parent_id'] ?? 0);
|
|
||||||
if ($parentId <= 0) return;
|
|
||||||
|
|
||||||
// ---- Tuition (recompute from enrollments) ----
|
|
||||||
$enrollments = $this->enrollmentModel
|
|
||||||
->where('parent_id', $parentId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$registered = [];
|
|
||||||
$withdrawn = [];
|
|
||||||
foreach ($enrollments as $e) {
|
|
||||||
$row = [
|
|
||||||
'student_id' => (int)($e['student_id'] ?? 0),
|
|
||||||
'class_section_id' => $e['class_section_id'] ?? null,
|
|
||||||
'enrollment_status'=> (string)($e['enrollment_status'] ?? ''),
|
|
||||||
];
|
|
||||||
if (in_array($row['enrollment_status'], ['enrolled','payment pending'], true)) {
|
|
||||||
$registered[] = $row;
|
|
||||||
} elseif (in_array($row['enrollment_status'], ['withdrawn','refund pending','withdraw under review'], true)) {
|
|
||||||
$withdrawn[] = $row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refund window check – if after deadline, withdrawn still billed
|
|
||||||
$refundDeadline = (string)($this->configModel->getConfig('refund_deadline') ?? '');
|
|
||||||
$refundAllowed = true;
|
|
||||||
try {
|
|
||||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
||||||
$tz = new \DateTimeZone($tzName);
|
|
||||||
$today = new \DateTimeImmutable('today', $tz);
|
|
||||||
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
|
|
||||||
$refundAllowed = $today <= $deadline;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$refundAllowed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tuitionStudents = $registered;
|
|
||||||
if (!$refundAllowed) {
|
|
||||||
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grade threshold and fees
|
|
||||||
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
|
|
||||||
$firstStudentFee = (float)($this->configModel->getConfig('first_student_fee') ?? 350);
|
|
||||||
$secondStudentFee = (float)($this->configModel->getConfig('second_student_fee') ?? 200);
|
|
||||||
$youthFee = (float)($this->configModel->getConfig('youth_fee') ?? 180);
|
|
||||||
|
|
||||||
// Normalize grades for tuition students
|
|
||||||
foreach ($tuitionStudents as &$s) {
|
|
||||||
$name = null;
|
|
||||||
if (!empty($s['class_section_id'])) {
|
|
||||||
$name = $this->classSectionModel->getClassSectionNameBySectionId($s['class_section_id']);
|
|
||||||
}
|
|
||||||
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
|
|
||||||
}
|
|
||||||
unset($s);
|
|
||||||
|
|
||||||
// Count regular vs youth and compute tuition
|
|
||||||
$regularCount = 0;
|
|
||||||
$youthCount = 0;
|
|
||||||
foreach ($tuitionStudents as $s) {
|
|
||||||
$lvl = $this->parseGradeLevel($s['grade_name']);
|
|
||||||
if ($lvl > $gradeFee) $youthCount++; else $regularCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tuitionSubtotal = 0.0;
|
|
||||||
$tuitionSubtotal += $youthCount * $youthFee;
|
|
||||||
if ($regularCount >= 2) {
|
|
||||||
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
|
|
||||||
} elseif ($regularCount === 1) {
|
|
||||||
$tuitionSubtotal += $firstStudentFee;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Event charges (parent-year) ----
|
|
||||||
$eventSubtotal = 0.0;
|
|
||||||
try {
|
|
||||||
$events = $this->eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear) ?? [];
|
|
||||||
foreach ($events as $ev) { $eventSubtotal += (float)($ev['charged'] ?? 0.0); }
|
|
||||||
} catch (\Throwable $e) {}
|
|
||||||
|
|
||||||
// ---- Additional charges (per-invoice) ----
|
|
||||||
$additionalSubtotal = 0.0;
|
|
||||||
try {
|
|
||||||
$rows = $this->additionalChargeModel
|
|
||||||
->select('charge_type, amount')
|
|
||||||
->where('invoice_id', $invoiceId)
|
|
||||||
->where('status', 'applied')
|
|
||||||
->findAll();
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$amt = (float)($r['amount'] ?? 0);
|
|
||||||
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
|
|
||||||
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
|
|
||||||
$additionalSubtotal += $amt;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {}
|
|
||||||
|
|
||||||
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
|
|
||||||
|
|
||||||
// ---- Payments / Discounts / Refunds ----
|
|
||||||
$db = $this->db;
|
|
||||||
$table = $this->paymentModel->table;
|
|
||||||
$hasStatus = $db->fieldExists('status', $table);
|
|
||||||
$hasVoid = $db->fieldExists('is_void', $table);
|
|
||||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
|
||||||
|
|
||||||
$qb = $this->paymentModel
|
|
||||||
->where('invoice_id', $invoiceId)
|
|
||||||
->where('school_year', $schoolYear);
|
|
||||||
|
|
||||||
if ($hasStatus) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->whereNotIn('status', $exclude)
|
|
||||||
->orWhere('status IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasVoid) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->where('is_void', 0)
|
|
||||||
->orWhere('is_void IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
$payments = $qb->findAll();
|
|
||||||
$totalPaid = 0.0;
|
|
||||||
foreach ($payments as $p) { $totalPaid += (float)($p['paid_amount'] ?? 0); }
|
|
||||||
|
|
||||||
$discRow = $this->db->table('discount_usages')
|
|
||||||
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
|
|
||||||
->where('invoice_id', $invoiceId)
|
|
||||||
->get()->getRowArray();
|
|
||||||
$totalDisc = (float)($discRow['total_disc'] ?? 0);
|
|
||||||
|
|
||||||
$refundRow = $this->db->table('refunds')
|
|
||||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
|
||||||
->where('invoice_id', $invoiceId)
|
|
||||||
->whereIn('status', ['Partial','Paid'])
|
|
||||||
->get()->getRowArray();
|
|
||||||
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
|
|
||||||
|
|
||||||
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
|
|
||||||
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
|
|
||||||
|
|
||||||
$updateData = [
|
|
||||||
'total_amount' => $newTotal,
|
|
||||||
'paid_amount' => $totalPaid,
|
|
||||||
'balance' => $newBalance,
|
|
||||||
'status' => $newStatus,
|
|
||||||
'has_discount' => ($totalDisc > 0.0) ? 1 : 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($this->db->fieldExists('discount', $this->invoiceModel->table)) {
|
|
||||||
$updateData['discount'] = $totalDisc;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->invoiceModel->update($invoiceId, $updateData);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a grade name into an integer level for tuition rules.
|
|
||||||
* Kindergarten -> 1, Youth -> > 9, numeric grades passthrough.
|
|
||||||
*/
|
|
||||||
private function parseGradeLevel($grade): int
|
|
||||||
{
|
|
||||||
if (is_numeric($grade)) return (int)$grade;
|
|
||||||
if (!is_string($grade)) return 999;
|
|
||||||
$g = strtoupper(trim((string)$grade));
|
|
||||||
$g = preg_replace('/\s+/', ' ', $g);
|
|
||||||
$g = str_replace(['.', '_', '-'], ['', '', ' '], $g);
|
|
||||||
// KG/K/Kindergarten
|
|
||||||
$kg = ['K','KG','K G','KINDER','KINDERGARTEN'];
|
|
||||||
if (in_array($g, $kg, true)) return 1;
|
|
||||||
// Pre-K -> treat below regular
|
|
||||||
$pk = ['PK','P K','PREK','PRE K','PRE KINDER','PREKINDER'];
|
|
||||||
if (in_array($g, $pk, true)) return -1;
|
|
||||||
// Youth variants: Y, YOUTH, Y1, YOUTH2 -> map to 10+
|
|
||||||
if (preg_match('/^Y(?:OUTH)?\s*(\d+)?$/', $g, $m)) {
|
|
||||||
$n = isset($m[1]) && $m[1] !== '' ? max(1, (int)$m[1]) : 1;
|
|
||||||
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
|
|
||||||
return $gradeFee + $n;
|
|
||||||
}
|
|
||||||
if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $g, $m)) {
|
|
||||||
return (int)$m[1];
|
|
||||||
}
|
|
||||||
return 999;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect parent/invoice/payment data to trigger handlePaymentReceived().
|
|
||||||
*
|
|
||||||
* @return array [$data, $studentdata]
|
|
||||||
*/
|
|
||||||
private function buildPaymentEventData(
|
|
||||||
int $invoiceId,
|
|
||||||
string $transactionIdOrRef,
|
|
||||||
float $amount,
|
|
||||||
string $paymentMethod,
|
|
||||||
string $paymentDate,
|
|
||||||
?string $checkNumber,
|
|
||||||
int $installmentSeq,
|
|
||||||
float $preBalance, // ✅ new param: initial (pre-payment) balance
|
|
||||||
float $postBalance // ✅ new param: computed post-payment balance
|
|
||||||
): array {
|
|
||||||
$invoice = $this->invoiceModel->find($invoiceId);
|
|
||||||
if (!$invoice) {
|
|
||||||
throw new \RuntimeException("Invoice {$invoiceId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
$parentRow = $this->db->table('users')
|
|
||||||
->select('id, firstname, lastname, email')
|
|
||||||
->where('id', $invoice['parent_id'])
|
|
||||||
->get()
|
|
||||||
->getRowArray();
|
|
||||||
|
|
||||||
if (!$parentRow) {
|
|
||||||
throw new \RuntimeException("Parent not found for invoice {$invoiceId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Students (adjust joins to your schema)
|
|
||||||
$studentRows = $this->db->table('students s')
|
|
||||||
->select('s.id, s.firstname, s.lastname, sc.class_section_id')
|
|
||||||
->join('student_class sc', 'sc.student_id = s.id', 'left')
|
|
||||||
->where('s.parent_id', $invoice['parent_id'])
|
|
||||||
->where('sc.school_year', $this->schoolYear)
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'user_id' => (int) $parentRow['id'],
|
|
||||||
'email' => $parentRow['email'],
|
|
||||||
'firstname' => $parentRow['firstname'],
|
|
||||||
'lastname' => $parentRow['lastname'],
|
|
||||||
'school_year' => $this->schoolYear,
|
|
||||||
'semester' => $this->semester,
|
|
||||||
'invoice_id' => $invoiceId,
|
|
||||||
'transaction_id' => $transactionIdOrRef,
|
|
||||||
'payment_date' => $paymentDate,
|
|
||||||
'amount' => $amount,
|
|
||||||
'method' => $paymentMethod,
|
|
||||||
'check_number' => $checkNumber,
|
|
||||||
'installment_seq' => $installmentSeq,
|
|
||||||
'invoice_total' => (float) $invoice['total_amount'],
|
|
||||||
'pre_balance' => $preBalance, // ✅ the captured pre-payment balance
|
|
||||||
'post_balance' => $postBalance, // ✅ computed post-payment balance
|
|
||||||
'portalLink' => site_url('parent/invoices/' . $invoiceId),
|
|
||||||
];
|
|
||||||
|
|
||||||
return [$data, $studentRows];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Controllers\View\EmailController;
|
||||||
|
|
||||||
|
class EmailService
|
||||||
|
{
|
||||||
|
protected array $senders;
|
||||||
|
protected EmailController $mailer;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$json = env('MAIL_SENDERS', '{}');
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
$this->senders = is_array($decoded) ? $decoded : [];
|
||||||
|
$this->mailer = new EmailController();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSenderDetails(string $fromKey = 'general'): array
|
||||||
|
{
|
||||||
|
$senders = $this->senders;
|
||||||
|
if (isset($senders[$fromKey])) {
|
||||||
|
return $senders[$fromKey];
|
||||||
|
}
|
||||||
|
return $senders['general'] ?? [
|
||||||
|
'email' => getenv('SMTP_USER') ?: 'no-reply@alrahmaisgl.org',
|
||||||
|
'name' => 'Al Rahma Sunday School',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NEW: expose configured sender keys to build a dropdown in the UI */
|
||||||
|
public function listSenders(): array
|
||||||
|
{
|
||||||
|
// returns: ['general' => ['email' => '...', 'name' => '...'], 'finance' => [...], ...]
|
||||||
|
return $this->senders ?: [
|
||||||
|
'general' => [
|
||||||
|
'email' => getenv('SMTP_USER') ?: 'no-reply@alrahmaisgl.org',
|
||||||
|
'name' => 'Al Rahma Sunday School',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a single HTML email.
|
||||||
|
* Backward-compatible: you can ignore $attachments/$headers.
|
||||||
|
*/
|
||||||
|
public function send(string $to, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool
|
||||||
|
{
|
||||||
|
$attachmentsPayload = $this->normalizeAttachments($attachments);
|
||||||
|
// Headers are ignored by EmailController but kept for interface compatibility.
|
||||||
|
return $this->mailer->sendEmail($to, $subject, $message, $fromKey, null, null, $attachmentsPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NEW: BCC bulk send in a single SMTP call (To must be non-empty; we use the SMTP user).
|
||||||
|
*/
|
||||||
|
public function sendBcc(array $bccList, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool
|
||||||
|
{
|
||||||
|
if (empty($bccList)) {
|
||||||
|
return true; // nothing to send
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachmentsPayload = $this->normalizeAttachments($attachments);
|
||||||
|
$allOk = true;
|
||||||
|
foreach ($bccList as $addr) {
|
||||||
|
$ok = $this->mailer->sendEmail($addr, $subject, $message, $fromKey, null, null, $attachmentsPayload);
|
||||||
|
if (!$ok) {
|
||||||
|
$allOk = false;
|
||||||
|
log_message('error', 'EmailService::sendBcc failed for ' . $addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $allOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize various attachment inputs to the format expected by EmailController.
|
||||||
|
*/
|
||||||
|
private function normalizeAttachments(array $attachments): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($attachments as $file) {
|
||||||
|
if (is_object($file) && method_exists($file, 'isValid') && $file->isValid()) {
|
||||||
|
$newName = $file->getRandomName();
|
||||||
|
$path = WRITEPATH . 'uploads/' . $newName;
|
||||||
|
$file->move(WRITEPATH . 'uploads', $newName, true);
|
||||||
|
$out[] = ['path' => $path, 'name' => $file->getClientName()];
|
||||||
|
} elseif (is_string($file) && is_file($file)) {
|
||||||
|
$out[] = ['path' => $file, 'name' => basename($file)];
|
||||||
|
} elseif (is_array($file) && !empty($file['path'])) {
|
||||||
|
$out[] = ['path' => $file['path'], 'name' => $file['name'] ?? basename($file['path'])];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\ExpenseModel;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
|
||||||
|
|
||||||
class ExpenseController extends BaseController
|
|
||||||
{
|
|
||||||
protected $expenseModel;
|
|
||||||
protected $userModel;
|
|
||||||
protected $configModel;
|
|
||||||
protected $schoolYear;
|
|
||||||
protected $semester;
|
|
||||||
protected $retailors;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->expenseModel = new ExpenseModel();
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
|
|
||||||
// Default list of common retailors; adjust as needed
|
|
||||||
$this->retailors = [
|
|
||||||
'Amazon',
|
|
||||||
'Walmart',
|
|
||||||
'Costco',
|
|
||||||
'BJ\'s',
|
|
||||||
'Market Basket',
|
|
||||||
'Aldi',
|
|
||||||
'Hannaford',
|
|
||||||
'Sam\'s Club',
|
|
||||||
'HomeGoods',
|
|
||||||
'Hostinger',
|
|
||||||
'Wicked Cheesy',
|
|
||||||
'Shatila',
|
|
||||||
'Brothers Pizzeria',
|
|
||||||
'Paradise Biryani Pointe',
|
|
||||||
'Emad Leiman',
|
|
||||||
'Nova Trampoline Park',
|
|
||||||
'Lubin\'s Awards',
|
|
||||||
'Dollar Tree',
|
|
||||||
'Stop & Shop',
|
|
||||||
'Dunkin\' Donuts',
|
|
||||||
'Giovanni\'s Pizza',
|
|
||||||
'Trader Joes'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return staff users (admins/teachers/etc.) excluding parents/guests.
|
|
||||||
*/
|
|
||||||
private function staffUsers(): array
|
|
||||||
{
|
|
||||||
$rows = $this->userModel
|
|
||||||
->select('users.id, users.firstname, users.lastname, roles.name AS role_name')
|
|
||||||
->join('user_roles', 'user_roles.user_id = users.id', 'left')
|
|
||||||
->join('roles', 'roles.id = user_roles.role_id', 'left')
|
|
||||||
->where('roles.name IS NOT NULL', null, false)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$excludedRoles = array_map('strtolower', ['parent', 'student', 'guest']);
|
|
||||||
$staff = [];
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$roleName = strtolower((string) ($row['role_name'] ?? ''));
|
|
||||||
$id = (int) ($row['id'] ?? 0);
|
|
||||||
if ($id <= 0 || in_array($roleName, $excludedRoles, true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isset($staff[$id])) {
|
|
||||||
$staff[$id] = [
|
|
||||||
'id' => $id,
|
|
||||||
'firstname' => $row['firstname'] ?? '',
|
|
||||||
'lastname' => $row['lastname'] ?? '',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uasort($staff, static function ($a, $b) {
|
|
||||||
$nameA = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
|
|
||||||
$nameB = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
|
|
||||||
return strcasecmp($nameA, $nameB);
|
|
||||||
});
|
|
||||||
|
|
||||||
return array_values($staff);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
$expenses = $this->expenseModel
|
|
||||||
->select("
|
|
||||||
expenses.*,
|
|
||||||
u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname,
|
|
||||||
approver.firstname AS approver_firstname, approver.lastname AS approver_lastname
|
|
||||||
")
|
|
||||||
->join('users u', 'u.id = expenses.purchased_by', 'left')
|
|
||||||
->join('users approver', 'approver.id = expenses.approved_by', 'left')
|
|
||||||
->orderBy('expenses.created_at', 'DESC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
// Enrich each row with a URL that goes through Files::receipt($name)
|
|
||||||
// We store only the filename in 'receipt_path' (e.g., "1759...f62.png")
|
|
||||||
$expenses = array_map(function ($row) {
|
|
||||||
$name = $row['receipt_path'] ?? null;
|
|
||||||
$row['receipt_url'] = $this->receiptUrl($name);
|
|
||||||
return $row;
|
|
||||||
}, $expenses);
|
|
||||||
|
|
||||||
return view('expenses/index', ['expenses' => $expenses]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create()
|
|
||||||
{
|
|
||||||
$users = $this->staffUsers();
|
|
||||||
|
|
||||||
return view('expenses/create', [
|
|
||||||
'users' => $users,
|
|
||||||
'retailors' => $this->retailors,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store()
|
|
||||||
{
|
|
||||||
$rules = [
|
|
||||||
'category' => 'required|in_list[Expense,Purchase,Reimbursement,Donation]',
|
|
||||||
'amount' => 'required|decimal|greater_than[0]',
|
|
||||||
// Frontend sends purchased_by as "id|Full Name"
|
|
||||||
'purchased_by' => 'required',
|
|
||||||
// Optional extra fields
|
|
||||||
'retailor' => 'permit_empty|max_length[255]',
|
|
||||||
'date_of_purchase' => 'permit_empty',
|
|
||||||
// allow JPG/JPEG/PNG/WEBP/GIF and PDF up to 2MB
|
|
||||||
'receipt' => 'uploaded[receipt]'
|
|
||||||
. '|max_size[receipt,2048]'
|
|
||||||
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
|
|
||||||
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]',
|
|
||||||
];
|
|
||||||
|
|
||||||
$messages = [
|
|
||||||
'receipt' => [
|
|
||||||
'uploaded' => 'Receipt file is required.',
|
|
||||||
'max_size' => 'Maximum file size is 2MB.',
|
|
||||||
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
|
||||||
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
|
||||||
]
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!$this->validate($rules, $messages)) {
|
|
||||||
return redirect()->back()->withInput()->with('error', $this->validator->listErrors());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safe values
|
|
||||||
$category = (string) $this->request->getPost('category');
|
|
||||||
$amount = (string) $this->request->getPost('amount');
|
|
||||||
$description = (string) $this->request->getPost('description');
|
|
||||||
$retailor = trim((string) $this->request->getPost('retailor'));
|
|
||||||
$datePurchase = (string) $this->request->getPost('date_of_purchase');
|
|
||||||
$userId = (int) (session()->get('user_id') ?? 0);
|
|
||||||
$isDonation = ($category === 'Donation');
|
|
||||||
|
|
||||||
// Parse "purchased_by" as "7|John Doe"
|
|
||||||
$purchasedInfo = (string) $this->request->getPost('purchased_by');
|
|
||||||
[$purchasedById, $purchasedByName] = array_pad(explode('|', $purchasedInfo, 2), 2, null);
|
|
||||||
$purchasedById = (int) $purchasedById;
|
|
||||||
|
|
||||||
// School context
|
|
||||||
$schoolYear = $this->schoolYear ?: date('Y');
|
|
||||||
$semester = $this->semester ?: 'Fall';
|
|
||||||
|
|
||||||
// Handle upload: store under writable/uploads/receipts and save only the filename
|
|
||||||
$receiptName = null;
|
|
||||||
$file = $this->request->getFile('receipt');
|
|
||||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
|
||||||
$stored = $file->store('receipts'); // -> writable/uploads/receipts/<randomname>.ext
|
|
||||||
$receiptName = basename($stored);
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = $isDonation ? 'approved' : 'pending';
|
|
||||||
$statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null;
|
|
||||||
|
|
||||||
$this->expenseModel->insert([
|
|
||||||
'category' => $category,
|
|
||||||
'amount' => $amount,
|
|
||||||
'receipt_path' => $receiptName, // filename only
|
|
||||||
'description' => $description,
|
|
||||||
'retailor' => ($retailor !== '') ? $retailor : null,
|
|
||||||
'date_of_purchase' => ($datePurchase !== '') ? $datePurchase : null,
|
|
||||||
'purchased_by' => $purchasedById,
|
|
||||||
'added_by' => $userId,
|
|
||||||
'status' => $status,
|
|
||||||
'status_reason'=> $statusReason,
|
|
||||||
'approved_by' => $isDonation ? $userId : null,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'semester' => $semester,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return redirect()->to('/expenses/index')->with('success', 'Record added successfully!');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function updateStatus()
|
|
||||||
{
|
|
||||||
$data = $this->request->getJSON(true);
|
|
||||||
$id = isset($data['id']) ? (int)$data['id'] : null;
|
|
||||||
$status = $data['status'] ?? null;
|
|
||||||
$reason = $data['reason'] ?? '';
|
|
||||||
$userId = (int) (session()->get('user_id') ?? 0);
|
|
||||||
|
|
||||||
if (!$id || !in_array($status, ['approved', 'denied'], true)) {
|
|
||||||
log_message('error', 'Invalid status or ID');
|
|
||||||
return $this->response->setJSON(['error' => 'Invalid data']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$expense = $this->expenseModel->find($id);
|
|
||||||
if (!$expense) {
|
|
||||||
log_message('error', 'Expense not found for ID ' . $id);
|
|
||||||
return $this->response->setJSON(['error' => 'Expense not found']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$success = $this->expenseModel->update($id, [
|
|
||||||
'status' => $status,
|
|
||||||
'status_reason' => $reason,
|
|
||||||
'approved_by' => $userId,
|
|
||||||
'updated_by' => $userId
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!$success) {
|
|
||||||
log_message('error', 'Expense update failed for ID ' . $id);
|
|
||||||
return $this->response->setJSON(['error' => 'Update failed']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->response->setJSON(['success' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a public URL for a receipt filename through Files::receipt($name).
|
|
||||||
* Expects just the filename (e.g., "1759113425_1c443e607e1900f92f62.png").
|
|
||||||
*/
|
|
||||||
private function receiptUrl(?string $filename): ?string
|
|
||||||
{
|
|
||||||
if (!$filename) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Route should be defined as: $routes->get('receipts/(:any)', 'Files::receipt/$1');
|
|
||||||
return site_url('receipts/' . $filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function edit(int $id)
|
|
||||||
{
|
|
||||||
$expense = $this->expenseModel->find($id);
|
|
||||||
if (!$expense) {
|
|
||||||
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// same user list you use in create()
|
|
||||||
$users = $this->staffUsers();
|
|
||||||
|
|
||||||
return view('expenses/edit', [
|
|
||||||
'expense' => $expense,
|
|
||||||
'users' => $users,
|
|
||||||
'retailors' => $this->retailors,
|
|
||||||
'receipt_url' => $expense['receipt_path'] ? site_url('receipts/' . basename($expense['receipt_path'])) : null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(int $id)
|
|
||||||
{
|
|
||||||
helper(['form']);
|
|
||||||
|
|
||||||
$expense = $this->expenseModel->find($id);
|
|
||||||
if (!$expense) {
|
|
||||||
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base rules
|
|
||||||
$rules = [
|
|
||||||
'category' => 'required|in_list[Expense,Purchase,Reimbursement,Donation]',
|
|
||||||
'amount' => 'required|decimal|greater_than[0]',
|
|
||||||
'purchased_by' => 'required', // still "id|Full Name"
|
|
||||||
'retailor' => 'permit_empty|max_length[255]',
|
|
||||||
'date_of_purchase' => 'permit_empty',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Optional new receipt validation (only if provided)
|
|
||||||
$file = $this->request->getFile('receipt');
|
|
||||||
if ($file && $file->isValid() && ($file->getSize() ?? 0) > 0) {
|
|
||||||
$rules['receipt'] = 'max_size[receipt,2048]'
|
|
||||||
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
|
|
||||||
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$this->validate($rules)) {
|
|
||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse "id|Name"
|
|
||||||
[$purchasedById] = array_pad(explode('|', (string) $this->request->getPost('purchased_by'), 2), 2, null);
|
|
||||||
$purchasedById = (int) $purchasedById;
|
|
||||||
$category = (string) $this->request->getPost('category');
|
|
||||||
$isDonation = ($category === 'Donation');
|
|
||||||
$userId = (int) (session()->get('user_id') ?? 0);
|
|
||||||
|
|
||||||
// Keep old receipt unless replaced or removed
|
|
||||||
$receiptName = $expense['receipt_path'];
|
|
||||||
if ($file && $file->isValid() && !$file->hasMoved() && ($file->getSize() ?? 0) > 0) {
|
|
||||||
$stored = $file->store('receipts');
|
|
||||||
$receiptName = basename($stored);
|
|
||||||
}
|
|
||||||
if ($this->request->getPost('remove_receipt') === '1') {
|
|
||||||
$receiptName = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updateData = [
|
|
||||||
'category' => $category,
|
|
||||||
'amount' => (string) $this->request->getPost('amount'),
|
|
||||||
'description' => (string) $this->request->getPost('description'),
|
|
||||||
'retailor' => trim((string) $this->request->getPost('retailor')) ?: null,
|
|
||||||
'date_of_purchase' => (string) $this->request->getPost('date_of_purchase') ?: null,
|
|
||||||
'purchased_by' => $purchasedById,
|
|
||||||
'receipt_path' => $receiptName,
|
|
||||||
'updated_by' => $userId,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($isDonation) {
|
|
||||||
$updateData['status'] = 'approved';
|
|
||||||
$updateData['status_reason'] = 'Marked as Donation (non-reimbursable).';
|
|
||||||
$updateData['approved_by'] = $userId ?: null;
|
|
||||||
$updateData['reimbursement_id'] = null;
|
|
||||||
} elseif (($expense['category'] ?? '') === 'Donation') {
|
|
||||||
// Moving a donation back to a reimbursable category: clear the marker.
|
|
||||||
$updateData['status_reason'] = null;
|
|
||||||
$updateData['approved_by'] = $expense['approved_by'] ?? null;
|
|
||||||
$updateData['status'] = $expense['status'] ?? 'pending';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->expenseModel->update($id, $updateData);
|
|
||||||
|
|
||||||
return redirect()->to('/expenses/index')->with('success', 'Expense updated.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,588 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\AdditionalChargeModel;
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use CodeIgniter\Events\Events;
|
|
||||||
use App\Models\InvoiceModel;
|
|
||||||
use CodeIgniter\I18n\Time;
|
|
||||||
|
|
||||||
class ExtraChargesController extends BaseController
|
|
||||||
{
|
|
||||||
protected $additionalChargeModel;
|
|
||||||
protected $db;
|
|
||||||
protected $configModel;
|
|
||||||
protected $semester;
|
|
||||||
protected $schoolYear;
|
|
||||||
protected $attendanceDataModel;
|
|
||||||
protected $studentModel;
|
|
||||||
protected $emergencyContactModel;
|
|
||||||
protected $userModel;
|
|
||||||
protected $teacherClassSection;
|
|
||||||
protected $invoiceModel;
|
|
||||||
protected $classSectionModel;
|
|
||||||
protected $studentClassModel;
|
|
||||||
protected $enableAttendance;
|
|
||||||
protected $attendanceDayModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
// Initialize the database connection in the constructor
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->invoiceModel = new InvoiceModel();
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
// API-first: delegate to JSON list endpoint
|
|
||||||
return $this->apiList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render HTML management page */
|
|
||||||
public function page()
|
|
||||||
{
|
|
||||||
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
|
||||||
$status = $this->request->getGet('status') ?: null;
|
|
||||||
$yearSelect = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
|
||||||
|
|
||||||
$rows = [];
|
|
||||||
if ($parentId > 0) {
|
|
||||||
$rows = $this->additionalChargeModel
|
|
||||||
->byParentTerm($parentId, $this->schoolYear, $this->semester, $status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load ALL parents for current view
|
|
||||||
$parents = $this->userModel->select('users.id, users.firstname, users.lastname')
|
|
||||||
->join('user_roles', 'user_roles.user_id = users.id')
|
|
||||||
->join('roles', 'roles.id = user_roles.role_id')
|
|
||||||
->where('LOWER(roles.name) =', 'parent')
|
|
||||||
->where('user_roles.deleted_at IS NULL', null, false)
|
|
||||||
->orderBy('users.lastname', 'ASC')
|
|
||||||
->orderBy('users.firstname', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
// Map parent id -> "Lastname, Firstname" (no email/ID shown)
|
|
||||||
$parentMap = [];
|
|
||||||
foreach ($parents as $p) {
|
|
||||||
$label = trim(($p['lastname'] ?? '') . ', ' . ($p['firstname'] ?? ''));
|
|
||||||
$parentMap[(int)$p['id']] = $label !== '' && $label !== ',' ? $label : ('User #' . (int)$p['id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔹 Get ALL invoices for ALL parents this year, group by parent_id
|
|
||||||
$parentIds = array_map(fn($r) => (int)$r['id'], $parents);
|
|
||||||
$invoicesByParent = [];
|
|
||||||
if (!empty($parentIds)) {
|
|
||||||
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $yearSelect);
|
|
||||||
foreach ($all as $inv) {
|
|
||||||
$pid = (int)$inv['parent_id'];
|
|
||||||
$invoicesByParent[$pid][] = [
|
|
||||||
'id' => (int)$inv['id'],
|
|
||||||
'invoice_number' => (string)$inv['invoice_number'],
|
|
||||||
'status' => (string)($inv['status'] ?? ''),
|
|
||||||
'balance' => (float)($inv['balance'] ?? 0),
|
|
||||||
'issue_date' => $inv['issue_date'] ?? null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Label for preselect (if filtering by a parent)
|
|
||||||
$selectedParentLabel = ($parentId > 0 && isset($parentMap[$parentId])) ? $parentMap[$parentId] : '';
|
|
||||||
|
|
||||||
// Optional filters
|
|
||||||
$status = $this->request->getGet('status') ?: null;
|
|
||||||
$q = trim((string)($this->request->getGet('q') ?? ''));
|
|
||||||
|
|
||||||
// ✅ Always pull all charges for the selected year & current semester (all parents)
|
|
||||||
$rows = $this->additionalChargeModel->listAllForTerm(
|
|
||||||
$yearSelect,
|
|
||||||
$this->semester,
|
|
||||||
$status,
|
|
||||||
$q,
|
|
||||||
50 // per-page
|
|
||||||
);
|
|
||||||
$pager = $this->additionalChargeModel->pager;
|
|
||||||
|
|
||||||
// Build school year options from data (additional_charges + invoices)
|
|
||||||
$schoolYears = [];
|
|
||||||
try {
|
|
||||||
$q1 = $this->db->table('additional_charges')->select('DISTINCT school_year', false)
|
|
||||||
->where('school_year IS NOT NULL', null, false)
|
|
||||||
->orderBy('school_year', 'DESC')->get()->getResultArray();
|
|
||||||
foreach ($q1 as $r) {
|
|
||||||
$val = (string)($r['school_year'] ?? '');
|
|
||||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {}
|
|
||||||
try {
|
|
||||||
$q2 = $this->db->table('invoices')->select('DISTINCT school_year', false)
|
|
||||||
->where('school_year IS NOT NULL', null, false)
|
|
||||||
->orderBy('school_year', 'DESC')->get()->getResultArray();
|
|
||||||
foreach ($q2 as $r) {
|
|
||||||
$val = (string)($r['school_year'] ?? '');
|
|
||||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {}
|
|
||||||
if (empty($schoolYears) && is_string($this->schoolYear) && $this->schoolYear !== '') {
|
|
||||||
// fallback: generate recent years around configured schoolYear
|
|
||||||
$schoolYears[] = $this->schoolYear;
|
|
||||||
// Optionally add previous/next
|
|
||||||
[$start, $end] = explode('-', $this->schoolYear) + [0 => date('Y'), 1 => date('Y')+1];
|
|
||||||
$start = (int)$start;
|
|
||||||
for ($i = 1; $i <= 3; $i++) {
|
|
||||||
$schoolYears[] = ($start - $i) . '-' . (($start - $i) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rsort($schoolYears);
|
|
||||||
|
|
||||||
return view('payment/extra_charges', [
|
|
||||||
'q' => $q,
|
|
||||||
'pager' => $pager,
|
|
||||||
'rows' => $rows,
|
|
||||||
'parents' => $parents,
|
|
||||||
'parentMap' => $parentMap,
|
|
||||||
'invoicesByParent' => $invoicesByParent, // 🔹 pass the grouped map
|
|
||||||
'parentId' => $parentId,
|
|
||||||
'selectedParentLabel' => $selectedParentLabel,
|
|
||||||
'status' => $status,
|
|
||||||
'schoolYear' => $yearSelect,
|
|
||||||
'schoolYears' => $schoolYears,
|
|
||||||
'semester' => $this->semester,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function wantsJson(): bool
|
|
||||||
{
|
|
||||||
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
|
|
||||||
return $this->request->isAJAX() || str_contains($accept, 'application/json');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Used by Select2 AJAX: returns {results:[{id,text}]} */
|
|
||||||
public function parentOptions()
|
|
||||||
{
|
|
||||||
$q = trim((string)($this->request->getGet('q') ?? ''));
|
|
||||||
$limit = 20;
|
|
||||||
|
|
||||||
$builder = $this->userModel
|
|
||||||
->select('users.id, users.firstname, users.lastname, users.email')
|
|
||||||
->join('user_roles', 'user_roles.user_id = users.id')
|
|
||||||
->join('roles', 'roles.id = user_roles.role_id')
|
|
||||||
// If your role name may have different case, use LOWER(...):
|
|
||||||
// ->where('LOWER(roles.name) =', 'parent')
|
|
||||||
->where('roles.name', 'parent')
|
|
||||||
// Make sure this produces IS NULL; if not, use the raw condition line below
|
|
||||||
->where('user_roles.deleted_at', null);
|
|
||||||
|
|
||||||
// Alternative guaranteed-IS-NULL:
|
|
||||||
// $builder->where('user_roles.deleted_at IS NULL', null, false);
|
|
||||||
|
|
||||||
if ($q !== '') {
|
|
||||||
$builder->groupStart()
|
|
||||||
->like('users.firstname', $q)
|
|
||||||
->orLike('users.lastname', $q)
|
|
||||||
->orLike('users.email', $q)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $builder
|
|
||||||
->orderBy('users.lastname', 'ASC')
|
|
||||||
->orderBy('users.firstname', 'ASC')
|
|
||||||
->limit($limit)
|
|
||||||
->find();
|
|
||||||
|
|
||||||
$results = array_map(function ($r) {
|
|
||||||
$name = trim(($r['lastname'] ?? '') . ', ' . ($r['firstname'] ?? ''));
|
|
||||||
$email = $r['email'] ?? '';
|
|
||||||
$label = $name !== ',' ? $name : ('User #' . $r['id']);
|
|
||||||
if ($email) $label .= ' — ' . $email;
|
|
||||||
$label .= ' (ID: ' . $r['id'] . ')';
|
|
||||||
return ['id' => (int)$r['id'], 'text' => $label];
|
|
||||||
}, $rows);
|
|
||||||
|
|
||||||
return $this->response->setJSON(['results' => $results]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper for preselect label in the modal */
|
|
||||||
private function getParentLabel(int $id): string
|
|
||||||
{
|
|
||||||
$p = $this->userModel->select('firstname, lastname, email')->find($id);
|
|
||||||
if (!$p) return 'User #' . $id;
|
|
||||||
$name = trim(($p['lastname'] ?? '') . ', ' . ($p['firstname'] ?? ''));
|
|
||||||
$email = $p['email'] ?? '';
|
|
||||||
$label = $name !== ',' ? $name : ('User #' . $id);
|
|
||||||
if ($email) $label .= ' — ' . $email;
|
|
||||||
return $label . ' (ID: ' . $id . ')';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update($id)
|
|
||||||
{
|
|
||||||
$row = $this->additionalChargeModel->find($id);
|
|
||||||
if (!$row) {
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
|
||||||
return redirect()->back()->with('error', 'Charge not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $this->request->getPost();
|
|
||||||
$newAmount = isset($data['amount']) ? (float)$data['amount'] : (float)$row['amount'];
|
|
||||||
$delta = $newAmount - (float)$row['amount'];
|
|
||||||
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
$db->transStart();
|
|
||||||
|
|
||||||
// Update the charge first
|
|
||||||
$this->additionalChargeModel->update($id, [
|
|
||||||
'title' => trim($data['title'] ?? $row['title']),
|
|
||||||
'description' => trim($data['description'] ?? $row['description']),
|
|
||||||
'amount' => $newAmount,
|
|
||||||
'due_date' => $data['due_date'] ?? $row['due_date'],
|
|
||||||
'charge_type' => $data['charge_type'] ?? $row['charge_type'],
|
|
||||||
// keep status as-is
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If it’s already applied on an invoice and the amount changed, reflect the delta
|
|
||||||
if ($row['status'] === 'applied' && !empty($row['invoice_id']) && abs($delta) > 0.00001) {
|
|
||||||
if ($delta > 0) {
|
|
||||||
$this->invoiceModel->applyAdditionalCharge((int)$row['invoice_id'], $delta);
|
|
||||||
} else {
|
|
||||||
$this->invoiceModel->reverseAdditionalCharge((int)$row['invoice_id'], -$delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$db->transComplete();
|
|
||||||
|
|
||||||
if (!$db->transStatus()) {
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to update charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
|
||||||
return redirect()->back()->with('error', 'Failed to update charge.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true, 'id' => (int)$id, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
|
||||||
return redirect()->back()->with('status', 'Charge updated.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// (removed duplicate model-like listAllForTerm method; use AdditionalChargeModel::listAllForTerm)
|
|
||||||
|
|
||||||
// ExtraChargesController.php
|
|
||||||
public function invoicesForParent()
|
|
||||||
{
|
|
||||||
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
|
||||||
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
|
||||||
|
|
||||||
$rows = ($parentId > 0)
|
|
||||||
? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? [])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
$results = array_map(function ($inv) {
|
|
||||||
$id = (int)($inv['id'] ?? 0);
|
|
||||||
$num = (string)($inv['invoice_number'] ?? ('INV-' . $id));
|
|
||||||
$status = strtolower((string)($inv['status'] ?? ''));
|
|
||||||
$balance = (float)($inv['balance'] ?? 0);
|
|
||||||
$issue = $inv['issue_date'] ?? null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $id,
|
|
||||||
'invoice_number' => $num,
|
|
||||||
'status' => $status,
|
|
||||||
'balance' => $balance,
|
|
||||||
'issue_date' => $issue,
|
|
||||||
'text' => $num
|
|
||||||
. ($status ? ' — ' . ucfirst($status) : '')
|
|
||||||
. ' (Bal: $' . number_format($balance, 2) . ')',
|
|
||||||
];
|
|
||||||
}, $rows);
|
|
||||||
|
|
||||||
return $this->response->setJSON(['results' => $results]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function store()
|
|
||||||
{
|
|
||||||
$data = $this->request->getPost();
|
|
||||||
|
|
||||||
$rules = [
|
|
||||||
'parent_id' => 'required|integer', // this is actually the user_id of the parent
|
|
||||||
'title' => 'required|string|min_length[2]',
|
|
||||||
'amount' => 'required|decimal',
|
|
||||||
'charge_type' => 'required|in_list[add,deduct]',
|
|
||||||
'due_date' => 'permit_empty|valid_date',
|
|
||||||
'invoice_id' => 'permit_empty|integer',
|
|
||||||
];
|
|
||||||
if (!$this->validate($rules)) {
|
|
||||||
$msg = implode(' ', $this->validator->getErrors());
|
|
||||||
if ($this->wantsJson()) {
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'ok' => false,
|
|
||||||
'error' => $msg,
|
|
||||||
'csrf_token' => csrf_token(),
|
|
||||||
'csrf_hash' => csrf_hash(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return redirect()->back()->withInput()->with('error', $msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
$invoiceId = !empty($data['invoice_id']) ? (int)$data['invoice_id'] : null;
|
|
||||||
$chargeType = (string)$data['charge_type'];
|
|
||||||
|
|
||||||
$amountAbs = round(abs((float)$data['amount']), 2);
|
|
||||||
$signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs;
|
|
||||||
|
|
||||||
$payload = [
|
|
||||||
'parent_id' => (int)$data['parent_id'], // ← users.id of the parent
|
|
||||||
'invoice_id' => $invoiceId,
|
|
||||||
'school_year' => $data['school_year'] ?? $this->schoolYear,
|
|
||||||
'semester' => $data['semester'] ?? $this->semester,
|
|
||||||
'charge_type' => $chargeType,
|
|
||||||
'title' => trim($data['title']),
|
|
||||||
'description' => trim($data['description'] ?? ''),
|
|
||||||
'amount' => $signedAmount,
|
|
||||||
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
|
|
||||||
'status' => $invoiceId ? 'applied' : 'pending',
|
|
||||||
'created_by' => (int)(session()->get('user_id') ?? 0),
|
|
||||||
'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->db->transStart();
|
|
||||||
|
|
||||||
// BEFORE
|
|
||||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
|
|
||||||
|
|
||||||
// Insert charge
|
|
||||||
$this->additionalChargeModel->insert($payload);
|
|
||||||
$chargeId = (int)$this->additionalChargeModel->getInsertID();
|
|
||||||
|
|
||||||
// Apply to invoice if present
|
|
||||||
if ($invoiceId) {
|
|
||||||
try {
|
|
||||||
if ($chargeType === 'add') {
|
|
||||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
|
||||||
} else {
|
|
||||||
$this->invoiceModel->deductAdditionalCharge($invoiceId, $amountAbs);
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'apply/deductAdditionalCharge failed: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER
|
|
||||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
|
|
||||||
|
|
||||||
// Parent USER (not parent table)
|
|
||||||
$parentUser = $this->userModel->getUserInfoById($data['parent_id']);
|
|
||||||
|
|
||||||
$this->db->transComplete();
|
|
||||||
|
|
||||||
if (!$this->db->transStatus()) {
|
|
||||||
$err = $this->db->error();
|
|
||||||
log_message('error', 'Transaction failed in ExtraChargesController::store: ' . json_encode($err));
|
|
||||||
if ($this->wantsJson()) {
|
|
||||||
return $this->response->setJSON(['ok' => false, 'error' => 'Failed to save charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
|
||||||
}
|
|
||||||
return redirect()->back()->withInput()->with('error', 'Failed to save charge.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build event payload (same style as your payment handler)
|
|
||||||
// allow either first_name/last_name OR firstname/lastname from users table
|
|
||||||
$first = $parentUser['first_name'] ?? $parentUser['firstname'] ?? '';
|
|
||||||
$last = $parentUser['last_name'] ?? $parentUser['lastname'] ?? '';
|
|
||||||
$parentName = trim($first . ' ' . $last);
|
|
||||||
|
|
||||||
$invoiceBefore = $this->normalizeRow($invoiceBefore);
|
|
||||||
$invoiceAfter = $this->normalizeRow($invoiceAfter);
|
|
||||||
|
|
||||||
$invoiceTotal = $invoiceAfter ? (float)($invoiceAfter['total_amount'] ?? 0) : 0.0;
|
|
||||||
$preBalance = $invoiceBefore ? (float)($invoiceBefore['balance'] ?? 0) : 0.0;
|
|
||||||
$postBalance = $invoiceAfter ? (float)($invoiceAfter['balance'] ?? 0) : 0.0;
|
|
||||||
|
|
||||||
$eventData = [
|
|
||||||
// user identity (parent user)
|
|
||||||
'user_id' => (int)($parentUser['id'] ?? 0),
|
|
||||||
'email' => $parentUser['email'] ?? null,
|
|
||||||
'firstname' => $first,
|
|
||||||
'lastname' => $last,
|
|
||||||
'parentName' => $parentName,
|
|
||||||
|
|
||||||
// context
|
|
||||||
'school_year' => $payload['school_year'],
|
|
||||||
'semester' => $payload['semester'],
|
|
||||||
|
|
||||||
// invoice
|
|
||||||
'invoice_id' => $invoiceId,
|
|
||||||
'invoice_number' => $invoiceAfter['invoice_number'] ?? ($invoiceId ? 'INV-' . $invoiceId : null),
|
|
||||||
|
|
||||||
// charge details
|
|
||||||
'charge_id' => $chargeId,
|
|
||||||
'charge_title' => $payload['title'],
|
|
||||||
'charge_desc' => $payload['description'],
|
|
||||||
'charge_type' => $payload['charge_type'], // add|deduct
|
|
||||||
'amount_signed' => $signedAmount,
|
|
||||||
'amount_abs' => $amountAbs,
|
|
||||||
'due_date' => $payload['due_date'],
|
|
||||||
'created_at' => $payload['created_at'],
|
|
||||||
|
|
||||||
// money snapshot
|
|
||||||
'pre_balance' => $preBalance,
|
|
||||||
'post_balance' => $postBalance,
|
|
||||||
'invoice_total' => $invoiceTotal,
|
|
||||||
|
|
||||||
// links
|
|
||||||
'portal_link' => base_url('/login'),
|
|
||||||
'invoice_link' => $invoiceId ? site_url('parent/invoices/view/' . $invoiceId) : site_url('parent/invoices'),
|
|
||||||
];
|
|
||||||
|
|
||||||
$students = []; // keep [] unless you want to include children under this user
|
|
||||||
Events::trigger('extraCharge', $eventData, $students);
|
|
||||||
|
|
||||||
if ($this->wantsJson()) {
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'ok' => true,
|
|
||||||
'id' => $chargeId,
|
|
||||||
'invoice_id' => $invoiceId,
|
|
||||||
'parent_id' => (int)$data['parent_id'],
|
|
||||||
'csrf_token' => csrf_token(),
|
|
||||||
'csrf_hash' => csrf_hash(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return redirect()->to(site_url('admin/charges'))->with('status', 'Charge recorded.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark a charge as void and roll back its impact on the invoice if applied.
|
|
||||||
*/
|
|
||||||
public function void($id)
|
|
||||||
{
|
|
||||||
$row = $this->additionalChargeModel->find((int)$id);
|
|
||||||
if (!$row) {
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
|
||||||
return redirect()->back()->with('error', 'Charge not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
|
||||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
|
||||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
|
||||||
$status = (string)($row['status'] ?? 'pending');
|
|
||||||
|
|
||||||
$this->db->transStart();
|
|
||||||
|
|
||||||
if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) {
|
|
||||||
try {
|
|
||||||
if ($chargeType === 'add') {
|
|
||||||
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
|
|
||||||
} else {
|
|
||||||
// voiding a deduction -> add back
|
|
||||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'void(): invoice adjust failed: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->additionalChargeModel->update((int)$id, [
|
|
||||||
'status' => 'void',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->db->transComplete();
|
|
||||||
|
|
||||||
if (!$this->db->transStatus()) {
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to void charge']);
|
|
||||||
return redirect()->back()->with('error', 'Failed to void charge.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
|
||||||
return redirect()->back()->with('status', 'Charge voided.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse a previously applied charge: undo invoice impact and return to pending state.
|
|
||||||
*/
|
|
||||||
public function reverse($id)
|
|
||||||
{
|
|
||||||
$row = $this->additionalChargeModel->find((int)$id);
|
|
||||||
if (!$row) {
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
|
||||||
return redirect()->back()->with('error', 'Charge not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
|
||||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
|
||||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
|
||||||
$status = (string)($row['status'] ?? 'pending');
|
|
||||||
|
|
||||||
if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) {
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Nothing to reverse']);
|
|
||||||
return redirect()->back()->with('error', 'Nothing to reverse.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->db->transStart();
|
|
||||||
try {
|
|
||||||
if ($chargeType === 'add') {
|
|
||||||
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
|
|
||||||
} else {
|
|
||||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'reverse(): invoice adjust failed: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->additionalChargeModel->update((int)$id, [
|
|
||||||
'status' => 'pending',
|
|
||||||
'invoice_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->db->transComplete();
|
|
||||||
|
|
||||||
if (!$this->db->transStatus()) {
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to reverse charge']);
|
|
||||||
return redirect()->back()->with('error', 'Failed to reverse charge.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
|
||||||
return redirect()->back()->with('status', 'Charge reversed to pending.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** JSON: list charges for the current term (with optional filters). */
|
|
||||||
public function apiList()
|
|
||||||
{
|
|
||||||
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
|
||||||
$sem = (string)($this->request->getGet('semester') ?? $this->semester);
|
|
||||||
$status = $this->request->getGet('status') ?: null;
|
|
||||||
$q = trim((string)($this->request->getGet('q') ?? '')) ?: null;
|
|
||||||
$per = (int)($this->request->getGet('per_page') ?? 50);
|
|
||||||
|
|
||||||
$rows = $this->additionalChargeModel->listAllForTerm($year, $sem, $status, $q, $per);
|
|
||||||
$pager = $this->additionalChargeModel->pager;
|
|
||||||
|
|
||||||
$meta = [
|
|
||||||
'perPage' => method_exists($pager, 'getPerPage') ? $pager->getPerPage() : $per,
|
|
||||||
'page' => method_exists($pager, 'getCurrentPage') ? $pager->getCurrentPage() : null,
|
|
||||||
'total' => method_exists($pager, 'getTotal') ? $pager->getTotal() : null,
|
|
||||||
'pageCount' => method_exists($pager, 'getPageCount') ? $pager->getPageCount() : null,
|
|
||||||
];
|
|
||||||
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'ok' => true,
|
|
||||||
'school_year' => $year,
|
|
||||||
'semester' => $sem,
|
|
||||||
'rows' => $rows,
|
|
||||||
'pager' => $meta,
|
|
||||||
'csrf_token' => csrf_token(),
|
|
||||||
'csrf_hash' => csrf_hash(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize query result into a single associative row
|
|
||||||
private function normalizeRow($row): ?array
|
|
||||||
{
|
|
||||||
if (!$row) return null;
|
|
||||||
if (is_array($row) && isset($row[0]) && is_array($row[0])) {
|
|
||||||
return $row[0]; // unwrap first row from a result set
|
|
||||||
}
|
|
||||||
return is_array($row) ? $row : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ConfigurationModel;
|
||||||
|
use App\Models\PaymentModel;
|
||||||
|
use App\Models\InvoiceModel;
|
||||||
|
use App\Models\ClassSectionModel;
|
||||||
|
|
||||||
|
class FeeCalculationService
|
||||||
|
{
|
||||||
|
public function calculateRefund(array $students, int $parentId): float
|
||||||
|
{
|
||||||
|
$configModel = new ConfigurationModel();
|
||||||
|
$paymentModel = new PaymentModel();
|
||||||
|
$invoiceModel = new InvoiceModel();
|
||||||
|
$classSectionModel = new ClassSectionModel();
|
||||||
|
|
||||||
|
$schoolYear = $configModel->getConfig('school_year');
|
||||||
|
$refundDeadline = date('Y-m-d', strtotime($configModel->getConfig('refund_deadline')));
|
||||||
|
$weekOfStudy = (float) ($configModel->getConfig('weeks_study') ?? 8);
|
||||||
|
$schoolEndDate = date('Y-m-d', strtotime($configModel->getConfig('last_school_day')));
|
||||||
|
$totalPaid = $paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
|
||||||
|
|
||||||
|
if ($totalPaid <= 0) {
|
||||||
|
log_message('info', "No payments made. Refund = 0.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify and enrich student data
|
||||||
|
$registeredStudents = [];
|
||||||
|
$withdrawnStudents = [];
|
||||||
|
|
||||||
|
foreach ($students as &$student) {
|
||||||
|
$gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']);
|
||||||
|
$student['grade'] = strtoupper(trim($gradeName));
|
||||||
|
|
||||||
|
if (in_array($student['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) {
|
||||||
|
$withdrawnStudents[] = $student;
|
||||||
|
} elseif (
|
||||||
|
in_array($student['enrollment_status'], ['enrolled', 'payment pending']) &&
|
||||||
|
$student['admission_status'] === 'accepted'
|
||||||
|
) {
|
||||||
|
$registeredStudents[] = $student;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($student);
|
||||||
|
|
||||||
|
if (empty($withdrawnStudents)) {
|
||||||
|
log_message('info', "No withdrawn students found. Refund = 0.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine all students for proper fee tiering
|
||||||
|
$allStudents = array_merge($registeredStudents, $withdrawnStudents);
|
||||||
|
|
||||||
|
// Sort all students by grade for correct tiering
|
||||||
|
usort($allStudents, function ($a, $b) {
|
||||||
|
return $this->compareGrades($a['grade'], $b['grade']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retrieve fee configs
|
||||||
|
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
|
||||||
|
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
|
||||||
|
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180);
|
||||||
|
|
||||||
|
// Assign tuition_fee to all students (before filtering refunds)
|
||||||
|
$regularCount = 0;
|
||||||
|
foreach ($allStudents as &$student) {
|
||||||
|
$gradeLevel = $this->getGradeLevel($student['grade']);
|
||||||
|
|
||||||
|
if ($gradeLevel > 9) {
|
||||||
|
$studentFee = $youthFee;
|
||||||
|
} else {
|
||||||
|
$studentFee = ($regularCount === 0) ? $firstStudentFee : $secondStudentFee;
|
||||||
|
$regularCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($student);
|
||||||
|
|
||||||
|
// Calculate refund for withdrawn students
|
||||||
|
$refundAmount = 0;
|
||||||
|
|
||||||
|
foreach ($withdrawnStudents as $student) {
|
||||||
|
if (empty($student['withdrawal_date'])) {
|
||||||
|
log_message('warning', "Missing withdraw date for student ID: {$student['student_id']}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$withdrawDate = date('Y-m-d', strtotime($student['withdrawal_date']));
|
||||||
|
if (strtotime($withdrawDate) > strtotime($refundDeadline)) {
|
||||||
|
log_message('info', "Withdraw date {$withdrawDate} is after refund deadline {$refundDeadline}. No refund for this student.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$withdrawDateObj = new \DateTime($withdrawDate);
|
||||||
|
$schoolEndDateObj = new \DateTime($schoolEndDate);
|
||||||
|
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
|
||||||
|
$weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7)));
|
||||||
|
|
||||||
|
//$studentFee = $student['tuition_fee'];
|
||||||
|
$proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining;
|
||||||
|
$refundAmount += $proportionalRefund;
|
||||||
|
|
||||||
|
log_message('info', "Student ID {$student['student_id']} refund portion: {$proportionalRefund} of {$studentFee} for {$weeksRemaining} weeks.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($refundAmount > $totalPaid) {
|
||||||
|
log_message('info', "Refund capped at total paid amount: {$totalPaid}");
|
||||||
|
return $totalPaid;
|
||||||
|
}
|
||||||
|
|
||||||
|
log_message('info', "Final calculated refund: {$refundAmount}");
|
||||||
|
return $refundAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function compareGrades($gradeA, $gradeB)
|
||||||
|
{
|
||||||
|
$valA = $this->getGradeLevel($gradeA);
|
||||||
|
$valB = $this->getGradeLevel($gradeB);
|
||||||
|
|
||||||
|
if ($valA !== $valB) return $valA <=> $valB;
|
||||||
|
|
||||||
|
// Same level, compare suffix
|
||||||
|
preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeA), $suffixA);
|
||||||
|
preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeB), $suffixB);
|
||||||
|
|
||||||
|
return strcmp($suffixA[1] ?? '', $suffixB[1] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getGradeLevel($grade)
|
||||||
|
{
|
||||||
|
if (strtoupper($grade) === 'K') return 0;
|
||||||
|
if (strtoupper($grade) === 'Y') return 99;
|
||||||
|
|
||||||
|
if (preg_match('/^(\d+)([A-Z]*)$/i', $grade, $matches)) {
|
||||||
|
return (int) $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 999; // fallback for unknown/malformed grades
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function calculateTotalTuitionFee(array $students): float
|
||||||
|
{
|
||||||
|
$configModel = new ConfigurationModel();
|
||||||
|
$classSectionModel = new \App\Models\ClassSectionModel();
|
||||||
|
|
||||||
|
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
|
||||||
|
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
|
||||||
|
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180);
|
||||||
|
|
||||||
|
// ✅ Pre-fetch and assign grade/class section names before sorting
|
||||||
|
foreach ($students as &$student) {
|
||||||
|
$gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']);
|
||||||
|
$student['grade'] = strtoupper(trim($gradeName));
|
||||||
|
}
|
||||||
|
unset($student); // break reference
|
||||||
|
|
||||||
|
// ✅ Sort students by grade
|
||||||
|
usort($students, function ($a, $b) {
|
||||||
|
return $this->compareGrades($a['grade'], $b['grade']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$regularCount = 0;
|
||||||
|
$totalFee = 0;
|
||||||
|
|
||||||
|
// ✅ Calculate fee
|
||||||
|
foreach ($students as $student) {
|
||||||
|
$gradeLevel = $this->getGradeLevel($student['grade']);
|
||||||
|
|
||||||
|
if ($gradeLevel > 9) {
|
||||||
|
$totalFee += $youthFee;
|
||||||
|
} else {
|
||||||
|
$totalFee += ($regularCount === 0) ? $firstStudentFee : $secondStudentFee;
|
||||||
|
$regularCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totalFee;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
|
||||||
use Config\Database;
|
|
||||||
|
|
||||||
class FilesController extends Controller
|
|
||||||
{
|
|
||||||
public function receipt(string $name)
|
|
||||||
{
|
|
||||||
// 1) Path traversal guard
|
|
||||||
if ($name !== basename($name)) {
|
|
||||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Allow-list extensions (optional but safer)
|
|
||||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
||||||
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
|
||||||
if (!in_array($ext, $allowed, true)) {
|
|
||||||
throw PageNotFoundException::forPageNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Build path under writable
|
|
||||||
$path = WRITEPATH . 'uploads/receipts/' . $name;
|
|
||||||
if (!is_file($path)) {
|
|
||||||
throw PageNotFoundException::forPageNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) MIME detection (finfo is more reliable than mime_content_type)
|
|
||||||
$mime = 'application/octet-stream';
|
|
||||||
if (function_exists('finfo_open')) {
|
|
||||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
|
||||||
if ($fi) {
|
|
||||||
$detected = finfo_file($fi, $path);
|
|
||||||
if ($detected) $mime = $detected;
|
|
||||||
finfo_close($fi);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Caching (ETag + Last-Modified) and 304 support
|
|
||||||
$mtime = filemtime($path) ?: time();
|
|
||||||
$etag = md5($name . '|' . $mtime . '|' . filesize($path));
|
|
||||||
|
|
||||||
$ifNoneMatch = $this->request->getHeaderLine('If-None-Match');
|
|
||||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
|
||||||
|
|
||||||
if ($ifNoneMatch === $etag || (strtotime($ifModifiedSince) >= $mtime)) {
|
|
||||||
return $this->response
|
|
||||||
->setStatusCode(304)
|
|
||||||
->setHeader('ETag', $etag)
|
|
||||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Stream the file inline
|
|
||||||
return $this->response
|
|
||||||
->setStatusCode(200)
|
|
||||||
->setHeader('Content-Type', $mime)
|
|
||||||
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
|
|
||||||
->setHeader('Content-Length', (string) filesize($path))
|
|
||||||
->setHeader('ETag', $etag)
|
|
||||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
|
||||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
|
||||||
->setBody(file_get_contents($path));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reimb(string $name)
|
|
||||||
{
|
|
||||||
// 1) Path traversal guard
|
|
||||||
if ($name !== basename($name)) {
|
|
||||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Allow-list extensions
|
|
||||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
||||||
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
|
||||||
if (!in_array($ext, $allowed, true)) {
|
|
||||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Build path under writable (REIMBURSEMENTS)
|
|
||||||
$path = WRITEPATH . 'uploads/reimbursements/' . $name;
|
|
||||||
if (!is_file($path)) {
|
|
||||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) MIME detection (prefer finfo)
|
|
||||||
$mime = 'application/octet-stream';
|
|
||||||
if (function_exists('finfo_open')) {
|
|
||||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
|
||||||
if ($fi) {
|
|
||||||
$detected = finfo_file($fi, $path);
|
|
||||||
if ($detected) {
|
|
||||||
$mime = $detected;
|
|
||||||
}
|
|
||||||
finfo_close($fi);
|
|
||||||
}
|
|
||||||
} elseif (function_exists('mime_content_type')) {
|
|
||||||
$mime = mime_content_type($path) ?: $mime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Caching (ETag + Last-Modified) and 304 support
|
|
||||||
$mtime = filemtime($path) ?: time();
|
|
||||||
$size = filesize($path) ?: 0;
|
|
||||||
$etag = md5($name . '|' . $mtime . '|' . $size);
|
|
||||||
|
|
||||||
$ifNoneMatch = trim($this->request->getHeaderLine('If-None-Match'), '"');
|
|
||||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
|
||||||
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
|
|
||||||
|
|
||||||
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
|
|
||||||
($imsTime !== false && $imsTime >= $mtime)
|
|
||||||
) {
|
|
||||||
return $this->response
|
|
||||||
->setStatusCode(304)
|
|
||||||
->setHeader('ETag', $etag)
|
|
||||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Stream the file inline
|
|
||||||
return $this->response
|
|
||||||
->setStatusCode(200)
|
|
||||||
->setHeader('Content-Type', $mime)
|
|
||||||
->setHeader('X-Content-Type-Options', 'nosniff')
|
|
||||||
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
|
|
||||||
->setHeader('Content-Length', (string) $size)
|
|
||||||
->setHeader('ETag', $etag)
|
|
||||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
|
||||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
|
||||||
->setBody(file_get_contents($path));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function earlyDismissalSignature(string $name)
|
|
||||||
{
|
|
||||||
// 1) Path traversal guard
|
|
||||||
if ($name !== basename($name)) {
|
|
||||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Allow-list extensions
|
|
||||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
||||||
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
|
||||||
if (!in_array($ext, $allowed, true)) {
|
|
||||||
throw PageNotFoundException::forPageNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Build path under writable (EARLY DISMISSAL SIGNATURES)
|
|
||||||
$path = WRITEPATH . 'uploads/early_dismissal_signatures/' . $name;
|
|
||||||
if (!is_file($path)) {
|
|
||||||
throw PageNotFoundException::forPageNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) MIME detection (prefer finfo)
|
|
||||||
$mime = 'application/octet-stream';
|
|
||||||
if (function_exists('finfo_open')) {
|
|
||||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
|
||||||
if ($fi) {
|
|
||||||
$detected = finfo_file($fi, $path);
|
|
||||||
if ($detected) {
|
|
||||||
$mime = $detected;
|
|
||||||
}
|
|
||||||
finfo_close($fi);
|
|
||||||
}
|
|
||||||
} elseif (function_exists('mime_content_type')) {
|
|
||||||
$mime = mime_content_type($path) ?: $mime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Caching (ETag + Last-Modified) and 304 support
|
|
||||||
$mtime = filemtime($path) ?: time();
|
|
||||||
$size = filesize($path) ?: 0;
|
|
||||||
$etag = md5($name . '|' . $mtime . '|' . $size);
|
|
||||||
|
|
||||||
$ifNoneMatch = trim($this->request->getHeaderLine('If-None-Match'), '"');
|
|
||||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
|
||||||
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
|
|
||||||
|
|
||||||
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
|
|
||||||
($imsTime !== false && $imsTime >= $mtime)
|
|
||||||
) {
|
|
||||||
return $this->response
|
|
||||||
->setStatusCode(304)
|
|
||||||
->setHeader('ETag', $etag)
|
|
||||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Stream the file inline
|
|
||||||
return $this->response
|
|
||||||
->setStatusCode(200)
|
|
||||||
->setHeader('Content-Type', $mime)
|
|
||||||
->setHeader('X-Content-Type-Options', 'nosniff')
|
|
||||||
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
|
|
||||||
->setHeader('Content-Length', (string) $size)
|
|
||||||
->setHeader('ETag', $etag)
|
|
||||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
|
||||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
|
||||||
->setBody(file_get_contents($path));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function examDraftTeacher(string $name)
|
|
||||||
{
|
|
||||||
return $this->serveExamDraftFile($name, 'drafts');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function examDraftFinal(string $name)
|
|
||||||
{
|
|
||||||
return $this->serveExamDraftFile($name, 'finals');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function serveExamDraftFile(string $name, string $subdir)
|
|
||||||
{
|
|
||||||
if ($name !== basename($name)) {
|
|
||||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
|
||||||
}
|
|
||||||
|
|
||||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
||||||
$allowed = ['doc', 'docx', 'pdf'];
|
|
||||||
if (!in_array($ext, $allowed, true)) {
|
|
||||||
throw PageNotFoundException::forPageNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = WRITEPATH . 'uploads/exams/' . trim($subdir, '/') . '/' . $name;
|
|
||||||
if (!is_file($path)) {
|
|
||||||
throw PageNotFoundException::forPageNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
$mime = 'application/octet-stream';
|
|
||||||
if (function_exists('finfo_open')) {
|
|
||||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
|
||||||
if ($fi) {
|
|
||||||
$detected = finfo_file($fi, $path);
|
|
||||||
if ($detected) {
|
|
||||||
$mime = $detected;
|
|
||||||
}
|
|
||||||
finfo_close($fi);
|
|
||||||
}
|
|
||||||
} elseif (function_exists('mime_content_type')) {
|
|
||||||
$mime = mime_content_type($path) ?: $mime;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mtime = filemtime($path) ?: time();
|
|
||||||
$size = filesize($path) ?: 0;
|
|
||||||
$etag = md5($name . '|' . $mtime . '|' . $size);
|
|
||||||
|
|
||||||
$ifNoneMatch = trim($this->request->getHeaderLine('If-None-Match'), '"');
|
|
||||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
|
||||||
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
|
|
||||||
|
|
||||||
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
|
|
||||||
($imsTime !== false && $imsTime >= $mtime)
|
|
||||||
) {
|
|
||||||
return $this->response
|
|
||||||
->setStatusCode(304)
|
|
||||||
->setHeader('ETag', $etag)
|
|
||||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
|
||||||
}
|
|
||||||
|
|
||||||
$downloadName = $this->buildDraftDownloadName($name, $subdir);
|
|
||||||
|
|
||||||
return $this->response
|
|
||||||
->setStatusCode(200)
|
|
||||||
->setHeader('Content-Type', $mime)
|
|
||||||
->setHeader('X-Content-Type-Options', 'nosniff')
|
|
||||||
->setHeader('Content-Disposition', 'inline; filename="' . $downloadName . '.' . $ext . '"')
|
|
||||||
->setHeader('Content-Length', (string) $size)
|
|
||||||
->setHeader('ETag', $etag)
|
|
||||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
|
||||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
|
||||||
->setBody(file_get_contents($path));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildDraftDownloadName(string $filename, string $subdir): string
|
|
||||||
{
|
|
||||||
$column = $subdir === 'finals' ? 'final_file' : 'teacher_file';
|
|
||||||
$db = Database::connect();
|
|
||||||
$row = $db->table('exam_drafts ed')
|
|
||||||
->select('ed.version, ed.exam_type, ed.class_section_id, cs.class_section_name')
|
|
||||||
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left')
|
|
||||||
->where('ed.' . $column, $filename)
|
|
||||||
->limit(1)
|
|
||||||
->get()
|
|
||||||
->getRowArray();
|
|
||||||
|
|
||||||
$classLabel = trim((string) ($row['class_section_name'] ?? 'Class' . ($row['class_section_id'] ?? '0')));
|
|
||||||
$typeLabel = trim((string) ($row['exam_type'] ?? 'Exam'));
|
|
||||||
$version = 'v' . max(1, (int) ($row['version'] ?? 1));
|
|
||||||
|
|
||||||
$parts = array_filter([
|
|
||||||
$this->slugify($classLabel),
|
|
||||||
$this->slugify($typeLabel),
|
|
||||||
$version,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return implode('_', $parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function slugify(string $value): string
|
|
||||||
{
|
|
||||||
$value = trim($value);
|
|
||||||
$value = preg_replace('/[^\p{L}\p{N}]+/u', '_', $value);
|
|
||||||
$value = trim($value, '_');
|
|
||||||
return $value === '' ? 'Exam' : mb_strtolower($value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
use App\Models\TeacherClassModel;
|
|
||||||
use App\Models\StudentModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Models\GradingLockModel;
|
|
||||||
use App\Models\MissingScoreOverrideModel;
|
|
||||||
|
|
||||||
class FinalController extends BaseController
|
|
||||||
{
|
|
||||||
protected $db;
|
|
||||||
protected $schoolYear;
|
|
||||||
protected $semester;
|
|
||||||
protected $configModel;
|
|
||||||
protected $studentModel;
|
|
||||||
protected $teacherClassModel;
|
|
||||||
protected $gradingLockModel;
|
|
||||||
protected $missingScoreOverrideModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->studentModel = new StudentModel();
|
|
||||||
$this->teacherClassModel = new TeacherClassModel();
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
$this->gradingLockModel = new GradingLockModel();
|
|
||||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function add()
|
|
||||||
{
|
|
||||||
// Accept class_section_id from POST/GET with session fallback
|
|
||||||
$incoming = $this->request->getPost('class_section_id')
|
|
||||||
?? $this->request->getGet('class_section_id')
|
|
||||||
?? session()->get('class_section_id');
|
|
||||||
|
|
||||||
$classSectionId = (int) ($incoming ?: 0);
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('status', 'Missing class section.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist for downstream calls
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
$semester = $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->schoolYear;
|
|
||||||
try {
|
|
||||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
|
||||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'final_exam');
|
|
||||||
|
|
||||||
return view('teacher/add_final_exam', [
|
|
||||||
'students' => $result['students'],
|
|
||||||
'semester' => $result['semester'],
|
|
||||||
'schoolYear' => $result['schoolYear'],
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'missingOkMap' => $missingOkMap,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return redirect()->back()->with('status', $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function showFinalMngt()
|
|
||||||
{
|
|
||||||
$incoming = $this->request->getPost('class_section_id')
|
|
||||||
?? $this->request->getGet('class_section_id')
|
|
||||||
?? session()->get('class_section_id');
|
|
||||||
|
|
||||||
$classSectionId = (int) ($incoming ?: 0);
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('status', 'Missing class section.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$semester = $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->schoolYear;
|
|
||||||
try {
|
|
||||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
|
||||||
$scoresLocked = $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
|
||||||
|
|
||||||
return view('grading/final', [
|
|
||||||
'students' => $result['students'],
|
|
||||||
'semester' => $result['semester'],
|
|
||||||
'schoolYear' => $result['schoolYear'],
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'scoresLocked' => $scoresLocked,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return redirect()->back()->with('status', $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
|
|
||||||
{
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
throw new \InvalidArgumentException('Invalid class section id.');
|
|
||||||
}
|
|
||||||
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
// Subquery: pick ONE final exam row per student (latest by id)
|
|
||||||
$latestFinalSub = $this->db->table('final_exam')
|
|
||||||
->select('student_id, MAX(id) AS max_id')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->groupBy('student_id')
|
|
||||||
->getCompiledSelect();
|
|
||||||
|
|
||||||
// Main query: roster + LEFT JOIN the single final exam row
|
|
||||||
$qb = $this->studentModel
|
|
||||||
->select('
|
|
||||||
s.id AS student_id,
|
|
||||||
s.school_id,
|
|
||||||
s.firstname,
|
|
||||||
s.lastname,
|
|
||||||
fe.score
|
|
||||||
')
|
|
||||||
->from('students s')
|
|
||||||
->join(
|
|
||||||
'student_class sc',
|
|
||||||
'sc.student_id = s.id
|
|
||||||
AND sc.class_section_id = ' . (int) $classSectionId . '
|
|
||||||
AND sc.school_year = ' . $this->db->escape($schoolYear),
|
|
||||||
'inner',
|
|
||||||
false
|
|
||||||
)
|
|
||||||
->join('(' . $latestFinalSub . ') fl', 'fl.student_id = s.id', 'left', false)
|
|
||||||
->join('final_exam fe', 'fe.id = fl.max_id', 'left')
|
|
||||||
->orderBy('s.lastname', 'ASC')
|
|
||||||
->orderBy('s.firstname', 'ASC');
|
|
||||||
|
|
||||||
$qb->distinct();
|
|
||||||
$rows = $qb->get()->getResultArray();
|
|
||||||
|
|
||||||
// Ensure one row per student
|
|
||||||
$unique = [];
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$unique[(int)$r['student_id']] = $r;
|
|
||||||
}
|
|
||||||
$students = array_values($unique);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'students' => $students,
|
|
||||||
'semester' => $semester,
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTeacherSelectedSemester(): string
|
|
||||||
{
|
|
||||||
helper('semester_selection_helper');
|
|
||||||
return selected_teacher_semester();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,568 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Models\CurrentFlagModel;
|
|
||||||
use App\Models\FlagModel;
|
|
||||||
use App\Models\StudentModel;
|
|
||||||
use App\Models\ClassSectionModel;
|
|
||||||
use App\Models\StudentClassModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
|
|
||||||
|
|
||||||
class FlagController extends Controller
|
|
||||||
{
|
|
||||||
protected $userModel;
|
|
||||||
protected $db;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
//$this->userModel = new UserModel();
|
|
||||||
helper(['url', 'form']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
$currentFlagModel = new CurrentFlagModel();
|
|
||||||
$classSectionModel = new ClassSectionModel();
|
|
||||||
$studentClassModel = new StudentClassModel();
|
|
||||||
$configModel = new ConfigurationModel();
|
|
||||||
|
|
||||||
// Format grades with id and name for passing to the view
|
|
||||||
$grades = [];
|
|
||||||
|
|
||||||
// Retrieve flags and class sections that currently have active students
|
|
||||||
$flags = $currentFlagModel->findAll();
|
|
||||||
$schoolYear = $configModel->getConfig('school_year');
|
|
||||||
$studentCounts = $studentClassModel->getStudentCountsBySection($schoolYear);
|
|
||||||
$classSections = [];
|
|
||||||
|
|
||||||
if (!empty($studentCounts)) {
|
|
||||||
$classSectionIdsWithStudents = array_keys($studentCounts);
|
|
||||||
$classSections = $classSectionModel
|
|
||||||
->orderBy('class_section_name', 'ASC')
|
|
||||||
->whereIn('class_section_id', $classSectionIdsWithStudents)
|
|
||||||
->findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($classSections as $section) {
|
|
||||||
$grades[] = [
|
|
||||||
'id' => $section['class_section_id'],
|
|
||||||
'name' => $section['class_section_name']
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map updater user IDs to display names for the view
|
|
||||||
$updaterIds = [];
|
|
||||||
foreach ($flags as $flag) {
|
|
||||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
|
||||||
if (!empty($flag[$key])) {
|
|
||||||
$updaterIds[(int)$flag[$key]] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$updaterMap = [];
|
|
||||||
if (!empty($updaterIds)) {
|
|
||||||
$rows = $this->db->table('users')
|
|
||||||
->select('id, firstname, lastname')
|
|
||||||
->whereIn('id', array_keys($updaterIds))
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$name = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
|
||||||
$updaterMap[(int)$row['id']] = $name !== '' ? $name : ('User #' . $row['id']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($flags as &$flag) {
|
|
||||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
|
||||||
$id = (int)($flag[$key] ?? 0);
|
|
||||||
$flag[$key . '_name'] = $id > 0 ? ($updaterMap[$id] ?? null) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($flag);
|
|
||||||
|
|
||||||
// Pass flags and formatted grades to the view
|
|
||||||
return view('flags/flags_management', [
|
|
||||||
'flags' => $flags,
|
|
||||||
'grades' => $grades // Now grades include both id and class_section_name
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function history()
|
|
||||||
{
|
|
||||||
$flagModel = new FlagModel();
|
|
||||||
|
|
||||||
$schoolYear = (string)($this->request->getGet('school_year') ?? '');
|
|
||||||
$semester = (string)($this->request->getGet('semester') ?? '');
|
|
||||||
|
|
||||||
$builder = $flagModel->orderBy('flag_datetime', 'DESC');
|
|
||||||
if ($schoolYear !== '') {
|
|
||||||
$builder->where('school_year', $schoolYear);
|
|
||||||
}
|
|
||||||
if ($semester !== '') {
|
|
||||||
$builder->where('semester', $semester);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'flags' => $builder->findAll(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function processedFlags()
|
|
||||||
{
|
|
||||||
$flagModel = new FlagModel();
|
|
||||||
$classSectionModel = new ClassSectionModel();
|
|
||||||
|
|
||||||
$schoolYear = (string)($this->request->getGet('school_year') ?? '');
|
|
||||||
$semester = (string)($this->request->getGet('semester') ?? '');
|
|
||||||
|
|
||||||
$builder = $flagModel->orderBy('flag_datetime', 'DESC');
|
|
||||||
if ($schoolYear !== '') {
|
|
||||||
$builder->where('school_year', $schoolYear);
|
|
||||||
}
|
|
||||||
if ($semester !== '') {
|
|
||||||
$builder->where('semester', $semester);
|
|
||||||
}
|
|
||||||
|
|
||||||
$flags = $builder->findAll();
|
|
||||||
|
|
||||||
$gradeIds = [];
|
|
||||||
foreach ($flags as $flag) {
|
|
||||||
$gradeValue = $flag['grade'] ?? '';
|
|
||||||
if (is_numeric($gradeValue)) {
|
|
||||||
$gradeIds[(int)$gradeValue] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$gradeMap = [];
|
|
||||||
if (!empty($gradeIds)) {
|
|
||||||
$rows = $classSectionModel
|
|
||||||
->select('class_section_id, class_section_name')
|
|
||||||
->whereIn('class_section_id', array_keys($gradeIds))
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$gradeMap[(int)$row['class_section_id']] = (string)($row['class_section_name'] ?? '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map updater user IDs to display names for the view
|
|
||||||
$updaterIds = [];
|
|
||||||
foreach ($flags as $flag) {
|
|
||||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
|
||||||
if (!empty($flag[$key])) {
|
|
||||||
$updaterIds[(int)$flag[$key]] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$updaterMap = [];
|
|
||||||
if (!empty($updaterIds)) {
|
|
||||||
$rows = $this->db->table('users')
|
|
||||||
->select('id, firstname, lastname')
|
|
||||||
->whereIn('id', array_keys($updaterIds))
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$name = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
|
||||||
$updaterMap[(int)$row['id']] = $name !== '' ? $name : ('User #' . $row['id']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($flags as &$flag) {
|
|
||||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
|
||||||
$id = (int)($flag[$key] ?? 0);
|
|
||||||
$flag[$key . '_name'] = $id > 0 ? ($updaterMap[$id] ?? null) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$gradeValue = $flag['grade'] ?? '';
|
|
||||||
if (is_numeric($gradeValue)) {
|
|
||||||
$flag['grade'] = $gradeMap[(int)$gradeValue] ?? (string)$gradeValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($flag);
|
|
||||||
|
|
||||||
return view('flags/processed_flags', [
|
|
||||||
'flags' => $flags
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function incidentAnalysis()
|
|
||||||
{
|
|
||||||
$flagModel = new FlagModel();
|
|
||||||
$classSectionModel = new ClassSectionModel();
|
|
||||||
|
|
||||||
$schoolYear = (string)($this->request->getGet('school_year') ?? '');
|
|
||||||
$semester = (string)($this->request->getGet('semester') ?? '');
|
|
||||||
|
|
||||||
$builder = $flagModel->orderBy('flag_datetime', 'DESC');
|
|
||||||
if ($schoolYear !== '') {
|
|
||||||
$builder->where('school_year', $schoolYear);
|
|
||||||
}
|
|
||||||
if ($semester !== '') {
|
|
||||||
$builder->where('semester', $semester);
|
|
||||||
}
|
|
||||||
|
|
||||||
$flags = $builder->findAll();
|
|
||||||
|
|
||||||
$gradeIds = [];
|
|
||||||
foreach ($flags as $flag) {
|
|
||||||
$gradeValue = $flag['grade'] ?? '';
|
|
||||||
if (is_numeric($gradeValue)) {
|
|
||||||
$gradeIds[(int)$gradeValue] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$gradeMap = [];
|
|
||||||
if (!empty($gradeIds)) {
|
|
||||||
$rows = $classSectionModel
|
|
||||||
->select('class_section_id, class_section_name')
|
|
||||||
->whereIn('class_section_id', array_keys($gradeIds))
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$gradeMap[(int)$row['class_section_id']] = (string)($row['class_section_name'] ?? '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$students = [];
|
|
||||||
foreach ($flags as $flag) {
|
|
||||||
$studentId = (string)($flag['student_id'] ?? '');
|
|
||||||
$studentName = (string)($flag['student_name'] ?? '');
|
|
||||||
$studentKey = $studentId !== '' ? 'id:' . $studentId : 'name:' . strtolower(trim($studentName));
|
|
||||||
$gradeValue = $flag['grade'] ?? '';
|
|
||||||
$gradeLabel = $gradeValue;
|
|
||||||
if (is_numeric($gradeValue)) {
|
|
||||||
$gradeLabel = $gradeMap[(int)$gradeValue] ?? (string)$gradeValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($students[$studentKey])) {
|
|
||||||
$students[$studentKey] = [
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'student_name' => $studentName,
|
|
||||||
'grade' => (string)$gradeLabel,
|
|
||||||
'total' => 0,
|
|
||||||
'open' => 0,
|
|
||||||
'closed' => 0,
|
|
||||||
'canceled' => 0,
|
|
||||||
'last_incident' => null,
|
|
||||||
'logs' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$students[$studentKey]['total'] += 1;
|
|
||||||
|
|
||||||
$state = (string)($flag['flag_state'] ?? '');
|
|
||||||
if ($state === 'Open') {
|
|
||||||
$students[$studentKey]['open'] += 1;
|
|
||||||
} elseif ($state === 'Closed') {
|
|
||||||
$students[$studentKey]['closed'] += 1;
|
|
||||||
} elseif ($state === 'Canceled') {
|
|
||||||
$students[$studentKey]['canceled'] += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($students[$studentKey]['last_incident'] === null) {
|
|
||||||
$students[$studentKey]['last_incident'] = $flag['flag_datetime'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$students[$studentKey]['logs'][] = [
|
|
||||||
'flag' => $flag['flag'] ?? '',
|
|
||||||
'flag_state' => $state,
|
|
||||||
'flag_datetime' => $flag['flag_datetime'] ?? null,
|
|
||||||
'open_description' => $flag['open_description'] ?? null,
|
|
||||||
'close_description' => $flag['close_description'] ?? null,
|
|
||||||
'cancel_description' => $flag['cancel_description'] ?? null,
|
|
||||||
'action_taken' => $flag['action_taken'] ?? null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$studentRows = array_values($students);
|
|
||||||
usort($studentRows, function ($a, $b) {
|
|
||||||
if ($a['total'] === $b['total']) {
|
|
||||||
return strcasecmp($a['student_name'], $b['student_name']);
|
|
||||||
}
|
|
||||||
return $b['total'] <=> $a['total'];
|
|
||||||
});
|
|
||||||
|
|
||||||
return view('flags/incident_analysis', [
|
|
||||||
'students' => $studentRows,
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
'semester' => $semester,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getStudentsByGrade($gradeId)
|
|
||||||
{
|
|
||||||
$studentClassModel = new StudentClassModel(); // Model for the student_class table
|
|
||||||
$studentModel = new StudentModel(); // Model for the students table
|
|
||||||
|
|
||||||
// Fetch student IDs for the selected grade
|
|
||||||
$studentIds = $studentClassModel
|
|
||||||
->active()
|
|
||||||
->where('student_class.class_section_id', $gradeId)
|
|
||||||
->findColumn('student_id');
|
|
||||||
|
|
||||||
// If no student IDs are found, return an empty JSON array
|
|
||||||
if (empty($studentIds)) {
|
|
||||||
return $this->response->setJSON([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch student details from students table using the retrieved IDs
|
|
||||||
$students = $studentModel
|
|
||||||
->whereIn('id', $studentIds)
|
|
||||||
->where('is_active', 1)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
// Prepare data for JSON response with both ID and full name
|
|
||||||
$studentData = [];
|
|
||||||
foreach ($students as $student) {
|
|
||||||
$studentData[] = [
|
|
||||||
'id' => $student['id'], // Student ID
|
|
||||||
'name' => $student['firstname'] . ' ' . $student['lastname'] // Full name
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return JSON response with student ID and full name
|
|
||||||
return $this->response->setJSON($studentData);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addFlag()
|
|
||||||
{
|
|
||||||
$currentFlagModel = new CurrentFlagModel();
|
|
||||||
$studentModel = new StudentModel();
|
|
||||||
$configModel = new ConfigurationModel();
|
|
||||||
|
|
||||||
// Get the data from the form submission
|
|
||||||
$studentId = $this->request->getPost('student');
|
|
||||||
$flagType = $this->request->getPost('flag');
|
|
||||||
$description = $this->request->getPost('description');
|
|
||||||
$flagState = $this->request->getPost('flag_state');
|
|
||||||
$stateDescription = $this->request->getPost('state_description'); // Additional description for "Closed" or "Canceled"
|
|
||||||
|
|
||||||
// Retrieve student information from the database
|
|
||||||
$student = $studentModel->find($studentId);
|
|
||||||
if (!$student) {
|
|
||||||
session()->setFlashdata('error', 'Student not found.');
|
|
||||||
return $this->index();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the user_id from the session for updated_by
|
|
||||||
$userId = session()->get('user_id');
|
|
||||||
|
|
||||||
// Get the semester and school year from configuration
|
|
||||||
$semester = $configModel->getConfig('semester');
|
|
||||||
$schoolYear = $configModel->getConfig('school_year');
|
|
||||||
|
|
||||||
// Set the current system date and time for flag_datetime
|
|
||||||
$currentDateTime = utc_now();
|
|
||||||
|
|
||||||
// Check if a flag of the same type already exists for this student
|
|
||||||
$existingFlag = $currentFlagModel->where('student_id', $studentId)
|
|
||||||
->where('flag', $flagType)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existingFlag) {
|
|
||||||
// If the flag exists, update it based on the new state and description
|
|
||||||
$data = [
|
|
||||||
'flag_datetime' => $currentDateTime,
|
|
||||||
'updated_by_open' => $userId
|
|
||||||
];
|
|
||||||
|
|
||||||
// Append the new description to the appropriate field based on flag state
|
|
||||||
if ($flagState === 'Closed') {
|
|
||||||
$data['flag_state'] = 'Closed';
|
|
||||||
$data['close_description'] = ($existingFlag['close_description'] ?? '') . PHP_EOL . $stateDescription;
|
|
||||||
} elseif ($flagState === 'Canceled') {
|
|
||||||
$data['flag_state'] = 'Canceled';
|
|
||||||
$data['cancel_description'] = ($existingFlag['cancel_description'] ?? '') . PHP_EOL . $stateDescription;
|
|
||||||
} else {
|
|
||||||
$data['open_description'] = $existingFlag['open_description'] . PHP_EOL . $description;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the existing flag
|
|
||||||
$currentFlagModel->update($existingFlag['id'], $data);
|
|
||||||
session()->setFlashdata('success', 'Flag updated successfully!');
|
|
||||||
} else {
|
|
||||||
// If no existing flag is found, prepare the data for insertion
|
|
||||||
$data = [
|
|
||||||
'student_id' => $student['id'],
|
|
||||||
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
|
|
||||||
'grade' => $this->request->getPost('grade'),
|
|
||||||
'flag' => $flagType,
|
|
||||||
'flag_datetime' => $currentDateTime,
|
|
||||||
'flag_state' => 'Open', // Set initial state to 'Open'
|
|
||||||
'updated_by_open' => $userId,
|
|
||||||
'open_description' => $description,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear
|
|
||||||
];
|
|
||||||
|
|
||||||
// Insert the new flag
|
|
||||||
if ($currentFlagModel->insert($data)) {
|
|
||||||
session()->setFlashdata('success', 'New incident added successfully!');
|
|
||||||
} else {
|
|
||||||
session()->setFlashdata('error', 'Failed to add new incident.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Redirect to the list view
|
|
||||||
return $this->index();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateState($id)
|
|
||||||
{
|
|
||||||
log_message('debug', 'updateState method called for ID: ' . $id);
|
|
||||||
log_message('debug', 'Flag state: ' . $this->request->getPost('flag_state'));
|
|
||||||
|
|
||||||
$currentFlagModel = new CurrentFlagModel();
|
|
||||||
|
|
||||||
// Get the new flag state from the form
|
|
||||||
$newState = $this->request->getPost('flag_state');
|
|
||||||
|
|
||||||
if (!$newState) {
|
|
||||||
session()->setFlashdata('error', 'incident state not provided.');
|
|
||||||
return $this->index();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the flag state in the database
|
|
||||||
if ($currentFlagModel->update($id, ['flag_state' => $newState])) {
|
|
||||||
session()->setFlashdata('success', 'Incident state updated successfully!');
|
|
||||||
} else {
|
|
||||||
$errors = $currentFlagModel->errors();
|
|
||||||
log_message('error', 'Failed to update incident state: ' . print_r($errors, true));
|
|
||||||
session()->setFlashdata('error', 'Failed to update incident state.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect back to the flags list
|
|
||||||
return $this->index();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function closeFlag($flagId)
|
|
||||||
{
|
|
||||||
// Log the whole session array
|
|
||||||
log_message('debug', json_encode(session()->get()));
|
|
||||||
$currentFlagModel = new CurrentFlagModel();
|
|
||||||
$userId = session()->get('user_id');
|
|
||||||
|
|
||||||
// Get the current flag data
|
|
||||||
$flagData = $currentFlagModel->find($flagId);
|
|
||||||
|
|
||||||
// Log and check if flag data is found
|
|
||||||
if (!$flagData) {
|
|
||||||
log_message('error', "incident with ID {$flagId} not found in database.");
|
|
||||||
session()->setFlashdata('error', 'Incident not found.');
|
|
||||||
return redirect()->back();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proceed only if flag is not closed
|
|
||||||
if ($flagData['flag_state'] !== 'Closed') {
|
|
||||||
// Retrieve 'close_description' from POST request
|
|
||||||
$closeDescription = $this->request->getPost('state_description');
|
|
||||||
$actionTaken = $this->request->getPost('action_taken');
|
|
||||||
|
|
||||||
// Debugging: Check if the close description is received
|
|
||||||
if (empty($closeDescription)) {
|
|
||||||
session()->setFlashdata('error', 'Close description is missing.');
|
|
||||||
return redirect()->back();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the flag state to Closed
|
|
||||||
$currentFlagModel->update($flagId, [
|
|
||||||
'flag_state' => 'Closed',
|
|
||||||
'updated_by_closed' => $userId,
|
|
||||||
'close_description' => $closeDescription, // Use dynamic description from form
|
|
||||||
'action_taken' => $actionTaken
|
|
||||||
]);
|
|
||||||
|
|
||||||
$flagData = $currentFlagModel->find($flagId);
|
|
||||||
return $this->moveToHistory($flagData);
|
|
||||||
} else {
|
|
||||||
session()->setFlashdata('error', 'Incident is already closed.');
|
|
||||||
}
|
|
||||||
return $this->index();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function cancelFlag($flagId)
|
|
||||||
{
|
|
||||||
$currentFlagModel = new CurrentFlagModel();
|
|
||||||
$userId = session()->get('user_id');
|
|
||||||
|
|
||||||
// Retrieve 'cancel_description' from POST request
|
|
||||||
$cancelDescription = $this->request->getPost('state_description');
|
|
||||||
$actionTaken = $this->request->getPost('action_taken');
|
|
||||||
|
|
||||||
// Get the current flag data
|
|
||||||
$flagData = $currentFlagModel->find($flagId);
|
|
||||||
|
|
||||||
// Check if flag data is found
|
|
||||||
if (!$flagData) {
|
|
||||||
log_message('error', "Incident with ID {$flagId} not found in database.");
|
|
||||||
session()->setFlashdata('error', 'Incident not found.');
|
|
||||||
return redirect()->back();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the flag is not already canceled
|
|
||||||
if ($flagData['flag_state'] !== 'Canceled') {
|
|
||||||
// Update the flag state to Canceled
|
|
||||||
$currentFlagModel->update($flagId, [
|
|
||||||
'flag_state' => 'Canceled',
|
|
||||||
'updated_by_canceled' => $userId,
|
|
||||||
'cancel_description' => $cancelDescription,
|
|
||||||
'action_taken' => $actionTaken
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Retrieve updated flag data to ensure latest state
|
|
||||||
$flagData = $currentFlagModel->find($flagId);
|
|
||||||
return $this->moveToHistory($flagData);
|
|
||||||
} else {
|
|
||||||
session()->setFlashdata('error', 'Incident is already canceled.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->index();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private function moveToHistory($flagData)
|
|
||||||
{
|
|
||||||
$flagModel = new FlagModel();
|
|
||||||
|
|
||||||
// Prepare data for the flag table
|
|
||||||
$dataToInsert = [
|
|
||||||
'student_id' => $flagData['student_id'],
|
|
||||||
'student_name' => $flagData['student_name'],
|
|
||||||
'grade' => $flagData['grade'],
|
|
||||||
'flag' => $flagData['flag'],
|
|
||||||
'flag_datetime' => $flagData['flag_datetime'],
|
|
||||||
'flag_state' => $flagData['flag_state'],
|
|
||||||
'updated_by_open' => $flagData['updated_by_open'],
|
|
||||||
'open_description' => $flagData['open_description'],
|
|
||||||
'updated_by_closed' => $flagData['updated_by_closed'],
|
|
||||||
'close_description' => $flagData['close_description'],
|
|
||||||
'action_taken' => $flagData['action_taken'],
|
|
||||||
'updated_by_canceled' => $flagData['updated_by_canceled'],
|
|
||||||
'cancel_description' => $flagData['cancel_description'],
|
|
||||||
'semester' => $flagData['semester'],
|
|
||||||
'school_year' => $flagData['school_year'],
|
|
||||||
'created_at' => $flagData['created_at'],
|
|
||||||
'updated_at' => $flagData['updated_at']
|
|
||||||
];
|
|
||||||
|
|
||||||
// Insert data into the flag table
|
|
||||||
if ($flagModel->insert($dataToInsert)) {
|
|
||||||
// Delete the entry from the current_flag table
|
|
||||||
$currentFlagModel = new CurrentFlagModel();
|
|
||||||
$currentFlagModel->delete($flagData['id']);
|
|
||||||
session()->setFlashdata('success', 'Incident has been moved to history.');
|
|
||||||
} else {
|
|
||||||
session()->setFlashdata('error', 'Failed to move incident to history.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->index();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,656 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Models\HomeworkModel;
|
|
||||||
use App\Models\StudentModel;
|
|
||||||
use App\Models\StudentClassModel;
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
use App\Models\TeacherClassModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use RuntimeException;
|
|
||||||
use App\Services\SemesterScoreService;
|
|
||||||
use Config\Services;
|
|
||||||
use App\Controllers\View\GradingController;
|
|
||||||
use App\Models\GradingLockModel;
|
|
||||||
use App\Models\MissingScoreOverrideModel;
|
|
||||||
|
|
||||||
class HomeworkController extends Controller
|
|
||||||
{
|
|
||||||
protected $db;
|
|
||||||
protected $semesterScoreService;
|
|
||||||
protected $configModel;
|
|
||||||
protected $homeworkModel;
|
|
||||||
protected $userModel;
|
|
||||||
protected $schoolYear;
|
|
||||||
protected $semester;
|
|
||||||
protected $studentClassModel;
|
|
||||||
protected $studentModel;
|
|
||||||
protected $teacherClassModel;
|
|
||||||
protected $gradingLockModel;
|
|
||||||
protected $missingScoreOverrideModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
$this->teacherClassModel = new TeacherClassModel();
|
|
||||||
$this->homeworkModel = new HomeworkModel();
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->studentClassModel = new StudentClassModel();
|
|
||||||
$this->studentModel = new StudentModel();
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
|
|
||||||
// Log the service initialization
|
|
||||||
log_message('debug', 'Initializing SemesterScoreService');
|
|
||||||
|
|
||||||
//$this->semesterScoreService = Services::SemesterScoreService();
|
|
||||||
$this->semesterScoreService = service('semesterScoreService');
|
|
||||||
// $this->semesterScoreService = \Config\Services::semesterScoreService();
|
|
||||||
$this->gradingLockModel = new GradingLockModel();
|
|
||||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
|
||||||
|
|
||||||
// Check if the service is null
|
|
||||||
if ($this->semesterScoreService === null) {
|
|
||||||
log_message('error', 'SemesterScoreService is null');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateHomeworkScores(array $scores = null, int $updatedBy = null, int $classSectionId = null)
|
|
||||||
{
|
|
||||||
$scores = $this->request->getPost('scores');
|
|
||||||
$semester = $this->request->getPost('semester') ?? $this->semester;
|
|
||||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
|
||||||
|
|
||||||
if ($updatedBy === null) {
|
|
||||||
$updatedBy = session()->get('user_id');
|
|
||||||
}
|
|
||||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id');
|
|
||||||
$classSectionId = (int) ($classSectionId ?? 0);
|
|
||||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
|
||||||
$studentIds = is_array($scores) ? array_keys($scores) : array_keys((array) $missingOk);
|
|
||||||
$indexSet = [];
|
|
||||||
if (is_array($scores)) {
|
|
||||||
foreach ($scores as $studentId => $hwData) {
|
|
||||||
if (!is_array($hwData)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($hwData as $index => $score) {
|
|
||||||
if (is_numeric($index)) {
|
|
||||||
$indexSet[(int)$index] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$checkedItems = [];
|
|
||||||
if (is_array($missingOk)) {
|
|
||||||
foreach ($missingOk as $studentId => $indexes) {
|
|
||||||
if (!is_array($indexes)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($indexes as $index => $flag) {
|
|
||||||
if (!is_numeric($index)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$checkedItems[] = [
|
|
||||||
'student_id' => (int) $studentId,
|
|
||||||
'item_index' => (int) $index,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->missingScoreOverrideModel->replaceOverrides(
|
|
||||||
$classSectionId,
|
|
||||||
(string) $semester,
|
|
||||||
(string) $schoolYear,
|
|
||||||
'homework',
|
|
||||||
$studentIds,
|
|
||||||
array_keys($indexSet),
|
|
||||||
$checkedItems,
|
|
||||||
$updatedBy
|
|
||||||
);
|
|
||||||
|
|
||||||
if (is_array($scores)) {
|
|
||||||
foreach ($scores as $studentId => $hwData) {
|
|
||||||
if (!is_numeric($studentId)) continue;
|
|
||||||
|
|
||||||
$student = $this->studentModel->find($studentId);
|
|
||||||
if (!$student) continue;
|
|
||||||
|
|
||||||
foreach ($hwData as $index => $score) {
|
|
||||||
$rawScore = $score ?? null;
|
|
||||||
$isBlank = $rawScore === null || (is_string($rawScore) && trim($rawScore) === '');
|
|
||||||
$normalizedScore = (!$isBlank && is_numeric($rawScore)) ? (float) $rawScore : null;
|
|
||||||
|
|
||||||
// STEP 1: Always fetch the existing record
|
|
||||||
$existing = $this->homeworkModel->where([
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'homework_index' => $index,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
//'teacher_id' => $updatedBy,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
])->first();
|
|
||||||
|
|
||||||
// STEP 2: Save numeric score (blank -> null)
|
|
||||||
$data = [
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'school_id' => $student['school_id'] ?? '',
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'homework_index' => $index,
|
|
||||||
'score' => $normalizedScore,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'updated_at' => utc_now(),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($isBlank) {
|
|
||||||
if ($existing) {
|
|
||||||
$this->homeworkModel->update($existing['id'], $data);
|
|
||||||
log_message('debug', "Updated homework ID {$existing['id']} with score null");
|
|
||||||
} else {
|
|
||||||
$data['created_at'] = utc_now();
|
|
||||||
$this->homeworkModel->insert($data);
|
|
||||||
log_message('debug', "Inserted blank homework for student $studentId index $index");
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$this->homeworkModel->update($existing['id'], $data);
|
|
||||||
log_message('debug', "Updated homework ID {$existing['id']} with score {$normalizedScore}");
|
|
||||||
} else {
|
|
||||||
$data['created_at'] = utc_now();
|
|
||||||
$this->homeworkModel->insert($data);
|
|
||||||
log_message('debug', "Inserted score for student $studentId index $index");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$studentUserInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
|
||||||
|
|
||||||
// Call the updateScoresForStudents method
|
|
||||||
try {
|
|
||||||
$this->semesterScoreService->updateScoresForStudents($studentUserInfo, $semester, $schoolYear);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->to(base_url('/teacher/addHomework'))->with('status', 'Homework scores updated successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function addNextHomeworkColumn()
|
|
||||||
{
|
|
||||||
$updatedBy = session()->get('user_id');
|
|
||||||
$classSectionId = session()->get('class_section_id');
|
|
||||||
$selectedSemester = $this->getSelectedSemester();
|
|
||||||
$normalized = $this->normalizeSemesterSelection($selectedSemester);
|
|
||||||
$semesterLabel = $normalized !== '' ? ucfirst($normalized) : $selectedSemester;
|
|
||||||
|
|
||||||
// Step 1: Get the highest existing homework_index
|
|
||||||
$existingIndexes = $this->homeworkModel
|
|
||||||
->select('homework_index')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
//->where('teacher_id', $updatedBy)
|
|
||||||
->whereIn('semester', $this->getSemesterVariants($semesterLabel))
|
|
||||||
->where('school_year', $this->schoolYear)
|
|
||||||
->groupBy('homework_index')
|
|
||||||
->orderBy('homework_index', 'DESC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$maxIndex = 0;
|
|
||||||
foreach ($existingIndexes as $row) {
|
|
||||||
if (isset($row['homework_index']) && is_numeric($row['homework_index'])) {
|
|
||||||
$maxIndex = max($maxIndex, (int)$row['homework_index']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$nextIndex = $maxIndex + 1;
|
|
||||||
|
|
||||||
// Step 2: Get all students in the class
|
|
||||||
$students = $this->studentClassModel
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
// Step 3: Insert a new homework row for each student if not already exists
|
|
||||||
$firstInsertedId = null;
|
|
||||||
foreach ($students as $i => $student) {
|
|
||||||
$studentId = $student['student_id'];
|
|
||||||
|
|
||||||
// Check if record already exists
|
|
||||||
$existing = $this->homeworkModel
|
|
||||||
->where('student_id', $studentId)
|
|
||||||
->where('homework_index', $nextIndex)
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
//->where('teacher_id', $updatedBy)
|
|
||||||
->whereIn('semester', $this->getSemesterVariants($semesterLabel))
|
|
||||||
->where('school_year', $this->schoolYear)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existing) continue;
|
|
||||||
|
|
||||||
$insertData = [
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'school_id' => '', // Optional: populate if needed
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'homework_index' => $nextIndex,
|
|
||||||
'score' => null,
|
|
||||||
'semester' => $semesterLabel,
|
|
||||||
'school_year' => $this->schoolYear,
|
|
||||||
'created_at' => utc_now(),
|
|
||||||
'updated_at' => utc_now()
|
|
||||||
];
|
|
||||||
|
|
||||||
$id = $this->homeworkModel->insert($insertData, true);
|
|
||||||
if ($i === 0) {
|
|
||||||
$firstInsertedId = $id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Return index and ID to frontend
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'status' => 'success',
|
|
||||||
'homework_index' => $nextIndex,
|
|
||||||
'new_homework_id' => $firstInsertedId
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function showHomework()
|
|
||||||
{
|
|
||||||
$updatedBy = session()->get('user_id');
|
|
||||||
$classSectionId = $this->getClassSectionIdForTeacher($updatedBy);
|
|
||||||
if (!$classSectionId) {
|
|
||||||
return redirect()->back()->with('status', 'No class section found for the current teacher.');
|
|
||||||
}
|
|
||||||
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
$semester = $this->getSelectedSemester();
|
|
||||||
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $semester, $this->schoolYear);
|
|
||||||
if (empty($homeworkHeaders)) {
|
|
||||||
$homeworkHeaders = [1];
|
|
||||||
}
|
|
||||||
$students = $this->getStudentsWithHomeworkScores(
|
|
||||||
$classSectionId,
|
|
||||||
$homeworkHeaders,
|
|
||||||
$semester,
|
|
||||||
$this->schoolYear
|
|
||||||
);
|
|
||||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $this->schoolYear, 'homework');
|
|
||||||
|
|
||||||
return view('teacher/add_homework', [
|
|
||||||
'students' => $students,
|
|
||||||
'homeworkHeaders' => $homeworkHeaders,
|
|
||||||
'semester' => $semester,
|
|
||||||
'schoolYear' => $this->schoolYear,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'missingOkMap' => $missingOkMap,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In your GradingController (or the controller that owns this action)
|
|
||||||
public function showHomeworkMngt()
|
|
||||||
{
|
|
||||||
// Accept POST first, then GET (query string), then session fallback
|
|
||||||
$incomingId = $this->request->getPost('class_section_id');
|
|
||||||
if (!$incomingId) {
|
|
||||||
$incomingId = $this->request->getGet('class_section_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
|
|
||||||
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('status', 'No class section found for the current teacher or selection.');
|
|
||||||
}
|
|
||||||
$updatedBy = (int) (session()->get('user_id') ?? 0);
|
|
||||||
|
|
||||||
$semesterParam = $this->request->getPost('semester')
|
|
||||||
?? $this->request->getGet('semester')
|
|
||||||
?? $this->request->getGet('filter');
|
|
||||||
$normalizedSemester = $this->normalizeSemesterSelection($semesterParam);
|
|
||||||
if ($normalizedSemester !== '') {
|
|
||||||
$label = ucfirst($normalizedSemester);
|
|
||||||
$session = session();
|
|
||||||
$session->set('teacher_scores_selected_semester', $label);
|
|
||||||
$session->set('semester', $label);
|
|
||||||
}
|
|
||||||
|
|
||||||
$schoolYearParam = $this->request->getPost('school_year') ?? $this->request->getGet('school_year');
|
|
||||||
$schoolYear = $this->normalizeSchoolYear($schoolYearParam) ?? $this->schoolYear;
|
|
||||||
/*
|
|
||||||
// OPTIONAL (recommended): ensure the classSectionId belongs to this teacher for the active term
|
|
||||||
$updatedBy = (int) (session()->get('user_id') ?? 0);
|
|
||||||
if ($updatedBy > 0) {
|
|
||||||
// If you have a TeacherClassModel with new schema (position = 'main' or 'ta'):
|
|
||||||
$isOwned = $this->teacherClassModel
|
|
||||||
->where('teacher_id', $updatedBy)
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $this->schoolYear)
|
|
||||||
->where('semester', $this->semester)
|
|
||||||
->countAllResults() > 0;
|
|
||||||
|
|
||||||
if (!$isOwned) {
|
|
||||||
return redirect()->back()->with('status', 'You do not have access to that class section.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
// Persist chosen class section in the session
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
// Load data for the view
|
|
||||||
$semester = $this->getSelectedSemester();
|
|
||||||
$homeworkScores = $this->getHomeworkScoresByStudent($classSectionId, $semester, $schoolYear);
|
|
||||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $schoolYear);
|
|
||||||
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $semester, $schoolYear);
|
|
||||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
|
||||||
|
|
||||||
// Render
|
|
||||||
return view('grading/homework', [
|
|
||||||
'homeworkScores' => $homeworkScores,
|
|
||||||
'students' => $students,
|
|
||||||
'semester' => $semester,
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
'homeworkHeaders' => $homeworkHeaders,
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'hasHomeworkCols' => !empty($homeworkHeaders),
|
|
||||||
'isManagement' => true,
|
|
||||||
'scoresLocked' => $scoresLocked,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function updateHomework()
|
|
||||||
{
|
|
||||||
$scores = $this->request->getPost('scores');
|
|
||||||
$studentIds = $this->request->getPost('student_ids');
|
|
||||||
$semester = $this->request->getPost('semester') ?? $this->getSelectedSemester();
|
|
||||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
|
||||||
|
|
||||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
|
||||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id'); // persisted in showHomeworkMngt
|
|
||||||
$classSectionIdInt = (int) ($classSectionId ?? 0);
|
|
||||||
if ($classSectionIdInt > 0 && $this->isScoresLocked($classSectionIdInt, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($scores && $studentIds && $semester && $schoolYear && $classSectionId) {
|
|
||||||
$this->updateHomeworkScores($scores, $updatedBy, $classSectionId);
|
|
||||||
session()->setFlashdata('status', 'Homework scores updated successfully.');
|
|
||||||
|
|
||||||
// ⬇️ Directly call the method and return its view
|
|
||||||
return $this->showHomeworkMngt($updatedBy);
|
|
||||||
}
|
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId);
|
|
||||||
// Call the updateScoresForStudents method
|
|
||||||
try {
|
|
||||||
|
|
||||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
return redirect()->back()->with('status', 'Failed to update scores.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
|
||||||
{
|
|
||||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────── Helpers ────────────────
|
|
||||||
|
|
||||||
private function saveHomeworkScores(array $scores, array $studentIds, string $semester, string $schoolYear, int $updatedBy, int $classSectionId)
|
|
||||||
{
|
|
||||||
$now = utc_now();
|
|
||||||
|
|
||||||
foreach ($studentIds as $studentId) {
|
|
||||||
if (!isset($scores[$studentId])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($scores[$studentId] as $index => $score) {
|
|
||||||
$isBlank = $score === null || (is_string($score) && trim($score) === '');
|
|
||||||
|
|
||||||
// Check if the record exists
|
|
||||||
$existing = $this->homeworkModel
|
|
||||||
->where('student_id', $studentId)
|
|
||||||
->where('homework_index', $index)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
//->where('teacher_id', $updatedBy)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($isBlank) {
|
|
||||||
if ($existing) {
|
|
||||||
$this->homeworkModel->update($existing['id'], [
|
|
||||||
'score' => null,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
$this->homeworkModel->insert([
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'homework_index' => $index,
|
|
||||||
'score' => null,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'created_at' => $now,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'homework_index' => $index,
|
|
||||||
'score' => is_numeric($score) ? floatval($score) : null,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'updated_at' => $now,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$this->homeworkModel->update($existing['id'], $data);
|
|
||||||
} else {
|
|
||||||
$data['created_at'] = $now;
|
|
||||||
$data['school_id'] = ''; // Set if needed
|
|
||||||
$this->homeworkModel->insert($data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
|
||||||
{
|
|
||||||
// Step 1: Get student IDs from student_class table
|
|
||||||
$studentClassRows = $this->studentClassModel
|
|
||||||
->select('student_id')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$studentIds = array_column($studentClassRows, 'student_id');
|
|
||||||
|
|
||||||
if (empty($studentIds)) {
|
|
||||||
return []; // No students found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Get student data from students table
|
|
||||||
$students = $this->studentModel
|
|
||||||
->whereIn('id', $studentIds)
|
|
||||||
->where('is_active', 1)
|
|
||||||
->orderBy('lastname', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
return $students;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getClassSectionIdForTeacher($updatedBy)
|
|
||||||
{
|
|
||||||
$class = $this->teacherClassModel->where('teacher_id', $updatedBy)->first();
|
|
||||||
return $class['class_section_id'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private function getHomeworkScoresByStudent($classSectionId, $semester, $schoolYear)
|
|
||||||
{
|
|
||||||
$semVariants = $this->getSemesterVariants($semester);
|
|
||||||
$rows = $this->homeworkModel
|
|
||||||
->select('student_id, homework_index, score')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->whereIn('semester', $semVariants)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$studentScores = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$studentId = $row['student_id'];
|
|
||||||
$index = $row['homework_index'];
|
|
||||||
$score = $row['score'];
|
|
||||||
|
|
||||||
$studentScores[$studentId]['scores'][$index] = $score;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $studentScores;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders, $semester, $schoolYear)
|
|
||||||
{
|
|
||||||
$semVariants = $this->getSemesterVariants($semester);
|
|
||||||
$studentClasses = $this->studentClassModel
|
|
||||||
->active()
|
|
||||||
->where('student_class.class_section_id', $classSectionId)
|
|
||||||
->findAll();
|
|
||||||
$students = [];
|
|
||||||
|
|
||||||
foreach ($studentClasses as $sc) {
|
|
||||||
$student = $this->studentModel
|
|
||||||
->where('id', $sc['student_id'])
|
|
||||||
->where('is_active', 1)
|
|
||||||
->first();
|
|
||||||
if (!$student) continue;
|
|
||||||
|
|
||||||
$scores = [];
|
|
||||||
foreach ($homeworkHeaders as $ids) {
|
|
||||||
$entry = $this->homeworkModel->where('homework_index', $ids)
|
|
||||||
->where('student_id', $sc['student_id'])
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->whereIn('semester', $semVariants)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->first();
|
|
||||||
// Keep null/blank when no entry exists yet
|
|
||||||
$scores[$ids] = $entry['score'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$students[] = [
|
|
||||||
'student_id' => $sc['student_id'],
|
|
||||||
'firstname' => $student['firstname'],
|
|
||||||
'lastname' => $student['lastname'],
|
|
||||||
'scores' => $scores
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
|
||||||
return $students;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getHomeworkHeaders($classSectionId, $semester, $schoolYear)
|
|
||||||
{
|
|
||||||
|
|
||||||
$semVariants = $this->getSemesterVariants($semester);
|
|
||||||
$rows = $this->homeworkModel
|
|
||||||
->select('homework_index')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
//->where('teacher_id', $updatedBy)
|
|
||||||
->whereIn('semester', $semVariants)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->orderBy('homework_index', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$headers = [];
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$index = $row['homework_index'];
|
|
||||||
if (!is_array($index)) {
|
|
||||||
$headers[$index] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($headers);
|
|
||||||
return array_keys($headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getSemesterVariants(?string $semester): array
|
|
||||||
{
|
|
||||||
$raw = trim((string) $semester);
|
|
||||||
$variants = [
|
|
||||||
$raw,
|
|
||||||
strtolower($raw),
|
|
||||||
ucfirst(strtolower($raw)),
|
|
||||||
];
|
|
||||||
$normalized = $this->normalizeSemesterSelection($raw);
|
|
||||||
if ($normalized === 'fall') {
|
|
||||||
$variants[] = 'First Semester';
|
|
||||||
$variants[] = 'Semester 1';
|
|
||||||
$variants[] = '1st Semester';
|
|
||||||
$variants[] = 'Fall Semester';
|
|
||||||
} elseif ($normalized === 'spring') {
|
|
||||||
$variants[] = 'Second Semester';
|
|
||||||
$variants[] = 'Semester 2';
|
|
||||||
$variants[] = '2nd Semester';
|
|
||||||
$variants[] = 'Spring Semester';
|
|
||||||
}
|
|
||||||
return array_values(array_unique(array_filter($variants, static fn($v) => $v !== '')));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getSelectedSemester(): string
|
|
||||||
{
|
|
||||||
$session = session();
|
|
||||||
$selected = $session->get('teacher_scores_selected_semester');
|
|
||||||
if (!empty($selected)) {
|
|
||||||
return (string) $selected;
|
|
||||||
}
|
|
||||||
return $session->get('semester') ?? $this->semester;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeSemesterSelection(?string $input): string
|
|
||||||
{
|
|
||||||
$value = strtolower(trim((string) $input));
|
|
||||||
if ($value === 'fall' || str_contains($value, '1')) {
|
|
||||||
return 'fall';
|
|
||||||
}
|
|
||||||
if ($value === 'spring' || str_contains($value, '2')) {
|
|
||||||
return 'spring';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeSchoolYear(?string $input): ?string
|
|
||||||
{
|
|
||||||
$value = trim((string) $input);
|
|
||||||
if ($value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (preg_match('/^\\d{4}\\s*[-\\/]\\s*\\d{4}$/', $value)) {
|
|
||||||
return preg_replace('/\\s+/', '', str_replace('/', '-', $value));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Models\CalendarModel;
|
|
||||||
use App\Models\HomeworkModel;
|
|
||||||
|
|
||||||
class HomeworkTrackingController extends BaseController
|
|
||||||
{
|
|
||||||
protected $configModel;
|
|
||||||
protected $calendarModel;
|
|
||||||
protected $homeworkModel;
|
|
||||||
protected $db;
|
|
||||||
|
|
||||||
protected string $semester;
|
|
||||||
protected string $schoolYear;
|
|
||||||
/** cache for teacher assignment presence per term */
|
|
||||||
private array $teacherAssignmentCache = [];
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->calendarModel = new CalendarModel();
|
|
||||||
$this->homeworkModel = new HomeworkModel();
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
|
|
||||||
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
|
||||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
// Semester-based date range (Fall or Spring) derived from school year start.
|
|
||||||
[$start, $end] = $this->getDateRangeForSemester($this->schoolYear, $this->semester);
|
|
||||||
|
|
||||||
// Move start to the first Sunday on/after start
|
|
||||||
if ((int)$start->format('w') !== 0) {
|
|
||||||
$start = (clone $start)->modify('next sunday');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all Sundays within range
|
|
||||||
$sundays = [];
|
|
||||||
$d = clone $start;
|
|
||||||
while ($d <= $end) {
|
|
||||||
$sundays[] = $d->format('Y-m-d');
|
|
||||||
$d->modify('+7 days');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map "no school" events by date (Y-m-d) using explicit DB flag
|
|
||||||
$events = $this->calendarModel->getEventsBySchoolYear($this->schoolYear) ?? [];
|
|
||||||
$eventDays = [];
|
|
||||||
foreach ($events as $ev) {
|
|
||||||
$ymd = substr((string)($ev['date'] ?? ''), 0, 10);
|
|
||||||
$no = (int)($ev['no_school'] ?? 0);
|
|
||||||
if ($ymd && $no === 1) {
|
|
||||||
$eventDays[$ymd] = true; // yellow highlight for no-school day
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map Sundays to ordinal homework_index, skipping event Sundays
|
|
||||||
$dateToIndex = [];
|
|
||||||
$idx = 0;
|
|
||||||
foreach ($sundays as $ymd) {
|
|
||||||
if (!isset($eventDays[$ymd])) {
|
|
||||||
$idx++;
|
|
||||||
$dateToIndex[$ymd] = $idx;
|
|
||||||
} else {
|
|
||||||
$dateToIndex[$ymd] = null; // event day (no homework expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$limitToSemester = $this->hasTeacherAssignments($this->schoolYear, $this->semester);
|
|
||||||
|
|
||||||
// Aggregate homework presence + first entered date per class_section_id + homework_index
|
|
||||||
$hwQ = $this->db->table('homework')
|
|
||||||
->select('class_section_id, homework_index, MIN(created_at) AS first_created, COUNT(*) AS cnt')
|
|
||||||
->where('school_year', $this->schoolYear);
|
|
||||||
if ($limitToSemester && $this->semester !== '') {
|
|
||||||
$hwQ->where('semester', $this->semester);
|
|
||||||
}
|
|
||||||
$rows = $hwQ->groupBy('class_section_id, homework_index')
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
$hasHomework = [];
|
|
||||||
$hwEnteredAt = [];
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$csid = (int)($r['class_section_id'] ?? 0);
|
|
||||||
$hi = (int)($r['homework_index'] ?? 0);
|
|
||||||
$cnt = (int)($r['cnt'] ?? 0);
|
|
||||||
if ($csid > 0 && $hi > 0 && $cnt > 0) {
|
|
||||||
$hasHomework[$csid][$hi] = true;
|
|
||||||
$dateStr = substr((string)($r['first_created'] ?? ''), 0, 10);
|
|
||||||
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build date-based presence mapped to the nearest prior non-NoSchool Sunday.
|
|
||||||
$hb = $this->db->table('homework')
|
|
||||||
->select("class_section_id, DATE(created_at) AS hw_date, MIN(created_at) AS first_created, COUNT(*) AS cnt", false)
|
|
||||||
->where('school_year', $this->schoolYear);
|
|
||||||
if ($limitToSemester && $this->semester !== '') {
|
|
||||||
$hb->where('semester', $this->semester);
|
|
||||||
}
|
|
||||||
$rowsByDate = $hb->groupBy('class_section_id, DATE(created_at)', '', false)
|
|
||||||
->orderBy('hw_date', 'ASC', false)
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
$hasHomeworkByDate = [];
|
|
||||||
$hwEnteredAtByDate = [];
|
|
||||||
foreach ($rowsByDate as $r) {
|
|
||||||
$csid = (int)($r['class_section_id'] ?? 0);
|
|
||||||
$d = substr((string)($r['hw_date'] ?? ''), 0, 10);
|
|
||||||
$cnt = (int)($r['cnt'] ?? 0);
|
|
||||||
$firstCreated = substr((string)($r['first_created'] ?? ''), 0, 10);
|
|
||||||
if ($csid <= 0 || !$d || $cnt <= 0) { continue; }
|
|
||||||
|
|
||||||
// Find the index of the last Sunday on or before $d
|
|
||||||
$baseIndex = -1;
|
|
||||||
for ($i = count($sundays) - 1; $i >= 0; $i--) {
|
|
||||||
if ($sundays[$i] <= $d) { $baseIndex = $i; break; }
|
|
||||||
}
|
|
||||||
if ($baseIndex < 0) { continue; }
|
|
||||||
|
|
||||||
// Map this homework day to only the nearest prior non–No School Sunday
|
|
||||||
$j = $baseIndex;
|
|
||||||
while ($j >= 0 && !empty($eventDays[$sundays[$j]])) { $j--; }
|
|
||||||
if ($j < 0) { continue; }
|
|
||||||
$sd = $sundays[$j];
|
|
||||||
|
|
||||||
// Mark homework on that Sunday for this class_section (single mapping)
|
|
||||||
$hasHomeworkByDate[$csid][$sd] = true;
|
|
||||||
if (empty($hwEnteredAtByDate[$csid][$sd])) {
|
|
||||||
$hwEnteredAtByDate[$csid][$sd] = $firstCreated ?: $d;
|
|
||||||
} else {
|
|
||||||
// Keep the earliest date for display
|
|
||||||
$existing = (string)$hwEnteredAtByDate[$csid][$sd];
|
|
||||||
$candidate = $firstCreated ?: $d;
|
|
||||||
if ($candidate && (!$existing || $candidate < $existing)) {
|
|
||||||
$hwEnteredAtByDate[$csid][$sd] = $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch teachers and TAs per section for this term
|
|
||||||
$tb = $this->db->table('teacher_class tc')
|
|
||||||
->select('tc.class_section_id, tc.position, cs.class_section_name, cs.class_id, u.firstname, u.lastname')
|
|
||||||
->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left')
|
|
||||||
->join('users u', 'u.id = tc.teacher_id', 'left')
|
|
||||||
->where('tc.school_year', $this->schoolYear);
|
|
||||||
$teacherRows = $tb->where('tc.class_section_id IS NOT NULL', null, false)
|
|
||||||
->whereIn('tc.position', ['main','ta'])
|
|
||||||
->orderBy('cs.class_id', 'ASC')
|
|
||||||
->orderBy('cs.class_section_name', 'ASC')
|
|
||||||
->orderBy("FIELD(tc.position, 'main','ta')", '', false)
|
|
||||||
->orderBy('u.firstname', 'ASC')
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
$bySection = [];
|
|
||||||
foreach ($teacherRows as $r) {
|
|
||||||
$sid = (int)($r['class_section_id'] ?? 0);
|
|
||||||
if ($sid <= 0) continue;
|
|
||||||
if (!isset($bySection[$sid])) {
|
|
||||||
$bySection[$sid] = [
|
|
||||||
'class_section_id' => $sid,
|
|
||||||
'class_id' => (int)($r['class_id'] ?? 0),
|
|
||||||
'class_section_name' => (string)($r['class_section_name'] ?? ''),
|
|
||||||
'teachers' => [],
|
|
||||||
'tas' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$name = trim(($r['firstname'] ?? '') . ' ' . ($r['lastname'] ?? ''));
|
|
||||||
$pos = strtolower((string)($r['position'] ?? ''));
|
|
||||||
if ($name !== '') {
|
|
||||||
if ($pos === 'main') { $bySection[$sid]['teachers'][] = $name; }
|
|
||||||
elseif ($pos === 'ta') { $bySection[$sid]['tas'][] = $name; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$teachers = array_values($bySection);
|
|
||||||
|
|
||||||
// Sort by grade order: KG(13) → Grade 1..11 → Youth(12) → others
|
|
||||||
usort($teachers, function ($a, $b) {
|
|
||||||
$ai = (int)($a['class_id'] ?? 0);
|
|
||||||
$bi = (int)($b['class_id'] ?? 0);
|
|
||||||
$ord = function ($cid) {
|
|
||||||
if ($cid === 13) return 0; // KG first
|
|
||||||
if ($cid >= 1 && $cid <= 11) return $cid; // Grades 1..11
|
|
||||||
if ($cid === 12) return 99; // Youth last of known
|
|
||||||
return 200 + $cid; // any others after
|
|
||||||
};
|
|
||||||
$cmp = $ord($ai) <=> $ord($bi);
|
|
||||||
if ($cmp !== 0) return $cmp;
|
|
||||||
return strnatcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? '');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simple server-side pagination for rows (8 lines per page)
|
|
||||||
$perPage = 8;
|
|
||||||
$totalRows = count($teachers);
|
|
||||||
$totalPages = max(1, (int)ceil($totalRows / $perPage));
|
|
||||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
|
||||||
if ($page < 1) $page = 1;
|
|
||||||
if ($page > $totalPages) $page = $totalPages;
|
|
||||||
$offset = ($page - 1) * $perPage;
|
|
||||||
$teachersPage = array_slice($teachers, $offset, $perPage);
|
|
||||||
|
|
||||||
return view('grading/homework_tracking', [
|
|
||||||
'semester' => $this->semester,
|
|
||||||
'schoolYear' => $this->schoolYear,
|
|
||||||
'sundays' => $sundays,
|
|
||||||
'eventDays' => $eventDays,
|
|
||||||
'dateToIndex' => $dateToIndex,
|
|
||||||
'teachers' => $teachersPage,
|
|
||||||
'hasHomework' => $hasHomework,
|
|
||||||
'hwEnteredAt' => $hwEnteredAt,
|
|
||||||
'hasHomeworkByDate' => $hasHomeworkByDate,
|
|
||||||
'hwEnteredAtByDate' => $hwEnteredAtByDate,
|
|
||||||
'page' => $page,
|
|
||||||
'totalPages' => $totalPages,
|
|
||||||
'perPage' => $perPage,
|
|
||||||
'totalRows' => $totalRows,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if teacher_class has any rows for the given school year.
|
|
||||||
* The semester argument is ignored because assignments are tracked per year.
|
|
||||||
*/
|
|
||||||
private function hasTeacherAssignments(string $schoolYear, string $semester): bool
|
|
||||||
{
|
|
||||||
$year = trim((string)$schoolYear);
|
|
||||||
if ($year === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists($year, $this->teacherAssignmentCache)) {
|
|
||||||
return $this->teacherAssignmentCache[$year];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cnt = $this->db->table('teacher_class')
|
|
||||||
->where('school_year', $year)
|
|
||||||
->countAllResults();
|
|
||||||
|
|
||||||
$this->teacherAssignmentCache[$year] = $cnt > 0;
|
|
||||||
return $this->teacherAssignmentCache[$year];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute start/end dates for the given semester within the school year.
|
|
||||||
* Fall: 09/21/{startYear} to 01/18/{startYear+1}
|
|
||||||
* Spring: 01/25/{startYear+1} to 05/31/{startYear+1}
|
|
||||||
* Defaults to today if parsing fails.
|
|
||||||
*/
|
|
||||||
private function getDateRangeForSemester(string $schoolYear, string $semester): array
|
|
||||||
{
|
|
||||||
$startYear = null;
|
|
||||||
if (preg_match('/\b(20\d{2})\b/', $schoolYear, $m)) {
|
|
||||||
$startYear = (int)$m[1];
|
|
||||||
}
|
|
||||||
if ($startYear === null) {
|
|
||||||
$today = new \DateTime('today');
|
|
||||||
return [$today, $today];
|
|
||||||
}
|
|
||||||
|
|
||||||
$nextYear = $startYear + 1;
|
|
||||||
$sem = strtolower(trim($semester));
|
|
||||||
|
|
||||||
$start = $end = null;
|
|
||||||
try {
|
|
||||||
if ($sem === 'spring') {
|
|
||||||
$start = new \DateTime("{$nextYear}-01-25");
|
|
||||||
$end = new \DateTime("{$nextYear}-05-31");
|
|
||||||
} else {
|
|
||||||
// default/fall
|
|
||||||
$start = new \DateTime("{$startYear}-09-21");
|
|
||||||
$end = new \DateTime("{$nextYear}-01-18");
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$today = new \DateTime('today');
|
|
||||||
return [$today, $today];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$start, $end];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,364 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
use App\Models\TeacherClassModel;
|
|
||||||
use App\Models\StudentClassModel;
|
|
||||||
use App\Models\StudentModel;
|
|
||||||
use App\Models\MidtermExamModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use RuntimeException;
|
|
||||||
use App\Services\SemesterScoreService;
|
|
||||||
use Config\Services;
|
|
||||||
use App\Models\GradingLockModel;
|
|
||||||
use App\Models\MissingScoreOverrideModel;
|
|
||||||
|
|
||||||
|
|
||||||
class MidtermController extends BaseController
|
|
||||||
{
|
|
||||||
protected $db;
|
|
||||||
protected $semesterScoreService;
|
|
||||||
protected $schoolYear; // Declare global variable for the controller
|
|
||||||
protected $semester;
|
|
||||||
protected $configModel;
|
|
||||||
protected $studentModel;
|
|
||||||
protected $teacherClassModel;
|
|
||||||
protected $gradingLockModel;
|
|
||||||
protected $missingScoreOverrideModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->studentModel = new StudentModel();
|
|
||||||
$this->teacherClassModel = new TeacherClassModel();
|
|
||||||
$this->semesterScoreService = service('semesterScoreService');
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
$this->gradingLockModel = new GradingLockModel();
|
|
||||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function add()
|
|
||||||
{
|
|
||||||
// 1) class_section_id from the view (POST/GET) with session fallback
|
|
||||||
$incoming = $this->request->getPost('class_section_id')
|
|
||||||
?? $this->request->getGet('class_section_id')
|
|
||||||
?? session()->get('class_section_id');
|
|
||||||
|
|
||||||
$classSectionId = (int) ($incoming ?: 0);
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('status', 'Missing class section.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// (optional) persist for downstream use
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
// (optional) access check if you restrict by teacher assignments
|
|
||||||
// if (!$this->canAccessClassSection((int)(session()->get('user_id') ?? 0), $classSectionId)) {
|
|
||||||
// return redirect()->back()->with('status', 'You do not have access to that class section.');
|
|
||||||
// }
|
|
||||||
|
|
||||||
$semester = $this->getSelectedSemesterFromRequest();
|
|
||||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
|
||||||
try {
|
|
||||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
|
||||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'midterm_exam');
|
|
||||||
|
|
||||||
return view('teacher/add_midterm_exam', [
|
|
||||||
'students' => $result['students'],
|
|
||||||
'semester' => $result['semester'],
|
|
||||||
'schoolYear' => $result['schoolYear'],
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'missingOkMap' => $missingOkMap,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return redirect()->back()->with('status', $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function update()
|
|
||||||
{
|
|
||||||
$updatedBy = session()->get('user_id');
|
|
||||||
$scores = $this->request->getPost('final_score');
|
|
||||||
$classSectionId = session()->get('class_section_id');
|
|
||||||
|
|
||||||
if (!is_array($scores)) {
|
|
||||||
return redirect()->back()->with('error', 'No scores submitted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$semester = $this->getSelectedSemesterFromRequest();
|
|
||||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
|
||||||
$classSectionId = (int) ($classSectionId ?? 0);
|
|
||||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
$this->saveExamScores('midterm_exam', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
|
||||||
|
|
||||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
|
||||||
$studentIds = array_keys($scores);
|
|
||||||
$checkedItems = [];
|
|
||||||
if (is_array($missingOk)) {
|
|
||||||
foreach ($missingOk as $studentId => $flags) {
|
|
||||||
$checkedItems[] = [
|
|
||||||
'student_id' => (int) $studentId,
|
|
||||||
'item_index' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->missingScoreOverrideModel->replaceOverrides(
|
|
||||||
$classSectionId,
|
|
||||||
(string) $semester,
|
|
||||||
(string) $schoolYear,
|
|
||||||
'midterm_exam',
|
|
||||||
$studentIds,
|
|
||||||
null,
|
|
||||||
$checkedItems,
|
|
||||||
$updatedBy,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
|
||||||
// Call the updateScoresForStudents method
|
|
||||||
try {
|
|
||||||
|
|
||||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->to(base_url('/teacher/addMidtermExam'))
|
|
||||||
->with('status', 'Midterm exam scores updated successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateMngt()
|
|
||||||
{
|
|
||||||
$scores = $this->request->getPost('final_score');
|
|
||||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id'); // default to session user
|
|
||||||
$classSectionId = session()->get('class_section_id');
|
|
||||||
|
|
||||||
if (!is_array($scores)) {
|
|
||||||
return redirect()->back()->with('error', 'No scores submitted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$semester = $this->getSelectedSemesterFromRequest();
|
|
||||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
|
||||||
$classSectionId = (int) ($classSectionId ?? 0);
|
|
||||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
$this->saveExamScores('midterm_exam', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
|
||||||
|
|
||||||
session()->setFlashdata('status', 'Midterm exam scores updated successfully.');
|
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
|
||||||
// Call the updateScoresForStudents method
|
|
||||||
try {
|
|
||||||
|
|
||||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
// ⬇️ Directly call the method and return its view
|
|
||||||
return $this->showMidtermMngt();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function showMidtermMngt()
|
|
||||||
{
|
|
||||||
// 1) Get class_section_id from POST -> GET -> session
|
|
||||||
$incoming = $this->request->getPost('class_section_id')
|
|
||||||
?? $this->request->getGet('class_section_id')
|
|
||||||
?? session()->get('class_section_id');
|
|
||||||
|
|
||||||
$classSectionId = (int) ($incoming ?: 0);
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('status', 'Missing class section.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 2) Load roster + saved scores for this class/term
|
|
||||||
$semester = $this->getSelectedSemesterFromRequest();
|
|
||||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
|
||||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
|
||||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
|
||||||
|
|
||||||
// 3) Render view (no teacherId anymore)
|
|
||||||
return view('grading/midterm', [
|
|
||||||
'students' => $result['students'],
|
|
||||||
'semester' => $result['semester'],
|
|
||||||
'schoolYear' => $result['schoolYear'],
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'scoresLocked' => $scoresLocked,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return redirect()->back()->with('status', $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
|
||||||
{
|
|
||||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private function saveExamScores(string $table, array $scores, int $updatedBy, int $classSectionId, string $semester, string $schoolYear): void
|
|
||||||
{
|
|
||||||
$builder = $this->db->table($table);
|
|
||||||
$now = utc_now();
|
|
||||||
|
|
||||||
foreach ($scores as $studentId => $data) {
|
|
||||||
if (!is_numeric($studentId) || !isset($data['score'])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$score = is_numeric($data['score']) ? (float) $data['score'] : null;
|
|
||||||
|
|
||||||
$existing = $builder
|
|
||||||
->where('student_id', $studentId)
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->get()
|
|
||||||
->getRow();
|
|
||||||
|
|
||||||
$dataToSave = [
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'score' => $score,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'updated_at' => $now,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$builder->where('id', $existing->id)->update($dataToSave);
|
|
||||||
} else {
|
|
||||||
$dataToSave['created_at'] = $now;
|
|
||||||
$builder->insert($dataToSave);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get roster + saved midterm scores for a specific classSection/term.
|
|
||||||
*/
|
|
||||||
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
|
|
||||||
{
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
throw new \InvalidArgumentException('Invalid class section id.');
|
|
||||||
}
|
|
||||||
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
// Subquery: pick ONE midterm row per student (latest by id).
|
|
||||||
// If your table doesn't have 'id', swap to MAX(updated_at) or MAX(exam_date).
|
|
||||||
$latestMidtermSub = $this->db->table('midterm_exam')
|
|
||||||
->select('student_id, MAX(id) AS max_id')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->groupBy('student_id')
|
|
||||||
->getCompiledSelect();
|
|
||||||
|
|
||||||
// Main query: roster for this class/term + LEFT JOIN the single midterm row
|
|
||||||
$qb = $this->studentModel
|
|
||||||
->select('
|
|
||||||
s.id AS student_id,
|
|
||||||
s.school_id,
|
|
||||||
s.firstname,
|
|
||||||
s.lastname,
|
|
||||||
me.score
|
|
||||||
')
|
|
||||||
->from('students s')
|
|
||||||
->join(
|
|
||||||
'student_class sc',
|
|
||||||
'sc.student_id = s.id
|
|
||||||
AND sc.class_section_id = ' . (int) $classSectionId . '
|
|
||||||
AND sc.school_year = ' . $this->db->escape($schoolYear),
|
|
||||||
'inner',
|
|
||||||
false
|
|
||||||
)
|
|
||||||
// Join the "latest midterm per student" subquery, then the actual midterm row
|
|
||||||
->join('(' . $latestMidtermSub . ') ml', 'ml.student_id = s.id', 'left', false)
|
|
||||||
->join('midterm_exam me', 'me.id = ml.max_id', 'left')
|
|
||||||
->orderBy('s.lastname', 'ASC')
|
|
||||||
->orderBy('s.firstname', 'ASC');
|
|
||||||
|
|
||||||
// DISTINCT protects against rare duplicate sc rows that match the same select columns
|
|
||||||
$qb->distinct();
|
|
||||||
|
|
||||||
$rows = $qb->get()->getResultArray();
|
|
||||||
|
|
||||||
// Final safety net: ensure one row per student_id
|
|
||||||
$unique = [];
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$unique[(int)$r['student_id']] = $r;
|
|
||||||
}
|
|
||||||
$students = array_values($unique);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'students' => $students,
|
|
||||||
'semester' => $semester,
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTeacherSelectedSemester(): string
|
|
||||||
{
|
|
||||||
helper('semester_selection_helper');
|
|
||||||
return selected_teacher_semester();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getSelectedSemesterFromRequest(): string
|
|
||||||
{
|
|
||||||
$semesterParam = $this->request->getPost('semester')
|
|
||||||
?? $this->request->getGet('semester')
|
|
||||||
?? $this->request->getGet('filter');
|
|
||||||
$normalized = $this->normalizeSemesterSelection($semesterParam);
|
|
||||||
if ($normalized !== '') {
|
|
||||||
$label = ucfirst($normalized);
|
|
||||||
$session = session();
|
|
||||||
$session->set('teacher_scores_selected_semester', $label);
|
|
||||||
$session->set('semester', $label);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->getTeacherSelectedSemester();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getSelectedSchoolYearFromRequest(): ?string
|
|
||||||
{
|
|
||||||
$yearParam = $this->request->getPost('school_year') ?? $this->request->getGet('school_year');
|
|
||||||
return $this->normalizeSchoolYear($yearParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeSemesterSelection(?string $input): string
|
|
||||||
{
|
|
||||||
$value = strtolower(trim((string) $input));
|
|
||||||
if ($value === 'fall' || str_contains($value, '1')) {
|
|
||||||
return 'fall';
|
|
||||||
}
|
|
||||||
if ($value === 'spring' || str_contains($value, '2')) {
|
|
||||||
return 'spring';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeSchoolYear(?string $input): ?string
|
|
||||||
{
|
|
||||||
$value = trim((string) $input);
|
|
||||||
if ($value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (preg_match('/^\\d{4}\\s*[-\\/]\\s*\\d{4}$/', $value)) {
|
|
||||||
return preg_replace('/\\s+/', '', str_replace('/', '-', $value));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\NavItemModel;
|
||||||
|
use App\Models\RoleNavItemModel;
|
||||||
|
use CodeIgniter\Cache\CacheInterface;
|
||||||
|
|
||||||
|
class NavbarService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected NavItemModel $navModel = new NavItemModel(),
|
||||||
|
protected RoleNavItemModel $mapModel = new RoleNavItemModel(),
|
||||||
|
protected ?CacheInterface $cache = null
|
||||||
|
){
|
||||||
|
$this->cache ??= cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMenuForRoles(array $roles): array
|
||||||
|
{
|
||||||
|
$roles = array_map(fn($r)=>strtolower(trim((string)$r)), $roles);
|
||||||
|
$cacheKey = 'navbar_' . md5(json_encode($roles));
|
||||||
|
|
||||||
|
if ($menu = $this->cache->get($cacheKey)) {
|
||||||
|
return $menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which items this user can see
|
||||||
|
$allowedIds = $this->mapModel->getNavItemIdsForRoles($roles);
|
||||||
|
if (empty($allowedIds)) return [];
|
||||||
|
|
||||||
|
// Load all enabled items, then filter
|
||||||
|
$rows = $this->navModel->where('is_enabled', 1)
|
||||||
|
->orderBy('sort_order', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
// index by id
|
||||||
|
$byId = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if (in_array($r['id'], $allowedIds, true)) {
|
||||||
|
$r['children'] = [];
|
||||||
|
$byId[$r['id']] = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tree
|
||||||
|
$tree = [];
|
||||||
|
foreach ($byId as $id => &$node) {
|
||||||
|
$pid = $node['menu_parent_id'];
|
||||||
|
if ($pid && isset($byId[$pid])) {
|
||||||
|
$byId[$pid]['children'][] = &$node;
|
||||||
|
} else {
|
||||||
|
$tree[] = &$node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->save($cacheKey, $tree, 300); // 5 minutes
|
||||||
|
return $tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
// simplest: flush; or if you have a tagged cache, clear only keys
|
||||||
|
cache()->clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use CodeIgniter\Events\Events;
|
||||||
|
|
||||||
|
class NotificationService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Trigger a notification event by type and payload.
|
||||||
|
*
|
||||||
|
* @param string $eventType The name of the event (e.g., 'userRegistered')
|
||||||
|
* @param array $payload The data to pass to the listener
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function trigger(string $eventType, array $payload): void
|
||||||
|
{
|
||||||
|
Events::trigger($eventType, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for sending to a specific user.
|
||||||
|
*
|
||||||
|
* @param int $userId
|
||||||
|
* @param string $title
|
||||||
|
* @param string $message
|
||||||
|
* @param array $channels
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function toUser(int $userId, string $title, string $message, array $channels = ['in_app']): void
|
||||||
|
{
|
||||||
|
self::trigger('customNotification', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'title' => $title,
|
||||||
|
'message' => $message,
|
||||||
|
'channels' => $channels
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
use App\Models\TeacherClassModel;
|
|
||||||
use App\Models\StudentClassModel;
|
|
||||||
use App\Models\StudentModel;
|
|
||||||
use App\Models\ParticipationModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use RuntimeException;
|
|
||||||
use App\Services\SemesterScoreService;
|
|
||||||
use Config\Services;
|
|
||||||
use App\Models\GradingLockModel;
|
|
||||||
use App\Models\MissingScoreOverrideModel;
|
|
||||||
|
|
||||||
|
|
||||||
class ParticipationController extends BaseController
|
|
||||||
{
|
|
||||||
protected $db;
|
|
||||||
protected $semesterScoreService;
|
|
||||||
protected $schoolYear; // Declare global variable for the controller
|
|
||||||
protected $semester;
|
|
||||||
protected $configModel;
|
|
||||||
protected $studentModel;
|
|
||||||
protected $teacherClassModel;
|
|
||||||
protected $gradingLockModel;
|
|
||||||
protected $missingScoreOverrideModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->studentModel = new StudentModel();
|
|
||||||
$this->teacherClassModel = new TeacherClassModel();
|
|
||||||
|
|
||||||
$this->semesterScoreService = service('semesterScoreService');
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
$this->gradingLockModel = new GradingLockModel();
|
|
||||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function add()
|
|
||||||
{
|
|
||||||
// 1) Get class_section_id from the view (POST/GET) with session fallback
|
|
||||||
$incoming = $this->request->getPost('class_section_id')
|
|
||||||
?? $this->request->getGet('class_section_id')
|
|
||||||
?? session()->get('class_section_id');
|
|
||||||
|
|
||||||
$classSectionId = (int) ($incoming ?: 0);
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('status', 'Missing class section.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Optional) persist for downstream pages
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
// (Optional) access check if you’re restricting by teacher/TA roles
|
|
||||||
// if (!$this->canAccessClassSection((int)(session()->get('user_id') ?? 0), $classSectionId)) {
|
|
||||||
// return redirect()->back()->with('status', 'You do not have access to that class section.');
|
|
||||||
// }
|
|
||||||
|
|
||||||
$semester = $this->getSelectedSemesterFromRequest();
|
|
||||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
|
||||||
try {
|
|
||||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
|
||||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'participation');
|
|
||||||
|
|
||||||
return view('teacher/add_participation', [
|
|
||||||
'students' => $result['students'],
|
|
||||||
'semester' => $result['semester'],
|
|
||||||
'schoolYear' => $result['schoolYear'],
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'missingOkMap' => $missingOkMap,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return redirect()->back()->with('status', $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function update()
|
|
||||||
{
|
|
||||||
$updatedBy = session()->get('user_id');
|
|
||||||
$scores = $this->request->getPost('final_score');
|
|
||||||
$classSectionId = session()->get('class_section_id');
|
|
||||||
|
|
||||||
if (!is_array($scores)) {
|
|
||||||
return redirect()->back()->with('error', 'No scores submitted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$semester = $this->getSelectedSemesterFromRequest();
|
|
||||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
|
||||||
$classSectionId = (int) ($classSectionId ?? 0);
|
|
||||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
$this->saveParticipationScores('participation', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
|
||||||
|
|
||||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
|
||||||
$studentIds = array_keys($scores);
|
|
||||||
$checkedItems = [];
|
|
||||||
if (is_array($missingOk)) {
|
|
||||||
foreach ($missingOk as $studentId => $flags) {
|
|
||||||
$checkedItems[] = [
|
|
||||||
'student_id' => (int) $studentId,
|
|
||||||
'item_index' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->missingScoreOverrideModel->replaceOverrides(
|
|
||||||
$classSectionId,
|
|
||||||
(string) $semester,
|
|
||||||
(string) $schoolYear,
|
|
||||||
'participation',
|
|
||||||
$studentIds,
|
|
||||||
null,
|
|
||||||
$checkedItems,
|
|
||||||
$updatedBy,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
|
||||||
// Call the updateScoresForStudents method
|
|
||||||
try {
|
|
||||||
|
|
||||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->to(base_url('/teacher/addParticipation'))
|
|
||||||
->with('status', 'Participation scores updated successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateMngt()
|
|
||||||
{
|
|
||||||
$scores = $this->request->getPost('final_score');
|
|
||||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id'); // default to session user
|
|
||||||
$classSectionId = session()->get('class_section_id');
|
|
||||||
|
|
||||||
if (!is_array($scores)) {
|
|
||||||
return redirect()->back()->with('error', 'No scores submitted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$semester = $this->getSelectedSemesterFromRequest();
|
|
||||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
|
||||||
$classSectionId = (int) ($classSectionId ?? 0);
|
|
||||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
$this->saveParticipationScores('participation', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
|
||||||
|
|
||||||
session()->setFlashdata('status', 'Participation scores updated successfully.');
|
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
|
||||||
// Call the updateScoresForStudents method
|
|
||||||
try {
|
|
||||||
|
|
||||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
// ⬇️ Directly call the method and return its view
|
|
||||||
return $this->showParticipationMngt();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function showParticipationMngt()
|
|
||||||
{
|
|
||||||
// Get class_section_id from view (POST/GET) with session fallback
|
|
||||||
$incoming = $this->request->getPost('class_section_id')
|
|
||||||
?? $this->request->getGet('class_section_id')
|
|
||||||
?? session()->get('class_section_id');
|
|
||||||
|
|
||||||
$classSectionId = (int) ($incoming ?: 0);
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('status', 'Missing class section.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// OPTIONAL: enforce access like we did for homework/midterm
|
|
||||||
// if (!$this->canAccessClassSection((int)(session()->get('user_id') ?? 0), $classSectionId)) {
|
|
||||||
// return redirect()->back()->with('status', 'You do not have access to that class section.');
|
|
||||||
// }
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Pull roster + saved (participation/midterm) scores for this section/term
|
|
||||||
// If you have a dedicated participation table, call its getter here instead.
|
|
||||||
$semester = $this->getSelectedSemesterFromRequest();
|
|
||||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
|
||||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
|
||||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
|
||||||
|
|
||||||
return view('grading/participation', [
|
|
||||||
'students' => $result['students'], // already includes school_id
|
|
||||||
'semester' => $result['semester'],
|
|
||||||
'schoolYear' => $result['schoolYear'],
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'scoresLocked' => $scoresLocked,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return redirect()->back()->with('status', $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
|
||||||
{
|
|
||||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private function saveParticipationScores(string $table, array $scores, int $updatedBy, int $classSectionId, string $semester, string $schoolYear): void
|
|
||||||
{
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
$builder = $db->table($table);
|
|
||||||
$now = utc_now();
|
|
||||||
|
|
||||||
foreach ($scores as $studentId => $data) {
|
|
||||||
if (!is_numeric($studentId) || !isset($data['score'])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$score = is_numeric($data['score']) ? (float) $data['score'] : null;
|
|
||||||
|
|
||||||
$existing = $builder
|
|
||||||
->where('student_id', $studentId)
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->get()
|
|
||||||
->getRow();
|
|
||||||
|
|
||||||
$dataToSave = [
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'score' => $score,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'updated_at' => $now,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$builder->where('id', $existing->id)->update($dataToSave);
|
|
||||||
} else {
|
|
||||||
$dataToSave['created_at'] = $now;
|
|
||||||
$builder->insert($dataToSave);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
|
|
||||||
{
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
throw new \InvalidArgumentException('Invalid class section id.');
|
|
||||||
}
|
|
||||||
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
// Build a DISTINCT roster subquery correctly (no 'DISTINCT' inside select string)
|
|
||||||
$rosterSub = $this->db->table('student_class')
|
|
||||||
->select('student_id') // <-- just the column
|
|
||||||
->distinct() // <-- ask CI4 to add DISTINCT
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->getCompiledSelect();
|
|
||||||
|
|
||||||
// Main query: Students + LEFT JOIN participation for same section/term
|
|
||||||
$students = $this->db->table('students s')
|
|
||||||
->select('
|
|
||||||
s.id AS student_id,
|
|
||||||
s.school_id,
|
|
||||||
s.firstname,
|
|
||||||
s.lastname,
|
|
||||||
p.score
|
|
||||||
')
|
|
||||||
->join("({$rosterSub}) sc", 'sc.student_id = s.id', 'inner', false)
|
|
||||||
->join(
|
|
||||||
'participation p',
|
|
||||||
'p.student_id = s.id
|
|
||||||
AND p.class_section_id = ' . (int)$classSectionId . '
|
|
||||||
AND p.semester = ' . $this->db->escape($semester) . '
|
|
||||||
AND p.school_year = ' . $this->db->escape($schoolYear),
|
|
||||||
'left',
|
|
||||||
false
|
|
||||||
)
|
|
||||||
->where('s.is_active', 1)
|
|
||||||
->orderBy('s.lastname', 'ASC')
|
|
||||||
->orderBy('s.firstname', 'ASC')
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'students' => $students,
|
|
||||||
'semester' => $semester,
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTeacherSelectedSemester(): string
|
|
||||||
{
|
|
||||||
helper('semester_selection_helper');
|
|
||||||
return selected_teacher_semester();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getSelectedSemesterFromRequest(): string
|
|
||||||
{
|
|
||||||
$semesterParam = $this->request->getPost('semester')
|
|
||||||
?? $this->request->getGet('semester')
|
|
||||||
?? $this->request->getGet('filter');
|
|
||||||
$normalized = $this->normalizeSemesterSelection($semesterParam);
|
|
||||||
if ($normalized !== '') {
|
|
||||||
$label = ucfirst($normalized);
|
|
||||||
$session = session();
|
|
||||||
$session->set('teacher_scores_selected_semester', $label);
|
|
||||||
$session->set('semester', $label);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->getTeacherSelectedSemester();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getSelectedSchoolYearFromRequest(): ?string
|
|
||||||
{
|
|
||||||
$yearParam = $this->request->getPost('school_year') ?? $this->request->getGet('school_year');
|
|
||||||
return $this->normalizeSchoolYear($yearParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeSemesterSelection(?string $input): string
|
|
||||||
{
|
|
||||||
$value = strtolower(trim((string) $input));
|
|
||||||
if ($value === 'fall' || str_contains($value, '1')) {
|
|
||||||
return 'fall';
|
|
||||||
}
|
|
||||||
if ($value === 'spring' || str_contains($value, '2')) {
|
|
||||||
return 'spring';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeSchoolYear(?string $input): ?string
|
|
||||||
{
|
|
||||||
$value = trim((string) $input);
|
|
||||||
if ($value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (preg_match('/^\\d{4}\\s*[-\\/]\\s*\\d{4}$/', $value)) {
|
|
||||||
return preg_replace('/\\s+/', '', str_replace('/', '-', $value));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,365 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\PaymentNotificationLogModel;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Models\InvoiceModel;
|
|
||||||
use App\Models\PaymentModel;
|
|
||||||
use App\Models\FamilyGuardianModel;
|
|
||||||
use App\Services\EmailService;
|
|
||||||
use App\Services\NotificationService;
|
|
||||||
|
|
||||||
class PaymentNotificationController extends BaseController
|
|
||||||
{
|
|
||||||
protected PaymentNotificationLogModel $logModel;
|
|
||||||
protected UserModel $userModel;
|
|
||||||
protected ConfigurationModel $configModel;
|
|
||||||
protected InvoiceModel $invoiceModel;
|
|
||||||
protected PaymentModel $paymentModel;
|
|
||||||
protected FamilyGuardianModel $familyGuardianModel;
|
|
||||||
protected EmailService $emailService;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->logModel = new PaymentNotificationLogModel();
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->invoiceModel = new InvoiceModel();
|
|
||||||
$this->paymentModel = new PaymentModel();
|
|
||||||
$this->familyGuardianModel = new FamilyGuardianModel();
|
|
||||||
$this->emailService = new EmailService();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
helper(['form', 'url']);
|
|
||||||
|
|
||||||
$from = $this->request->getGet('from');
|
|
||||||
$to = $this->request->getGet('to');
|
|
||||||
$type = $this->request->getGet('type');
|
|
||||||
|
|
||||||
$rows = $this->logModel->listLogs($from, $to, $type);
|
|
||||||
|
|
||||||
// Preload parent names
|
|
||||||
$parents = [];
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$pid = (int) ($r['parent_id'] ?? 0);
|
|
||||||
if ($pid && !isset($parents[$pid])) {
|
|
||||||
$u = $this->userModel->select('firstname,lastname')->find($pid);
|
|
||||||
$parents[$pid] = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('payment/notification_management', [
|
|
||||||
'logs' => $rows,
|
|
||||||
'parentsById' => $parents,
|
|
||||||
'filters' => ['from' => $from, 'to' => $to, 'type' => $type],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function wantsJson(): bool
|
|
||||||
{
|
|
||||||
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
|
|
||||||
$fmt = strtolower((string)($this->request->getGet('format') ?? $this->request->getPost('format') ?? ''));
|
|
||||||
return $this->request->isAJAX() || str_contains($accept, 'application/json') || $fmt === 'json';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/payments/notifications/send
|
|
||||||
* Body: parent_id? (int), type? ('no_payment'|'installment'), school_year?
|
|
||||||
* - When parent_id provided: send for that parent only (if balance > 0 or force=1)
|
|
||||||
* - Otherwise: send to all parents with positive balance for selected school year
|
|
||||||
* respecting idempotency (1 email per month/type) unless force=1.
|
|
||||||
*/
|
|
||||||
public function send()
|
|
||||||
{
|
|
||||||
$parentId = (int)($this->request->getPost('parent_id') ?? 0);
|
|
||||||
$typeInput = (string)($this->request->getPost('type') ?? '');
|
|
||||||
$force = (int)($this->request->getPost('force') ?? 0) === 1;
|
|
||||||
$schoolYear = (string)($this->request->getPost('school_year') ?? ($this->configModel->getConfig('school_year') ?? date('Y')));
|
|
||||||
$returnTo = (string)($this->request->getPost('return_to') ?? '');
|
|
||||||
|
|
||||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
||||||
$tz = new \DateTimeZone($tzName);
|
|
||||||
$now = new \DateTime('now', $tz);
|
|
||||||
$year = (int)$now->format('Y');
|
|
||||||
$month= (int)$now->format('n');
|
|
||||||
|
|
||||||
// Helper: compute current total balance across invoices for this parent/year
|
|
||||||
$computeBalance = function (int $pid) use ($schoolYear): array {
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
$rows = $db->table('invoices')
|
|
||||||
->select('id, total_amount')
|
|
||||||
->where('parent_id', $pid)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->get()->getResultArray();
|
|
||||||
$paymentsTbl = 'payments';
|
|
||||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
|
||||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
|
||||||
|
|
||||||
$sumBalance = 0.0; $latestId = null;
|
|
||||||
foreach ($rows as $ir) {
|
|
||||||
$iid = (int)$ir['id'];
|
|
||||||
if ($latestId === null) $latestId = $iid;
|
|
||||||
$total = (float)($ir['total_amount'] ?? 0);
|
|
||||||
|
|
||||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
|
||||||
if ($hasStatus) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
|
||||||
->orWhere('status IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
if ($hasVoid) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->where('is_void', 0)
|
|
||||||
->orWhere('is_void IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
$paid = (float)($qb->get()->getRowArray()['tot'] ?? 0);
|
|
||||||
|
|
||||||
$disc = (float)($db->table('discount_usages')->select('COALESCE(SUM(discount_amount),0) AS tot')->where('invoice_id', $iid)->get()->getRowArray()['tot'] ?? 0);
|
|
||||||
$rfnd = (float)($db->table('refunds')->select('COALESCE(SUM(refund_paid_amount),0) AS tot')->where('invoice_id', $iid)->whereIn('status', ['Partial','Paid'])->get()->getRowArray()['tot'] ?? 0);
|
|
||||||
|
|
||||||
$sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2));
|
|
||||||
}
|
|
||||||
return [$sumBalance, $latestId];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: get secondary guardian email
|
|
||||||
$getSecondary = function (int $primaryUserId): ?string {
|
|
||||||
$row = $this->familyGuardianModel->where('user_id', $primaryUserId)->first();
|
|
||||||
if (!$row || empty($row['family_id'])) return null;
|
|
||||||
$others = $this->familyGuardianModel
|
|
||||||
->where('family_id', (int)$row['family_id'])
|
|
||||||
->where('user_id !=', $primaryUserId)
|
|
||||||
->where('receive_emails', 1)
|
|
||||||
->findAll();
|
|
||||||
foreach ($others as $g) {
|
|
||||||
$email = $this->userModel->select('email')->find((int)$g['user_id'])['email'] ?? null;
|
|
||||||
if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) return $email;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compose subject/body using same content as CLI command
|
|
||||||
$compose = function (int $pid, float $balance) use ($schoolYear, $now): array {
|
|
||||||
$u = $this->userModel->find($pid) ?: [];
|
|
||||||
$parentName = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
|
|
||||||
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
|
|
||||||
$monthYear = $now->format('F Y');
|
|
||||||
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
|
|
||||||
$balanceFmt = '$' . number_format($balance, 2);
|
|
||||||
$intro = 'This is your monthly installment reminder for ' . $schoolYear . '.';
|
|
||||||
// Compute remaining/max installments similar to Manual Payment UI
|
|
||||||
$installmentEndRaw = (string)$this->configModel->getConfig('installment_date');
|
|
||||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
||||||
$tz = new \DateTimeZone($tzName);
|
|
||||||
$today = new \DateTimeImmutable('today', $tz);
|
|
||||||
$end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; }
|
|
||||||
$remMonths = 0;
|
|
||||||
if ($end) {
|
|
||||||
$y = (int)$end->format('Y') - (int)$today->format('Y');
|
|
||||||
$m = (int)$end->format('n') - (int)$today->format('n');
|
|
||||||
$remMonths = $y * 12 + $m;
|
|
||||||
if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1;
|
|
||||||
if ($remMonths < 0) $remMonths = 0;
|
|
||||||
}
|
|
||||||
$remainingInst = max(1, $remMonths);
|
|
||||||
$maxInst = max(($remMonths ?: 0), ($balance > 0 ? 2 : 0));
|
|
||||||
$instDueFmt = '$' . number_format($remainingInst > 0 ? ($balance / $remainingInst) : 0, 2);
|
|
||||||
$bodyHtml = <<<HTML
|
|
||||||
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
|
|
||||||
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder</h2>
|
|
||||||
<p>{$greeting}</p>
|
|
||||||
<p>{$intro}</p>
|
|
||||||
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Remaining installments:</strong> {$remainingInst}</li>
|
|
||||||
<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>
|
|
||||||
<li><strong>Maximum installments available:</strong> {$maxInst}</li>
|
|
||||||
</ul>
|
|
||||||
<p>As a friendly reminder, our tuition installments are due at the beginning of each month.</p>
|
|
||||||
<p>You can review your invoice and payment options by logging in to your parent portal.</p>
|
|
||||||
<p>Thank you for your prompt attention.</p>
|
|
||||||
<p><em>Reminder Period:</em> {$monthYear}</p>
|
|
||||||
</div>
|
|
||||||
HTML;
|
|
||||||
$body = view('emails/_wrap_layout', [ 'title' => 'Monthly Tuition Reminder', 'body_html' => $bodyHtml ], ['saveData' => true]);
|
|
||||||
return [$subject, $body];
|
|
||||||
};
|
|
||||||
|
|
||||||
$results = [ 'sent' => 0, 'skipped' => 0, 'failed' => 0, 'details' => [] ];
|
|
||||||
|
|
||||||
if ($parentId > 0) {
|
|
||||||
[$balance, $latestInv] = $computeBalance($parentId);
|
|
||||||
|
|
||||||
// detect type if not provided
|
|
||||||
if ($typeInput === 'no_payment' || $typeInput === 'installment') {
|
|
||||||
$type = $typeInput;
|
|
||||||
} else {
|
|
||||||
$hasPayments = $this->paymentModel
|
|
||||||
->where('parent_id', $parentId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->countAllResults() > 0;
|
|
||||||
$type = $hasPayments ? 'installment' : 'no_payment';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotency: skip if already sent this month for this type
|
|
||||||
if (!$force && $this->logModel->existsForPeriod($parentId, $year, $month, $type)) {
|
|
||||||
$results['skipped']++;
|
|
||||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'already_sent'];
|
|
||||||
} else {
|
|
||||||
|
|
||||||
if ($balance <= 0 && !$force) {
|
|
||||||
$results['skipped']++;
|
|
||||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'no_balance'];
|
|
||||||
// For HTML submit, redirect back with details
|
|
||||||
if (!$this->wantsJson()) {
|
|
||||||
$dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear));
|
|
||||||
return redirect()->to($dest)->with('status', 'No balance to remind this parent.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Compute current-month installment suggestion
|
|
||||||
$installmentEndRaw = (string)$this->configModel->getConfig('installment_date');
|
|
||||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
||||||
$tz = new \DateTimeZone($tzName);
|
|
||||||
$nowD = new \DateTimeImmutable('today', $tz);
|
|
||||||
$endD = null; try { if ($installmentEndRaw) $endD = new \DateTimeImmutable($installmentEndRaw, $tz); } catch (\Throwable $e) { $endD = null; }
|
|
||||||
$remMonths = 0;
|
|
||||||
if ($endD) {
|
|
||||||
$y = (int)$endD->format('Y') - (int)$nowD->format('Y');
|
|
||||||
$m = (int)$endD->format('n') - (int)$nowD->format('n');
|
|
||||||
$remMonths = $y * 12 + $m;
|
|
||||||
if ((int)$endD->format('j') > (int)$nowD->format('j')) $remMonths += 1;
|
|
||||||
if ($remMonths < 0) $remMonths = 0;
|
|
||||||
}
|
|
||||||
$remInst = max(1, $remMonths);
|
|
||||||
$instDue = round($balance / $remInst, 2);
|
|
||||||
$toEmail = $this->userModel->select('email')->find($parentId)['email'] ?? null;
|
|
||||||
$toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null;
|
|
||||||
$ccEmail = $getSecondary($parentId);
|
|
||||||
[$subject, $body] = $compose($parentId, $balance);
|
|
||||||
|
|
||||||
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
|
|
||||||
if ($ok && $ccEmail && strcasecmp($ccEmail, (string)$toEmail) !== 0) {
|
|
||||||
$this->emailService->send($ccEmail, $subject, $body, 'finance');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->logModel->insert([
|
|
||||||
'parent_id' => $parentId,
|
|
||||||
'invoice_id' => $latestInv,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'period_year' => $year,
|
|
||||||
'period_month' => $month,
|
|
||||||
'type' => $type,
|
|
||||||
'to_email' => $toEmail,
|
|
||||||
'cc_email' => $ccEmail,
|
|
||||||
'head_fa_notified' => 0,
|
|
||||||
'subject' => $subject,
|
|
||||||
'body' => $body,
|
|
||||||
'status' => $ok ? 'sent' : 'failed',
|
|
||||||
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
|
|
||||||
'balance_snapshot' => $balance,
|
|
||||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($ok) { $results['sent']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'sent', 'balance' => $balance, 'installment_due' => $instDue, 'remaining' => $remInst]; }
|
|
||||||
else { $results['failed']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'failed']; }
|
|
||||||
|
|
||||||
// For HTML submit, redirect back with detailed message
|
|
||||||
if (!$this->wantsJson()) {
|
|
||||||
$dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear));
|
|
||||||
$msg = sprintf('Reminder sent. Remaining balance: $%s. Current installment due: $%s. Remaining installments: %d',
|
|
||||||
number_format($balance, 2), number_format($instDue, 2), (int)$remInst);
|
|
||||||
return redirect()->to($dest)->with('status', $msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single-parent branch handled; otherwise do bulk
|
|
||||||
} else {
|
|
||||||
|
|
||||||
// Bulk: parents with positive balance in selected year
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
$rows = $db->table('invoices')
|
|
||||||
->select('DISTINCT parent_id', false)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->get()->getResultArray();
|
|
||||||
$pids = array_values(array_unique(array_map(static fn($r) => (int)$r['parent_id'], $rows)));
|
|
||||||
|
|
||||||
foreach ($pids as $pid) {
|
|
||||||
[$balance, $latestInv] = $computeBalance($pid);
|
|
||||||
if ($balance <= 0 && !$force) { $results['skipped']++; continue; }
|
|
||||||
|
|
||||||
$hasPayments = $this->paymentModel
|
|
||||||
->where('parent_id', $pid)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->countAllResults() > 0;
|
|
||||||
$type = $hasPayments ? 'installment' : 'no_payment';
|
|
||||||
|
|
||||||
if (!$force && $this->logModel->existsForPeriod($pid, $year, $month, $type)) { $results['skipped']++; continue; }
|
|
||||||
|
|
||||||
$toEmail = $this->userModel->select('email')->find($pid)['email'] ?? null;
|
|
||||||
$toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null;
|
|
||||||
$ccEmail = $getSecondary($pid);
|
|
||||||
[$subject, $body] = $compose($pid, $balance);
|
|
||||||
|
|
||||||
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
|
|
||||||
if ($ok && $ccEmail && strcasecmp($ccEmail, (string)$toEmail) !== 0) {
|
|
||||||
$this->emailService->send($ccEmail, $subject, $body, 'finance');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: notify head of finance in-app
|
|
||||||
foreach ($this->getHeadOfFinanceUsers() as $u) {
|
|
||||||
NotificationService::toUser((int)$u['id'], 'Payment Reminder Sent',
|
|
||||||
sprintf('A %s reminder was sent to parent #%d (balance: $%0.2f).', $type, $pid, $balance),
|
|
||||||
['in_app']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->logModel->insert([
|
|
||||||
'parent_id' => $pid,
|
|
||||||
'invoice_id' => $latestInv,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'period_year' => $year,
|
|
||||||
'period_month' => $month,
|
|
||||||
'type' => $type,
|
|
||||||
'to_email' => $toEmail,
|
|
||||||
'cc_email' => $ccEmail,
|
|
||||||
'head_fa_notified' => 1,
|
|
||||||
'subject' => $subject,
|
|
||||||
'body' => $body,
|
|
||||||
'status' => $ok ? 'sent' : 'failed',
|
|
||||||
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
|
|
||||||
'balance_snapshot' => $balance,
|
|
||||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($ok) $results['sent']++; else $results['failed']++;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respond
|
|
||||||
if ($this->wantsJson()) {
|
|
||||||
return $this->response->setJSON(['ok' => true] + $results + [
|
|
||||||
'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$msg = sprintf('Reminders — sent: %d, skipped: %d, failed: %d', (int)$results['sent'], (int)$results['skipped'], (int)$results['failed']);
|
|
||||||
$dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear));
|
|
||||||
return redirect()->to($dest)->with('status', $msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getHeadOfFinanceUsers(): array
|
|
||||||
{
|
|
||||||
$heads = $this->userModel->getUsersByRole('head of department (finance)');
|
|
||||||
if (!empty($heads)) return $heads;
|
|
||||||
$acct = $this->userModel->getUsersByRole('accountant');
|
|
||||||
return $acct ?: [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Models\PaymentTransactionModel;
|
|
||||||
use CodeIgniter\RESTful\ResourceController;
|
|
||||||
|
|
||||||
class PaymentTransactionController extends ResourceController
|
|
||||||
{
|
|
||||||
protected $paymentTransactionModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->paymentTransactionModel = new PaymentTransactionModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
// API: Create a new payment transaction (installment)
|
|
||||||
public function createAPI()
|
|
||||||
{
|
|
||||||
$data = [
|
|
||||||
'transaction_id' => $this->request->getPost('transaction_id'),
|
|
||||||
'payment_id' => $this->request->getPost('payment_id'),
|
|
||||||
'transaction_date' => $this->request->getPost('transaction_date'),
|
|
||||||
'amount' => $this->request->getPost('amount'),
|
|
||||||
'payment_method' => $this->request->getPost('payment_method'),
|
|
||||||
'payment_status' => 'Pending',
|
|
||||||
'transaction_fee' => $this->request->getPost('transaction_fee'),
|
|
||||||
'payment_reference' => $this->request->getPost('payment_reference'),
|
|
||||||
'is_full_payment' => $this->request->getPost('is_full_payment') ? 1 : 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($this->paymentTransactionModel->save($data)) {
|
|
||||||
return $this->respondCreated($data);
|
|
||||||
} else {
|
|
||||||
return $this->failValidationErrors($this->paymentTransactionModel->errors());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// API: Get all transactions for a specific payment
|
|
||||||
public function getByPaymentAPI($paymentId)
|
|
||||||
{
|
|
||||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
|
||||||
if ($transactions) {
|
|
||||||
return $this->respond($transactions);
|
|
||||||
} else {
|
|
||||||
return $this->failNotFound('Transactions not found for the payment.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// View: Get all transactions for a specific payment (for web views)
|
|
||||||
public function getByPayment($paymentId)
|
|
||||||
{
|
|
||||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
|
||||||
return view('payment_transaction_list', ['transactions' => $transactions]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// View: Create a new payment transaction
|
|
||||||
public function create()
|
|
||||||
{
|
|
||||||
return view('payment_transaction_create');
|
|
||||||
}
|
|
||||||
|
|
||||||
// API: Update payment status by transaction ID
|
|
||||||
public function updateStatusAPI($transactionId)
|
|
||||||
{
|
|
||||||
$status = $this->request->getPost('status');
|
|
||||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
|
||||||
return $this->respond(['status' => 'success']);
|
|
||||||
} else {
|
|
||||||
return $this->failNotFound('Transaction not found.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// View: Update payment status by transaction ID
|
|
||||||
public function updateStatus($transactionId)
|
|
||||||
{
|
|
||||||
$status = $this->request->getPost('status');
|
|
||||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
|
||||||
return redirect()->to('/payment_transactions');
|
|
||||||
} else {
|
|
||||||
return redirect()->back()->with('error', 'Failed to update status.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\PayPalPaymentModel;
|
|
||||||
use App\Models\PaymentModel;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Models\InvoiceModel;
|
|
||||||
|
|
||||||
class PaypalTransactionsController extends BaseController
|
|
||||||
{
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
$model = new PayPalPaymentModel();
|
|
||||||
|
|
||||||
$keyword = $this->request->getGet('q');
|
|
||||||
$perPage = 10;
|
|
||||||
|
|
||||||
if ($keyword) {
|
|
||||||
$transactions = $model
|
|
||||||
->groupStart()
|
|
||||||
->like('transaction_id', $keyword)
|
|
||||||
->orLike('payer_email', $keyword)
|
|
||||||
->orLike('event_type', $keyword)
|
|
||||||
->orLike('order_id', $keyword)
|
|
||||||
->orLike('parent_school_id', $keyword)
|
|
||||||
->groupEnd()
|
|
||||||
->orderBy('created_at', 'DESC')
|
|
||||||
->paginate($perPage);
|
|
||||||
} else {
|
|
||||||
$transactions = $model
|
|
||||||
->orderBy('created_at', 'DESC')
|
|
||||||
->paginate($perPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('administrator/paypal_transactions', [
|
|
||||||
'transactions' => $transactions,
|
|
||||||
'pager' => $model->pager,
|
|
||||||
'keyword' => $keyword
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exportCsv()
|
|
||||||
{
|
|
||||||
$model = new PayPalPaymentModel();
|
|
||||||
$keyword = $this->request->getGet('q');
|
|
||||||
|
|
||||||
if ($keyword) {
|
|
||||||
$transactions = $model
|
|
||||||
->groupStart()
|
|
||||||
->like('transaction_id', $keyword)
|
|
||||||
->orLike('payer_email', $keyword)
|
|
||||||
->orLike('event_type', $keyword)
|
|
||||||
->orLike('order_id', $keyword)
|
|
||||||
->orLike('parent_school_id', $keyword)
|
|
||||||
->groupEnd()
|
|
||||||
->orderBy('created_at', 'DESC')
|
|
||||||
->findAll();
|
|
||||||
} else {
|
|
||||||
$transactions = $model->orderBy('created_at', 'DESC')->findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
$filename = 'paypal_transactions_' . date('Ymd_His') . '.csv';
|
|
||||||
|
|
||||||
header('Content-Type: text/csv');
|
|
||||||
header("Content-Disposition: attachment; filename=\"$filename\"");
|
|
||||||
|
|
||||||
$output = fopen('php://output', 'w');
|
|
||||||
|
|
||||||
// CSV headers
|
|
||||||
fputcsv($output, [
|
|
||||||
'ID', 'Transaction ID', 'Order ID', 'Parent School ID',
|
|
||||||
'Email', 'Amount', 'Net Amount', 'Currency',
|
|
||||||
'Status', 'Event Type', 'Created At'
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach ($transactions as $t) {
|
|
||||||
fputcsv($output, [
|
|
||||||
$t['id'],
|
|
||||||
$t['transaction_id'],
|
|
||||||
$t['order_id'],
|
|
||||||
$t['parent_school_id'],
|
|
||||||
$t['payer_email'],
|
|
||||||
$t['amount'],
|
|
||||||
$t['net_amount'],
|
|
||||||
$t['currency'],
|
|
||||||
$t['status'],
|
|
||||||
$t['event_type'],
|
|
||||||
$t['created_at'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fclose($output);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
class PhoneFormatterService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Format a raw phone number to (xxx)-xxx-xxxx format
|
||||||
|
*
|
||||||
|
* @param string $number
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function formatPhoneNumber(string $number): ?string
|
||||||
|
{
|
||||||
|
// Remove non-digit characters
|
||||||
|
$digits = preg_replace('/\D/', '', $number);
|
||||||
|
|
||||||
|
// Ensure it's exactly 10 digits
|
||||||
|
if (strlen($digits) === 10) {
|
||||||
|
return sprintf(
|
||||||
|
'%s-%s-%s',
|
||||||
|
substr($digits, 0, 3),
|
||||||
|
substr($digits, 3, 3),
|
||||||
|
substr($digits, 6)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid number
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,540 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Models\ProjectModel;
|
|
||||||
use App\Models\StudentModel;
|
|
||||||
use App\Models\StudentClassModel;
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
use App\Models\TeacherClassModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use RuntimeException;
|
|
||||||
use App\Services\SemesterScoreService;
|
|
||||||
use Config\Services;
|
|
||||||
use App\Models\GradingLockModel;
|
|
||||||
use App\Models\MissingScoreOverrideModel;
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectController extends Controller
|
|
||||||
{
|
|
||||||
protected $db;
|
|
||||||
protected ConfigurationModel $configModel;
|
|
||||||
protected string $schoolYear;
|
|
||||||
protected $semesterScoreService;
|
|
||||||
protected $gradingLockModel;
|
|
||||||
protected $missingScoreOverrideModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->semesterScoreService = service('semesterScoreService');
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
|
||||||
$this->gradingLockModel = new GradingLockModel();
|
|
||||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addProject()
|
|
||||||
{
|
|
||||||
$updatedBy = session()->get('user_id');
|
|
||||||
$teacherClassModel = new TeacherClassModel();
|
|
||||||
$studentClassModel = new StudentClassModel();
|
|
||||||
$studentModel = new StudentModel();
|
|
||||||
$projectModel = new ProjectModel();
|
|
||||||
|
|
||||||
$teacherClass = $teacherClassModel->where('teacher_id', $updatedBy)->first();
|
|
||||||
if (!$teacherClass) {
|
|
||||||
return redirect()->back()->with('status', 'No class section found for the current teacher.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$classSectionId = $teacherClass['class_section_id'];
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
$semester = $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->getTeacherSchoolYear();
|
|
||||||
|
|
||||||
$projectRows = $projectModel
|
|
||||||
->select('id, project_index')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->orderBy('project_index', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$projectHeaders = [];
|
|
||||||
foreach ($projectRows as $row) {
|
|
||||||
$projectHeaders[$row['project_index']][] = $row['id'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$headerLabels = array_keys($projectHeaders);
|
|
||||||
|
|
||||||
$studentsClasses = $studentClassModel
|
|
||||||
->active()
|
|
||||||
->where('student_class.class_section_id', $classSectionId)
|
|
||||||
->findAll();
|
|
||||||
$students = [];
|
|
||||||
|
|
||||||
foreach ($studentsClasses as $studentClass) {
|
|
||||||
$studentId = $studentClass['student_id'];
|
|
||||||
$student = $studentModel
|
|
||||||
->where('id', $studentId)
|
|
||||||
->where('is_active', 1)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($student) {
|
|
||||||
$scores = [];
|
|
||||||
foreach ($projectHeaders as $index => $ids) {
|
|
||||||
$entry = $projectModel
|
|
||||||
->where('student_id', $studentId)
|
|
||||||
->where('project_index', $index)
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$scores[$index] = $entry['score'] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$students[] = [
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'firstname' => $student['firstname'],
|
|
||||||
'lastname' => $student['lastname'],
|
|
||||||
'scores' => $scores
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
|
||||||
|
|
||||||
return view('/teacher/add_project', [
|
|
||||||
'students' => $students,
|
|
||||||
'projectHeaders' => $headerLabels,
|
|
||||||
'semester' => $semester,
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
'missingOkMap' => $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'project'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateProjectScores(array $scores = null, int $updatedBy = null, int $classSectionId = null)
|
|
||||||
{
|
|
||||||
$scores = $this->request->getPost('scores');
|
|
||||||
$projectModel = new ProjectModel();
|
|
||||||
$studentModel = new StudentModel();
|
|
||||||
|
|
||||||
if ($updatedBy === null) {
|
|
||||||
$updatedBy = session()->get('user_id');
|
|
||||||
}
|
|
||||||
$classSectionId = session()->get('class_section_id');
|
|
||||||
$semester = $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->getTeacherSchoolYear();
|
|
||||||
$classSectionId = (int) ($classSectionId ?? 0);
|
|
||||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_array($scores)) {
|
|
||||||
return redirect()->back()->with('error', 'No project scores submitted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
|
||||||
$studentIds = array_keys($scores);
|
|
||||||
$indexSet = [];
|
|
||||||
foreach ($scores as $studentId => $projects) {
|
|
||||||
if (!is_array($projects)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($projects as $index => $score) {
|
|
||||||
if (is_numeric($index)) {
|
|
||||||
$indexSet[(int)$index] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$checkedItems = [];
|
|
||||||
if (is_array($missingOk)) {
|
|
||||||
foreach ($missingOk as $studentId => $indexes) {
|
|
||||||
if (!is_array($indexes)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($indexes as $index => $flag) {
|
|
||||||
if (!is_numeric($index)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$checkedItems[] = [
|
|
||||||
'student_id' => (int) $studentId,
|
|
||||||
'item_index' => (int) $index,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->missingScoreOverrideModel->replaceOverrides(
|
|
||||||
$classSectionId,
|
|
||||||
(string) $semester,
|
|
||||||
(string) $schoolYear,
|
|
||||||
'project',
|
|
||||||
$studentIds,
|
|
||||||
array_keys($indexSet),
|
|
||||||
$checkedItems,
|
|
||||||
$updatedBy
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($scores as $studentId => $projects) {
|
|
||||||
if (!is_numeric($studentId)) continue;
|
|
||||||
|
|
||||||
$student = $studentModel->find($studentId);
|
|
||||||
if (!$student) continue;
|
|
||||||
|
|
||||||
foreach ($projects as $index => $score) {
|
|
||||||
$normalizedScore = is_numeric($score) ? (float) $score : null;
|
|
||||||
|
|
||||||
$existing = $projectModel->where([
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'project_index' => $index,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'semester' => $semester
|
|
||||||
])->first();
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'school_id' => $student['school_id'],
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'project_index' => $index,
|
|
||||||
'score' => $normalizedScore,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'updated_at' => utc_now(),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$projectModel->update($existing['id'], $data);
|
|
||||||
} else {
|
|
||||||
$data['created_at'] = utc_now();
|
|
||||||
$projectModel->insert($data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$studentTeacherInfo = $studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
|
||||||
// Call the updateScoresForStudents method
|
|
||||||
try {
|
|
||||||
|
|
||||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
return redirect()->to(base_url('/teacher/addProject'))->with('status', 'Project scores updated successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addNextProjectColumn()
|
|
||||||
{
|
|
||||||
$updatedBy = session()->get('user_id');
|
|
||||||
$classSectionId = session()->get('class_section_id');
|
|
||||||
$semester = $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->getTeacherSchoolYear();
|
|
||||||
|
|
||||||
$projectModel = new ProjectModel();
|
|
||||||
$studentClassModel = new StudentClassModel();
|
|
||||||
|
|
||||||
$existingIndexes = $projectModel
|
|
||||||
->select('project_index')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('updated_by', $updatedBy)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->groupBy('project_index')
|
|
||||||
->orderBy('project_index', 'DESC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$maxIndex = 0;
|
|
||||||
foreach ($existingIndexes as $row) {
|
|
||||||
if (isset($row['project_index']) && is_numeric($row['project_index'])) {
|
|
||||||
$maxIndex = max($maxIndex, (int)$row['project_index']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$nextIndex = $maxIndex + 1;
|
|
||||||
|
|
||||||
$students = $studentClassModel
|
|
||||||
->active()
|
|
||||||
->where('student_class.class_section_id', $classSectionId)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
foreach ($students as $student) {
|
|
||||||
$exists = $projectModel->where([
|
|
||||||
'student_id' => $student['student_id'],
|
|
||||||
'project_index' => $nextIndex,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
])->first();
|
|
||||||
|
|
||||||
if (!$exists) {
|
|
||||||
$projectModel->insert([
|
|
||||||
'student_id' => $student['student_id'],
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'project_index' => $nextIndex,
|
|
||||||
'score' => null,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'created_at' => utc_now(),
|
|
||||||
'updated_at' => utc_now()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'status' => 'success',
|
|
||||||
'project_index' => $nextIndex
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function showProjectMngt()
|
|
||||||
{
|
|
||||||
// Accept POST first, then GET (query string), then session fallback
|
|
||||||
$incomingId = $this->request->getPost('class_section_id');
|
|
||||||
if (!$incomingId) {
|
|
||||||
$incomingId = $this->request->getGet('class_section_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
|
|
||||||
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('status', 'No class section found for the current teacher or selection.');
|
|
||||||
}
|
|
||||||
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
$semester = $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->getTeacherSchoolYear();
|
|
||||||
$projectScores = $this->getProjectScoresByStudent($classSectionId, $semester, $schoolYear);
|
|
||||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $schoolYear);
|
|
||||||
$projectHeaders = $this->getProjectHeaders($classSectionId, $semester, $schoolYear);
|
|
||||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
|
||||||
|
|
||||||
return view('grading/project', [
|
|
||||||
'projectScores' => $projectScores,
|
|
||||||
'students' => $students,
|
|
||||||
'semester' => $semester,
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
'projectHeaders' => $projectHeaders,
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'scoresLocked' => $scoresLocked,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateProject()
|
|
||||||
{
|
|
||||||
$studentModel = new StudentModel();
|
|
||||||
$scores = $this->request->getPost('scores');
|
|
||||||
$studentIds = $this->request->getPost('student_ids');
|
|
||||||
$semester = $this->request->getPost('semester') ?? $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->request->getPost('school_year') ?? $this->getTeacherSchoolYear();
|
|
||||||
|
|
||||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
|
||||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id'); // already stored in showProjectMngt
|
|
||||||
$classSectionId = (int) ($classSectionId ?? 0);
|
|
||||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($scores && $studentIds && $semester && $schoolYear && $classSectionId) {
|
|
||||||
$this->updateProjectScores($scores, $updatedBy, $classSectionId);
|
|
||||||
session()->setFlashdata('status', 'Project scores updated successfully.');
|
|
||||||
|
|
||||||
// ⬇️ Directly call the method and return its view
|
|
||||||
return $this->showProjectMngt($updatedBy);
|
|
||||||
}
|
|
||||||
|
|
||||||
$studentTeacherInfo = $studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
|
||||||
// Call the updateScoresForStudents method
|
|
||||||
try {
|
|
||||||
|
|
||||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
return redirect()->back()->with('status', 'Failed to update scores.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
|
||||||
{
|
|
||||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**____________________helpers________________________ */
|
|
||||||
|
|
||||||
private function saveProjectScores(array $scores, array $studentIds, string $semester, string $schoolYear, int $updatedBy, int $classSectionId)
|
|
||||||
{
|
|
||||||
$projectModel = new \App\Models\ProjectModel();
|
|
||||||
$now = utc_now();
|
|
||||||
|
|
||||||
foreach ($studentIds as $studentId) {
|
|
||||||
if (!isset($scores[$studentId])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($scores[$studentId] as $index => $score) {
|
|
||||||
// Check if the record exists
|
|
||||||
$existing = $projectModel
|
|
||||||
->where('student_id', $studentId)
|
|
||||||
->where('project_index', $index)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('updated_by', $updatedBy)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'project_index' => $index,
|
|
||||||
'score' => is_numeric($score) ? floatval($score) : null,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'updated_at' => $now,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($existing && isset($existing['id'])) {
|
|
||||||
$projectModel->update($existing['id'], $data);
|
|
||||||
} else {
|
|
||||||
$data['created_at'] = $now;
|
|
||||||
$data['school_id'] = ''; // Set if needed
|
|
||||||
$projectModel->insert($data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getProjectHeaders($classSectionId, $semester, $schoolYear)
|
|
||||||
{
|
|
||||||
$projectModel = new ProjectModel();
|
|
||||||
$rows = $projectModel
|
|
||||||
->select('project_index')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->orderBy('project_index', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$headers = [];
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$index = $row['project_index'];
|
|
||||||
if (!is_array($index)) {
|
|
||||||
$headers[$index] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($headers);
|
|
||||||
return array_keys($headers);
|
|
||||||
}
|
|
||||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
|
||||||
{
|
|
||||||
$studentClassModel = new \App\Models\StudentClassModel();
|
|
||||||
$studentModel = new \App\Models\StudentModel();
|
|
||||||
|
|
||||||
// Step 1: Get student IDs from student_class table
|
|
||||||
$studentClassRows = $studentClassModel
|
|
||||||
->select('student_id')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$studentIds = array_column($studentClassRows, 'student_id');
|
|
||||||
|
|
||||||
if (empty($studentIds)) {
|
|
||||||
return []; // No students found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Get student data from students table
|
|
||||||
$students = $studentModel
|
|
||||||
->whereIn('id', $studentIds)
|
|
||||||
->where('is_active', 1)
|
|
||||||
->orderBy('lastname', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
return $students;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getClassSectionIdForTeacher($updatedBy)
|
|
||||||
{
|
|
||||||
$teacherClassModel = new TeacherClassModel();
|
|
||||||
$class = $teacherClassModel->where('teacher_id', $updatedBy)->first();
|
|
||||||
return $class['class_section_id'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getProjectScoresByStudent($classSectionId, $semester, $schoolYear)
|
|
||||||
{
|
|
||||||
$projectModel = new ProjectModel();
|
|
||||||
|
|
||||||
$rows = $projectModel
|
|
||||||
->select('student_id, project_index, score')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$studentScores = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$studentId = $row['student_id'];
|
|
||||||
$index = $row['project_index'];
|
|
||||||
$score = $row['score'];
|
|
||||||
|
|
||||||
$studentScores[$studentId]['scores'][$index] = $score;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $studentScores;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getStudentsWithProjectScores($classSectionId, $projectHeaders)
|
|
||||||
{
|
|
||||||
$studentClassModel = new StudentClassModel();
|
|
||||||
$studentModel = new StudentModel();
|
|
||||||
$projectModel = new ProjectModel();
|
|
||||||
|
|
||||||
$studentClasses = $studentClassModel
|
|
||||||
->active()
|
|
||||||
->where('student_class.class_section_id', $classSectionId)
|
|
||||||
->findAll();
|
|
||||||
$students = [];
|
|
||||||
|
|
||||||
foreach ($studentClasses as $sc) {
|
|
||||||
$student = $studentModel
|
|
||||||
->where('id', $sc['student_id'])
|
|
||||||
->where('is_active', 1)
|
|
||||||
->first();
|
|
||||||
if (!$student) continue;
|
|
||||||
|
|
||||||
$scores = [];
|
|
||||||
foreach ($projectHeaders as $index => $ids) {
|
|
||||||
$entry = $projectModel->where('project_index', $index)
|
|
||||||
->where('student_id', $sc['student_id'])
|
|
||||||
->first();
|
|
||||||
$scores[$index] = $entry['score'] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$students[] = [
|
|
||||||
'student_id' => $sc['student_id'],
|
|
||||||
'firstname' => $student['firstname'],
|
|
||||||
'lastname' => $student['lastname'],
|
|
||||||
'scores' => $scores
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
|
||||||
return $students;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTeacherSelectedSemester(): string
|
|
||||||
{
|
|
||||||
helper('semester_selection_helper');
|
|
||||||
return selected_teacher_semester();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTeacherSchoolYear(): string
|
|
||||||
{
|
|
||||||
return (string) $this->schoolYear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,577 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
use App\Models\TeacherClassModel;
|
|
||||||
use App\Models\StudentClassModel;
|
|
||||||
use App\Models\StudentModel;
|
|
||||||
use App\Models\QuizModel;
|
|
||||||
use App\Models\HomeworkModel;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Controllers\View\GradingController;
|
|
||||||
use RuntimeException;
|
|
||||||
use App\Services\SemesterScoreService;
|
|
||||||
use Config\Services;
|
|
||||||
use App\Models\GradingLockModel;
|
|
||||||
use App\Models\MissingScoreOverrideModel;
|
|
||||||
|
|
||||||
class QuizController extends Controller
|
|
||||||
{
|
|
||||||
protected $db;
|
|
||||||
protected $semesterScoreService;
|
|
||||||
protected $quizModel;
|
|
||||||
protected $configModel;
|
|
||||||
protected $homeworkModel;
|
|
||||||
protected $userModel;
|
|
||||||
protected $schoolYear;
|
|
||||||
protected $semester;
|
|
||||||
protected $studentClassModel;
|
|
||||||
protected $studentModel;
|
|
||||||
protected $teacherClassModel;
|
|
||||||
protected $gradingLockModel;
|
|
||||||
protected $missingScoreOverrideModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
$this->teacherClassModel = new TeacherClassModel();
|
|
||||||
$this->homeworkModel = new HomeworkModel();
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->studentClassModel = new StudentClassModel();
|
|
||||||
$this->studentModel = new StudentModel();
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->quizModel = new QuizModel();
|
|
||||||
$this->semesterScoreService = service('semesterScoreService');
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
$this->gradingLockModel = new GradingLockModel();
|
|
||||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateQuizScores(array $scores = null, int $updatedBy = null, int $classSectionId = null)
|
|
||||||
{
|
|
||||||
$scores = $this->request->getPost('scores');
|
|
||||||
log_message('error', '✅ Raw Scores: ' . print_r($scores, true));
|
|
||||||
|
|
||||||
if ($updatedBy === null) {
|
|
||||||
$updatedBy = session()->get('user_id');
|
|
||||||
}
|
|
||||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id');
|
|
||||||
$semester = $this->request->getPost('semester') ?? $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
|
||||||
$classSectionId = (int) ($classSectionId ?? 0);
|
|
||||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
|
||||||
$studentIds = is_array($scores) ? array_keys($scores) : array_keys((array) $missingOk);
|
|
||||||
$indexSet = [];
|
|
||||||
if (is_array($scores)) {
|
|
||||||
foreach ($scores as $studentId => $quizData) {
|
|
||||||
if (!is_array($quizData)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($quizData as $index => $score) {
|
|
||||||
if (is_numeric($index)) {
|
|
||||||
$indexSet[(int)$index] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$checkedItems = [];
|
|
||||||
if (is_array($missingOk)) {
|
|
||||||
foreach ($missingOk as $studentId => $indexes) {
|
|
||||||
if (!is_array($indexes)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($indexes as $index => $flag) {
|
|
||||||
if (!is_numeric($index)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$checkedItems[] = [
|
|
||||||
'student_id' => (int) $studentId,
|
|
||||||
'item_index' => (int) $index,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->missingScoreOverrideModel->replaceOverrides(
|
|
||||||
$classSectionId,
|
|
||||||
(string) $semester,
|
|
||||||
(string) $schoolYear,
|
|
||||||
'quiz',
|
|
||||||
$studentIds,
|
|
||||||
array_keys($indexSet),
|
|
||||||
$checkedItems,
|
|
||||||
$updatedBy
|
|
||||||
);
|
|
||||||
|
|
||||||
if (is_array($scores)) {
|
|
||||||
foreach ($scores as $studentId => $quizData) {
|
|
||||||
if (!is_numeric($studentId) || $studentId <= 0) continue;
|
|
||||||
|
|
||||||
// Normalize: if single input submitted
|
|
||||||
if (!is_array($quizData)) {
|
|
||||||
$quizData = [1 => $quizData];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($quizData as $quizNumber => $score) {
|
|
||||||
if (!is_numeric($quizNumber)) continue;
|
|
||||||
|
|
||||||
$isBlank = $score === null || (is_string($score) && trim($score) === '');
|
|
||||||
$normalizedScore = (!$isBlank && is_numeric($score)) ? (float) $score : null;
|
|
||||||
|
|
||||||
$existing = $this->quizModel->where([
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'quiz_index' => $quizNumber,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
])->first();
|
|
||||||
|
|
||||||
if ($isBlank) {
|
|
||||||
if ($existing) {
|
|
||||||
$this->quizModel->update($existing['id'], [
|
|
||||||
'score' => null,
|
|
||||||
'updated_at' => utc_now(),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
$this->quizModel->insert([
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'school_id' => '', // Optional
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'quiz_index' => $quizNumber,
|
|
||||||
'score' => null,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'created_at' => utc_now(),
|
|
||||||
'updated_at' => utc_now()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$this->quizModel->update($existing['id'], [
|
|
||||||
'score' => $normalizedScore,
|
|
||||||
'updated_at' => utc_now()
|
|
||||||
]);
|
|
||||||
log_message('debug', "✅ Updated quiz ID {$existing['id']} with score {$normalizedScore}");
|
|
||||||
} else {
|
|
||||||
$this->quizModel->insert([
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'school_id' => '', // Optional
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'quiz_index' => $quizNumber,
|
|
||||||
'score' => $normalizedScore,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'created_at' => utc_now(),
|
|
||||||
'updated_at' => utc_now()
|
|
||||||
]);
|
|
||||||
log_message('debug', "✅ Inserted quiz for student $studentId quiz $quizNumber with score $score");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
|
||||||
// Call the updateScoresForStudents method
|
|
||||||
try {
|
|
||||||
|
|
||||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
return redirect()->to(base_url('/teacher/addQuiz'))->with('status', 'Quiz scores updated successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addQuiz()
|
|
||||||
{
|
|
||||||
// 1) class_section_id from view (POST/GET) with session fallback
|
|
||||||
$incoming = $this->request->getPost('class_section_id')
|
|
||||||
?? $this->request->getGet('class_section_id')
|
|
||||||
?? session()->get('class_section_id');
|
|
||||||
|
|
||||||
$classSectionId = (int) ($incoming ?: 0);
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('status', 'Missing class section.');
|
|
||||||
}
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
|
|
||||||
// 3) Headers: distinct quiz_index for this class/term
|
|
||||||
$semester = $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->schoolYear;
|
|
||||||
$quizHeaderRows = $this->quizModel->select('quiz_index')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->groupBy('quiz_index')
|
|
||||||
->orderBy('quiz_index', 'ASC')
|
|
||||||
->get()->getResultArray();
|
|
||||||
|
|
||||||
$quizHeaders = array_map(static fn($r) => (int)$r['quiz_index'], $quizHeaderRows); // [1,2,3,...]
|
|
||||||
|
|
||||||
// 4) Roster (distinct students for this section/term)
|
|
||||||
$roster = $this->db->table('student_class sc')
|
|
||||||
->select('s.id AS student_id, s.firstname, s.lastname, s.school_id')
|
|
||||||
->distinct() // ✅ apply DISTINCT correctly
|
|
||||||
->join('students s', 's.id = sc.student_id', 'inner')
|
|
||||||
->where('sc.class_section_id', $classSectionId)
|
|
||||||
->where('sc.school_year', $schoolYear)
|
|
||||||
->orderBy('s.lastname', 'ASC')
|
|
||||||
->orderBy('s.firstname', 'ASC')
|
|
||||||
->get()->getResultArray();
|
|
||||||
|
|
||||||
|
|
||||||
// 5) All existing quiz scores (single query) -> pivot
|
|
||||||
$scoreRows = $this->quizModel->select('id, student_id, quiz_index, score')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->get()->getResultArray();
|
|
||||||
|
|
||||||
$scoresMap = []; // [student_id][quiz_index] => score
|
|
||||||
$idMap = []; // [student_id][quiz_index] => id (if you need to update existing rows)
|
|
||||||
|
|
||||||
foreach ($scoreRows as $r) {
|
|
||||||
$sid = (int)$r['student_id'];
|
|
||||||
$qi = (int)$r['quiz_index'];
|
|
||||||
$scoresMap[$sid][$qi] = $r['score'];
|
|
||||||
$idMap[$sid][$qi] = (int)$r['id'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Build students array with per-quiz scores
|
|
||||||
$students = [];
|
|
||||||
foreach ($roster as $st) {
|
|
||||||
$sid = (int)$st['student_id'];
|
|
||||||
|
|
||||||
$quizScores = [];
|
|
||||||
foreach ($quizHeaders as $qi) {
|
|
||||||
$quizScores[$qi] = $scoresMap[$sid][$qi] ?? ''; // empty if none yet
|
|
||||||
}
|
|
||||||
|
|
||||||
$students[] = [
|
|
||||||
'student_id' => $sid,
|
|
||||||
'firstname' => $st['firstname'],
|
|
||||||
'lastname' => $st['lastname'],
|
|
||||||
'school_id' => $st['school_id'] ?? null,
|
|
||||||
'scores' => $quizScores,
|
|
||||||
// If your view needs existing row ids to decide update vs insert:
|
|
||||||
'existingIds' => $idMap[$sid] ?? [], // optional
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7) Render
|
|
||||||
return view('teacher/add_quiz', [
|
|
||||||
'students' => $students,
|
|
||||||
'quizHeaders' => $quizHeaders, // e.g., [1,2,3]
|
|
||||||
'semester' => $semester,
|
|
||||||
'schoolYear' => $this->schoolYear,
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'missingOkMap' => $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $this->schoolYear, 'quiz'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function addNextQuizColumn()
|
|
||||||
{
|
|
||||||
$updatedBy = session()->get('user_id');
|
|
||||||
$classSectionId = session()->get('class_section_id');
|
|
||||||
|
|
||||||
if (!$updatedBy || !$classSectionId) {
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => 'Missing teacher or class section data.'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
$semester = $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->schoolYear;
|
|
||||||
$existingQuizNumbers = $this->quizModel
|
|
||||||
->select('quiz_index')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->groupBy('quiz_index')
|
|
||||||
->orderBy('quiz_index', 'DESC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$maxQuizNumber = 0;
|
|
||||||
foreach ($existingQuizNumbers as $row) {
|
|
||||||
if (is_numeric($row['quiz_index'])) {
|
|
||||||
$maxQuizNumber = max($maxQuizNumber, (int)$row['quiz_index']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$nextQuizNumber = $maxQuizNumber + 1;
|
|
||||||
|
|
||||||
if ($nextQuizNumber <= 0) {
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => '🚫 Invalid quiz number',
|
|
||||||
'calculated' => $nextQuizNumber
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$students = $this->studentClassModel
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
if (empty($students)) {
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => 'No students found in this class section.'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$firstInsertedId = null;
|
|
||||||
|
|
||||||
foreach ($students as $i => $student) {
|
|
||||||
$exists = $this->quizModel
|
|
||||||
->where([
|
|
||||||
'student_id' => $student['student_id'],
|
|
||||||
'quiz_index' => $nextQuizNumber,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'semester' => $this->semester,
|
|
||||||
'school_year' => $this->schoolYear,
|
|
||||||
])
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($exists) continue;
|
|
||||||
|
|
||||||
$insertData = [
|
|
||||||
'student_id' => $student['student_id'],
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'updated_by' => $updatedBy,
|
|
||||||
'quiz_index' => $nextQuizNumber,
|
|
||||||
'score' => null,
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'created_at' => utc_now(),
|
|
||||||
'updated_at' => utc_now(),
|
|
||||||
];
|
|
||||||
|
|
||||||
log_message('error', '✅ Inserting quiz row: ' . json_encode($insertData));
|
|
||||||
|
|
||||||
try {
|
|
||||||
$id = $this->quizModel->insert($insertData, true);
|
|
||||||
if ($i === 0) $firstInsertedId = $id;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => '❌ Insert failed: ' . $e->getMessage(),
|
|
||||||
'debug' => $insertData
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'status' => 'success',
|
|
||||||
'quiz_index' => $nextQuizNumber,
|
|
||||||
'new_quiz_id' => $firstInsertedId
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => '❌ Unexpected error: ' . $e->getMessage()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function showQuizMngt()
|
|
||||||
{
|
|
||||||
// Accept POST first, then GET (query string), then session fallback
|
|
||||||
$incomingId = $this->request->getPost('class_section_id');
|
|
||||||
if (!$incomingId) {
|
|
||||||
$incomingId = $this->request->getGet('class_section_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
|
|
||||||
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('status', 'No class section found for the current teacher or selection.');
|
|
||||||
}
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
|
||||||
$semester = $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->schoolYear;
|
|
||||||
$quizScores = $this->getquizScoresByStudent($classSectionId, $semester, $schoolYear);
|
|
||||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $schoolYear);
|
|
||||||
$quizHeaders = $this->getquizHeaders($classSectionId, $semester, $schoolYear);
|
|
||||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
|
||||||
|
|
||||||
return view('grading/quiz', [
|
|
||||||
'quizScores' => $quizScores,
|
|
||||||
'students' => $students,
|
|
||||||
'semester' => $semester,
|
|
||||||
'schoolYear' => $schoolYear,
|
|
||||||
'quizHeaders' => $quizHeaders,
|
|
||||||
'classSectionId' => $classSectionId,
|
|
||||||
'scoresLocked' => $scoresLocked,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateQuiz()
|
|
||||||
{
|
|
||||||
$scores = $this->request->getPost('scores');
|
|
||||||
$studentIds = $this->request->getPost('student_ids');
|
|
||||||
$semester = $this->request->getPost('semester') ?? $this->getTeacherSelectedSemester();
|
|
||||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
|
||||||
|
|
||||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
|
||||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id'); // or query it
|
|
||||||
$classSectionId = (int) ($classSectionId ?? 0);
|
|
||||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($scores && $studentIds && $semester && $schoolYear && $classSectionId) {
|
|
||||||
$this->updateQuizScores($scores, $updatedBy, $classSectionId);
|
|
||||||
session()->setFlashdata('status', 'quiz scores updated successfully.');
|
|
||||||
|
|
||||||
// ⬇️ Directly call the method and return its view
|
|
||||||
return $this->showQuizMngt();
|
|
||||||
}
|
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
|
||||||
// Call the updateScoresForStudents method
|
|
||||||
try {
|
|
||||||
|
|
||||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
return redirect()->back()->with('status', 'Failed to update scores.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
|
||||||
{
|
|
||||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
|
||||||
{
|
|
||||||
// Step 1: Get student IDs from student_class table
|
|
||||||
$studentClassRows = $this->studentClassModel
|
|
||||||
->select('student_id')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$studentIds = array_column($studentClassRows, 'student_id');
|
|
||||||
|
|
||||||
if (empty($studentIds)) {
|
|
||||||
return []; // No students found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Get student data from students table
|
|
||||||
$students = $this->studentModel
|
|
||||||
->whereIn('id', $studentIds)
|
|
||||||
->where('is_active', 1)
|
|
||||||
->orderBy('lastname', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
return $students;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getquizHeaders($classSectionId, $semester, $schoolYear)
|
|
||||||
{
|
|
||||||
$rows = $this->quizModel
|
|
||||||
->select('quiz_index')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->orderBy('quiz_index', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$headers = [];
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$index = $row['quiz_index'];
|
|
||||||
if (!is_array($index)) {
|
|
||||||
$headers[$index] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($headers);
|
|
||||||
return array_keys($headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getClassSectionIdForTeacher($updatedBy)
|
|
||||||
{
|
|
||||||
$teacherClassModel = new TeacherClassModel();
|
|
||||||
$class = $teacherClassModel->where('teacher_id', $updatedBy)->first();
|
|
||||||
return $class['class_section_id'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private function getquizScoresByStudent($classSectionId, $semester, $schoolYear)
|
|
||||||
{
|
|
||||||
$rows = $this->quizModel
|
|
||||||
->select('student_id, quiz_index, score')
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$studentScores = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$studentId = $row['student_id'];
|
|
||||||
$index = $row['quiz_index'];
|
|
||||||
$score = $row['score'];
|
|
||||||
|
|
||||||
$studentScores[$studentId]['scores'][$index] = $score;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $studentScores;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getStudentsWithquizScores($classSectionId, $quizHeaders)
|
|
||||||
{
|
|
||||||
$studentClasses = $this->studentClassModel
|
|
||||||
->active()
|
|
||||||
->where('student_class.class_section_id', $classSectionId)
|
|
||||||
->findAll();
|
|
||||||
$students = [];
|
|
||||||
|
|
||||||
foreach ($studentClasses as $sc) {
|
|
||||||
$student = $this->studentModel
|
|
||||||
->where('id', $sc['student_id'])
|
|
||||||
->where('is_active', 1)
|
|
||||||
->first();
|
|
||||||
if (!$student) continue;
|
|
||||||
|
|
||||||
$scores = [];
|
|
||||||
foreach ($quizHeaders as $index => $ids) {
|
|
||||||
$entry = $this->quizModel->where('quiz_index', $ids)
|
|
||||||
->where('student_id', $sc['student_id'])
|
|
||||||
->first();
|
|
||||||
$scores[$index] = $entry['score'] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$students[] = [
|
|
||||||
'student_id' => $sc['student_id'],
|
|
||||||
'firstname' => $student['firstname'],
|
|
||||||
'lastname' => $student['lastname'],
|
|
||||||
'scores' => $scores
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
|
||||||
return $students;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTeacherSelectedSemester(): string
|
|
||||||
{
|
|
||||||
helper('semester_selection_helper');
|
|
||||||
return selected_teacher_semester();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use App\Models\AuthorizedUserModel;
|
|
||||||
use App\Models\RoleModel;
|
|
||||||
use App\Models\ParentModel;
|
|
||||||
use App\Models\UserRoleModel;
|
|
||||||
use App\Services\EmailService;
|
|
||||||
use App\Services\SchoolIdService;
|
|
||||||
use App\Services\PhoneFormatterService;
|
|
||||||
|
|
||||||
class RegisterController extends Controller
|
|
||||||
{
|
|
||||||
protected $db;
|
|
||||||
protected $configModel;
|
|
||||||
protected $semester;
|
|
||||||
protected $schoolYear;
|
|
||||||
protected $userRoleModel;
|
|
||||||
protected $roleModel;
|
|
||||||
protected $userModel;
|
|
||||||
protected $parentModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
// Load the database service
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
// Check if the database connection is established
|
|
||||||
if (!$this->db->connect()) {
|
|
||||||
log_message('error', 'Database connection failed.');
|
|
||||||
throw new \Exception('Database connection failed.');
|
|
||||||
} else {
|
|
||||||
log_message('info', 'Database connection successful.');
|
|
||||||
}
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->userRoleModel = new UserRoleModel();
|
|
||||||
$this->roleModel = new RoleModel();
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->parentModel = new ParentModel();
|
|
||||||
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
log_message('debug', 'CAPTCHA session value: ' . session()->get('captcha_answer'));
|
|
||||||
|
|
||||||
// Only generate CAPTCHA if not already set (first visit or after submission)
|
|
||||||
if (!session()->has('captcha_answer')) {
|
|
||||||
$length = rand(4, 8);
|
|
||||||
$captchaText = $this->generateCaptchaText($length);
|
|
||||||
session()->set('captcha_answer', $captchaText);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data['captchaQuestion'] = session()->get('captcha_answer');
|
|
||||||
|
|
||||||
// ✅ NO need to check policy_accepted here
|
|
||||||
return view('user/register', $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function generateCaptchaText($length)
|
|
||||||
{
|
|
||||||
$characters = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghjkmnopqrstuvwxyz0123456789';
|
|
||||||
$captchaText = '';
|
|
||||||
|
|
||||||
for ($i = 0; $i < $length; $i++) {
|
|
||||||
$captchaText .= $characters[rand(0, strlen($characters) - 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $captchaText;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function success()
|
|
||||||
{
|
|
||||||
// Start session
|
|
||||||
$session = session();
|
|
||||||
|
|
||||||
// Check if the session has 'user_email'
|
|
||||||
if (!$session->has('user_email')) {
|
|
||||||
// If not, redirect to the registration page
|
|
||||||
return redirect()->to('/register');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get 'user_email' from session
|
|
||||||
$email = $session->get('user_email');
|
|
||||||
|
|
||||||
// Pass the 'email' variable to the success view
|
|
||||||
return view('success', ['email' => $email]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function register()
|
|
||||||
{
|
|
||||||
helper(['url', 'form']);
|
|
||||||
log_message('debug', 'CAPTCHA session value: ' . session()->get('captcha_answer'));
|
|
||||||
|
|
||||||
/* ───────────── 1. Load services ───────────── */
|
|
||||||
$schoolIdService = new SchoolIdService();
|
|
||||||
|
|
||||||
/* ───────────── 2. Sanitize & raw data ───────────── */
|
|
||||||
$post = $this->formatUserInput($this->request); // safe / formatted
|
|
||||||
$rawPost = $this->request->getPost(); // raw (for flags etc.)
|
|
||||||
$captcha = $rawPost['captcha'] ?? '';
|
|
||||||
|
|
||||||
/* ───────────── 3. Validation rules ───────────── */
|
|
||||||
$rules = [
|
|
||||||
'firstname' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
|
|
||||||
'lastname' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
|
|
||||||
'gender' => 'required|in_list[Male,Female]',
|
|
||||||
'email' => 'required|valid_email|max_length[50]',
|
|
||||||
'confirm_email' => 'required|matches[email]',
|
|
||||||
'cellphone' => 'required|regex_match[/^[\d\s\-\(\)\.]+$/]|min_length[10]|max_length[20]',
|
|
||||||
'address_street' => 'permit_empty|regex_match[/^[a-zA-Z0-9\s\-]+$/]|max_length[150]',
|
|
||||||
'apt' => 'permit_empty|regex_match[/^[a-zA-Z0-9\s-]+$/]|max_length[10]',
|
|
||||||
'city' => 'required|alpha_space|min_length[2]|max_length[30]',
|
|
||||||
'state' => 'required|in_list[CT,ME,MA,NH,NY,RI,VT]',
|
|
||||||
'zip' => 'required|regex_match[/^\d{5}$/]',
|
|
||||||
'accept_school_policy' => 'required',
|
|
||||||
'captcha' => 'required|alpha_numeric|min_length[4]|max_length[10]',
|
|
||||||
];
|
|
||||||
|
|
||||||
// 1. Check if "I am a parent" checkbox is ticked
|
|
||||||
$isParent = isset($rawPost['is_parent']) && $rawPost['is_parent'] == '1';
|
|
||||||
|
|
||||||
// 2. If parent, handle second parent logic
|
|
||||||
if ($isParent) {
|
|
||||||
$noSecondParent = isset($rawPost['no_second_parent_info']) && $rawPost['no_second_parent_info'] == '1';
|
|
||||||
|
|
||||||
$secondParentProvided = !empty($rawPost['second_firstname']) ||
|
|
||||||
!empty($rawPost['second_lastname']) ||
|
|
||||||
!empty($rawPost['second_gender']) ||
|
|
||||||
!empty($rawPost['second_email']) ||
|
|
||||||
!empty($rawPost['second_cellphone']);
|
|
||||||
|
|
||||||
// 3. Error: must provide second parent or check "no second parent"
|
|
||||||
if (!$noSecondParent && !$secondParentProvided) {
|
|
||||||
return redirect()->back()->withInput()->with('errors', [
|
|
||||||
'second_parent_info' => 'As a parent, please provide second parent information or check "No second parent info".'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Add second parent field rules only if "no second parent" is NOT checked
|
|
||||||
if (!$noSecondParent) {
|
|
||||||
$rules += [
|
|
||||||
'second_firstname' => 'required|regex_match[/^[a-zA-Z\s\-]+$/]|min_length[2]|max_length[30]',
|
|
||||||
'second_lastname' => 'required|regex_match[/^[a-zA-Z\s\-]+$/]|min_length[2]|max_length[30]',
|
|
||||||
'second_gender' => 'required|in_list[Male,Female]',
|
|
||||||
'second_email' => 'required|valid_email|max_length[50]',
|
|
||||||
'second_cellphone' => 'required|regex_match[/^[\d\s\-\(\)\.]+$/]|min_length[10]|max_length[20]',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log_message('debug', '🧪 Starting validation check...');
|
|
||||||
if (!$this->validate($rules)) {
|
|
||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────── 4. CAPTCHA check ───────────── */
|
|
||||||
if ($captcha !== session()->get('captcha_answer')) {
|
|
||||||
return redirect()->back()
|
|
||||||
->withInput()
|
|
||||||
->with('errors', ['captcha' => '']) // keeps input marked as invalid
|
|
||||||
->with('top_error', 'Incorrect CAPTCHA answer. Please try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────── 5. Unique e-mail ───────────── */
|
|
||||||
// Step 1: Check if a user with this email exists
|
|
||||||
$existingUser = $this->userModel->where('email', $post['email'])->first();
|
|
||||||
|
|
||||||
if ($existingUser) {
|
|
||||||
// Step 2: Check if the user has a token (i.e., not verified yet)
|
|
||||||
if (!empty($existingUser['token']) && $existingUser['is_verified'] == 0) {
|
|
||||||
// User exists and is unverified
|
|
||||||
return redirect()->back()->withInput()->with('error',
|
|
||||||
'This email address is already registered and is pending activation. Please check your email to activate your account.');
|
|
||||||
} else {
|
|
||||||
// User exists and is already active or has no token
|
|
||||||
return redirect()->back()->withInput()->with('error',
|
|
||||||
'The email address you entered is already in use. Please try a different one.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────── 6. Determine role ───────────── */
|
|
||||||
$roleName = !empty($rawPost['is_parent']) ? 'parent' : 'guest';
|
|
||||||
$role = $this->roleModel->where('name', $roleName)->first();
|
|
||||||
if (!$role) {
|
|
||||||
return redirect()->back()->withInput()->with('errors', ['role' => 'Role not found']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────── 7. Build & insert user ───────────── */
|
|
||||||
$token = bin2hex(random_bytes(48));
|
|
||||||
$userData = [
|
|
||||||
'firstname' => $post['firstname'],
|
|
||||||
'lastname' => $post['lastname'],
|
|
||||||
'gender' => $post['gender'],
|
|
||||||
'email' => $post['email'],
|
|
||||||
'cellphone' => $post['cellphone'],
|
|
||||||
'address_street' => $post['address_street'] ?? null,
|
|
||||||
'apt' => $post['apt'] ?? null,
|
|
||||||
'city' => $post['city'],
|
|
||||||
'state' => $post['state'],
|
|
||||||
'zip' => $post['zip'],
|
|
||||||
'token' => $token,
|
|
||||||
'is_verified'=> 0,
|
|
||||||
'accept_school_policy' => (int) $post['accept_school_policy'],
|
|
||||||
'status' => 'Inactive',
|
|
||||||
'school_id' => $schoolIdService->generateUserSchoolId(),
|
|
||||||
'semester' => $this->semester,
|
|
||||||
'school_year'=> $this->schoolYear,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->db->transStart();
|
|
||||||
|
|
||||||
if (!$this->userModel->insert($userData)) {
|
|
||||||
$this->db->transRollback();
|
|
||||||
return redirect()->back()->withInput()->with('errors', $this->userModel->errors());
|
|
||||||
}
|
|
||||||
$firstParentId = $this->userModel->getInsertID();
|
|
||||||
|
|
||||||
$this->userRoleModel->insert([
|
|
||||||
'user_id' => $firstParentId,
|
|
||||||
'role_id' => $role['id'],
|
|
||||||
'created_at' => utc_now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
/* ───────────── 8. Optional second-parent insert ───────────── */
|
|
||||||
if ($isParent) {
|
|
||||||
if (empty($rawPost['no_second_parent_info'])) {
|
|
||||||
$this->parentModel->insert([
|
|
||||||
'secondparent_firstname' => $post['second_firstname'],
|
|
||||||
'secondparent_lastname' => $post['second_lastname'],
|
|
||||||
'secondparent_gender' => $post['second_gender'],
|
|
||||||
'secondparent_email' => $post['second_email'],
|
|
||||||
'secondparent_phone' => $post['second_cellphone'],
|
|
||||||
'firstparent_id' => $firstParentId,
|
|
||||||
'semester' => $this->semester,
|
|
||||||
'school_year' => $this->schoolYear,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->db->transComplete();
|
|
||||||
if (!$this->db->transStatus()) {
|
|
||||||
return redirect()->back()->withInput()->with('error', 'Registration failed, please try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────── 9. Confirmation e-mail ───────────── */
|
|
||||||
$recipientName = $post['firstname'] . ' ' . $post['lastname'];
|
|
||||||
$activationLink = site_url('/user/confirm/' . $token);
|
|
||||||
$orgName = 'Al Rahma Sunday School';
|
|
||||||
$contactInfo = 'alrahma.isgl@gmail.com';
|
|
||||||
$logoUrl = 'https://alrahmaisgl.org/assets/images/alrahma_logo.png';
|
|
||||||
|
|
||||||
$emailData = [
|
|
||||||
'recipientName' => $recipientName,
|
|
||||||
'activationLink' => $activationLink,
|
|
||||||
'orgName' => $orgName,
|
|
||||||
'contactInfo' => $contactInfo,
|
|
||||||
'logoUrl' => $logoUrl,
|
|
||||||
'subject' => 'Email Confirmation'
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($isParent) {
|
|
||||||
$html = view('emails/welcome_parent', $emailData);
|
|
||||||
} else {
|
|
||||||
$html = view('emails/welcome_staff', $emailData);
|
|
||||||
}
|
|
||||||
|
|
||||||
(new EmailService())->send($post['email'], 'Email Confirmation', $html, 'general');
|
|
||||||
|
|
||||||
|
|
||||||
/* ───────────── 10. Redirect to success ───────────── */
|
|
||||||
session()->set('user_email', $post['email']);
|
|
||||||
session()->remove('captcha_answer'); // Clear CAPTCHA after success
|
|
||||||
return redirect()->to('/register/success');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatName(string $name): string
|
|
||||||
{
|
|
||||||
$name = trim($name);
|
|
||||||
return ucwords(strtolower($name), " -");
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatEmail(?string $email): string
|
|
||||||
{
|
|
||||||
$email = strtolower(trim($email));
|
|
||||||
/*
|
|
||||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
||||||
throw new \InvalidArgumentException('Please enter a valid email address.');
|
|
||||||
}*/
|
|
||||||
return $email;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatAddress(string $address): string
|
|
||||||
{
|
|
||||||
return ucwords(strtolower(trim($address)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatUserInput($request): array
|
|
||||||
{
|
|
||||||
$formatter = new PhoneFormatterService();
|
|
||||||
$post = (array) $request->getPost();
|
|
||||||
|
|
||||||
// tiny helper: safe getter + trim + cast to string
|
|
||||||
$g = static function (string $key) use ($post): string {
|
|
||||||
return trim((string) ($post[$key] ?? ''));
|
|
||||||
};
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'firstname' => $this->formatName($g('firstname')),
|
|
||||||
'lastname' => $this->formatName($g('lastname')),
|
|
||||||
'email' => $this->formatEmail($g('email')),
|
|
||||||
'gender' => $g('gender'),
|
|
||||||
'city' => $this->formatName($g('city')),
|
|
||||||
'cellphone' => $formatter->formatPhoneNumber($g('cellphone')), // ✅ safe
|
|
||||||
'address_street' => $this->formatAddress($g('address_street')),
|
|
||||||
'apt' => strtoupper($g('apt')),
|
|
||||||
'state' => strtoupper($g('state')),
|
|
||||||
'zip' => $g('zip'),
|
|
||||||
'accept_school_policy' => (int) ($post['accept_school_policy'] ?? 0),
|
|
||||||
];
|
|
||||||
|
|
||||||
// if user says "no second parent info", skip entirely
|
|
||||||
$noSecondInfo = !empty($post['no_second_parent_info']);
|
|
||||||
|
|
||||||
// collect (safely) and see if anything was provided
|
|
||||||
$second_firstname = $this->formatName($g('second_firstname'));
|
|
||||||
$second_lastname = $this->formatName($g('second_lastname'));
|
|
||||||
$second_gender = $g('second_gender');
|
|
||||||
$second_email = $this->formatEmail($g('second_email'));
|
|
||||||
$second_cellphone = $formatter->formatPhoneNumber($g('second_cellphone')); // ✅ safe
|
|
||||||
|
|
||||||
$secondProvided = !$noSecondInfo && (
|
|
||||||
$second_firstname !== '' ||
|
|
||||||
$second_lastname !== '' ||
|
|
||||||
$second_gender !== '' ||
|
|
||||||
$second_email !== '' ||
|
|
||||||
preg_replace('/\D/', '', $second_cellphone) !== ''
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($secondProvided) {
|
|
||||||
$data['second_firstname'] = $second_firstname;
|
|
||||||
$data['second_lastname'] = $second_lastname;
|
|
||||||
$data['second_gender'] = $second_gender;
|
|
||||||
$data['second_email'] = $second_email;
|
|
||||||
$data['second_cellphone'] = $second_cellphone;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\RoleModel;
|
||||||
|
|
||||||
|
class RoleService
|
||||||
|
{
|
||||||
|
public function __construct(private RoleModel $roles = new RoleModel()) {}
|
||||||
|
|
||||||
|
/** @param string[] $roleKeys names or slugs */
|
||||||
|
public function bestDashboardRouteFor(array $roleKeys): string
|
||||||
|
{
|
||||||
|
$rows = $this->roles->findByNamesOrSlugs($roleKeys);
|
||||||
|
if (!empty($rows)) {
|
||||||
|
// first row is highest priority due to orderBy('priority','ASC')
|
||||||
|
return $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard';
|
||||||
|
}
|
||||||
|
return '/landing_page/guest_dashboard';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
class SchoolIdService
|
||||||
|
{
|
||||||
|
protected $db;
|
||||||
|
protected $userModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->db = \Config\Database::connect();
|
||||||
|
$this->userModel = new UserModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignSchoolIdToUser($userId)
|
||||||
|
{
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
if (!$user || $user['school_id']) {
|
||||||
|
return $user['school_id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schoolId = $this->generateUserSchoolId();
|
||||||
|
|
||||||
|
$this->userModel->update($userId, ['school_id' => $schoolId]);
|
||||||
|
|
||||||
|
return $schoolId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateStudentSchoolId()
|
||||||
|
{
|
||||||
|
$year = date('y');
|
||||||
|
$maxId = 0;
|
||||||
|
|
||||||
|
// Match the real prefix: STU + year
|
||||||
|
$studentMax = $this->db->table('students')
|
||||||
|
->select('MAX(school_id) AS max_id')
|
||||||
|
->like('school_id', "STU{$year}", 'after')
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if (!empty($studentMax['max_id'])) {
|
||||||
|
// Skip "STUyy" (5 chars)
|
||||||
|
$numericPart = substr($studentMax['max_id'], 5);
|
||||||
|
$maxId = (int) $numericPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextId = str_pad($maxId + 1, 5, '0', STR_PAD_LEFT);
|
||||||
|
return "STU{$year}{$nextId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function generateUserSchoolId()
|
||||||
|
{
|
||||||
|
$year = date('y');
|
||||||
|
$maxId = 0;
|
||||||
|
|
||||||
|
// Get maximum user ID for current year
|
||||||
|
$userMax = $this->db->table('users')->select('MAX(school_id) AS max_id')
|
||||||
|
->notLike('school_id', 'S%') // Exclude student IDs
|
||||||
|
->like('school_id', $year, 'after')->get()->getRowArray();
|
||||||
|
|
||||||
|
if (!empty($userMax['max_id'])) {
|
||||||
|
$numericPart = substr($userMax['max_id'], 2); // Skip 2-digit year
|
||||||
|
$maxId = max($maxId, (int)$numericPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextId = str_pad($maxId + 1, 5, '0', STR_PAD_LEFT);
|
||||||
|
return "{$year}{$nextId}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,550 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\ScoreCommentModel;
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Models\StudentModel;
|
|
||||||
use App\Models\SemesterScoreModel;
|
|
||||||
use App\Models\GradingLockModel;
|
|
||||||
use App\Models\MissingScoreOverrideModel;
|
|
||||||
|
|
||||||
class ScoreCommentController extends BaseController
|
|
||||||
{
|
|
||||||
protected $db;
|
|
||||||
protected $configModel;
|
|
||||||
protected $semester;
|
|
||||||
protected $schoolYear;
|
|
||||||
protected $scoreCommentModel;
|
|
||||||
protected $studentModel;
|
|
||||||
protected $semesterScoreModel;
|
|
||||||
protected $gradingLockModel;
|
|
||||||
protected $missingScoreOverrideModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
// Load the database service
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
// Check if the database connection is established
|
|
||||||
if (!$this->db->connect()) {
|
|
||||||
log_message('error', 'Database connection failed.');
|
|
||||||
throw new \Exception('Database connection failed.');
|
|
||||||
} else {
|
|
||||||
log_message('info', 'Database connection successful.');
|
|
||||||
}
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->scoreCommentModel = new ScoreCommentModel();
|
|
||||||
$this->studentModel = new StudentModel();
|
|
||||||
$this->semesterScoreModel = new SemesterScoreModel();
|
|
||||||
$this->gradingLockModel = new GradingLockModel();
|
|
||||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function saveComments()
|
|
||||||
{
|
|
||||||
$comments = $this->request->getPost('comments');
|
|
||||||
$classSectionId = (int)($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0);
|
|
||||||
$schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear);
|
|
||||||
$rawSelectedSemester = $this->request->getPost('selected_semester');
|
|
||||||
$normalizedSelected = $this->normalizeSemesterSelection($rawSelectedSemester);
|
|
||||||
if ($normalizedSelected !== '') {
|
|
||||||
$semester = ucfirst($normalizedSelected);
|
|
||||||
} else {
|
|
||||||
$semester = session()->get('teacher_scores_selected_semester')
|
|
||||||
?? session()->get('semester')
|
|
||||||
?? $this->semester;
|
|
||||||
}
|
|
||||||
$teacherId = session()->get('user_id');
|
|
||||||
|
|
||||||
if ($classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('error', 'Missing class section.');
|
|
||||||
}
|
|
||||||
if ($this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_array($comments)) {
|
|
||||||
return redirect()->back()->with('error', 'No comments were submitted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
|
||||||
|
|
||||||
$studentIds = array_map('intval', array_keys($comments));
|
|
||||||
$studentNames = $this->getStudentFirstNames($studentIds);
|
|
||||||
|
|
||||||
$isFall = strtolower((string) $semester) === 'fall';
|
|
||||||
$isSpring = strtolower((string) $semester) === 'spring';
|
|
||||||
$commentTypes = ['ptap_comment'];
|
|
||||||
if ($isFall) {
|
|
||||||
$commentTypes[] = 'midterm_comment';
|
|
||||||
}
|
|
||||||
if ($isSpring) {
|
|
||||||
$commentTypes[] = 'final_comment';
|
|
||||||
}
|
|
||||||
foreach ($commentTypes as $type) {
|
|
||||||
$checkedItems = [];
|
|
||||||
if (is_array($missingOk)) {
|
|
||||||
foreach ($missingOk as $studentId => $types) {
|
|
||||||
if (!is_array($types) || empty($types[$type])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$checkedItems[] = [
|
|
||||||
'student_id' => (int) $studentId,
|
|
||||||
'item_index' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->missingScoreOverrideModel->replaceOverrides(
|
|
||||||
$classSectionId,
|
|
||||||
(string) $semester,
|
|
||||||
(string) $schoolYear,
|
|
||||||
$type,
|
|
||||||
$studentIds,
|
|
||||||
null,
|
|
||||||
$checkedItems,
|
|
||||||
$teacherId,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$errors = [];
|
|
||||||
$payload = [];
|
|
||||||
|
|
||||||
foreach ($comments as $studentId => $commentTypes) {
|
|
||||||
foreach ($commentTypes as $scoreType => $comment) {
|
|
||||||
$normalizedScoreType = strtolower((string)$scoreType);
|
|
||||||
$trimmedComment = trim((string)$comment);
|
|
||||||
if ($trimmedComment === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$validationError = $this->validateComment($trimmedComment, (int)$studentId, $normalizedScoreType, $studentNames);
|
|
||||||
if ($validationError !== null) {
|
|
||||||
$errors[] = $validationError;
|
|
||||||
log_message(
|
|
||||||
'warning',
|
|
||||||
"ScoreComment validation failed: teacher_id={$teacherId}, student_id={$studentId}, score_type={$normalizedScoreType}, error={$validationError}, length=" . mb_strlen($trimmedComment, 'UTF-8')
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload[] = [
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'score_type' => $normalizedScoreType, // 'ptap', 'midterm', 'final'
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'comment' => $trimmedComment,
|
|
||||||
'commented_by' => $teacherId,
|
|
||||||
'created_at' => utc_now(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($errors)) {
|
|
||||||
return redirect()->back()->with('errors', $errors)->withInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($payload as $data) {
|
|
||||||
$existing = $this->scoreCommentModel
|
|
||||||
->where('student_id', $data['student_id'])
|
|
||||||
->groupStart()
|
|
||||||
->where('class_section_id', $data['class_section_id'])
|
|
||||||
->orWhere('class_section_id', null)
|
|
||||||
->groupEnd()
|
|
||||||
->where('score_type', $data['score_type'])
|
|
||||||
->where('semester', $data['semester'])
|
|
||||||
->where('school_year', $data['school_year'])
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$this->scoreCommentModel->update($existing['id'], $data);
|
|
||||||
} else {
|
|
||||||
$this->scoreCommentModel->insert($data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->back()->with('status', 'Comments saved successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function viewCommentsMngt()
|
|
||||||
{
|
|
||||||
$rawSelectedSemester = $this->request->getGet('semester')
|
|
||||||
?? $this->request->getPost('semester')
|
|
||||||
?? session()->get('teacher_scores_selected_semester')
|
|
||||||
?? session()->get('semester')
|
|
||||||
?? $this->semester;
|
|
||||||
$normalizedSelected = $this->normalizeSemesterSelection($rawSelectedSemester);
|
|
||||||
if ($normalizedSelected !== '') {
|
|
||||||
$activeSemester = ucfirst($normalizedSelected);
|
|
||||||
} else {
|
|
||||||
$activeSemester = $this->semester;
|
|
||||||
}
|
|
||||||
|
|
||||||
$incoming = $this->request->getPost('class_section_id')
|
|
||||||
?? $this->request->getGet('class_section_id')
|
|
||||||
?? session()->get('class_section_id');
|
|
||||||
|
|
||||||
$isAllSections = is_string($incoming) && strtolower(trim($incoming)) === 'allsections';
|
|
||||||
$classSectionId = $isAllSections ? null : (int) ($incoming ?: 0);
|
|
||||||
|
|
||||||
if (!$isAllSections && $classSectionId <= 0) {
|
|
||||||
return redirect()->back()->with('status', 'Missing class section.');
|
|
||||||
}
|
|
||||||
session()->set('class_section_id', $isAllSections ? 'allsections' : $classSectionId);
|
|
||||||
|
|
||||||
$builder = $this->db->table('student_class sc')
|
|
||||||
->distinct('SELECT s.id AS student_id, s.firstname, s.lastname, s.school_id')
|
|
||||||
->join('students s', 's.id = sc.student_id', 'inner')
|
|
||||||
->where('sc.school_year', $this->schoolYear);
|
|
||||||
|
|
||||||
if (!$isAllSections) {
|
|
||||||
$builder->where('sc.class_section_id', $classSectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$students = $builder
|
|
||||||
->orderBy('s.lastname', 'ASC')
|
|
||||||
->orderBy('s.firstname', 'ASC')
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
// 3) Load only comments for these students in this term (ptap, midterm, final)
|
|
||||||
$comments = [];
|
|
||||||
if (!empty($students)) {
|
|
||||||
$studentIds = array_map(static fn($r) => (int)$r['student_id'], $students);
|
|
||||||
|
|
||||||
$rawCommentsBuilder = $this->scoreCommentModel
|
|
||||||
->whereIn('student_id', $studentIds)
|
|
||||||
->whereIn('score_type', ['midterm', 'final', 'ptap', 'attendance'])
|
|
||||||
->where('semester', $activeSemester)
|
|
||||||
->where('school_year', $this->schoolYear);
|
|
||||||
|
|
||||||
if (!$isAllSections) {
|
|
||||||
$rawCommentsBuilder->groupStart()
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->orWhere('class_section_id', null); // legacy rows without class_section_id
|
|
||||||
$rawCommentsBuilder->groupEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
$rawComments = $rawCommentsBuilder->findAll();
|
|
||||||
|
|
||||||
$existingAttendance = [];
|
|
||||||
foreach ($rawComments as $c) {
|
|
||||||
$sid = (int)$c['student_id'];
|
|
||||||
$typ = (string)$c['score_type'];
|
|
||||||
$comments[$sid][$typ] = [
|
|
||||||
'comment' => $c['comment'] ?? null,
|
|
||||||
'comment_review' => $c['comment_review'] ?? null,
|
|
||||||
'commented_by' => $c['commented_by'] ?? null,
|
|
||||||
'reviewed_by' => $c['reviewed_by'] ?? null,
|
|
||||||
];
|
|
||||||
if ($typ === 'attendance') {
|
|
||||||
$existingAttendance[$sid] = $c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
helper('attendance_comment');
|
|
||||||
// Autogenerate attendance comments for display when missing
|
|
||||||
$attendanceScores = [];
|
|
||||||
$scoreRows = $this->semesterScoreModel
|
|
||||||
->select(['student_id', 'attendance_score'])
|
|
||||||
->whereIn('student_id', $studentIds)
|
|
||||||
->where('semester', $activeSemester)
|
|
||||||
->where('school_year', $this->schoolYear)
|
|
||||||
->findAll();
|
|
||||||
foreach ($scoreRows as $row) {
|
|
||||||
$attendanceScores[(int)$row['student_id']] = isset($row['attendance_score'])
|
|
||||||
? (float)$row['attendance_score']
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
$attendanceInserts = [];
|
|
||||||
$attendanceUpdates = [];
|
|
||||||
foreach ($students as $student) {
|
|
||||||
$sid = (int)$student['student_id'];
|
|
||||||
$hasComment = isset($comments[$sid]['attendance']['comment']) && trim((string)$comments[$sid]['attendance']['comment']) !== '';
|
|
||||||
if ($hasComment) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$score = $attendanceScores[$sid] ?? null;
|
|
||||||
if ($score === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$auto = attendance_comment_from_score($score, $student['firstname'] ?? '');
|
|
||||||
if ($auto !== null) {
|
|
||||||
$comments[$sid]['attendance'] = [
|
|
||||||
'comment' => $auto,
|
|
||||||
'comment_review' => $comments[$sid]['attendance']['comment_review'] ?? null,
|
|
||||||
'commented_by' => $comments[$sid]['attendance']['commented_by'] ?? null,
|
|
||||||
'reviewed_by' => $comments[$sid]['attendance']['reviewed_by'] ?? null,
|
|
||||||
];
|
|
||||||
if (isset($existingAttendance[$sid])) {
|
|
||||||
$attendanceUpdates[] = [
|
|
||||||
'id' => $existingAttendance[$sid]['id'],
|
|
||||||
'comment' => $auto,
|
|
||||||
'commented_by' => $existingAttendance[$sid]['commented_by'] ?? null,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
$attendanceInserts[] = [
|
|
||||||
'student_id' => $sid,
|
|
||||||
'score_type' => 'attendance',
|
|
||||||
'semester' => $activeSemester,
|
|
||||||
'school_year' => $this->schoolYear,
|
|
||||||
'comment' => $auto,
|
|
||||||
'commented_by'=> null,
|
|
||||||
'class_section_id' => $classSectionId,
|
|
||||||
'created_at' => utc_now(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!empty($attendanceUpdates)) {
|
|
||||||
foreach ($attendanceUpdates as $row) {
|
|
||||||
$id = $row['id'];
|
|
||||||
unset($row['id']);
|
|
||||||
$this->scoreCommentModel->update($id, $row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!empty($attendanceInserts)) {
|
|
||||||
$this->scoreCommentModel->insertBatch($attendanceInserts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Render
|
|
||||||
return view('grading/comments', [
|
|
||||||
'students' => $students,
|
|
||||||
'semester' => $activeSemester,
|
|
||||||
'schoolYear' => $this->schoolYear,
|
|
||||||
'classSectionId' => $isAllSections ? 'allsections' : $classSectionId,
|
|
||||||
'comments' => $comments,
|
|
||||||
'scoresLocked' => (!$isAllSections && $classSectionId > 0)
|
|
||||||
? $this->gradingLockModel->isLocked($classSectionId, $activeSemester, $this->schoolYear)
|
|
||||||
: false,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function updateComments()
|
|
||||||
{
|
|
||||||
$semester = $this->request->getPost('semester');
|
|
||||||
$schoolYear = $this->request->getPost('school_year');
|
|
||||||
$teacherId = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
|
||||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id');
|
|
||||||
$comments = $this->request->getPost('comments') ?? [];
|
|
||||||
$reviews = $this->request->getPost('reviews') ?? [];
|
|
||||||
|
|
||||||
$studentIds = array_unique(array_map(
|
|
||||||
'intval',
|
|
||||||
array_merge(array_keys($comments), array_keys($reviews))
|
|
||||||
));
|
|
||||||
$studentNames = $this->getStudentFirstNames($studentIds);
|
|
||||||
$isReviewer = $this->isReviewer();
|
|
||||||
$classSectionId = is_numeric($classSectionId) ? (int)$classSectionId : $classSectionId;
|
|
||||||
if (is_int($classSectionId) && $classSectionId > 0) {
|
|
||||||
if ($this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear)) {
|
|
||||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull attendance scores once for auto-generated attendance comments
|
|
||||||
$attendanceScores = [];
|
|
||||||
if (!empty($studentIds)) {
|
|
||||||
$attendanceBuilder = $this->semesterScoreModel
|
|
||||||
->select(['student_id', 'attendance_score'])
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->whereIn('student_id', $studentIds);
|
|
||||||
|
|
||||||
if (is_int($classSectionId) && $classSectionId > 0) {
|
|
||||||
$attendanceBuilder->where('class_section_id', $classSectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $attendanceBuilder->findAll();
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$attendanceScores[(int)$row['student_id']] = isset($row['attendance_score'])
|
|
||||||
? (float)$row['attendance_score']
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
helper('attendance_comment');
|
|
||||||
|
|
||||||
$errors = [];
|
|
||||||
$operations = [];
|
|
||||||
|
|
||||||
foreach ($comments as $studentId => $types) {
|
|
||||||
foreach ($types as $scoreType => $commentText) {
|
|
||||||
$normalizedScoreType = strtolower((string)$scoreType);
|
|
||||||
$commentText = trim((string)$commentText);
|
|
||||||
|
|
||||||
// Auto-generate attendance comment when empty based on the student's attendance score
|
|
||||||
if ($normalizedScoreType === 'attendance' && $commentText === '') {
|
|
||||||
$attendanceScore = $attendanceScores[$studentId] ?? null;
|
|
||||||
$auto = attendance_comment_from_score($attendanceScore, $studentNames[$studentId] ?? '');
|
|
||||||
if ($auto !== null) {
|
|
||||||
$commentText = $auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$reviewText = isset($reviews[$studentId][$scoreType])
|
|
||||||
? trim((string)$reviews[$studentId][$scoreType])
|
|
||||||
: '';
|
|
||||||
// Skip empty comment & empty review to avoid null inserts
|
|
||||||
if ($commentText === '' && $reviewText === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($commentText !== '') {
|
|
||||||
$validationError = $this->validateComment($commentText, (int)$studentId, $normalizedScoreType, $studentNames);
|
|
||||||
if ($validationError !== null) {
|
|
||||||
$errors[] = $validationError;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$operations[] = [
|
|
||||||
'student_id' => $studentId,
|
|
||||||
'class_section_id' => is_int($classSectionId) ? $classSectionId : null,
|
|
||||||
'score_type' => $normalizedScoreType,
|
|
||||||
'comment' => $commentText === '' ? null : $commentText,
|
|
||||||
'review' => $reviewText === '' ? null : $reviewText,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($errors)) {
|
|
||||||
return redirect()->back()->with('errors', $errors)->withInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($operations as $operation) {
|
|
||||||
$existingQuery = $this->scoreCommentModel
|
|
||||||
->where('student_id', $operation['student_id'])
|
|
||||||
->where('score_type', $operation['score_type'])
|
|
||||||
->where('semester', $semester)
|
|
||||||
->where('school_year', $schoolYear);
|
|
||||||
|
|
||||||
if (is_int($classSectionId) && $classSectionId > 0) {
|
|
||||||
$existingQuery->groupStart()
|
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->orWhere('class_section_id', null); // pick up legacy rows
|
|
||||||
$existingQuery->groupEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
$existing = $existingQuery->first();
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'comment' => $operation['comment'],
|
|
||||||
'commented_by' => $teacherId,
|
|
||||||
'class_section_id' => $operation['class_section_id'],
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($isReviewer && $operation['review'] !== null) {
|
|
||||||
$data['comment_review'] = $operation['review'];
|
|
||||||
$data['reviewed_by'] = $teacherId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
// Update
|
|
||||||
$this->scoreCommentModel->update($existing['id'], $data);
|
|
||||||
} else {
|
|
||||||
// Insert
|
|
||||||
$this->scoreCommentModel->insert(array_merge([
|
|
||||||
'student_id' => $operation['student_id'],
|
|
||||||
'score_type' => $operation['score_type'],
|
|
||||||
'semester' => $semester,
|
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'class_section_id' => $operation['class_section_id'],
|
|
||||||
], $data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return redirect()->back()->with('status', 'Comments updated successfully!');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to check if current user is a reviewer
|
|
||||||
private function isReviewer()
|
|
||||||
{
|
|
||||||
$currentUserRole = session()->get('role'); // Make sure this matches your session role key
|
|
||||||
|
|
||||||
// Get reviewer roles from configuration table
|
|
||||||
$reviewerRoles = $this->configModel->getConfig('comment_reviewer');
|
|
||||||
|
|
||||||
if (!$reviewerRoles) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert comma-separated string to array and trim whitespace
|
|
||||||
$allowedRoles = array_map('trim', explode(',', $reviewerRoles));
|
|
||||||
|
|
||||||
// Check if current user role is in the allowed roles
|
|
||||||
return in_array($currentUserRole, $allowedRoles);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getStudentFirstNames(array $studentIds): array
|
|
||||||
{
|
|
||||||
$ids = array_values(array_filter(array_unique(array_map('intval', $studentIds)), static function ($id) {
|
|
||||||
return $id > 0;
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (empty($ids)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $this->studentModel
|
|
||||||
->select(['id', 'firstname'])
|
|
||||||
->whereIn('id', $ids)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$names = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$names[(int)($row['id'] ?? 0)] = trim((string)($row['firstname'] ?? ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $names;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validateComment(string $comment, int $studentId, string $scoreType, array $studentNames): ?string
|
|
||||||
{
|
|
||||||
$trimmed = trim($comment);
|
|
||||||
if ($trimmed === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$firstName = $studentNames[$studentId] ?? '';
|
|
||||||
if ($firstName === '') {
|
|
||||||
return "Missing student first name for student ID {$studentId}.";
|
|
||||||
}
|
|
||||||
|
|
||||||
$length = mb_strlen($trimmed, 'UTF-8');
|
|
||||||
if ($length < 100) {
|
|
||||||
return "{$firstName}'s {$scoreType} comment must be at least 100 characters.";
|
|
||||||
}
|
|
||||||
if ($length > 400) {
|
|
||||||
return "{$firstName}'s {$scoreType} comment must be at most 400 characters.";
|
|
||||||
}
|
|
||||||
|
|
||||||
$pattern = '/^' . preg_quote($firstName, '/') . '\\b/i';
|
|
||||||
if (!preg_match($pattern, $trimmed)) {
|
|
||||||
return "{$firstName}'s {$scoreType} comment must start with the student's first name.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeSemesterSelection(?string $input): string
|
|
||||||
{
|
|
||||||
$value = strtolower(trim((string)$input));
|
|
||||||
if ($value === 'fall' || str_contains($value, '1')) {
|
|
||||||
return 'fall';
|
|
||||||
}
|
|
||||||
if ($value === 'spring' || str_contains($value, '2')) {
|
|
||||||
return 'spring';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,260 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Models\ConfigurationModel;
|
|
||||||
use App\Models\ClassSectionModel;
|
|
||||||
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
|
|
||||||
class ScorePredictor extends Controller
|
|
||||||
{
|
|
||||||
protected $db;
|
|
||||||
protected $configModel;
|
|
||||||
protected $semester;
|
|
||||||
protected $schoolYear;
|
|
||||||
protected $classSectionModel;
|
|
||||||
protected $studentClassModel;
|
|
||||||
protected $teacherModel;
|
|
||||||
protected $userModel;
|
|
||||||
protected $targetTrophy;
|
|
||||||
protected $targetLow;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
// Load the database service
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
// Check if the database connection is established
|
|
||||||
if (!$this->db->connect()) {
|
|
||||||
log_message('error', 'Database connection failed.');
|
|
||||||
throw new \Exception('Database connection failed.');
|
|
||||||
} else {
|
|
||||||
log_message('info', 'Database connection successful.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the config model
|
|
||||||
$this->configModel = new ConfigurationModel();
|
|
||||||
$this->classSectionModel = new ClassSectionModel();
|
|
||||||
|
|
||||||
// Retrieve the configuration values
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
$this->targetTrophy = $this->configModel->getConfig('trophy_score');
|
|
||||||
$this->targetLow = $this->configModel->getConfig('pass_score');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the normal cumulative distribution function (CDF)
|
|
||||||
public static function normalCDF($z)
|
|
||||||
{
|
|
||||||
$t = 1 / (1 + 0.3275911 * abs($z));
|
|
||||||
$a1 = 0.254829592;
|
|
||||||
$a2 = -0.284496736;
|
|
||||||
$a3 = 1.421413741;
|
|
||||||
$a4 = -1.453152027;
|
|
||||||
$a5 = 1.061405429;
|
|
||||||
$erf = 1 - ((((($a5 * $t + $a4) * $t + $a3) * $t + $a2) * $t + $a1) * $t) * exp(-$z * $z);
|
|
||||||
$sign = $z < 0 ? -1 : 1;
|
|
||||||
return 0.5 * (1 + $sign * $erf);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function combinedReport()
|
|
||||||
{
|
|
||||||
$request = service('request');
|
|
||||||
$currentSchoolYear = $this->schoolYear;
|
|
||||||
|
|
||||||
$selectedYear = $request->getVar('school_year') ?? $currentSchoolYear;
|
|
||||||
$classSectionId = $request->getVar('class_section_id');
|
|
||||||
// Only show class sections that have at least one student in the selected year
|
|
||||||
$classSections = $this->db->table('classSection cs')
|
|
||||||
->select('cs.*')
|
|
||||||
->join('student_class sc', 'sc.class_section_id = cs.class_section_id', 'inner')
|
|
||||||
->where('sc.school_year', $selectedYear)
|
|
||||||
->notLike('cs.class_section_name', 'KG', 'after')
|
|
||||||
->groupBy('cs.id')
|
|
||||||
->orderBy('cs.class_section_name', 'ASC')
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
$builder = $this->db->table('students s');
|
|
||||||
$builder->select('
|
|
||||||
s.id as student_id,
|
|
||||||
s.school_id,
|
|
||||||
s.firstname,
|
|
||||||
s.lastname,
|
|
||||||
fall.semester_score as fall_score,
|
|
||||||
spring.semester_score as spring_score');
|
|
||||||
// Also select class section for per-class trophy decision
|
|
||||||
$builder->select('sc.class_section_id as class_section_id');
|
|
||||||
$yearEsc = $this->db->escape($selectedYear);
|
|
||||||
$builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'left');
|
|
||||||
$builder->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left');
|
|
||||||
$builder->join('semester_scores fall', 'fall.student_id = s.id AND fall.semester = "fall" AND fall.school_year = "' . $selectedYear . '"', 'left');
|
|
||||||
$builder->join('semester_scores spring', 'spring.student_id = s.id AND spring.semester = "spring" AND spring.school_year = "' . $selectedYear . '"', 'left');
|
|
||||||
$builder->where('s.is_active', 1);
|
|
||||||
$builder->groupStart()
|
|
||||||
->where('cs.class_section_name IS NULL')
|
|
||||||
->orNotLike('cs.class_section_name', 'KG', 'after')
|
|
||||||
->groupEnd();
|
|
||||||
|
|
||||||
if (!empty($classSectionId)) {
|
|
||||||
$builder->where('sc.class_section_id', $classSectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$builder->groupBy('s.id');
|
|
||||||
$students = $builder->get()->getResultArray();
|
|
||||||
|
|
||||||
// Stats for spring
|
|
||||||
$springStatsQuery = $this->db->table('semester_scores')
|
|
||||||
->select('AVG(semester_scores.semester_score) as mean, STDDEV(semester_scores.semester_score) as std')
|
|
||||||
->join('student_class', 'student_class.student_id = semester_scores.student_id')
|
|
||||||
->join('classSection cs', 'cs.class_section_id = student_class.class_section_id', 'left')
|
|
||||||
->where('semester_scores.semester', 'spring')
|
|
||||||
->where('semester_scores.school_year', $selectedYear)
|
|
||||||
->where('student_class.school_year', $selectedYear)
|
|
||||||
->groupStart()
|
|
||||||
->where('cs.class_section_name IS NULL')
|
|
||||||
->orNotLike('cs.class_section_name', 'KG', 'after')
|
|
||||||
->groupEnd();
|
|
||||||
|
|
||||||
if (!empty($classSectionId)) {
|
|
||||||
$springStatsQuery->where('student_class.class_section_id', $classSectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$springStats = $springStatsQuery->get()->getRow();
|
|
||||||
$mean = floatval($springStats->mean ?? 70);
|
|
||||||
$std = floatval($springStats->std ?? 0);
|
|
||||||
|
|
||||||
$results = [];
|
|
||||||
// Use explicit trophy parameters per new requirements
|
|
||||||
// Trophy threshold: minimum 94.0; allow higher configured value if set
|
|
||||||
$trophyThreshold = max(94.0, (float)($this->targetTrophy ?? 94.0));
|
|
||||||
foreach ($students as $s) {
|
|
||||||
$fallScore = is_numeric($s['fall_score']) ? (float)$s['fall_score'] : null;
|
|
||||||
$springScore = is_numeric($s['spring_score']) ? (float)$s['spring_score'] : null;
|
|
||||||
|
|
||||||
$scoreParts = array_values(array_filter([$fallScore, $springScore], static function ($v) {
|
|
||||||
return $v !== null;
|
|
||||||
}));
|
|
||||||
$finalAvg = !empty($scoreParts) ? round(array_sum($scoreParts) / count($scoreParts), 1) : null;
|
|
||||||
|
|
||||||
// When we already have a spring score, the year is effectively finished for trophy/pass logic.
|
|
||||||
$waitingForSpring = ($fallScore !== null && $springScore === null);
|
|
||||||
|
|
||||||
// Compute required spring score for trophy/pass only if spring is missing.
|
|
||||||
$requiredHigh = $waitingForSpring ? round(2 * $trophyThreshold - $fallScore, 2) : null;
|
|
||||||
$requiredLow = $waitingForSpring ? round(2 * $this->targetLow - $fallScore, 2) : null;
|
|
||||||
|
|
||||||
$zHigh = ($requiredHigh !== null && $std > 0) ? ($requiredHigh - $mean) / $std : null;
|
|
||||||
$zLow = ($requiredLow !== null && $std > 0) ? ($requiredLow - $mean) / $std : null;
|
|
||||||
|
|
||||||
$probHigh = $zHigh !== null ? 1 - self::normalCDF($zHigh) : null;
|
|
||||||
$probLow = $zLow !== null ? self::normalCDF($zLow) : null;
|
|
||||||
|
|
||||||
// Trophy determination: only award when the student's final/available average meets the threshold.
|
|
||||||
$riskHigh = match (true) {
|
|
||||||
$finalAvg !== null && !$waitingForSpring && $finalAvg >= $trophyThreshold => 'Achieved Trophy',
|
|
||||||
$finalAvg !== null && !$waitingForSpring => 'Unreachable',
|
|
||||||
$requiredHigh !== null && $requiredHigh > 100 => 'Unreachable',
|
|
||||||
is_null($probHigh) => 'New student',
|
|
||||||
$probHigh >= 0.66 => 'Low',
|
|
||||||
$probHigh >= 0.33 => 'Medium',
|
|
||||||
default => 'High'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fail risk
|
|
||||||
$riskLow = match (true) {
|
|
||||||
$finalAvg !== null && !$waitingForSpring && $finalAvg < $this->targetLow => 'High',
|
|
||||||
$finalAvg !== null && !$waitingForSpring => 'Low',
|
|
||||||
$requiredLow !== null && $requiredLow > 100 => 'Unlikely to pass',
|
|
||||||
is_null($probLow) => 'New student',
|
|
||||||
$probLow >= 0.66 => 'High',
|
|
||||||
$probLow >= 0.33 => 'Medium',
|
|
||||||
default => 'Low'
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($finalAvg === null) {
|
|
||||||
$status = 'Undetermined';
|
|
||||||
} elseif ($waitingForSpring) {
|
|
||||||
$status = in_array($riskLow, ['High', 'Unlikely to pass'], true) ? 'May fail' : 'Can pass';
|
|
||||||
} else {
|
|
||||||
$status = $finalAvg >= 60 ? 'Passed' : 'Failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
$results[] = [
|
|
||||||
'student_id' => (int)($s['student_id'] ?? 0),
|
|
||||||
'school_id' => $s['school_id'],
|
|
||||||
'firstname' => $s['firstname'],
|
|
||||||
'lastname' => $s['lastname'],
|
|
||||||
'class_section_id' => $s['class_section_id'] ?? null,
|
|
||||||
'fall_score' => $s['fall_score'] ?? 'N/A',
|
|
||||||
'spring_score' => $s['spring_score'] ?? 'N/A',
|
|
||||||
'final_average' => $finalAvg ?? 'N/A',
|
|
||||||
'required_spring_score' => $requiredHigh ?? 'N/A',
|
|
||||||
'winning_risk' => $riskHigh,
|
|
||||||
'required_spring_to_pass' => $requiredLow ?? 'N/A',
|
|
||||||
'failure_risk' => $riskLow,
|
|
||||||
'status' => $status,
|
|
||||||
'trophy_awarded' => ($riskHigh === 'Achieved Trophy'),
|
|
||||||
'trophy_reason' => ($riskHigh === 'Achieved Trophy') ? 'threshold' : '',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a class section has zero threshold trophies, award trophies to top 3 ranked students
|
|
||||||
$bySection = [];
|
|
||||||
$hasThresholdTrophy = [];
|
|
||||||
foreach ($results as $idx => $row) {
|
|
||||||
$cid = (string)($row['class_section_id'] ?? '');
|
|
||||||
$bySection[$cid][] = $idx;
|
|
||||||
if (($row['trophy_reason'] ?? '') === 'threshold') {
|
|
||||||
$hasThresholdTrophy[$cid] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach ($bySection as $cid => $indices) {
|
|
||||||
if (!empty($hasThresholdTrophy[$cid])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$candidates = [];
|
|
||||||
foreach ($indices as $idx) {
|
|
||||||
$val = $results[$idx]['final_average'] ?? null;
|
|
||||||
if (!is_numeric($val)) {
|
|
||||||
$fall = $results[$idx]['fall_score'] ?? null;
|
|
||||||
$spring = $results[$idx]['spring_score'] ?? null;
|
|
||||||
$parts = [];
|
|
||||||
if (is_numeric($fall)) $parts[] = (float)$fall;
|
|
||||||
if (is_numeric($spring)) $parts[] = (float)$spring;
|
|
||||||
if (!empty($parts)) {
|
|
||||||
$val = array_sum($parts) / count($parts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (is_numeric($val)) {
|
|
||||||
$candidates[] = ['idx' => $idx, 'avg' => (float)$val];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (empty($candidates)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
usort($candidates, static fn($a, $b) => $b['avg'] <=> $a['avg']);
|
|
||||||
$top = array_slice($candidates, 0, 3);
|
|
||||||
foreach ($top as $entry) {
|
|
||||||
$i = $entry['idx'];
|
|
||||||
$results[$i]['trophy_awarded'] = true;
|
|
||||||
$results[$i]['trophy_reason'] = 'top3';
|
|
||||||
$results[$i]['winning_risk'] = 'Top 3 Trophy';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort students by final average descending
|
|
||||||
usort($results, function ($a, $b) {
|
|
||||||
return ($b['final_average'] ?? 0) <=> ($a['final_average'] ?? 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
return view('score_analysis/score_prediction', [
|
|
||||||
'students' => $results,
|
|
||||||
'school_year' => $selectedYear,
|
|
||||||
'classSections' => $classSections,
|
|
||||||
'selectedClassSectionId' => $classSectionId,
|
|
||||||
'semester' => $this->semester,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\PreferencesModel;
|
||||||
|
use App\Models\SettingsModel;
|
||||||
|
use CodeIgniter\HTTP\RequestInterface;
|
||||||
|
use CodeIgniter\I18n\Time;
|
||||||
|
|
||||||
|
class TimeService
|
||||||
|
{
|
||||||
|
private string $defaultUserTimezone = 'America/New_York';
|
||||||
|
private string $serverTimezone = 'UTC';
|
||||||
|
|
||||||
|
// Cached per-request user timezone
|
||||||
|
private ?string $cachedUserTz = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prime detection cache from the Request (optional call, lazy by default).
|
||||||
|
*/
|
||||||
|
public function primeFromRequest(?RequestInterface $request = null): void
|
||||||
|
{
|
||||||
|
$this->cachedUserTz = $this->detectUserTimezone($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the resolved user timezone (detect lazily if needed).
|
||||||
|
*/
|
||||||
|
public function userTimezone(?RequestInterface $request = null): string
|
||||||
|
{
|
||||||
|
if ($this->cachedUserTz !== null) {
|
||||||
|
return $this->cachedUserTz;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cachedUserTz = $this->detectUserTimezone($request);
|
||||||
|
return $this->cachedUserTz;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core detection logic (headers > user pref > settings > school config > default).
|
||||||
|
*/
|
||||||
|
public function detectUserTimezone(?RequestInterface $request = null): string
|
||||||
|
{
|
||||||
|
$request = $request ?: service('request');
|
||||||
|
|
||||||
|
// 1) Explicit client header
|
||||||
|
try {
|
||||||
|
$tzHeader = $request->getHeaderLine('X-Timezone')
|
||||||
|
?: $request->getHeaderLine('Timezone')
|
||||||
|
?: $request->getHeaderLine('Accept-Timezone')
|
||||||
|
?: null;
|
||||||
|
|
||||||
|
if ($tzHeader && in_array($tzHeader, timezone_identifiers_list(), true)) {
|
||||||
|
return $tzHeader;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) User preference (if logged-in)
|
||||||
|
try {
|
||||||
|
$userId = (int) (session('user_id') ?: 0);
|
||||||
|
if ($userId > 0) {
|
||||||
|
// Do not select a specific column; some DBs may not have it yet
|
||||||
|
$pref = (new PreferencesModel())
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->first();
|
||||||
|
$tz = $pref['timezone'] ?? null;
|
||||||
|
if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
|
||||||
|
return $tz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Global settings timezone (if available)
|
||||||
|
try {
|
||||||
|
$settings = (new SettingsModel())->getSettings();
|
||||||
|
$tz = $settings['timezone'] ?? null;
|
||||||
|
if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
|
||||||
|
return $tz;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) School attendance timezone (if defined)
|
||||||
|
try {
|
||||||
|
$tz = (string) (config('School')->attendance['timezone'] ?? '');
|
||||||
|
if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
|
||||||
|
return $tz;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Default
|
||||||
|
return $this->defaultUserTimezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server timezone (for storage/UTC operations).
|
||||||
|
*/
|
||||||
|
public function serverTimezone(): string
|
||||||
|
{
|
||||||
|
return $this->serverTimezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current time in user timezone as CI Time.
|
||||||
|
*/
|
||||||
|
public function nowLocal(?string $tz = null): Time
|
||||||
|
{
|
||||||
|
$tz = $tz ?: $this->userTimezone();
|
||||||
|
return Time::now($tz);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current time in UTC as CI Time.
|
||||||
|
*/
|
||||||
|
public function nowUTC(): Time
|
||||||
|
{
|
||||||
|
return Time::now($this->serverTimezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert any supported input to UTC string (Y-m-d H:i:s by default).
|
||||||
|
*
|
||||||
|
* @param string|Time|\DateTimeInterface|null $value
|
||||||
|
*/
|
||||||
|
public function toUTC($value, ?string $fromTz = null, string $format = 'Y-m-d H:i:s'): ?string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromTz = $fromTz ?: $this->userTimezone();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($value instanceof Time) {
|
||||||
|
return $value->setTimezone($this->serverTimezone)->toDateTimeString();
|
||||||
|
}
|
||||||
|
if ($value instanceof \DateTimeInterface) {
|
||||||
|
return Time::createFromInstance($value)
|
||||||
|
->setTimezone($this->serverTimezone)
|
||||||
|
->format($format);
|
||||||
|
}
|
||||||
|
|
||||||
|
// assume string
|
||||||
|
return Time::parse((string)$value, $fromTz)
|
||||||
|
->setTimezone($this->serverTimezone)
|
||||||
|
->format($format);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert UTC (or provided timezone) to user-local string.
|
||||||
|
*
|
||||||
|
* @param string|Time|\DateTimeInterface|null $value
|
||||||
|
*/
|
||||||
|
public function toLocal($value, ?string $sourceTz = null, ?string $targetTz = null, string $format = 'Y-m-d H:i:s'): ?string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetTz = $targetTz ?: $this->userTimezone();
|
||||||
|
if ($sourceTz === null && $this->isDateOnlyString($value)) {
|
||||||
|
// Date-only strings should not shift across timezones.
|
||||||
|
$sourceTz = $targetTz;
|
||||||
|
} else {
|
||||||
|
$sourceTz = $sourceTz ?: $this->serverTimezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($value instanceof Time) {
|
||||||
|
return $value->setTimezone($targetTz)->format($format);
|
||||||
|
}
|
||||||
|
if ($value instanceof \DateTimeInterface) {
|
||||||
|
return Time::createFromInstance($value)
|
||||||
|
->setTimezone($targetTz)
|
||||||
|
->format($format);
|
||||||
|
}
|
||||||
|
|
||||||
|
// assume string
|
||||||
|
return Time::parse((string)$value, $sourceTz)
|
||||||
|
->setTimezone($targetTz)
|
||||||
|
->format($format);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience formatter for user-local.
|
||||||
|
*/
|
||||||
|
public function formatLocal($value, string $format = 'Y-m-d H:i', ?string $sourceTz = null): string
|
||||||
|
{
|
||||||
|
return (string) ($this->toLocal($value, $sourceTz, null, $format) ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience formatter for UTC.
|
||||||
|
*/
|
||||||
|
public function formatUTC($value, string $format = 'Y-m-d H:i:s', ?string $fromTz = null): string
|
||||||
|
{
|
||||||
|
return (string) ($this->toUTC($value, $fromTz, $format) ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isDateOnlyString($value): bool
|
||||||
|
{
|
||||||
|
if (!is_string($value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,874 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use CodeIgniter\Events\Events;
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\UserModel; // Assuming you have a UserModel
|
|
||||||
use App\Models\PasswordResetModel; // Model for the password_resets table
|
|
||||||
use CodeIgniter\I18n\Time;
|
|
||||||
use App\Models\RoleModel;
|
|
||||||
use App\Models\UserRoleModel;
|
|
||||||
use App\Models\PasswordResetRequestModel;
|
|
||||||
use App\Models\RolePermissionModel;
|
|
||||||
use App\Models\IpAttemptModel;
|
|
||||||
use CodeIgniter\Controller;
|
|
||||||
use App\Controllers\View\EmailController;
|
|
||||||
use App\Models\LoginActivityModel; // Make sure this import is present
|
|
||||||
require_once APPPATH . 'Helpers/pbkdf2_helper.php';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class UserController extends BaseController
|
|
||||||
{
|
|
||||||
protected $userModel;
|
|
||||||
protected $roleModel;
|
|
||||||
protected $userRoleModel;
|
|
||||||
protected $db;
|
|
||||||
protected $passwordResetModel;
|
|
||||||
protected $loginActivityModel;
|
|
||||||
protected $resetRequestModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
// Load the database service
|
|
||||||
$this->db = \Config\Database::connect();
|
|
||||||
// Check if the database connection is established
|
|
||||||
if (!$this->db->connect()) {
|
|
||||||
log_message('error', 'Database connection failed.');
|
|
||||||
throw new \Exception('Database connection failed.');
|
|
||||||
} else {
|
|
||||||
log_message('info', 'Database connection successful.');
|
|
||||||
}
|
|
||||||
helper('auth');
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->roleModel = new RoleModel();
|
|
||||||
$this->userRoleModel = new UserRoleModel();
|
|
||||||
$this->passwordResetModel = new PasswordResetModel();
|
|
||||||
$this->loginActivityModel = new LoginActivityModel();
|
|
||||||
$this->resetRequestModel = new PasswordResetRequestModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to show the home page
|
|
||||||
public function home()
|
|
||||||
{
|
|
||||||
return view('/index');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to show the about page
|
|
||||||
public function about()
|
|
||||||
{
|
|
||||||
return view('/about');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to show the classes page
|
|
||||||
public function classes()
|
|
||||||
{
|
|
||||||
return view('/classes');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to show the contact page
|
|
||||||
public function contact()
|
|
||||||
{
|
|
||||||
return view('/contact');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function userList()
|
|
||||||
{
|
|
||||||
helper('url');
|
|
||||||
|
|
||||||
return view('user/user_list', [
|
|
||||||
'userListEndpoint' => site_url('api/users'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to show the list of users
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
// Fetch users along with their assigned roles
|
|
||||||
$builder = $this->db->table('users');
|
|
||||||
$builder->select('users.id, users.firstname, users.lastname, users.email, user_roles.role_id, roles.name as role, users.status, users.updated_at');
|
|
||||||
$builder->join('user_roles', 'users.id = user_roles.user_id', 'left');
|
|
||||||
$builder->join('roles', 'user_roles.role_id = roles.id', 'left');
|
|
||||||
|
|
||||||
$sort = $this->request->getGet('sort');
|
|
||||||
$order = $this->request->getGet('order') === 'desc' ? 'desc' : 'asc';
|
|
||||||
|
|
||||||
// Define allowed fields for sorting
|
|
||||||
$allowedFields = ['id', 'firstname', 'lastname', 'email', 'role'];
|
|
||||||
|
|
||||||
if (in_array($sort, $allowedFields)) {
|
|
||||||
$sort_column = $sort === 'role' ? 'roles.name' : 'users.' . $sort;
|
|
||||||
$builder->orderBy($sort_column, $order);
|
|
||||||
} else {
|
|
||||||
$builder->orderBy('users.id', 'asc');
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = $builder->get();
|
|
||||||
$users = $query->getResultArray();
|
|
||||||
|
|
||||||
// Fetch available roles
|
|
||||||
$roleBuilder = $this->db->table('roles');
|
|
||||||
$roleQuery = $roleBuilder->get();
|
|
||||||
$roles = $roleQuery->getResultArray();
|
|
||||||
|
|
||||||
return view('user/user_list', [
|
|
||||||
'users' => $users,
|
|
||||||
'roles' => $roles,
|
|
||||||
'sort' => $sort,
|
|
||||||
'order' => $order,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function userListData()
|
|
||||||
{
|
|
||||||
return $this->response->setJSON([
|
|
||||||
'users' => $this->buildUsersWithRoles(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildUsersWithRoles(): array
|
|
||||||
{
|
|
||||||
// Fetch all users
|
|
||||||
$users = $this->userModel->findAll();
|
|
||||||
if (!is_array($users)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregate guardian types (primary/secondary) by user_id
|
|
||||||
$gmap = [];
|
|
||||||
try {
|
|
||||||
$rows = $this->db->table('family_guardians')
|
|
||||||
->select('user_id, MAX(is_primary) AS max_primary, COUNT(*) AS cnt', false)
|
|
||||||
->groupBy('user_id')
|
|
||||||
->get()->getResultArray();
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$uid = (int)($r['user_id'] ?? 0);
|
|
||||||
if ($uid <= 0) continue;
|
|
||||||
$maxp = (int)($r['max_primary'] ?? 0);
|
|
||||||
$cnt = (int)($r['cnt'] ?? 0);
|
|
||||||
if ($cnt > 0) {
|
|
||||||
$gmap[$uid] = $maxp > 0 ? 'Primary' : 'Secondary';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
// ignore if table missing; leave map empty
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregate "second parent presence" from legacy parents table
|
|
||||||
$secondByUserId = [];
|
|
||||||
$secondByEmail = [];
|
|
||||||
try {
|
|
||||||
$parentFields = $this->db->getFieldNames('parents');
|
|
||||||
$hasSecondUid = in_array('secondparent_user_id', $parentFields, true);
|
|
||||||
$hasSecondEmail = in_array('secondparent_email', $parentFields, true);
|
|
||||||
|
|
||||||
if ($hasSecondUid) {
|
|
||||||
// Map: user_id present in parents.secondparent_user_id
|
|
||||||
$uidRows = $this->db->table('parents')
|
|
||||||
->select('secondparent_user_id AS uid, COUNT(*) AS cnt', false)
|
|
||||||
->where('secondparent_user_id !=', 0)
|
|
||||||
->groupBy('secondparent_user_id')
|
|
||||||
->get()->getResultArray();
|
|
||||||
foreach ($uidRows as $r) {
|
|
||||||
$u = (int)($r['uid'] ?? 0);
|
|
||||||
if ($u > 0) {
|
|
||||||
$secondByUserId[$u] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasSecondEmail) {
|
|
||||||
// Map: email present in parents.secondparent_email
|
|
||||||
$emailRows = $this->db->table('parents')
|
|
||||||
->select('LOWER(TRIM(secondparent_email)) AS em, COUNT(*) AS cnt', false)
|
|
||||||
->where('secondparent_email !=', '')
|
|
||||||
->groupBy('secondparent_email')
|
|
||||||
->get()->getResultArray();
|
|
||||||
foreach ($emailRows as $r) {
|
|
||||||
$em = trim(strtolower((string)($r['em'] ?? '')));
|
|
||||||
if ($em !== '') {
|
|
||||||
$secondByEmail[$em] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
// ignore if legacy table or fields are missing
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare an array to store user data along with their roles
|
|
||||||
$usersWithRoles = [];
|
|
||||||
|
|
||||||
// Loop through each user and get their roles
|
|
||||||
foreach ($users as $user) {
|
|
||||||
if (!is_array($user)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the user's roles from the user_roles table
|
|
||||||
$userRoles = $this->userRoleModel->where('user_id', $user['id'])->findAll() ?? [];
|
|
||||||
|
|
||||||
// Initialize arrays to hold role names/ids for this user
|
|
||||||
$roleNames = [];
|
|
||||||
$roleIds = [];
|
|
||||||
|
|
||||||
// Fetch role names from the roles table
|
|
||||||
foreach ($userRoles as $userRole) {
|
|
||||||
if (!is_array($userRole) || empty($userRole['role_id'])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$role = $this->roleModel->find($userRole['role_id']);
|
|
||||||
if ($role) {
|
|
||||||
$roleIds[] = (int) $role['id'];
|
|
||||||
$roleNames[] = $role['name'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parent type if user is a guardian in any family
|
|
||||||
$parentType = $gmap[(int)($user['id'] ?? 0)] ?? '';
|
|
||||||
|
|
||||||
// Second-from-parents flag: by secondparent_user_id or secondparent_email
|
|
||||||
$isSecond = false;
|
|
||||||
$uid = (int)($user['id'] ?? 0);
|
|
||||||
if ($uid > 0 && !empty($secondByUserId[$uid])) {
|
|
||||||
$isSecond = true;
|
|
||||||
} else {
|
|
||||||
$em = trim(strtolower((string)($user['email'] ?? '')));
|
|
||||||
if ($em !== '' && !empty($secondByEmail[$em])) $isSecond = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the user data along with role names to the result array
|
|
||||||
$usersWithRoles[] = [
|
|
||||||
'user' => $user,
|
|
||||||
'roles' => $roleNames,
|
|
||||||
'role_ids' => $roleIds,
|
|
||||||
'parent_type' => $parentType, // 'Primary', 'Secondary', or ''
|
|
||||||
'second_from_parents' => $isSecond ? 'Yes' : '',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $usersWithRoles;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to store a new user
|
|
||||||
public function store()
|
|
||||||
{
|
|
||||||
// Validate input data
|
|
||||||
$validation = \Config\Services::validation();
|
|
||||||
$validation->setRules([
|
|
||||||
'username' => 'required',
|
|
||||||
'password' => 'required|min_length[6]',
|
|
||||||
'lastname' => 'required',
|
|
||||||
'firstname' => 'required',
|
|
||||||
'cellphone' => 'required',
|
|
||||||
'email' => 'required|valid_email',
|
|
||||||
'address_street' => 'required',
|
|
||||||
'city' => 'required',
|
|
||||||
'state' => 'required',
|
|
||||||
'zip' => 'required',
|
|
||||||
'accept_school_policy' => 'required',
|
|
||||||
'role_id' => 'required|integer'
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!$this->validate($validation->getRules())) {
|
|
||||||
$errors = $validation->getErrors();
|
|
||||||
return redirect()->back()->withInput()->with('errors', $errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare user data for insertion
|
|
||||||
$userData = [
|
|
||||||
'username' => $this->request->getPost('username'),
|
|
||||||
'password' => pbkdf2_hash($this->request->getPost('password')),
|
|
||||||
'lastname' => ucfirst($this->request->getPost('lastname')),
|
|
||||||
'firstname' => ucfirst($this->request->getPost('firstname')),
|
|
||||||
'cellphone' => $this->request->getPost('cellphone'),
|
|
||||||
'email' => strtolower($this->request->getPost('email')),
|
|
||||||
'address_street' => $this->request->getPost('address_street'),
|
|
||||||
'city' => ucfirst($this->request->getPost('city')),
|
|
||||||
'state' => $this->request->getPost('state'),
|
|
||||||
'zip' => $this->request->getPost('zip'),
|
|
||||||
'accept_school_policy' => $this->request->getPost('accept_school_policy'),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Insert user data into the database
|
|
||||||
if ($this->userModel->save($userData) === false) {
|
|
||||||
return redirect()->back()->withInput()->with('errors', $this->userModel->errors());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve the newly created user ID
|
|
||||||
$userId = $this->userModel->getInsertID();
|
|
||||||
|
|
||||||
// Assign the role to the user in the user_roles table
|
|
||||||
$roleData = [
|
|
||||||
'user_id' => $userId,
|
|
||||||
'role_id' => $this->request->getPost('role_id'),
|
|
||||||
'created_at' => utc_now(),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($this->userRoleModel->save($roleData) === false) {
|
|
||||||
return redirect()->back()->withInput()->with('errors', $this->userRoleModel->errors());
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->to('/user');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to show the form for editing an existing user
|
|
||||||
public function edit($id)
|
|
||||||
{
|
|
||||||
$data['user'] = $this->userModel->find($id);
|
|
||||||
$data['roles'] = $this->roleModel->findAll();
|
|
||||||
$userRoles = $this->userRoleModel->where('user_id', $id)->findAll();
|
|
||||||
$data['assignedRoles'] = array_column($userRoles, 'role_id');
|
|
||||||
|
|
||||||
return view('user/edit', $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Method to delete an existing user
|
|
||||||
public function delete($id)
|
|
||||||
{
|
|
||||||
$this->userModel->delete($id);
|
|
||||||
|
|
||||||
// Delete the user's roles from the user_roles table
|
|
||||||
$this->userRoleModel->where('user_id', $id)->delete();
|
|
||||||
|
|
||||||
return redirect()->to('/user');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//This function is necessary to display the initial forgot password form where the user enters their email address.
|
|
||||||
public function forgotPassword()
|
|
||||||
{
|
|
||||||
return view('/user/forgot_password');
|
|
||||||
}
|
|
||||||
|
|
||||||
//This function is crucial for processing the forgot password request, generating a reset token, storing it in the database, and sending an email with the reset link.
|
|
||||||
public function processForgotPassword()
|
|
||||||
{
|
|
||||||
log_message('debug', 'Entered processForgotPassword');
|
|
||||||
|
|
||||||
$ip = $this->request->getIPAddress();
|
|
||||||
|
|
||||||
$recentAttempts = $this->resetRequestModel
|
|
||||||
->where('ip_address', $ip)
|
|
||||||
->where('requested_at >=', Time::now()->subHours(24)->toDateTimeString())
|
|
||||||
->countAllResults();
|
|
||||||
|
|
||||||
if ($recentAttempts >= 3) {
|
|
||||||
log_message('warning', "IP {$ip} blocked due to too many reset attempts.");
|
|
||||||
session()->setFlashdata('show_block_modal', true);
|
|
||||||
return redirect()->back();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log this attempt
|
|
||||||
$this->resetRequestModel->insert([
|
|
||||||
'ip_address' => $ip,
|
|
||||||
'requested_at' => Time::now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$email = strtolower($this->request->getPost('email'));
|
|
||||||
$user = $this->userModel->where('email', $email)->first();
|
|
||||||
|
|
||||||
// --- Handle unknown email ---
|
|
||||||
if (!$user) {
|
|
||||||
session()->setFlashdata('error', 'If this email is registered, you will receive a reset link.');
|
|
||||||
log_message('info', "Password reset requested for non-existing user {$email}");
|
|
||||||
return redirect()->back();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Handle unverified accounts ---
|
|
||||||
if ((int) $user['is_verified'] === 0) {
|
|
||||||
session()->setFlashdata('error', 'Please check your email and complete the account activation process before resetting your password.');
|
|
||||||
log_message('info', "Password reset blocked for unverified user {$email}");
|
|
||||||
return redirect()->back();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Verified user: continue with reset ---
|
|
||||||
$token = bin2hex(random_bytes(48));
|
|
||||||
$expires_at = Time::now()->addHours(1);
|
|
||||||
|
|
||||||
$this->passwordResetModel->insert([
|
|
||||||
'email' => $email,
|
|
||||||
'token' => $token,
|
|
||||||
'created_at' => Time::now(),
|
|
||||||
'expires_at' => $expires_at,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->sendResetEmail($email, $token);
|
|
||||||
|
|
||||||
session()->setFlashdata('success', 'A password reset link has been sent to your email.');
|
|
||||||
log_message('info', "Password reset email sent to {$email}");
|
|
||||||
|
|
||||||
return redirect()->back();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function blocked()
|
|
||||||
{
|
|
||||||
session()->setFlashdata('show_block_modal', true);
|
|
||||||
return redirect()->back(); // Show modal on the previous page
|
|
||||||
}
|
|
||||||
|
|
||||||
//This function is needed to display a confirmation page after the reset email has been sent, informing the user to check their email.
|
|
||||||
public function passwordResetConfirmation()
|
|
||||||
{
|
|
||||||
$email = session()->getFlashdata('reset_email');
|
|
||||||
|
|
||||||
return view('user/password_reset_confirmation', ['email' => $email]);
|
|
||||||
}
|
|
||||||
|
|
||||||
//This is a private helper function that handles sending the reset email. It’s separated out to keep the processForgotPassword function clean and focused.
|
|
||||||
public function sendResetEmail($email, $token)
|
|
||||||
{
|
|
||||||
$resetLink = site_url('/reset_password?token=' . $token);
|
|
||||||
|
|
||||||
// Render email template with data
|
|
||||||
$message = view('emails/reset_password', ['resetLink' => $resetLink]);
|
|
||||||
|
|
||||||
$emailController = new EmailController();
|
|
||||||
$subject = 'Password Reset Request';
|
|
||||||
|
|
||||||
if ($emailController->sendEmail($email, $subject, $message)) {
|
|
||||||
log_message('info', 'Password reset email successfully sent to ' . $email);
|
|
||||||
} else {
|
|
||||||
log_message('error', 'Failed to send password reset email to ' . $email);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//This function is necessary to display the form where the user can enter a new password, after clicking on the reset link in their email.
|
|
||||||
public function resetPassword()
|
|
||||||
{
|
|
||||||
// Retrieve the token from the query string
|
|
||||||
$token = $this->request->getGet('token');
|
|
||||||
|
|
||||||
if (!$token) {
|
|
||||||
return redirect()->to('/')->with('error', 'Invalid password reset link.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// You may want to validate the token here
|
|
||||||
$resetEntry = $this->passwordResetModel->where('token', $token)
|
|
||||||
->where('expires_at >=', Time::now())
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (!$resetEntry) {
|
|
||||||
return redirect()->to('/')->with('error', 'Invalid or expired reset link.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the reset password view with the token
|
|
||||||
return view('user/reset_password', ['token' => $token]);
|
|
||||||
}
|
|
||||||
|
|
||||||
//This function processes the new password submission, validating the token, updating the user's password, and cleaning up the reset entry.
|
|
||||||
public function processResetPassword()
|
|
||||||
{
|
|
||||||
$token = $this->request->getPost('token');
|
|
||||||
$newPassword = $this->request->getPost('password');
|
|
||||||
$passConfirm = $this->request->getPost('pass_confirm');
|
|
||||||
|
|
||||||
// Validate the input
|
|
||||||
if (!$this->validate([
|
|
||||||
'password' => [
|
|
||||||
'label' => 'Password',
|
|
||||||
'rules' => 'required|min_length[8]|regex_match[/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\-=\+*#$%&!?])[A-Za-z\d@\-=\+*#$%&!?]{8,}$/]',
|
|
||||||
'errors' => [
|
|
||||||
'required' => 'Password is required.',
|
|
||||||
'min_length' => 'Password must be at least 8 characters.',
|
|
||||||
'regex_match' => 'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character (@, -, =, +, *, #, $, %, &, !, ?).'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'pass_confirm' => [
|
|
||||||
'label' => 'Confirm Password',
|
|
||||||
'rules' => 'required|matches[password]',
|
|
||||||
'errors' => [
|
|
||||||
'required' => 'Password confirmation is required.',
|
|
||||||
'matches' => 'Passwords do not match.'
|
|
||||||
]
|
|
||||||
]
|
|
||||||
])) {
|
|
||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the password reset entry
|
|
||||||
$resetEntry = $this->passwordResetModel->where('token', $token)
|
|
||||||
->where('expires_at >=', Time::now())
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (!$resetEntry) {
|
|
||||||
return redirect()->to('/')->with('error', 'Invalid or expired reset link.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the user by email
|
|
||||||
$user = $this->userModel->where('email', $resetEntry['email'])->first();
|
|
||||||
|
|
||||||
if (!$user) {
|
|
||||||
return redirect()->to('/')->with('error', 'User not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🛠 Hash the password using PBKDF2
|
|
||||||
$hashedPassword = pbkdf2_hash($newPassword);
|
|
||||||
|
|
||||||
// Update the password in the users table and set the account status to 'Active'
|
|
||||||
// Reset failed login attempts, suspension status, and last failed login time
|
|
||||||
$this->userModel->update($user['id'], [
|
|
||||||
'failed_attempts' => 0, // Reset failed attempts
|
|
||||||
'is_suspended' => 0, // Unsuspend the account
|
|
||||||
'last_failed_at' => null, // Reset the last failed login timestamp
|
|
||||||
'password' => $hashedPassword,
|
|
||||||
'status' => 'Active'
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Delete the used token from the password reset table
|
|
||||||
$this->passwordResetModel->where('token', $token)->delete();
|
|
||||||
|
|
||||||
// Retrieve the user's IP address from the request
|
|
||||||
$ipAddress = $this->request->getIPAddress();
|
|
||||||
|
|
||||||
// Delete the corresponding entry from the ip_attempts table, only if it exists
|
|
||||||
$ipAttemptModel = new IpAttemptModel();
|
|
||||||
$existingAttempt = $ipAttemptModel->where('ip_address', $ipAddress)->first();
|
|
||||||
|
|
||||||
if ($existingAttempt) {
|
|
||||||
// IP address entry exists, so delete it
|
|
||||||
$ipAttemptModel->where('ip_address', $ipAddress)->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to a success page with a success message
|
|
||||||
return view('user/password_set_success');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function passwordSetSuccess()
|
|
||||||
{
|
|
||||||
return view('user/password_set_success');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function confirm($token)
|
|
||||||
{
|
|
||||||
log_message('info', 'Processing email confirmation with token: ' . $token);
|
|
||||||
|
|
||||||
|
|
||||||
$user = $this->userModel->where('token', $token)->first();
|
|
||||||
|
|
||||||
if (!$user || $user['is_verified'] == 1) {
|
|
||||||
return redirect()->to('/invalid_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Mark the user as verified and generate an account ID
|
|
||||||
$account_id = 'ACC' . str_pad($user['id'], 8, '0', STR_PAD_LEFT); // Example: ACC00000001
|
|
||||||
$this->userModel->update($user['id'], ['is_verified' => 1, 'token' => null, 'account_id' => $account_id]);
|
|
||||||
|
|
||||||
log_message('info', 'User verified and account ID generated: ' . $account_id);
|
|
||||||
|
|
||||||
// Redirect to the set password page
|
|
||||||
return redirect()->to('/set_password/' . $user['id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPassword($token)
|
|
||||||
{
|
|
||||||
//echo "Reached setPassword with token: " . esc($token);
|
|
||||||
//echo "Token received: " . $token;
|
|
||||||
$user = $this->userModel->where('token', $token)->first();
|
|
||||||
|
|
||||||
if (!$user || $user['is_verified'] == 1) {
|
|
||||||
return redirect()->to('/invalid_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('user/set_password', [
|
|
||||||
'userId' => $user['id'],
|
|
||||||
'token' => $token
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function savePassword()
|
|
||||||
{
|
|
||||||
$validation = \Config\Services::validation();
|
|
||||||
$validation->setRules([
|
|
||||||
'password' => [
|
|
||||||
'label' => 'Password',
|
|
||||||
'rules' => 'required|min_length[8]|regex_match[/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\-=\+*#$%&!?])[A-Za-z\d@\-=\+*#$%&!?]{8,}$/]',
|
|
||||||
'errors' => [
|
|
||||||
'required' => 'Password is required.',
|
|
||||||
'min_length' => 'Password must be at least 8 characters long.',
|
|
||||||
'regex_match' => 'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character (@, -, =, +, *, #, $, %, &, !, ?).'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'password_confirm' => [
|
|
||||||
'label' => 'Confirm Password',
|
|
||||||
'rules' => 'required|matches[password]',
|
|
||||||
'errors' => [
|
|
||||||
'required' => 'Password confirmation is required.',
|
|
||||||
'matches' => 'Passwords do not match.'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'user_id' => 'required|integer',
|
|
||||||
'token' => 'required'
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!$this->validate($validation->getRules())) {
|
|
||||||
return redirect()->back()->withInput()->with('errors', $validation->getErrors());
|
|
||||||
}
|
|
||||||
|
|
||||||
$userId = $this->request->getPost('user_id');
|
|
||||||
$token = $this->request->getPost('token');
|
|
||||||
$password = $this->request->getPost('password');
|
|
||||||
|
|
||||||
$user = $this->userModel->where('id', $userId)->where('token', $token)->first();
|
|
||||||
|
|
||||||
log_message('debug', "Attempting to set password for user $userId with token $token");
|
|
||||||
|
|
||||||
if (!$user || $user['is_verified'] == 1) {
|
|
||||||
return redirect()->to('/invalid_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
$hashedPassword = pbkdf2_hash($password); // Or use password_hash()
|
|
||||||
|
|
||||||
// Generate account_id only if it's not already set
|
|
||||||
$account_id = $user['account_id'] ?? null;
|
|
||||||
if (!$account_id) {
|
|
||||||
$account_id = 'ACC' . str_pad($userId, 8, '0', STR_PAD_LEFT);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->userModel->update($userId, [
|
|
||||||
'password' => $hashedPassword,
|
|
||||||
'status' => 'Active',
|
|
||||||
'is_verified' => 1,
|
|
||||||
'token' => null,
|
|
||||||
'account_id' => $account_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Optional: trigger event
|
|
||||||
$userData = $this->userModel->getUserInfoById($userId);
|
|
||||||
if (!is_array($userData)) {
|
|
||||||
$userData = (array) $userData;
|
|
||||||
}
|
|
||||||
$userData['id'] = $userId;
|
|
||||||
\CodeIgniter\Events\Events::trigger('user_registered', $userData);
|
|
||||||
|
|
||||||
return redirect()->to('/user/password_set_success');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function invalidToken()
|
|
||||||
{
|
|
||||||
return view('errors/invalid_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function selectRole()
|
|
||||||
{
|
|
||||||
log_message('info', 'Processing setRole form submission.');
|
|
||||||
|
|
||||||
if (!session()->get('is_logged_in')) {
|
|
||||||
log_message('error', 'User is not logged in.');
|
|
||||||
return redirect()->to('/user/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
$roleKey = (string) $this->request->getPost('role');
|
|
||||||
log_message('info', 'Role selected: ' . $roleKey);
|
|
||||||
|
|
||||||
$roleModel = new RoleModel();
|
|
||||||
$route = $roleModel->getRouteByNameOrSlug($roleKey);
|
|
||||||
|
|
||||||
if ($route === null) {
|
|
||||||
log_message('error', 'Invalid or inactive role selected: ' . $roleKey);
|
|
||||||
return redirect()->back()->with('error', 'Invalid role selected.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$userId = (int) session()->get('user_id');
|
|
||||||
log_message('info', 'User ID: ' . $userId);
|
|
||||||
|
|
||||||
// Persist the *exact* role name or slug—choose your convention.
|
|
||||||
// If you want to store the canonical name, fetch the row and use $row['name'].
|
|
||||||
$this->userModel->update($userId, ['role' => $roleKey]);
|
|
||||||
log_message('info', 'Role updated in database.');
|
|
||||||
|
|
||||||
log_message('info', 'Redirecting to role dashboard: ' . $route);
|
|
||||||
return redirect()->to($route);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function welcomeBack()
|
|
||||||
{
|
|
||||||
if (!session()->get('is_logged_in')) {
|
|
||||||
return redirect()->to('/user/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('user/welcome_back');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete_role($roleId)
|
|
||||||
{
|
|
||||||
// Fetch the role to be deleted
|
|
||||||
$role = $this->roleModel->find($roleId);
|
|
||||||
if (!$role) {
|
|
||||||
return redirect()->back()->with('error', 'Role not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the "Guest" role
|
|
||||||
$guestRole = $this->roleModel->where('name', 'Guest')->first();
|
|
||||||
if (!$guestRole) {
|
|
||||||
return redirect()->back()->with('error', 'Guest role not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update users with the deleted role to have the "Guest" role
|
|
||||||
$this->userModel->where('role_id', $roleId)->set(['role_id' => $guestRole['id'], 'role' => 'Guest', 'status' => 'Inactive'])->update();
|
|
||||||
|
|
||||||
// Verify that users have been updated
|
|
||||||
$updatedUsers = $this->userModel->where('role_id', $guestRole['id'])->findAll();
|
|
||||||
log_message('debug', 'Updated users: ' . print_r($updatedUsers, true));
|
|
||||||
|
|
||||||
// Delete the role
|
|
||||||
if ($this->roleModel->delete($roleId)) {
|
|
||||||
return redirect()->to('/administrator/user_roles')->with('success', 'Role deleted successfully. Users with this role have been assigned the Guest role.');
|
|
||||||
} else {
|
|
||||||
return redirect()->back()->with('error', 'Failed to delete role.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function loginActivity()
|
|
||||||
{
|
|
||||||
helper('url');
|
|
||||||
|
|
||||||
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
|
|
||||||
|
|
||||||
return view('user/login_activity', [
|
|
||||||
'loginActivityEndpoint' => site_url('api/login-activity'),
|
|
||||||
'defaultPerPage' => $perPage > 0 ? $perPage : 25,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function loginActivityData()
|
|
||||||
{
|
|
||||||
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
|
|
||||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
|
||||||
|
|
||||||
return $this->response->setJSON($this->buildLoginActivityPayload($perPage, $page));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to update an existing user
|
|
||||||
public function updateUser()
|
|
||||||
{
|
|
||||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
|
||||||
return redirect()->to(site_url('user/user_list'))->with('error', 'Invalid request.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = (int) $this->request->getPost('id');
|
|
||||||
if (!$id) {
|
|
||||||
return redirect()->to(site_url('user/user_list'))->with('error', 'User ID is missing.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->userModel->find($id);
|
|
||||||
if (!$user) {
|
|
||||||
return redirect()->to(site_url('user/user_list'))->with('error', 'User not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helpers
|
|
||||||
$toBool = fn($k) => $this->request->getPost($k) ? 1 : 0;
|
|
||||||
$toDT = function ($key) {
|
|
||||||
$v = trim((string)$this->request->getPost($key));
|
|
||||||
if ($v === '') return null;
|
|
||||||
// Expecting HTML datetime-local "YYYY-MM-DDTHH:MM"
|
|
||||||
$ts = strtotime($v);
|
|
||||||
return $ts ? date('Y-m-d H:i:s', $ts) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'id' => $id,
|
|
||||||
'account_id' => trim((string)$this->request->getPost('account_id')),
|
|
||||||
'lastname' => trim((string)$this->request->getPost('lastname')),
|
|
||||||
'firstname' => trim((string)$this->request->getPost('firstname')),
|
|
||||||
'gender' => trim((string)$this->request->getPost('gender')),
|
|
||||||
'cellphone' => trim((string)$this->request->getPost('cellphone')),
|
|
||||||
'email' => trim((string)$this->request->getPost('email')),
|
|
||||||
'address_street' => trim((string)$this->request->getPost('address_street')),
|
|
||||||
'apt' => trim((string)$this->request->getPost('apt')),
|
|
||||||
'city' => trim((string)$this->request->getPost('city')),
|
|
||||||
'state' => trim((string)$this->request->getPost('state')),
|
|
||||||
'zip' => trim((string)$this->request->getPost('zip')),
|
|
||||||
'accept_school_policy' => $toBool('accept_school_policy'),
|
|
||||||
'user_type' => trim((string)$this->request->getPost('user_type')),
|
|
||||||
'rfid_tag' => trim((string)$this->request->getPost('rfid_tag')),
|
|
||||||
'school_id' => trim((string)$this->request->getPost('school_id')),
|
|
||||||
'failed_attempts' => (string)$this->request->getPost('failed_attempts') === '' ? null : (int)$this->request->getPost('failed_attempts'),
|
|
||||||
'last_failed_at' => $toDT('last_failed_at'),
|
|
||||||
'semester' => trim((string)$this->request->getPost('semester')),
|
|
||||||
'school_year' => trim((string)$this->request->getPost('school_year')),
|
|
||||||
'status' => trim((string)$this->request->getPost('status')),
|
|
||||||
'is_suspended' => $toBool('is_suspended'),
|
|
||||||
'is_verified' => $toBool('is_verified'),
|
|
||||||
'token' => trim((string)$this->request->getPost('token')),
|
|
||||||
'updated_at' => $toDT('updated_at'),
|
|
||||||
'created_at' => $toDT('created_at'),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
$rules = [
|
|
||||||
'firstname' => 'required|min_length[2]|max_length[100]',
|
|
||||||
'lastname' => 'required|min_length[2]|max_length[100]',
|
|
||||||
'email' => "required|valid_email|is_unique[users.email,id,{$id}]",
|
|
||||||
'state' => 'permit_empty|max_length[2]',
|
|
||||||
'zip' => 'permit_empty|max_length[20]',
|
|
||||||
'cellphone' => 'permit_empty|max_length[50]',
|
|
||||||
'failed_attempts' => 'permit_empty|integer',
|
|
||||||
'gender' => 'permit_empty|in_list[male,female,MALE,FEMALE]', // adjust as needed
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!$this->validate($rules)) {
|
|
||||||
return redirect()->back()
|
|
||||||
->with('error', implode(' ', $this->validator->getErrors()))
|
|
||||||
->withInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$this->userModel->save($data)) {
|
|
||||||
return redirect()->back()->with('error', 'Failed to update user.')->withInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->to(site_url('user/user_list'))->with('success', 'User updated successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildLoginActivityPayload(int $perPage, int $page): array
|
|
||||||
{
|
|
||||||
$perPage = max(1, min($perPage, 200));
|
|
||||||
$page = max(1, $page);
|
|
||||||
|
|
||||||
$totalActivities = (int) $this->loginActivityModel->countAll();
|
|
||||||
$activities = $this->loginActivityModel
|
|
||||||
->orderBy('login_time', 'DESC')
|
|
||||||
->paginate($perPage, 'loginActivity', $page) ?? [];
|
|
||||||
|
|
||||||
$pager = $this->loginActivityModel->pager;
|
|
||||||
$currentPage = $pager ? (int) $pager->getCurrentPage('loginActivity') : $page;
|
|
||||||
$pageCount = $pager ? (int) $pager->getPageCount('loginActivity') : (int) max(1, ceil($totalActivities / $perPage));
|
|
||||||
|
|
||||||
$normalized = array_map(static function ($activity) {
|
|
||||||
if (!is_array($activity)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'user_id' => $activity['user_id'] ?? null,
|
|
||||||
'email' => $activity['email'] ?? null,
|
|
||||||
'login_time' => $activity['login_time'] ?? null,
|
|
||||||
'logout_time' => $activity['logout_time'] ?? null,
|
|
||||||
'ip_address' => $activity['ip_address'] ?? null,
|
|
||||||
'user_agent' => $activity['user_agent'] ?? null,
|
|
||||||
];
|
|
||||||
}, $activities);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'activities' => $normalized,
|
|
||||||
'pagination' => [
|
|
||||||
'total' => $totalActivities,
|
|
||||||
'perPage' => $perPage,
|
|
||||||
'currentPage' => $currentPage,
|
|
||||||
'pageCount' => $pageCount,
|
|
||||||
'hasNext' => $currentPage < $pageCount,
|
|
||||||
'hasPrevious' => $currentPage > 1,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,29 @@ use Illuminate\Http\Exceptions\HttpResponseException;
|
|||||||
|
|
||||||
abstract class ApiFormRequest extends FormRequest
|
abstract class ApiFormRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
public function validationData(): array
|
||||||
|
{
|
||||||
|
$json = json_decode($this->getContent() ?: '', true);
|
||||||
|
$json = is_array($json) ? $json : [];
|
||||||
|
|
||||||
|
return array_merge(
|
||||||
|
$this->query->all(),
|
||||||
|
$this->request->all(),
|
||||||
|
$this->json()->all(),
|
||||||
|
$json
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$json = json_decode($this->getContent() ?: '', true);
|
||||||
|
if (is_array($json)) {
|
||||||
|
$this->merge($json);
|
||||||
|
} else {
|
||||||
|
$this->merge($this->json()->all());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function failedValidation(Validator $validator): void
|
protected function failedValidation(Validator $validator): void
|
||||||
{
|
{
|
||||||
throw new HttpResponseException(
|
throw new HttpResponseException(
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class RegisterRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public static function ruleset(): array
|
||||||
|
{
|
||||||
|
return (new self())->rules();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'firstname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'],
|
||||||
|
'lastname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'],
|
||||||
|
'gender' => ['required', 'in:Male,Female'],
|
||||||
|
'email' => ['required', 'email', 'max:50'],
|
||||||
|
'confirm_email' => ['required', 'same:email'],
|
||||||
|
'cellphone' => ['required', 'regex:/^[\d\s\-\(\)\.]+$/', 'min:10', 'max:20'],
|
||||||
|
'address_street' => ['nullable', 'regex:/^[a-zA-Z0-9\s\-]+$/', 'max:150'],
|
||||||
|
'apt' => ['nullable', 'regex:/^[a-zA-Z0-9\s-]+$/', 'max:10'],
|
||||||
|
'city' => ['required', 'regex:/^[a-zA-Z\s]+$/', 'min:2', 'max:30'],
|
||||||
|
'state' => ['required', 'in:CT,ME,MA,NH,NY,RI,VT'],
|
||||||
|
'zip' => ['required', 'regex:/^\d{5}$/'],
|
||||||
|
'accept_school_policy' => ['required'],
|
||||||
|
'captcha' => ['required', 'alpha_num', 'min:4', 'max:10'],
|
||||||
|
'is_parent' => ['nullable', 'boolean'],
|
||||||
|
'no_second_parent_info' => ['nullable', 'boolean'],
|
||||||
|
'second_firstname' => ['exclude_if:no_second_parent_info,1', 'required_if:is_parent,1', 'regex:/^[a-zA-Z\s\-]+$/', 'min:2', 'max:30'],
|
||||||
|
'second_lastname' => ['exclude_if:no_second_parent_info,1', 'required_if:is_parent,1', 'regex:/^[a-zA-Z\s\-]+$/', 'min:2', 'max:30'],
|
||||||
|
'second_gender' => ['exclude_if:no_second_parent_info,1', 'required_if:is_parent,1', 'in:Male,Female'],
|
||||||
|
'second_email' => ['exclude_if:no_second_parent_info,1', 'required_if:is_parent,1', 'email', 'max:50'],
|
||||||
|
'second_cellphone' => ['exclude_if:no_second_parent_info,1', 'required_if:is_parent,1', 'regex:/^[\d\s\-\(\)\.]+$/', 'min:10', 'max:20'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Discounts;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class ApplyDiscountVoucherRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'voucher_id' => ['required', 'integer', 'min:1', 'exists:discount_vouchers,id'],
|
||||||
|
'parent_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'parent_ids.*' => ['integer', 'min:1'],
|
||||||
|
'allow_additional' => ['nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Discounts;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class StoreDiscountVoucherRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => ['required', 'string', 'max:50'],
|
||||||
|
'discount_type' => ['required', 'in:percent,fixed'],
|
||||||
|
'discount_value' => ['required', 'numeric', 'min:0'],
|
||||||
|
'max_uses' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'valid_from' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
|
||||||
|
'is_active' => ['nullable', 'boolean'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'school_year' => ['nullable', 'string'],
|
||||||
|
'semester' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Discounts;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class UpdateDiscountVoucherRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => ['sometimes', 'string', 'max:50'],
|
||||||
|
'discount_type' => ['sometimes', 'in:percent,fixed'],
|
||||||
|
'discount_value' => ['sometimes', 'numeric', 'min:0'],
|
||||||
|
'max_uses' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'valid_from' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
|
||||||
|
'is_active' => ['nullable', 'boolean'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'school_year' => ['nullable', 'string'],
|
||||||
|
'semester' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Expenses;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class StoreExpenseRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category' => ['required', 'in:Expense,Purchase,Reimbursement,Donation'],
|
||||||
|
'amount' => ['required', 'numeric', 'gt:0'],
|
||||||
|
'purchased_by' => ['required', 'integer', 'min:1'],
|
||||||
|
'retailor' => ['nullable', 'string', 'max:255'],
|
||||||
|
'date_of_purchase' => ['required', 'date_format:Y-m-d'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'receipt' => ['required', 'file', 'max:2048', 'mimes:jpg,jpeg,png,webp,gif,pdf'],
|
||||||
|
'school_year' => ['nullable', 'string'],
|
||||||
|
'semester' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Expenses;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class UpdateExpenseRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category' => ['required', 'in:Expense,Purchase,Reimbursement,Donation'],
|
||||||
|
'amount' => ['required', 'numeric', 'gt:0'],
|
||||||
|
'purchased_by' => ['required', 'integer', 'min:1'],
|
||||||
|
'retailor' => ['nullable', 'string', 'max:255'],
|
||||||
|
'date_of_purchase' => ['required', 'date_format:Y-m-d'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'receipt' => ['nullable', 'file', 'max:2048', 'mimes:jpg,jpeg,png,webp,gif,pdf'],
|
||||||
|
'remove_receipt' => ['nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Expenses;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class UpdateExpenseStatusRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => ['required', 'in:approved,denied'],
|
||||||
|
'reason' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\ExtraCharges;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class StoreExtraChargeRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'parent_id' => ['required', 'integer', 'min:1'],
|
||||||
|
'title' => ['required', 'string', 'min:2'],
|
||||||
|
'amount' => ['required', 'numeric'],
|
||||||
|
'charge_type' => ['required', 'in:add,deduct'],
|
||||||
|
'due_date' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'invoice_id' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'school_year' => ['nullable', 'string'],
|
||||||
|
'semester' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\ExtraCharges;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class UpdateExtraChargeRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => ['required', 'string', 'min:2'],
|
||||||
|
'amount' => ['required', 'numeric'],
|
||||||
|
'charge_type' => ['required', 'in:add,deduct'],
|
||||||
|
'due_date' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Files;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class FileNameRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255', 'regex:/^[^\/\\\\]+$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if ($this->route('name') !== null) {
|
||||||
|
$this->merge(['name' => $this->route('name')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Finance;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class FinancialReportRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'date_from' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'date_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:date_from'],
|
||||||
|
'school_year' => ['nullable', 'string', 'max:20'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Finance;
|
||||||
|
|
||||||
|
class FinancialSummaryRequest extends FinancialReportRequest
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Finance;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class FinancialUnpaidParentsRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'school_year' => ['nullable', 'string', 'max:20'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Grading;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class BelowSixtyEmailRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'student_id' => ['required', 'integer', 'min:1'],
|
||||||
|
'semester' => ['required', 'string', 'max:50'],
|
||||||
|
'school_year' => ['required', 'string', 'max:50'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Grading;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class BelowSixtyListRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'semester' => ['required', 'string', 'max:50'],
|
||||||
|
'school_year' => ['required', 'string', 'max:50'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Grading;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class BelowSixtyMeetingRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'student_id' => ['required', 'integer', 'min:1'],
|
||||||
|
'semester' => ['required', 'string', 'max:50'],
|
||||||
|
'school_year' => ['required', 'string', 'max:50'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Grading;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class BelowSixtyMeetingSaveRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'student_id' => ['required', 'integer', 'min:1'],
|
||||||
|
'semester' => ['required', 'string', 'max:50'],
|
||||||
|
'school_year' => ['required', 'string', 'max:50'],
|
||||||
|
'date' => ['required', 'string', 'max:20'],
|
||||||
|
'time' => ['nullable', 'string', 'max:20'],
|
||||||
|
'notes' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Grading;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class BelowSixtySendRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'student_id' => ['required', 'integer', 'min:1'],
|
||||||
|
'semester' => ['required', 'string', 'max:50'],
|
||||||
|
'school_year' => ['required', 'string', 'max:50'],
|
||||||
|
'subject' => ['nullable', 'string', 'max:255'],
|
||||||
|
'html' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Grading;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class BelowSixtyStatusRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'student_id' => ['required', 'integer', 'min:1'],
|
||||||
|
'semester' => ['required', 'string', 'max:50'],
|
||||||
|
'school_year' => ['required', 'string', 'max:50'],
|
||||||
|
'status' => ['required', 'string', 'in:Open,Closed,open,closed'],
|
||||||
|
'note' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Grading;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class GradingLockAllRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'semester' => ['required', 'string', 'max:50'],
|
||||||
|
'school_year' => ['required', 'string', 'max:50'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Grading;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class GradingLockRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'class_section_id' => ['required', 'integer', 'min:1'],
|
||||||
|
'semester' => ['required', 'string', 'max:50'],
|
||||||
|
'school_year' => ['required', 'string', 'max:50'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Grading;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class GradingOverviewRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'class_id' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'semester' => ['nullable', 'string', 'max:50'],
|
||||||
|
'school_year' => ['nullable', 'string', 'max:50'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Grading;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class GradingRefreshRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'class_section_id' => ['required', 'integer', 'min:1'],
|
||||||
|
'semester' => ['required', 'string', 'max:50'],
|
||||||
|
'school_year' => ['required', 'string', 'max:50'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Grading;
|
||||||
|
|
||||||
|
use App\Http\Requests\ApiFormRequest;
|
||||||
|
|
||||||
|
class GradingScoreShowRequest extends ApiFormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'semester' => ['required', 'string', 'max:50'],
|
||||||
|
'school_year' => ['required', 'string', 'max:50'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user