add controllers, servoices

This commit is contained in:
root
2026-03-09 02:52:13 -04:00
parent c8de5f7edc
commit d76c871cb7
501 changed files with 34439 additions and 21843 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Config;
use App\Services\EmailService;
use App\Services\SemesterScoreService;
use App\Services\Scores\SemesterScoreService;
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\SaveAttendanceNotificationNoteRequest;
use App\Http\Requests\AttendanceTracking\SendAttendanceManualEmailRequest;
use App\Services\AttendanceTrackingService;
use App\Services\AttendanceTracking\AttendanceTrackingService;
use Illuminate\Http\JsonResponse;
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,
]),
]);
}
}
+102
View File
@@ -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'],
]);
}
}
+107
View File
@@ -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 dont 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 AZ, 09 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];
}
}
+96
View File
@@ -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 its 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
-568
View File
@@ -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 nonNo 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 youre 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;
}
}
-577
View File
@@ -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;
}
}
+21
View File
@@ -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
-260
View File
@@ -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,
]);
}
}
+222
View File
@@ -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);
}
}
-874
View File
@@ -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. Its 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,
],
];
}
}
+23
View File
@@ -8,6 +8,29 @@ use Illuminate\Http\Exceptions\HttpResponseException;
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
{
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