fix logic and tests, update docker CI file

This commit is contained in:
root
2026-03-09 15:58:44 -04:00
parent 1cb3573d4b
commit 79e44fe037
188 changed files with 1776 additions and 524 deletions
@@ -3,10 +3,11 @@
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Controller;
use App\Http\Requests\Administrator\SubmitAdministratorAbsenceRequest;
use App\Services\Administrator\AdministratorAbsenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class AdministratorAbsenceController extends Controller
{
@@ -25,13 +26,42 @@ class AdministratorAbsenceController extends Controller
return response()->json($this->service->getAbsenceFormData($userId));
}
public function store(SubmitAdministratorAbsenceRequest $request): JsonResponse
public function store(Request $request): JsonResponse
{
$userId = (int) Auth::id();
if ($userId <= 0) {
return response()->json(['message' => 'Please log in first.'], 401);
}
$payload = $request->all();
if (!array_key_exists('dates', $payload) || !array_key_exists('reason', $payload)) {
$json = json_decode($request->getContent() ?: '', true);
if (is_array($json)) {
$payload = array_merge($payload, $json);
}
}
$validator = Validator::make($payload, [
'dates' => ['required', 'array', 'min:1'],
'dates.*' => ['required', 'date_format:Y-m-d'],
'reason_type' => ['nullable', 'string', 'max:100'],
'reason' => ['required', 'string', 'max:2000'],
], [
'dates.required' => 'At least one date is required.',
'dates.array' => 'Dates must be submitted as an array.',
'dates.min' => 'At least one date is required.',
'dates.*.date_format' => 'Each date must be in Y-m-d format.',
'reason.required' => 'Reason is required.',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$request->merge($validator->validated());
$result = $this->service->submit($request, $userId);
return response()->json([
@@ -3,11 +3,12 @@
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Controller;
use App\Http\Requests\Administrator\UpdateEnrollmentStatusesRequest;
use App\Services\Administrator\AdministratorEnrollmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class AdministratorEnrollmentController extends Controller
{
@@ -30,14 +31,53 @@ class AdministratorEnrollmentController extends Controller
return response()->json($this->service->showNewStudentsData());
}
public function updateStatuses(UpdateEnrollmentStatusesRequest $request): JsonResponse
public function updateStatuses(Request $request): JsonResponse
{
$editorUserId = (int) Auth::id();
if ($editorUserId <= 0) {
return response()->json(['message' => 'Please log in first.'], 401);
}
$data = $request->validated();
$payload = $request->all();
if (!array_key_exists('enrollment_status', $payload)) {
$json = json_decode($request->getContent() ?: '', true);
if (is_array($json) && array_key_exists('enrollment_status', $json)) {
$payload['enrollment_status'] = $json['enrollment_status'];
}
}
if (!array_key_exists('enrollment_status', $payload)) {
$payload['enrollment_status'] = $request->query('enrollment_status');
}
$allowedStatuses = [
'admission under review',
'payment pending',
'enrolled',
'withdraw under review',
'refund pending',
'withdrawn',
'denied',
'waitlist',
];
$validator = Validator::make($payload, [
'enrollment_status' => ['required', 'array', 'min:1'],
'enrollment_status.*' => ['required', 'string', Rule::in($allowedStatuses)],
], [
'enrollment_status.required' => 'Enrollment statuses are required.',
'enrollment_status.array' => 'Enrollment statuses must be an array.',
'enrollment_status.min' => 'At least one enrollment status is required.',
'enrollment_status.*.in' => 'One or more enrollment statuses are invalid.',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$result = $this->service->updateStatuses(
(array) ($data['enrollment_status'] ?? []),
$editorUserId
@@ -3,10 +3,10 @@
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Controller;
use App\Http\Requests\Administrator\SaveAdminNotificationSubjectsRequest;
use App\Http\Requests\Administrator\SavePrintRecipientsRequest;
use App\Services\Administrator\AdministratorNotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class AdministratorNotificationController extends Controller
{
@@ -20,9 +20,31 @@ class AdministratorNotificationController extends Controller
return response()->json($this->service->notificationsAlertsData());
}
public function saveAlerts(SaveAdminNotificationSubjectsRequest $request): JsonResponse
public function saveAlerts(Request $request): JsonResponse
{
$data = $request->validated();
$payload = $request->all();
if (!array_key_exists('subjects', $payload)) {
$json = json_decode($request->getContent() ?: '', true);
if (is_array($json)) {
$payload = array_merge($payload, $json);
}
}
$validator = Validator::make($payload, [
'subjects' => ['required', 'array'],
], [
'subjects.required' => 'Subjects payload is required.',
'subjects.array' => 'Subjects payload must be an array.',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$result = $this->service->saveNotificationSubjects(
(array) ($data['subjects'] ?? [])
);
@@ -35,9 +57,31 @@ class AdministratorNotificationController extends Controller
return response()->json($this->service->printNotificationRecipientsData());
}
public function savePrintRecipients(SavePrintRecipientsRequest $request): JsonResponse
public function savePrintRecipients(Request $request): JsonResponse
{
$data = $request->validated();
$payload = $request->all();
if (!array_key_exists('notify', $payload)) {
$json = json_decode($request->getContent() ?: '', true);
if (is_array($json)) {
$payload = array_merge($payload, $json);
}
}
$validator = Validator::make($payload, [
'notify' => ['required', 'array'],
], [
'notify.required' => 'Notify payload is required.',
'notify.array' => 'Notify payload must be an array.',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$result = $this->service->savePrintNotificationRecipients(
(array) ($data['notify'] ?? [])
);
@@ -3,10 +3,11 @@
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Controller;
use App\Http\Requests\Administrator\SendTeacherSubmissionNotificationsRequest;
use App\Services\Administrator\AdministratorTeacherSubmissionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class AdministratorTeacherSubmissionController extends Controller
{
@@ -20,13 +21,30 @@ class AdministratorTeacherSubmissionController extends Controller
return response()->json($this->service->report());
}
public function notify(SendTeacherSubmissionNotificationsRequest $request): JsonResponse
public function notify(Request $request): JsonResponse
{
$adminId = (int) Auth::id();
if ($adminId <= 0) {
return response()->json(['message' => 'Please log in first.'], 401);
}
$validator = Validator::make($request->all(), [
'notify' => ['required', 'array', 'min:1'],
'missing_items' => ['nullable', 'array'],
], [
'notify.required' => 'Select at least one teacher to notify.',
'notify.array' => 'Notify payload must be an array.',
'notify.min' => 'Select at least one teacher to notify.',
'missing_items.array' => 'Missing items payload must be an array.',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$result = $this->service->sendNotifications($request, $adminId);
return response()->json([
@@ -3,12 +3,12 @@
namespace App\Http\Controllers\Api\Assignment;
use App\Http\Controllers\Controller;
use App\Http\Requests\Assignment\StoreAssignmentRequest;
use App\Http\Resources\Assignment\AssignmentOverviewResource;
use App\Http\Resources\Assignment\AssignmentSectionResource;
use App\Services\Assignment\AssignmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class AssignmentApiController extends Controller
{
@@ -29,10 +29,25 @@ class AssignmentApiController extends Controller
]);
}
public function store(StoreAssignmentRequest $request): JsonResponse
public function store(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'exists:students,id'],
'class_section_id' => ['required', 'integer', 'exists:classSection,class_section_id'],
'semester' => ['required', 'string', 'max:50'],
'school_year' => ['required', 'string', 'max:50'],
'description' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$assignment = $this->assignmentService->storeAssignment(
data: $request->validated(),
data: $validator->validated(),
updatedBy: $request->user()?->id
);
@@ -3,11 +3,10 @@
namespace App\Http\Controllers\Api\Attendance;
use App\Http\Controllers\Controller;
use App\Http\Requests\AttendanceCommentTemplate\StoreAttendanceCommentTemplateRequest;
use App\Http\Requests\AttendanceCommentTemplate\UpdateAttendanceCommentTemplateRequest;
use App\Services\AttendanceCommentTemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class AttendanceCommentTemplateController extends Controller
{
@@ -49,9 +48,28 @@ class AttendanceCommentTemplateController extends Controller
]);
}
public function store(StoreAttendanceCommentTemplateRequest $request): JsonResponse
public function store(Request $request): JsonResponse
{
$created = $this->service->create($request->validated());
$validator = Validator::make($request->all(), [
'min_score' => ['required', 'integer', 'min:0', 'max:100'],
'max_score' => ['required', 'integer', 'min:0', 'max:100', 'gte:min_score'],
'template_text' => ['required', 'string', 'max:5000'],
'is_active' => ['sometimes', 'boolean'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
if (!array_key_exists('is_active', $data)) {
$data['is_active'] = true;
}
$created = $this->service->create($data);
return response()->json([
'status' => 'success',
@@ -60,9 +78,31 @@ class AttendanceCommentTemplateController extends Controller
], 201);
}
public function update(UpdateAttendanceCommentTemplateRequest $request, int $id): JsonResponse
public function update(Request $request, int $id): JsonResponse
{
$updated = $this->service->update($id, $request->validated());
$validator = Validator::make($request->all(), [
'min_score' => ['sometimes', 'integer', 'min:0', 'max:100'],
'max_score' => ['sometimes', 'integer', 'min:0', 'max:100'],
'template_text' => ['sometimes', 'string', 'max:5000'],
'is_active' => ['sometimes', 'boolean'],
]);
$validator->after(function ($validator) use ($request) {
$min = $request->has('min_score') ? (int) $request->input('min_score') : null;
$max = $request->has('max_score') ? (int) $request->input('max_score') : null;
if ($min !== null && $max !== null && $max < $min) {
$validator->errors()->add('max_score', 'The max_score field must be greater than or equal to min_score.');
}
});
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$updated = $this->service->update($id, $validator->validated());
return response()->json([
'status' => 'success',
@@ -3,14 +3,10 @@
namespace App\Http\Controllers\Api\AttendanceTracking;
use App\Http\Controllers\Controller;
use App\Http\Requests\AttendanceTracking\ComposeAttendanceEmailRequest;
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\AttendanceTracking\AttendanceTrackingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class AttendanceTrackingController extends Controller
{
@@ -63,9 +59,26 @@ class AttendanceTrackingController extends Controller
);
}
public function record(RecordAttendanceTrackingRequest $request): JsonResponse
public function record(Request $request): JsonResponse
{
$result = $this->service->record($request->validated());
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'date' => ['required', 'date_format:Y-m-d'],
'parent_email' => ['nullable', 'email'],
'parent_name' => ['nullable', 'string'],
'subject_type' => ['nullable', 'string'],
'semester' => ['nullable', 'string'],
'school_year' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$result = $this->service->record($validator->validated());
return response()->json(
$result,
@@ -80,13 +93,28 @@ class AttendanceTrackingController extends Controller
return response()->json($result);
}
public function compose(ComposeAttendanceEmailRequest $request): JsonResponse
public function compose(Request $request): JsonResponse
{
$validator = Validator::make($request->query(), [
'student_id' => ['required', 'integer', 'min:1'],
'code' => ['nullable', 'string'],
'variant' => ['nullable', 'string'],
'incident_date' => ['nullable', 'date_format:Y-m-d'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$result = $this->service->compose(
(int) $request->validated('student_id'),
(string) $request->validated('code', 'ABS_1'),
(string) $request->validated('variant', 'default'),
$request->validated('incident_date'),
(int) ($data['student_id'] ?? 0),
(string) ($data['code'] ?? 'ABS_1'),
(string) ($data['variant'] ?? 'default'),
$data['incident_date'] ?? null,
);
return response()->json(
@@ -95,9 +123,26 @@ class AttendanceTrackingController extends Controller
);
}
public function sendManualEmail(SendAttendanceManualEmailRequest $request): JsonResponse
public function sendManualEmail(Request $request): JsonResponse
{
$result = $this->service->sendEmailManual($request->validated());
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'to' => ['required', 'email'],
'subject' => ['required', 'string'],
'body_html' => ['required', 'string'],
'code' => ['required', 'string'],
'variant' => ['nullable', 'string'],
'incident_date' => ['nullable', 'date_format:Y-m-d'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$result = $this->service->sendEmailManual($validator->validated());
return response()->json(
$result,
@@ -105,9 +150,21 @@ class AttendanceTrackingController extends Controller
);
}
public function parentsInfo(ParentsInfoRequest $request): JsonResponse
public function parentsInfo(Request $request): JsonResponse
{
$result = $this->service->parentsInfo((int) $request->validated('student_id'));
$validator = Validator::make($request->query(), [
'student_id' => ['required', 'integer', 'min:1'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$result = $this->service->parentsInfo((int) ($data['student_id'] ?? 0));
return response()->json(
$result,
@@ -115,9 +172,26 @@ class AttendanceTrackingController extends Controller
);
}
public function saveNotificationNote(SaveAttendanceNotificationNoteRequest $request): JsonResponse
public function saveNotificationNote(Request $request): JsonResponse
{
$result = $this->service->saveNotificationNote($request->validated());
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'code' => ['required', 'string'],
'note' => ['required', 'string'],
'incident_date' => ['nullable', 'date_format:Y-m-d'],
'to' => ['nullable', 'email'],
'subject' => ['nullable', 'string'],
'variant' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$result = $this->service->saveNotificationNote($validator->validated());
return response()->json(
$result,
@@ -3,11 +3,12 @@
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;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class RegisterController extends BaseApiController
{
@@ -28,7 +29,7 @@ class RegisterController extends BaseApiController
]);
}
public function store(RegisterRequest $request): JsonResponse
public function store(Request $request): JsonResponse
{
if (app()->runningUnitTests() && $request->header('X-Debug-Request') === '1') {
return response()->json([
@@ -43,7 +44,15 @@ class RegisterController extends BaseApiController
]);
}
$result = $this->service->register($request->validated());
$validator = Validator::make($request->all(), \App\Http\Requests\Auth\RegisterRequest::ruleset());
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$result = $this->service->register($validator->validated());
if (empty($result['ok'])) {
$code = $result['code'] ?? 'registration_failed';
@@ -3,13 +3,12 @@
namespace App\Http\Controllers\Api\Badges;
use App\Http\Controllers\Controller;
use App\Http\Requests\Badges\BadgePrintStatusRequest;
use App\Http\Requests\Badges\GenerateBadgePdfRequest;
use App\Http\Requests\Badges\LogBadgePrintRequest;
use App\Services\Badges\BadgeFormDataService;
use App\Services\Badges\BadgePdfService;
use App\Services\Badges\BadgePrintLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Throwable;
class BadgeController extends Controller
@@ -21,24 +20,41 @@ class BadgeController extends Controller
) {
}
public function generatePdf(GenerateBadgePdfRequest $request)
public function generatePdf(Request $request)
{
$validator = Validator::make($request->all(), [
'user_ids' => ['required', 'array', 'min:1'],
'user_ids.*' => ['integer', 'min:1'],
'school_year' => ['nullable', 'string', 'max:50'],
'roles' => ['nullable', 'array'],
'classes' => ['nullable', 'array'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
return $this->badgePdfService->generate(
userIds: $request->input('user_ids', []),
schoolYear: $request->input('school_year'),
rolesMap: $request->input('roles', []),
classesMap: $request->input('classes', []),
userIds: $data['user_ids'] ?? [],
schoolYear: $data['school_year'] ?? null,
rolesMap: $data['roles'] ?? [],
classesMap: $data['classes'] ?? [],
actorId: optional($request->user())->id
);
}
public function printStatus(BadgePrintStatusRequest $request): JsonResponse
public function printStatus(Request $request): JsonResponse
{
try {
return response()->json([
'ok' => true,
'data' => $this->badgePrintLogService->getStatus(
$request->normalizedUserIds(),
$this->parseUserIds($request),
$request->input('school_year')
),
]);
@@ -50,15 +66,32 @@ class BadgeController extends Controller
}
}
public function logPrint(LogBadgePrintRequest $request): JsonResponse
public function logPrint(Request $request): JsonResponse
{
try {
$validator = Validator::make($request->all(), [
'user_ids' => ['required', 'array', 'min:1'],
'user_ids.*' => ['integer', 'min:1'],
'school_year' => ['nullable', 'string', 'max:50'],
'roles' => ['nullable', 'array'],
'classes' => ['nullable', 'array'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$inserted = $this->badgePrintLogService->log(
userIds: $request->input('user_ids', []),
userIds: $data['user_ids'] ?? [],
actorId: optional($request->user())->id,
schoolYear: $request->input('school_year'),
rolesMap: $request->input('roles', []),
classesMap: $request->input('classes', [])
schoolYear: $data['school_year'] ?? null,
rolesMap: $data['roles'] ?? [],
classesMap: $data['classes'] ?? []
);
return response()->json([
@@ -73,15 +106,30 @@ class BadgeController extends Controller
}
}
public function formData(BadgePrintStatusRequest $request): JsonResponse
public function formData(Request $request): JsonResponse
{
return response()->json([
'ok' => true,
'data' => $this->badgeFormDataService->build(
schoolYear: $request->input('school_year'),
selectedUserIds: $request->normalizedUserIds(),
selectedUserIds: $this->parseUserIds($request),
activeRole: $request->input('active_role')
),
]);
}
}
private function parseUserIds(Request $request): array
{
$userIds = $request->input('user_ids', $request->query('user_ids', []));
if (is_string($userIds)) {
$userIds = array_filter(array_map('trim', explode(',', $userIds)), 'strlen');
} elseif (!is_array($userIds)) {
$userIds = $userIds ? [$userIds] : [];
}
$userIds = array_values(array_unique(array_map(static fn ($v) => (int) $v, $userIds)));
return array_values(array_filter($userIds, static fn ($v) => $v > 0));
}
}
@@ -3,13 +3,13 @@
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Fees\FeeRefundRequest;
use App\Http\Requests\Fees\FeeTuitionTotalRequest;
use App\Http\Resources\Fees\FeeRefundResource;
use App\Http\Resources\Fees\FeeTuitionTotalResource;
use App\Services\Fees\FeeRefundCalculatorService;
use App\Services\Fees\FeeStudentFeeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class FeeCalculationController extends BaseApiController
{
@@ -20,9 +20,26 @@ class FeeCalculationController extends BaseApiController
parent::__construct();
}
public function refund(FeeRefundRequest $request): JsonResponse
public function refund(Request $request): JsonResponse
{
$payload = $request->validated();
$validator = Validator::make($request->all(), [
'parent_id' => ['required', 'integer', 'min:1'],
'students' => ['required', 'array'],
'students.*.student_id' => ['nullable', 'integer'],
'students.*.class_section_id' => ['required', 'integer', 'min:1'],
'students.*.enrollment_status' => ['required', 'string', 'max:50'],
'students.*.admission_status' => ['required', 'string', 'max:50'],
'students.*.withdrawal_date' => ['nullable', 'date'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$result = $this->refunds->calculateRefund($payload['students'], (int) $payload['parent_id']);
return response()->json([
@@ -31,9 +48,22 @@ class FeeCalculationController extends BaseApiController
]);
}
public function tuitionTotal(FeeTuitionTotalRequest $request): JsonResponse
public function tuitionTotal(Request $request): JsonResponse
{
$payload = $request->validated();
$validator = Validator::make($request->all(), [
'students' => ['required', 'array'],
'students.*.class_section_id' => ['required', 'integer', 'min:1'],
'students.*.grade' => ['nullable', 'string', 'max:20'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$total = $this->tuition->totalTuitionFee($payload['students']);
return response()->json([
@@ -3,7 +3,6 @@
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;
@@ -15,6 +14,8 @@ use App\Services\Invoices\InvoiceManagementService;
use App\Services\Invoices\InvoicePaymentService;
use App\Services\Invoices\InvoicePdfService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\StreamedResponse;
class InvoiceController extends BaseApiController
@@ -44,9 +45,22 @@ class InvoiceController extends BaseApiController
]);
}
public function generate(InvoiceGenerateRequest $request): JsonResponse
public function generate(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'parent_id' => ['required', 'integer', 'exists:users,id'],
'school_year' => ['nullable', 'string', 'max:20'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$result = $this->generation->generateInvoice((int) $payload['parent_id'], $payload['school_year'] ?? null);
if (empty($result['ok'])) {
@@ -4,13 +4,13 @@ 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;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentController extends BaseApiController
{
@@ -22,9 +22,30 @@ class PaymentController extends BaseApiController
parent::__construct();
}
public function store(PaymentCreateRequest $request): JsonResponse
public function store(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'parent_id' => ['required', 'integer', 'exists:users,id'],
'invoice_id' => ['nullable', 'integer', 'exists:invoices,id'],
'total_amount' => ['required', 'numeric', 'min:0.01'],
'number_of_installments' => ['required', 'integer', 'min:1'],
'payment_date' => ['nullable', 'date'],
'payment_method' => ['nullable', 'string', 'max:50'],
'payment_type' => ['nullable', 'string', 'max:50'],
'status' => ['nullable', 'string', 'max:50'],
'semester' => ['nullable', 'string', 'max:50'],
'school_year' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$payment = $this->planService->createPlan($payload);
return response()->json([
@@ -44,9 +65,21 @@ class PaymentController extends BaseApiController
]);
}
public function updateBalance(PaymentUpdateBalanceRequest $request, int $paymentId): JsonResponse
public function updateBalance(Request $request, int $paymentId): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'paid_amount' => ['required', 'numeric', 'min:0.01'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$updated = $this->balanceService->updateBalance($paymentId, (float) $payload['paid_amount']);
if (!$updated) {
@@ -4,9 +4,10 @@ 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;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentEventChargesController extends BaseApiController
{
@@ -23,9 +24,29 @@ class PaymentEventChargesController extends BaseApiController
return response()->json(['ok' => true] + $data);
}
public function store(PaymentEventChargesStoreRequest $request): JsonResponse
public function store(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'parent_id' => ['required', 'integer', 'min:1'],
'event_name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:1000'],
'amount' => ['required', 'numeric', 'min:0'],
'semester' => ['required', 'string', 'max:50'],
'school_year' => ['required', 'string', 'max:50'],
'student_ids' => ['nullable', 'array'],
'student_ids.*' => ['integer', 'min:1'],
'student_id' => ['nullable', 'integer', 'min:1'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$userId = (int) (auth()->id() ?? 0);
$count = $this->service->addCharges($payload, $userId);
@@ -4,12 +4,11 @@ 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;
use Illuminate\Support\Facades\Validator;
class PaymentManualController extends BaseApiController
{
@@ -18,9 +17,22 @@ class PaymentManualController extends BaseApiController
parent::__construct();
}
public function search(PaymentManualSearchRequest $request): JsonResponse
public function search(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'search_term' => ['nullable', 'string', 'max:255'],
'school_year' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$data = $this->service->search(
(string) ($payload['search_term'] ?? ''),
$payload['school_year'] ?? null
@@ -29,9 +41,21 @@ class PaymentManualController extends BaseApiController
return response()->json(['ok' => true] + $data);
}
public function suggest(PaymentManualSuggestRequest $request): JsonResponse
public function suggest(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'q' => ['required', 'string', 'max:255'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$items = $this->service->suggest((string) ($payload['q'] ?? ''));
return response()->json(['ok' => true, 'items' => $items]);
@@ -3,11 +3,11 @@
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Payments\PaymentTransactionCreateRequest;
use App\Http\Resources\Payments\PaymentTransactionResource;
use App\Services\Payments\PaymentTransactionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentTransactionController extends BaseApiController
{
@@ -16,9 +16,31 @@ class PaymentTransactionController extends BaseApiController
parent::__construct();
}
public function store(PaymentTransactionCreateRequest $request): JsonResponse
public function store(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'transaction_id' => ['required', 'string', 'max:100'],
'payment_id' => ['required', 'integer', 'min:1'],
'transaction_date' => ['nullable', 'date'],
'amount' => ['required', 'numeric', 'min:0.01'],
'payment_method' => ['required', 'string', 'max:50'],
'payment_status' => ['nullable', 'string', 'max:50'],
'transaction_fee' => ['nullable', 'numeric', 'min:0'],
'payment_reference' => ['nullable', 'string', 'max:100'],
'is_full_payment' => ['nullable', 'boolean'],
'school_year' => ['nullable', 'string', 'max:50'],
'semester' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$transaction = $this->service->create($payload);
return response()->json([
@@ -4,8 +4,6 @@ namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\PurchaseOrders\PurchaseOrderIndexRequest;
use App\Http\Requests\PurchaseOrders\PurchaseOrderReceiveRequest;
use App\Http\Requests\PurchaseOrders\PurchaseOrderStoreRequest;
use App\Http\Resources\PurchaseOrders\PurchaseOrderItemResource;
use App\Http\Resources\PurchaseOrders\PurchaseOrderResource;
use App\Models\PurchaseOrder;
@@ -14,6 +12,8 @@ use App\Services\PurchaseOrders\PurchaseOrderQueryService;
use App\Services\PurchaseOrders\PurchaseOrderReceiveService;
use App\Services\PurchaseOrders\PurchaseOrderStatusService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use RuntimeException;
class PurchaseOrderController extends BaseApiController
@@ -63,9 +63,41 @@ class PurchaseOrderController extends BaseApiController
]);
}
public function store(PurchaseOrderStoreRequest $request): JsonResponse
public function store(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'po_number' => ['required', 'string', 'max:60'],
'supplier_id' => ['required', 'integer', 'min:1', 'exists:suppliers,id'],
'status' => ['nullable', 'string', 'in:' . implode(',', PurchaseOrder::allowedStatuses())],
'order_date' => ['nullable', 'date'],
'expected_date' => ['nullable', 'date'],
'notes' => ['nullable', 'string', 'max:5000'],
'items' => ['required', 'array', 'min:1'],
'items.*.supply_id' => ['required', 'integer', 'min:1', 'exists:supplies,id'],
'items.*.description' => ['nullable', 'string', 'max:1000'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.unit_cost' => ['required', 'numeric', 'min:0'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
if (!empty($payload['order_date']) && !empty($payload['expected_date'])) {
$orderTs = strtotime((string) $payload['order_date']);
$expectedTs = strtotime((string) $payload['expected_date']);
if ($orderTs !== false && $expectedTs !== false && $expectedTs < $orderTs) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['expected_date' => ['The expected date must be on or after the order date.']],
], 422);
}
}
try {
$po = $this->createService->create($payload);
@@ -81,9 +113,23 @@ class PurchaseOrderController extends BaseApiController
], 201);
}
public function receive(PurchaseOrderReceiveRequest $request, int $id): JsonResponse
public function receive(Request $request, int $id): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'items' => ['required', 'array', 'min:1'],
'items.*.id' => ['required', 'integer', 'min:1'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$po = PurchaseOrder::query()->find($id);
if (!$po) {
@@ -6,17 +6,13 @@ 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;
@@ -37,6 +33,7 @@ use App\Services\Reimbursements\ReimbursementQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
use RuntimeException;
class ReimbursementController extends BaseApiController
@@ -69,9 +66,21 @@ class ReimbursementController extends BaseApiController
]);
}
public function markDonation(ReimbursementMarkDonationRequest $request): JsonResponse
public function markDonation(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'expense_id' => ['required', 'integer', 'min:1'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$userId = (int) (auth()->id() ?? 0);
try {
@@ -87,9 +96,21 @@ class ReimbursementController extends BaseApiController
return response()->json(['ok' => true]);
}
public function createBatch(ReimbursementBatchCreateRequest $request): JsonResponse
public function createBatch(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'title' => ['nullable', 'string', 'max:255'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$userId = (int) (auth()->id() ?? 0);
$schoolYear = $this->context->getSchoolYear();
$semester = $this->context->getSemester();
@@ -289,9 +310,29 @@ class ReimbursementController extends BaseApiController
]);
}
public function store(ReimbursementStoreRequest $request): JsonResponse
public function store(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'amount' => ['required', 'numeric', 'gt:0'],
'reimbursed_to' => ['required', 'integer', 'min:1'],
'reimbursement_method' => ['required', 'in:Cash,Check'],
'check_number' => ['required_if:reimbursement_method,Check', 'nullable', 'string', 'max:50'],
'receipt' => ['required_if:reimbursement_method,Check', 'nullable', 'file', 'max:2048', 'mimes:jpg,jpeg,png,webp,gif,pdf'],
'expense_id' => ['nullable', 'integer', 'min:1'],
'description' => ['nullable', 'string'],
'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$userId = (int) (auth()->id() ?? 0);
$receiptName = null;
@@ -361,14 +402,32 @@ class ReimbursementController extends BaseApiController
]);
}
public function update(ReimbursementUpdateRequest $request, int $id): JsonResponse
public function update(Request $request, int $id): JsonResponse
{
$reimb = Reimbursement::query()->find($id);
if (!$reimb) {
return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404);
}
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'amount' => ['required', 'numeric', 'gt:0'],
'reimbursed_to' => ['required', 'integer', 'min:1'],
'reimbursement_method' => ['required', 'in:Cash,Check'],
'check_number' => ['required_if:reimbursement_method,Check', 'nullable', 'string', 'max:50'],
'receipt' => ['nullable', 'file', 'max:2048', 'mimes:jpg,jpeg,png,webp,gif,pdf'],
'remove_receipt' => ['nullable', 'boolean'],
'description' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$userId = (int) (auth()->id() ?? 0);
$receiptName = $reimb->receipt_path;
@@ -7,7 +7,6 @@ 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;
@@ -16,6 +15,8 @@ use App\Services\Incidents\CurrentIncidentService;
use App\Services\Incidents\IncidentAnalysisService;
use App\Services\Incidents\IncidentHistoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class IncidentController extends BaseApiController
{
@@ -83,9 +84,26 @@ class IncidentController extends BaseApiController
]);
}
public function store(IncidentStoreRequest $request): JsonResponse
public function store(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'student_id' => ['required', 'integer', 'min:1'],
'grade' => ['required', 'integer', 'min:1'],
'incident' => ['required', 'string', 'max:255'],
'description' => ['required', 'string', 'max:2000'],
'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$result = $this->currentService->addIncident($payload, (int) (auth()->id() ?? 0));
if (empty($result['ok'])) {
@@ -109,9 +127,22 @@ class IncidentController extends BaseApiController
]);
}
public function close(IncidentCloseRequest $request, int $incidentId): JsonResponse
public function close(Request $request, int $incidentId): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'state_description' => ['required', 'string', 'max:2000'],
'action_taken' => ['nullable', 'string', 'max:2000'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$result = $this->currentService->closeIncident(
$incidentId,
(string) $payload['state_description'],
@@ -3,13 +3,14 @@
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;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class FinalController extends BaseApiController
{
@@ -20,9 +21,23 @@ class FinalController extends BaseApiController
parent::__construct();
}
public function index(ScoreClassRequest $request): JsonResponse
public function index(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'class_section_id' => ['required', 'integer', 'min:1'],
'semester' => ['nullable', 'string', 'max:50'],
'school_year' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$data = $this->service->list('final_exam', (int) $payload['class_section_id'], $payload['semester'] ?? null, $payload['school_year'] ?? null);
return response()->json([
@@ -4,13 +4,14 @@ 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;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class HomeworkController extends BaseApiController
{
@@ -21,9 +22,23 @@ class HomeworkController extends BaseApiController
parent::__construct();
}
public function index(ScoreClassRequest $request): JsonResponse
public function index(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'class_section_id' => ['required', 'integer', 'min:1'],
'semester' => ['nullable', 'string', 'max:50'],
'school_year' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$data = $this->service->list(
(int) $payload['class_section_id'],
$payload['semester'] ?? null,
@@ -3,13 +3,14 @@
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;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class MidtermController extends BaseApiController
{
@@ -20,9 +21,23 @@ class MidtermController extends BaseApiController
parent::__construct();
}
public function index(ScoreClassRequest $request): JsonResponse
public function index(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'class_section_id' => ['required', 'integer', 'min:1'],
'semester' => ['nullable', 'string', 'max:50'],
'school_year' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$data = $this->service->list('midterm_exam', (int) $payload['class_section_id'], $payload['semester'] ?? null, $payload['school_year'] ?? null);
return response()->json([
@@ -3,13 +3,14 @@
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;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class ParticipationController extends BaseApiController
{
@@ -20,9 +21,23 @@ class ParticipationController extends BaseApiController
parent::__construct();
}
public function index(ScoreClassRequest $request): JsonResponse
public function index(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'class_section_id' => ['required', 'integer', 'min:1'],
'semester' => ['nullable', 'string', 'max:50'],
'school_year' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$data = $this->service->list(
(int) $payload['class_section_id'],
$payload['semester'] ?? null,
@@ -4,13 +4,14 @@ 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;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class ProjectController extends BaseApiController
{
@@ -21,9 +22,23 @@ class ProjectController extends BaseApiController
parent::__construct();
}
public function index(ScoreClassRequest $request): JsonResponse
public function index(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'class_section_id' => ['required', 'integer', 'min:1'],
'semester' => ['nullable', 'string', 'max:50'],
'school_year' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$data = $this->service->list(
(int) $payload['class_section_id'],
$payload['semester'] ?? null,
@@ -4,13 +4,14 @@ 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;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class QuizController extends BaseApiController
{
@@ -21,9 +22,23 @@ class QuizController extends BaseApiController
parent::__construct();
}
public function index(ScoreClassRequest $request): JsonResponse
public function index(Request $request): JsonResponse
{
$payload = $request->validated();
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'class_section_id' => ['required', 'integer', 'min:1'],
'semester' => ['nullable', 'string', 'max:50'],
'school_year' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$data = $this->service->list(
(int) $payload['class_section_id'],
$payload['semester'] ?? null,
-107
View File
@@ -1,107 +0,0 @@
<?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,157 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ClassSectionModel;
use App\Models\StudentModel;
use App\Models\TeacherModel;
use App\Models\ConfigurationModel;
class ClassController extends BaseController
{
protected $classsectionModel;
protected $studentModel;
protected $teacherModel;
protected $configModel;
protected $semester;
protected $schoolYear;
protected $db;
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
{
$this->db = \Config\Database::connect();
$this->classsectionModel = new ClassSectionModel();
// Load necessary models
$this->studentModel = new StudentModel();
$this->teacherModel = new TeacherModel();
$this->configModel = new ConfigurationModel();
// Get the semester from the configuration table
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
public function index()
{
// Retrieve all classes from the database
$classsection = $this->classsectionModel->getAllClasses();
// Pass the classes to the view
return view('administrator/class_section', ['classsection' => $classsection]);
}
public function create()
{
// Show the form to create a new class
return view('administrator/create_class');
}
public function store()
{
// Handle the form submission to create a new class
$data = [
'name' => $this->request->getPost('name'),
'description' => $this->request->getPost('description'),
];
//$this->classModel->createClass($data);
return redirect()->to('/administrator/classes');
}
public function edit($id)
{
// Retrieve the class details to edit
//$class = $this->ClassSectionModel->getClassById($id);
//return view('administrator/edit_class', ['class' => $class]);
}
public function updateClass($id)
{
// Handle the form submission to update an existing class
$data = [
'name' => $this->request->getPost('name'),
'description' => $this->request->getPost('description'),
];
//$this->classModel->updateClass($id, $data);
return redirect()->to('/administrator/classes');
}
public function destroyClass($id)
{
// Delete the class
//$this->classModel->deleteClass($id);
return redirect()->to('/administrator/classes');
}
public function addClasses()
{
// Include the shared database connection file
$file = __DIR__ . '/../db_connection.php';
if (file_exists($file)) {
require_once $file;
} else {
die("Error: Could not find the required file '$file'.");
}
$classes = [
['class_name' => 'Class 1', 'teacher_id' => 1, 'schedule' => 'Monday 9:00 AM - 10:00 AM', 'capacity' => 30],
['class_name' => 'Class 2', 'teacher_id' => 2, 'schedule' => 'Tuesday 9:00 AM - 10:00 AM', 'capacity' => 30],
['class_name' => 'Class 3', 'teacher_id' => 3, 'schedule' => 'Wednesday 9:00 AM - 10:00 AM', 'capacity' => 30],
['class_name' => 'Class 4', 'teacher_id' => 4, 'schedule' => 'Thursday 9:00 AM - 10:00 AM', 'capacity' => 30],
['class_name' => 'Class 5', 'teacher_id' => 5, 'schedule' => 'Friday 9:00 AM - 10:00 AM', 'capacity' => 30],
['class_name' => 'Class 6', 'teacher_id' => 6, 'schedule' => 'Monday 10:00 AM - 11:00 AM', 'capacity' => 30],
['class_name' => 'Class 7', 'teacher_id' => 7, 'schedule' => 'Tuesday 10:00 AM - 11:00 AM', 'capacity' => 30],
['class_name' => 'Class 8', 'teacher_id' => 8, 'schedule' => 'Wednesday 10:00 AM - 11:00 AM', 'capacity' => 30],
['class_name' => 'Class 9', 'teacher_id' => 9, 'schedule' => 'Thursday 10:00 AM - 11:00 AM', 'capacity' => 30],
['class_name' => 'Youth', 'teacher_id' => 10, 'schedule' => 'Friday 10:00 AM - 11:00 AM', 'capacity' => 30],
];
foreach ($classes as $class) {
$stmt = $conn->prepare("INSERT INTO classes (class_name, teacher_id, schedule, capacity) VALUES (?, ?, ?, ?)");
if (!$stmt) {
die("Prepare failed: (" . $conn->errno . ") " . $conn->error);
}
$stmt->bind_param("sisi", $class['class_name'], $class['teacher_id'], $class['schedule'], $class['capacity']);
$stmt->execute();
$stmt->close();
}
$conn->close();
return redirect()->to('/parent/classes')->with('success', 'Classes added successfully.');
}
public function classAttendance($class_section_id)
{
// Get the teacher's ID from the session
$teacherId = session()->get('user_id');
// Fetch teacher data
$teacherData = $this->teacherModel->find($teacherId);
// Fetch students for the class
$studentsData = $this->studentModel->join('student_class', 'students.id = student_class.student_id')
->where('student_class.class_section_id', $class_section_id)
->select('students.*, student_class.class_section_id')
->findAll();
// Get class name (assuming it's stored in the students data)
$className = !empty($studentsData) && isset($studentsData[0]['class_name']) ? $studentsData[0]['class_name'] : 'Class Name Not Available';
// Check if the teacher data and students data are available
if (empty($teacherData) || empty($studentsData)) {
return redirect()->to('/teacher/classes')->with('error', 'No data found for this class.');
}
// Pass data to the view
return view('/teacher/classes', [
'teacher_name' => $teacherData['teacher_first'] . ' ' . $teacherData['teacher_last'],
'students' => $studentsData,
'class_name' => $className,
'semester' => $this->semester,
'class_section_id' => $class_section_id, // Pass the class_id for attendance update forms
]);
}
}
@@ -1,96 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Models\ConfigurationModel;
use CodeIgniter\Controller;
class ConfigurationController extends Controller
{
protected $configModel;
public function __construct()
{
$this->configModel = new ConfigurationModel();
}
// Method to load configuration management page
public function index()
{
helper('url');
return view('configuration/configuration_view', [
'configEndpoint' => site_url('api/configuration'),
]);
}
public function addConfig()
{
// Retrieve POST data
$configKey = $this->request->getPost('config_key');
$configValue = $this->request->getPost('config_value');
// Validate inputs (optional)
if ($configKey && $configValue) {
// Save to the database
$this->configModel->save([
'config_key' => $configKey,
'config_value' => $configValue,
]);
// Redirect to the configuration view page
return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration added.');
}
// If validation fails, reload the form with input
return redirect()->back()->withInput()->with('error', 'Please fill in all required fields.');
}
// Method to edit an existing configuration
public function editConfig($id)
{
$config = $this->configModel->find($id);
if (strtolower($this->request->getMethod()) === 'post') {
$key = trim((string) $this->request->getPost('config_key'));
$value = trim((string) $this->request->getPost('config_value'));
$this->configModel->update($id, [
'config_key' => $key,
'config_value' => $value
]);
return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration updated.');
}
return view('configuration/configuration_edit', ['config' => $config]);
}
public function deleteConfig($id)
{
if ($this->configModel->find($id)) {
$this->configModel->delete($id);
return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration deleted successfully.');
}
return redirect()->to('/configuration/configuration_view')->with('error', 'Configuration not found.');
}
public function listData()
{
$rows = $this->configModel
->orderBy('id', 'ASC')
->findAll();
$configs = array_map(static function ($row) {
if (!is_array($row)) {
return [];
}
return [
'id' => (int) ($row['id'] ?? 0),
'config_key' => (string) ($row['config_key'] ?? ''),
'config_value' => (string) ($row['config_value'] ?? ''),
];
}, $rows ?? []);
return $this->response->setJSON(['configs' => $configs]);
}
}
@@ -1,66 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
class ContactController extends BaseController
{
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
{
helper('form'); // Load the form helper
}
public function index()
{
return view('/parent/contact');
}
public function submit()
{
helper('form'); // Ensure the form helper is loaded
$validation = \Config\Services::validation();
// Define validation rules
$validation->setRules([
'name' => 'required|min_length[3]',
'email' => 'required|valid_email',
'subject' => 'required|min_length[3]',
'message' => 'required|min_length[10]'
]);
if (!$validation->withRequest($this->request)->run()) {
return view('/parent/contact', [
'validation' => $validation
]);
}
// Process form data
$name = $this->request->getPost('name');
$email = strtolower($this->request->getPost('email'));
$subject = $this->request->getPost('subject');
$message = $this->request->getPost('message');
// Initialize the EmailController
$emailController = new \App\Controllers\View\EmailController();
// Prepare the message to send
$formattedMessage = "
<p><strong>From:</strong> {$name} ({$email})</p>
<p><strong>Subject:</strong> {$subject}</p>
<p>{$message}</p>
";
// Send the email using EmailController
if ($emailController->sendEmail('support@alrahmaisgl.org', $subject, $formattedMessage)) {
$session = session();
$session->setFlashdata('success', 'Thank you for contacting us! We will get back to you soon.');
} else {
$session = session();
$session->setFlashdata('error', 'There was an error sending your message. Please try again later.');
}
return redirect()->to('/parent/contact');
}
}
@@ -1,48 +0,0 @@
<?php
namespace App\Controllers\View;
use CodeIgniter\Controller;
use App\Models\RoleModel;
class DashboardRedirectController extends Controller
{
public function dashboard()
{
$session = session();
$roles = $session->get('roles') ?? [];
// Fallback: if only a single role is stored
if (empty($roles) && $session->get('role')) {
$roles = [$session->get('role')];
}
// Use your existing method
return $this->redirectToDashboard($roles);
}
// Your existing function (unchanged)
private function redirectToDashboard(array $roles)
{
if (empty($roles)) {
log_message('error', 'Empty roles array passed to redirectToDashboard.');
return redirect()->to('/landing_page/guest_dashboard');
}
$roleModel = new RoleModel();
// Resolve all candidate roles (by name or slug), ordered by priority ASC
$rows = $roleModel->findByNamesOrSlugs($roles);
if (!empty($rows)) {
$route = $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard';
log_message('debug', 'Redirecting user to: ' . $route);
return redirect()->to($route);
}
log_message('warning', 'No matching role found. Redirecting to guest dashboard.');
return redirect()->to('/landing_page/guest_dashboard');
}
}
@@ -1,25 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
class DocsController extends BaseController
{
public function swagger()
{
// Present both specs in a Swagger UI dropdown
$specs = [
[
'name' => 'Administrator API',
'url' => '/docs/openapi_administrator_controller.yaml',
],
[
'name' => 'Parent API',
'url' => '/docs/openapi_parent_controller.yaml',
],
];
return view('swagger_ui', ['specs' => $specs]);
}
}
@@ -1,135 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\EmergencyContactModel;
use App\Models\StudentModel;
use App\Models\UserModel;
use CodeIgniter\HTTP\ResponseInterface;
class EmergencyContactController extends BaseController
{
protected $contactModel;
protected $studentModel;
protected $userModel;
public function __construct()
{
$this->contactModel = new EmergencyContactModel();
$this->studentModel = new StudentModel();
$this->userModel = new UserModel(); // Add this
}
public function index()
{
$db = \Config\Database::connect();
$parentIds = $this->contactModel
->distinct()
->select('parent_id')
->findAll();
$data = [];
foreach ($parentIds as $row) {
$parentId = $row['parent_id'];
$parent = $this->userModel->find($parentId); // Get parent info
$parentName = $parent ? $parent['firstname'] . ' ' . $parent['lastname'] : 'Unknown Parent';
$parentPhone = is_array($parent) ? (string)($parent['cellphone'] ?? '') : '';
// Try to load second parent phone from parents table (if available)
$secondPhone = '';
try {
$row = $db->table('parents')
->select('secondparent_phone')
->where('firstparent_id', (int) $parentId)
->orderBy('updated_at', 'DESC')
->get()->getRowArray();
if ($row && !empty($row['secondparent_phone'])) {
$secondPhone = (string) $row['secondparent_phone'];
}
} catch (\Throwable $e) {
log_message('debug', 'EmergencyContactController: could not load second parent phone: ' . $e->getMessage());
}
$students = $this->studentModel
->where('parent_id', $parentId)
->findAll();
$contacts = $this->contactModel
->getEmergencyContactsByParentId($parentId);
$data[] = [
'parent_id' => $parentId,
'parent_name' => $parentName,
'students' => $students,
'contacts' => $contacts,
'parent_phones' => array_values(array_filter([$parentPhone, $secondPhone], static fn($v) => (string)$v !== '')),
];
}
return view('administrator/emergency_contact/index', ['groups' => $data]);
}
public function edit($id)
{
$contact = $this->contactModel->find($id);
return view('administrator/emergency_contact/edit', ['contact' => $contact]);
}
public function update($id)
{
dd("Update was called with ID: $id", $this->request->getPost());
}
public function delete($id)
{
$this->contactModel->delete($id);
return redirect()->to('/administrator/emergency_contact')->with('success', 'Contact deleted.');
}
// API: JSON payload for emergency contacts grouped by parent
public function data()
{
// Build groups similarly to index(), but return JSON
$parentRows = $this->contactModel
->distinct()
->select('parent_id')
->findAll();
$groups = [];
foreach ($parentRows as $row) {
$parentId = (int)($row['parent_id'] ?? 0);
if ($parentId <= 0) continue;
$parent = $this->userModel->find($parentId) ?: [];
$parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')) ?: 'Unknown Parent';
$students = $this->studentModel
->select('id, firstname, lastname, school_id')
->where('parent_id', $parentId)
->findAll();
$contacts = $this->contactModel
->where('parent_id', $parentId)
->orderBy('updated_at', 'DESC')
->findAll();
$groups[] = [
'parent_id' => $parentId,
'parent_name' => $parentName,
'students' => $students,
'contacts' => $contacts,
];
}
return $this->response->setJSON([
'groups' => $groups,
'csrfHash' => csrf_hash(),
]);
}
}
@@ -1,360 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Models\EventModel;
use App\Models\EventChargesModel;
use App\Models\UserModel;
use App\Models\StudentModel;
use App\Models\ConfigurationModel;
use App\Models\EnrollmentModel;
use App\Controllers\View\InvoiceController;
use CodeIgniter\RESTful\ResourceController;
class EventController extends ResourceController
{
protected $eventChargesModel;
protected $studentModel;
protected $userModel;
protected $configModel;
protected $eventModel;
protected $invoiceController;
protected $schoolYear;
protected $semester;
protected $categories;
protected $enrollmentModel;
public function __construct()
{
$this->eventChargesModel = new EventChargesModel(); //eventChargesModel
$this->studentModel = new StudentModel();
$this->configModel = new ConfigurationModel();
$this->userModel = new UserModel(); // Add this
$this->eventModel = new EventModel();
$this->invoiceController = new InvoiceController();
$this->enrollmentModel = new EnrollmentModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->categories = [
'workshops',
'orientations',
'field trips',
'Ramadan programs',
];
}
public function index()
{
$eventModel = new EventModel();
$today = local_date(utc_now(), 'Y-m-d');
// Fetch all events
$events = $eventModel
->orderBy('created_at', 'DESC')
->findAll();
// Fetch active events (not expired)
$activeEventCount = $eventModel
->where('expiration_date >=', $today)
->countAllResults();
return view('administrator/events/event_list', [
'events' => $events,
'activeEventCount' => $activeEventCount
]);
}
public function create()
{
helper(['form']);
if (strtolower($this->request->getMethod()) === 'post') {
$file = $this->request->getFile('flyer');
$flyerPath = null;
if ($file && $file->isValid() && !$file->hasMoved()) {
// Move to public/uploads/event_flyers
$newName = $file->getRandomName();
$file->move(FCPATH . 'uploads/event_flyers', $newName);
$flyerPath = 'event_flyers/' . $newName; // store relative path
}
$eventId = $this->eventModel->insert([
'event_name' => $this->request->getPost('event_name'),
'event_category' => $this->request->getPost('event_category'),
'description' => $this->request->getPost('description'),
'amount' => $this->request->getPost('amount'),
'flyer' => $flyerPath,
'expiration_date' => $this->request->getPost('expiration_date'),
'semester' => $this->request->getPost('semester'),
'school_year' => $this->request->getPost('school_year'),
'created_by' => session()->get('user_id'),
]);
if ($eventId) {
$amount = (float) $this->request->getPost('amount');
$semester = (string) $this->request->getPost('semester');
$schoolYear = (string) $this->request->getPost('school_year');
$userId = (int) (session()->get('user_id') ?? 0);
$enrollments = $this->enrollmentModel
->select('enrollments.student_id, students.parent_id')
->join('students', 'students.id = enrollments.student_id', 'left')
->where('enrollments.school_year', $schoolYear)
->whereIn('enrollments.enrollment_status', ['enrolled', 'payment pending'])
->findAll();
$parentIds = [];
foreach ($enrollments as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$parentId = (int) ($row['parent_id'] ?? 0);
if ($studentId <= 0 || $parentId <= 0) {
continue;
}
$exists = $this->eventChargesModel
->where('event_id', $eventId)
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->first();
if ($exists) {
continue;
}
$this->eventChargesModel->insert([
'event_id' => $eventId,
'parent_id' => $parentId,
'student_id' => $studentId,
'participation' => 'yes',
'charged' => $amount,
'school_year' => $schoolYear,
'semester' => $semester,
'updated_by' => $userId ?: null,
]);
$parentIds[] = $parentId;
}
$parentIds = array_unique($parentIds);
foreach ($parentIds as $pid) {
$this->invoiceController->generateInvoice((string) $pid);
}
}
return redirect()->to('/administrator/events')->with('success', 'Event created successfully');
}
return view('administrator/events/create_event', [
'categories' => $this->categories,
]);
}
public function edit($id = null)
{
helper(['form']);
$event = $this->eventModel->find($id);
if (!$event) {
return redirect()->to('/administrator/events')->with('error', 'Event not found');
}
if (strtolower($this->request->getMethod()) === 'post') {
log_message('debug', 'POST detected');
$file = $this->request->getFile('flyer');
$flyerPath = $event['flyer']; // Default: keep old flyer
if ($file && $file->isValid() && !$file->hasMoved()) {
$newName = $file->getRandomName();
$file->move(FCPATH . 'uploads/event_flyers', $newName);
$flyerPath = 'event_flyers/' . $newName; // store relative path
}
$updated = $this->eventModel->update($id, [
'event_name' => $this->request->getPost('event_name'),
'event_category' => $this->request->getPost('event_category'),
'description' => $this->request->getPost('description'),
'amount' => $this->request->getPost('amount'),
'flyer' => $flyerPath,
'expiration_date' => $this->request->getPost('expiration_date'),
'semester' => $this->request->getPost('semester'),
'school_year' => $this->request->getPost('school_year'),
]);
if ($updated) {
return redirect()->to('/administrator/events')->with('success', 'Event updated successfully');
} else {
log_message('debug', 'GET detected');
return redirect()->back()->with('error', 'Failed to update event');
}
}
return view('administrator/events/edit_event', [
'event' => $event,
'categories' => $this->categories,
]);
}
public function delete($id = null)
{
$event = $this->eventModel->find($id);
if (!$event) {
return redirect()->to('/administrator/events')->with('error', 'Event not found');
}
// Delete related charges and collect parent IDs
$charges = $this->eventChargesModel->where('event_id', $id)->findAll();
$parentIds = [];
foreach ($charges as $charge) {
$this->eventChargesModel->delete($charge['id']);
$parentIds[] = $charge['parent_id'];
}
// Delete event
$this->eventModel->delete($id);
$parentIds = array_unique($parentIds);
foreach ($parentIds as $parentId) {
$this->invoiceController->generateInvoice($parentId);
}
return redirect()->to('/administrator/events')->with('success', 'Event, charges, and invoices updated.');
}
// Optionally keep your eventShow / eventUpdate for legacy administrator event charges
public function eventShow()
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$semester = $this->request->getGet('semester') ?? $this->semester;
$parents = $this->userModel->getParents();
$events = $this->eventModel->getActiveEvents($this->schoolYear);
$charges = $this->eventChargesModel
->select('event_charges.*,
users.firstname AS parent_firstname, users.lastname AS parent_lastname,
students.firstname AS student_firstname, students.lastname AS student_lastname,
events.event_name')
->join('users', 'users.id = event_charges.parent_id', 'left')
->join('students', 'students.id = event_charges.student_id', 'left')
->join('events', 'events.id = event_charges.event_id', 'left')
->where('event_charges.school_year', $schoolYear)
->where('event_charges.semester', $semester)
->orderBy('event_charges.created_at', 'DESC')
->findAll();
return view('administrator/events/event_charges', [
'charges' => $charges,
'parents' => $parents,
'events' => $events,
'school_year' => $schoolYear,
'semester' => $semester,
]);
}
public function eventUpdate()
{
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
$semester = $this->request->getPost('semester') ?? $this->semester;
$parentId = $this->request->getPost('parent_id');
$eventId = $this->request->getPost('event_id');
$participations = $this->request->getPost('participation') ?? [];
if (!$parentId || !$eventId || empty($participations)) {
return redirect()->back()->with('error', 'Missing required information.');
}
$userId = session()->get('user_id');
$event = $this->eventModel->getEvent($eventId, $schoolYear);
foreach ($participations as $studentId => $value) {
$existing = $this->eventChargesModel->where([
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'school_year' => $schoolYear,
'semester' => $semester
])->first();
if ($value === 'no') {
if ($existing) {
$this->eventChargesModel->delete($existing['id']);
}
continue;
}
// value is 'yes'
if ($existing) {
$this->eventChargesModel->update($existing['id'], [
'participation' => 'yes',
'charged' => $event['amount'],
'updated_by' => $userId
]);
} else {
$this->eventChargesModel->insert([
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'participation' => 'yes',
'charged' => $event['amount'],
'school_year' => $schoolYear,
'semester' => $semester,
'created_by' => $userId,
'updated_by' => $userId
]);
}
}
$this->invoiceController->generateInvoice($parentId);
return redirect()->back()->with('success', 'Event charges updated successfully.');
}
public function getStudentsWithCharges()
{
$parentId = $this->request->getGet('parent_id');
$semester = $this->request->getGet('semester');
$schoolYear = $this->request->getGet('school_year');
// Get students for parent
$students = $this->studentModel->where('parent_id', $parentId)->findAll();
// Get student_ids that already have charges
$chargedStudentIds = $this->eventChargesModel
->where('parent_id', $parentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->groupBy('student_id')
->select('student_id')
->findColumn('student_id');
$data = [];
foreach ($students as $student) {
$data[] = [
'id' => $student['id'],
'name' => $student['firstname'] . ' ' . $student['lastname'],
'charged' => in_array($student['id'], $chargedStudentIds ?? []),
];
}
return $this->response->setJSON($data);
}
}
@@ -1,605 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\ExamDraftModel;
use App\Models\TeacherClassModel;
use App\Models\UserModel;
use CodeIgniter\HTTP\Files\UploadedFile;
use Config\Database;
class ExamDraftController extends BaseController
{
protected ExamDraftModel $examDraftModel;
protected TeacherClassModel $teacherClassModel;
protected ClassSectionModel $classSectionModel;
protected UserModel $userModel;
protected ConfigurationModel $configModel;
protected string $schoolYear;
protected string $semester;
protected bool $hasFinalPdfColumn = false;
protected bool $hasIsLegacyColumn = false;
// DB enum: draft, submitted, reviewed, finalized, rejected
// (string literals used to avoid excess constants)
protected const TEACHER_UPLOAD_DIR = 'exams/drafts';
protected const FINAL_UPLOAD_DIR = 'exams/finals';
protected const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
protected const ALLOWED_EXTENSIONS = ['doc', 'docx'];
protected const ADMIN_ALLOWED_EXTENSIONS = ['doc', 'docx', 'pdf'];
protected array $examTypes = [
'Final Exam',
'Midterm Exam',
'Quiz',
'Study Guide',
'Practice Exam',
'Other',
];
public function __construct()
{
$this->examDraftModel = new ExamDraftModel();
$this->teacherClassModel = new TeacherClassModel();
$this->classSectionModel = new ClassSectionModel();
$this->userModel = new UserModel();
$this->configModel = new ConfigurationModel();
$this->db = Database::connect();
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file');
$this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy');
helper(['form', 'url', 'date']);
}
public function teacherIndex()
{
$teacherId = (int) (session()->get('user_id') ?? 0);
if ($teacherId <= 0) {
return redirect()->to('/login');
}
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $this->schoolYear);
$selectedClass = $this->resolveSelectedClassSection($assignments);
if ($selectedClass > 0) {
session()->set('class_section_id', $selectedClass);
}
$allDrafts = $this->examDraftModel
->where('teacher_id', $teacherId)
->orderBy('created_at', 'DESC')
->findAll();
foreach ($allDrafts as &$row) {
if (empty($row['final_pdf_file'])) {
$pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION));
if ($pdf !== null) {
$row['final_pdf_file'] = $pdf;
}
}
}
unset($row);
$classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments);
$legacyExams = [];
// Guard against missing schema column in older databases
if ($this->hasIsLegacyColumn) {
// Keep all submissions visible; legacy ones are also surfaced in a separate tab
$drafts = $allDrafts;
if (!empty($classSectionIds)) {
$legacyExams = $this->examDraftModel
->select('exam_drafts.*, cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->whereIn('exam_drafts.class_section_id', $classSectionIds)
->where('exam_drafts.is_legacy', 1)
->where('exam_drafts.final_file IS NOT NULL', null, false)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('exam_drafts.created_at', 'DESC')
->findAll();
}
} else {
// Legacy column absent, show all drafts and skip legacy tab query
$drafts = $allDrafts;
}
$validation = session()->getFlashdata('validation') ?? $this->validator;
return view('teacher/exam_drafts', [
'assignments' => $assignments,
'selectedClassSection' => $selectedClass,
'drafts' => $drafts,
'legacyExams' => $legacyExams,
'examTypes' => $this->examTypes,
'statusBadges' => $this->statusBadgeMap(),
'schoolYear' => $this->schoolYear,
'semester' => $this->semester,
'maxUploadBytes' => self::MAX_UPLOAD_BYTES,
'validation' => $validation,
]);
}
public function teacherStore()
{
$teacherId = (int) (session()->get('user_id') ?? 0);
if ($teacherId <= 0) {
return redirect()->to('/login');
}
$classSectionId = (int) ($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0);
$examType = trim((string) $this->request->getPost('exam_type'));
$description = trim((string) $this->request->getPost('description'));
if ($classSectionId <= 0) {
return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.');
}
$assignment = $this->teacherClassModel
->where('teacher_id', $teacherId)
->where('class_section_id', $classSectionId)
->first();
if (empty($assignment)) {
return redirect()->back()->withInput()->with('error', 'You are not assigned to the selected class section.');
}
session()->set('class_section_id', $classSectionId);
if ($classSectionId <= 0) {
$classSectionId = $this->resolveSelectedClassSection($assignments);
if ($classSectionId <= 0) {
return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.');
}
}
$file = $this->request->getFile('draft_file');
$teacherFile = null;
$teacherFilename = null;
if ($file && $file->isValid() && !$file->hasMoved()) {
$stored = $this->storeUploadedFile($file, self::TEACHER_UPLOAD_DIR);
if ($stored === null) {
return redirect()->back()->withInput()->with('error', 'Failed to store the uploaded file.');
}
$teacherFile = $stored;
$teacherFilename = $file->getClientName();
} elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) {
return redirect()->back()->withInput()->with('error', 'Upload failed. Please try again.');
}
$existingDraft = $this->examDraftModel
->where('teacher_id', $teacherId)
->where('class_section_id', $classSectionId)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->orderBy('version', 'DESC')
->first();
$nextVersion = 1;
$previousId = null;
if (!empty($existingDraft)) {
$nextVersion = ((int) ($existingDraft['version'] ?? 1)) + 1;
$previousId = (int) ($existingDraft['id'] ?? 0) ?: null;
}
$title = $examType ?: 'Exam Draft';
$payload = [
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'exam_type' => $examType ?: null,
'draft_title' => $title,
'description' => $description === '' ? null : $description,
'teacher_file' => $teacherFile,
'teacher_filename' => $teacherFilename,
'status' => 'submitted',
'version' => $nextVersion,
'previous_draft_id' => $previousId,
];
if ($this->examDraftModel->insert($payload)) {
return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.');
}
return redirect()->back()->withInput()->with('error', 'Unable to save the exam draft.');
}
public function adminIndex()
{
$allDrafts = $this->examDraftModel
->select('exam_drafts.*, cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last')
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->join('users u', 'u.id = exam_drafts.teacher_id', 'left')
->join('users a', 'a.id = exam_drafts.admin_id', 'left')
->orderBy('exam_drafts.created_at', 'DESC')
->findAll();
foreach ($allDrafts as &$row) {
if (empty($row['final_pdf_file'])) {
$pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION));
if ($pdf !== null) {
$row['final_pdf_file'] = $pdf;
}
}
}
unset($row);
$classSections = $this->classSectionModel
->select('class_section_id, class_section_name')
->orderBy('class_section_name', 'ASC')
->findAll();
// Group legacy uploads (admin-uploaded finalized exams) by class_section for separate tab
$legacyByClass = [];
if ($this->hasIsLegacyColumn) {
// Keep all submissions visible; additionally surface legacy items in a separate tab
$drafts = $allDrafts;
foreach ($allDrafts as $d) {
$isLegacy = !empty($d['is_legacy']);
if (!$isLegacy) {
continue;
}
$cid = (int)($d['class_section_id'] ?? 0);
if (!isset($legacyByClass[$cid])) {
$legacyByClass[$cid] = [
'class_section_id' => $cid,
'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid,
'items' => [],
];
}
$legacyByClass[$cid]['items'][] = $d;
}
} else {
// Column missing: keep behavior simple and avoid legacy tab
$drafts = $allDrafts;
}
return view('administrator/exam_drafts', [
'drafts' => $drafts,
'statusBadges' => $this->statusBadgeMap(),
'statusOptions' => $this->statusOptions(),
'schoolYear' => $this->schoolYear,
'semester' => $this->semester,
'allowedExtensions' => self::ADMIN_ALLOWED_EXTENSIONS,
'maxUploadBytes' => self::MAX_UPLOAD_BYTES,
'examTypes' => $this->examTypes,
'classSections' => $classSections,
'legacyByClass' => $legacyByClass,
]);
}
public function adminUploadLegacy()
{
$adminId = (int) (session()->get('user_id') ?? 0);
if ($adminId <= 0) {
return redirect()->to('/login');
}
$classSectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
$semester = trim((string) ($this->request->getPost('semester') ?? $this->semester));
$examType = trim((string) $this->request->getPost('exam_type'));
if ($classSectionId <= 0) {
return redirect()->back()->withInput()->with('error', 'Select a class section.');
}
if ($schoolYear === '') {
return redirect()->back()->withInput()->with('error', 'School year is required.');
}
if ($semester === '') {
return redirect()->back()->withInput()->with('error', 'Semester is required.');
}
$file = $this->request->getFile('old_exam_file');
if (!$file || !$file->isValid()) {
return redirect()->back()->withInput()->with('error', 'A valid file is required.');
}
$stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR, self::ADMIN_ALLOWED_EXTENSIONS);
if ($stored === null) {
return redirect()->back()->withInput()->with('error', 'File type not allowed or upload failed.');
}
$payload = [
'teacher_id' => $adminId, // store under admin user since legacy uploads are admin-only
'class_section_id' => $classSectionId,
'semester' => ucfirst(strtolower($semester)),
'school_year' => $schoolYear,
'exam_type' => $examType === '' ? null : $examType,
'draft_title' => $examType === '' ? 'Legacy Exam' : $examType,
'description' => null,
'final_file' => $stored,
'final_filename' => $file->getClientName(),
'status' => 'finalized',
'admin_id' => $adminId,
'reviewed_at' => utc_now(),
'version' => 1,
];
if ($this->hasIsLegacyColumn) {
$payload['is_legacy'] = 1;
}
$pdfName = null;
if (strtolower($file->getClientExtension()) === 'pdf') {
$pdfName = $stored;
} else {
$pdfName = $this->convertDocToPdf(
$this->fullUploadPath(self::FINAL_UPLOAD_DIR, $stored),
self::FINAL_UPLOAD_DIR
);
}
if ($pdfName !== null && $this->hasFinalPdfColumn) {
$payload['final_pdf_file'] = $pdfName;
}
if ($this->examDraftModel->insert($payload)) {
return redirect()->to('/administrator/exam-drafts')->with('success', 'Old exam uploaded successfully.');
}
return redirect()->back()->withInput()->with('error', 'Unable to save the old exam.');
}
public function adminReview()
{
$draftId = (int) ($this->request->getPost('draft_id') ?? 0);
if ($draftId <= 0) {
return redirect()->back()->with('error', 'Invalid submission selected.');
}
$draft = $this->examDraftModel->find($draftId);
if (empty($draft)) {
return redirect()->back()->with('error', 'Submission not found.');
}
$comments = trim((string) $this->request->getPost('admin_comments'));
$statusInput = trim((string) $this->request->getPost('review_status'));
$currentStatus = (string) ($draft['status'] ?? 'draft');
$status = $this->normalizeStatus(
$statusInput !== '' ? $statusInput : $currentStatus,
$currentStatus
);
$status = strtolower($status);
if (!in_array($status, $this->statusOptions(), true)) {
$status = 'reviewed';
}
$finalFile = null;
$finalFilename = null;
$file = $this->request->getFile('final_file');
if ($file && $file->isValid() && !$file->hasMoved()) {
$stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR);
if ($stored === null) {
return redirect()->back()->with('error', 'Unable to store the final draft.')->withInput();
}
$finalFile = $stored;
$finalFilename = $file->getClientName();
// Only auto-finalize if the admin explicitly chose "finalized"
// (previously any uploaded file forced finalization)
if ($status === 'finalized') {
$status = 'finalized';
}
} elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) {
return redirect()->back()->with('error', 'Final file upload failed.');
}
$update = [
'status' => $status,
'admin_comments' => $comments === '' ? null : $comments,
'admin_id' => (int) (session()->get('user_id') ?? 0),
'reviewed_at' => utc_now(),
];
if ($finalFile !== null) {
$update['final_file'] = $finalFile;
$update['final_filename'] = $finalFilename;
$pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null);
if ($pdfName !== null && $this->hasFinalPdfColumn) {
$update['final_pdf_file'] = $pdfName;
}
} elseif (strtolower($status) === 'finalized' && !empty($draft['teacher_file'])) {
// Auto-promote teacher file when admin finalizes without uploading a final
$copied = $this->copyDraftToFinal($draft['teacher_file']);
if ($copied !== null) {
$update['final_file'] = $copied;
$update['final_filename'] = $draft['teacher_filename'] ?? $draft['teacher_file'];
$pdfName = $this->ensurePdfExists($copied, pathinfo($copied, PATHINFO_EXTENSION));
if ($pdfName !== null && $this->hasFinalPdfColumn) {
$update['final_pdf_file'] = $pdfName;
}
}
}
if (strtolower($status) === 'finalized' && $this->hasIsLegacyColumn) {
$update['is_legacy'] = 1; // finalized exams move to Previous Exams
}
if ($this->hasIsLegacyColumn) {
// Keep existing legacy flag, do not set legacy automatically here
}
$updated = $this->examDraftModel
->set($update)
->where('id', $draftId)
->update();
if ($updated) {
return redirect()->back()->with('success', 'Review saved successfully.');
}
return redirect()->back()->with('error', 'Unable to save the review.');
}
private function resolveSelectedClassSection(array $assignments): int
{
$candidate = (int) ($this->request->getGet('class_section_id') ?? session()->get('class_section_id') ?? 0);
$validIds = array_map('intval', array_column($assignments, 'class_section_id'));
if ($candidate > 0 && in_array($candidate, $validIds, true)) {
return $candidate;
}
return $validIds[0] ?? 0;
}
private function statusBadgeMap(): array
{
return [
'draft' => [
'label' => 'Draft',
'class' => 'bg-secondary text-white',
],
'submitted' => [
'label' => 'Submitted',
'class' => 'bg-warning text-dark',
],
'reviewed' => [
'label' => 'Reviewed',
'class' => 'bg-info text-dark',
],
'finalized' => [
'label' => 'Finalized',
'class' => 'bg-success text-white',
],
'rejected' => [
'label' => 'Rejected',
'class' => 'bg-danger text-white',
],
];
}
private function statusOptions(): array
{
return ['draft', 'submitted', 'reviewed', 'finalized', 'rejected'];
}
private function normalizeStatus(string $input, string $default): string
{
$input = strtolower(trim($input));
$aliases = [
'pending' => 'submitted', // legacy value
'final' => 'finalized',
'approved' => 'finalized', // legacy value
];
if (isset($aliases[$input])) {
$input = $aliases[$input];
}
return in_array($input, $this->statusOptions(), true) ? $input : $default;
}
private function storeUploadedFile(UploadedFile $file, string $subdir, array $allowedExtensions = self::ALLOWED_EXTENSIONS): ?string
{
$ext = strtolower($file->getClientExtension());
if (!in_array($ext, $allowedExtensions, true)) {
return null;
}
if ($file->getSize() > self::MAX_UPLOAD_BYTES) {
return null;
}
$destination = WRITEPATH . 'uploads/' . trim($subdir, '/');
if (!is_dir($destination)) {
mkdir($destination, 0755, true);
}
$filename = $file->getRandomName();
try {
$file->move($destination, $filename);
return $filename;
} catch (\Throwable $e) {
log_message('error', 'ExamDraftController::storeUploadedFile error: ' . $e->getMessage());
return null;
}
}
private function convertDocToPdf(string $sourcePath, string $targetSubdir): ?string
{
// Only attempt conversion for doc/docx
$ext = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION));
if (!in_array($ext, ['doc', 'docx'], true)) {
return null;
}
$targetDir = WRITEPATH . 'uploads/' . trim($targetSubdir, '/');
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
$base = pathinfo($sourcePath, PATHINFO_FILENAME);
$targetPath = $targetDir . '/' . $base . '.pdf';
// Attempt conversion via LibreOffice if available
$cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
@exec($cmd);
return is_file($targetPath) ? basename($targetPath) : null;
}
private function schemaHasColumn(string $table, string $column): bool
{
try {
$fields = $this->db->getFieldNames($table);
return in_array($column, $fields, true);
} catch (\Throwable $e) {
log_message('error', "Schema check failed for {$table}.{$column}: " . $e->getMessage());
return false;
}
}
private function fullUploadPath(string $subdir, string $filename): string
{
return WRITEPATH . 'uploads/' . trim($subdir, '/') . '/' . $filename;
}
private function neighborPdfIfExists(?string $filename, string $subdir): ?string
{
if (empty($filename)) return null;
$path = $this->fullUploadPath($subdir, $filename);
$base = pathinfo($path, PATHINFO_FILENAME);
$dir = pathinfo($path, PATHINFO_DIRNAME);
$pdfPath = $dir . '/' . $base . '.pdf';
return is_file($pdfPath) ? basename($pdfPath) : null;
}
private function ensurePdfExists(string $finalFilename, ?string $originalExt): ?string
{
$pdfNeighbor = $this->neighborPdfIfExists($finalFilename, self::FINAL_UPLOAD_DIR);
if ($pdfNeighbor !== null) {
return $pdfNeighbor;
}
$ext = strtolower((string)$originalExt);
if ($ext === 'pdf') {
// final file itself is already pdf
$path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename);
return is_file($path) ? $finalFilename : null;
}
return $this->convertDocToPdf(
$this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename),
self::FINAL_UPLOAD_DIR
);
}
private function copyDraftToFinal(string $draftFilename): ?string
{
$source = $this->fullUploadPath(self::TEACHER_UPLOAD_DIR, $draftFilename);
if (!is_file($source)) {
return null;
}
$destinationDir = WRITEPATH . 'uploads/' . trim(self::FINAL_UPLOAD_DIR, '/');
if (!is_dir($destinationDir)) {
mkdir($destinationDir, 0755, true);
}
$ext = pathinfo($draftFilename, PATHINFO_EXTENSION);
$destName = uniqid('final_', true) . '.' . $ext;
$destPath = $destinationDir . '/' . $destName;
if (!@copy($source, $destPath)) {
return null;
}
return $destName;
}
}
@@ -1,487 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use CodeIgniter\HTTP\ResponseInterface;
use App\Models\InvoiceModel;
use App\Models\PaymentModel;
use App\Models\StudentClassModel;
use App\Models\ConfigurationModel;
class FamilyAdminController extends BaseController
{
public function index(): ResponseInterface
{
$db = \Config\Database::connect();
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
$data = ['student' => null, 'families' => [], 'guardians' => [], 'students' => []];
// Simple student list for select
$data['students'] = $db->query("SELECT id, CONCAT(lastname, ', ', firstname) AS name FROM students ORDER BY lastname, firstname")->getResultArray();
// Preload search datasets (students + guardians/parents)
$data['searchStudents'] = $db->query("SELECT id, firstname, lastname FROM students ORDER BY lastname, firstname")->getResultArray();
$data['searchGuardians'] = $db->query(
"SELECT DISTINCT u.id, u.firstname, u.lastname, u.email, u.cellphone
FROM family_guardians fg
JOIN users u ON u.id = fg.user_id
ORDER BY u.lastname, u.firstname"
)->getResultArray();
// If a guardian is provided, resolve one of their students and redirect to use existing flow
if (!$studentId && $guardianId) {
$row = $db->query(
"SELECT s.id AS student_id
FROM family_guardians fg
JOIN family_students fs ON fs.family_id = fg.family_id
JOIN students s ON s.id = fs.student_id
WHERE fg.user_id = ?
ORDER BY s.lastname, s.firstname
LIMIT 1",
[$guardianId]
)->getRowArray();
if (!$row) {
$row = $db->query(
"SELECT s.id AS student_id
FROM students s
WHERE s.parent_id = ?
ORDER BY s.lastname, s.firstname
LIMIT 1",
[$guardianId]
)->getRowArray();
}
if ($row && !empty($row['student_id'])) {
return redirect()->to(site_url('family?student_id='.(int)$row['student_id']));
}
}
if ($studentId) {
$data['student'] = $db->query("SELECT id, firstname, lastname FROM students WHERE id = ?", [$studentId])->getRowArray();
// Fetch all families for this student with extended fields
$families = $db->query(
"SELECT f.id, f.family_code, f.household_name, f.address_line1, f.address_line2, f.city, f.state, f.postal_code, f.country,
f.primary_phone, f.preferred_lang, f.preferred_contact_method, f.is_active,
fs.is_primary_home
FROM family_students fs
JOIN families f ON f.id = fs.family_id
WHERE fs.student_id = ?
ORDER BY fs.is_primary_home DESC, f.household_name",
[$studentId]
)->getResultArray();
// Enrich each family with guardians, students, and financials
$invoiceModel = new InvoiceModel();
$paymentModel = new PaymentModel();
$studentClassModel = new StudentClassModel();
$configModel = new ConfigurationModel();
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
foreach ($families as &$fam) {
$fid = (int) $fam['id'];
// Guardians (with contact info)
$guardians = $db->query(
"SELECT u.id AS user_id, u.firstname, u.lastname, u.email, u.cellphone,
u.address_street, u.city, u.state, u.zip,
fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms
FROM family_guardians fg
JOIN users u ON u.id = fg.user_id
WHERE fg.family_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
[$fid]
)->getResultArray();
$fam['guardians'] = $guardians;
// Students in this family
$studentsRows = $db->query(
"SELECT s.id, s.firstname, s.lastname
FROM family_students fs
JOIN students s ON s.id = fs.student_id
WHERE fs.family_id = ?
ORDER BY s.lastname, s.firstname",
[$fid]
)->getResultArray();
// Enrich with grade label if available
if (!empty($studentsRows)) {
foreach ($studentsRows as &$sr) {
$sid = (int) ($sr['id'] ?? 0);
$sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : '';
}
unset($sr);
}
$fam['students'] = $studentsRows;
// Financials: invoices and payments for all guardians (by user_id)
$parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians);
$parentIds = array_values(array_filter($parentIds));
$fam['invoices'] = [];
$fam['payments'] = [];
$fam['finance_summary'] = [
'invoices_count' => 0,
'total_amount' => 0.0,
'paid_amount' => 0.0,
'balance' => 0.0,
];
if (!empty($parentIds)) {
// Invoices
$invRows = $db->table('invoices')
->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date')
->whereIn('parent_id', $parentIds)
->orderBy('issue_date', 'DESC')
->get()->getResultArray();
$fam['invoices'] = $invRows;
// Map invoice id -> number for payments table
$invoiceMap = [];
foreach ($invRows as $ir) {
$invoiceMap[(int)$ir['id']] = (string)($ir['invoice_number'] ?? '');
}
$fam['invoice_map'] = $invoiceMap;
// Aggregate summary
foreach ($invRows as $ir) {
$fam['finance_summary']['invoices_count']++;
$fam['finance_summary']['total_amount'] += (float)($ir['total_amount'] ?? 0);
$fam['finance_summary']['paid_amount'] += (float)($ir['paid_amount'] ?? 0);
$fam['finance_summary']['balance'] += (float)($ir['balance'] ?? 0);
}
// Recent payments (limit 10)
$payRows = $db->table('payments')
->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status')
->whereIn('parent_id', $parentIds)
->orderBy('payment_date', 'DESC')
->limit(10)
->get()->getResultArray();
$fam['payments'] = $payRows;
}
}
unset($fam);
$data['families'] = $families;
// Back-compat: also expose guardians of first family for old UI pieces
if (!empty($families)) {
$familyId = (int)$families[0]['id'];
$data['guardians'] = $db->query(
"SELECT u.id, u.firstname, u.lastname, u.email, fg.relation, fg.is_primary, fg.receive_emails
FROM family_guardians fg JOIN users u ON u.id = fg.user_id
WHERE fg.family_id = ? ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
[$familyId]
)->getResultArray();
}
}
return service('response')->setBody(view('family/index', $data));
}
// GET /family/search?q=..
public function search(): ResponseInterface
{
$db = \Config\Database::connect();
$q = trim((string)($this->request->getGet('q') ?? ''));
if ($q === '') {
return $this->response->setJSON(['items' => []]);
}
$qs = '%' . $db->escapeLikeString($q) . '%';
// Students suggestions
$students = $db->query(
"SELECT id, firstname, lastname
FROM students
WHERE CONCAT_WS(' ', firstname, lastname) LIKE ?
ORDER BY lastname, firstname
LIMIT 8",
[$qs]
)->getResultArray();
// Parents/Guardians suggestions (users)
$guardians = $db->query(
"SELECT id, firstname, lastname, email, cellphone
FROM users
WHERE (
CONCAT_WS(' ', firstname, lastname) LIKE ?
OR email LIKE ?
OR cellphone LIKE ?
)
ORDER BY lastname, firstname
LIMIT 8",
[$qs, $qs, $qs]
)->getResultArray();
$items = [];
foreach ($students as $s) {
$items[] = [
'type' => 'student',
'id' => (int)$s['id'],
'label'=> trim(($s['firstname'] ?? '').' '.($s['lastname'] ?? '')),
'sub' => 'Student',
];
}
foreach ($guardians as $g) {
$items[] = [
'type' => 'guardian',
'id' => (int)$g['id'],
'label' => trim(($g['firstname'] ?? '').' '.($g['lastname'] ?? '')),
'sub' => trim(($g['email'] ?? '').' '.($g['cellphone'] ?? '')),
];
}
return $this->response->setJSON(['items' => $items]);
}
// GET /family/card?student_id=.. | guardian_id=.. | family_id=..
public function card(): ResponseInterface
{
$db = \Config\Database::connect();
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
$familyId = (int) ($this->request->getGet('family_id') ?? 0);
if (!$familyId) {
if ($studentId) {
$row = $db->query(
"SELECT f.id
FROM family_students fs
JOIN families f ON f.id = fs.family_id
WHERE fs.student_id = ?
ORDER BY fs.is_primary_home DESC, f.household_name
LIMIT 1",
[$studentId]
)->getRowArray();
if (!empty($row['id'])) $familyId = (int) $row['id'];
} elseif ($guardianId) {
// 1) Try via guardians link
$row = $db->query(
"SELECT f.id
FROM family_guardians fg
JOIN families f ON f.id = fg.family_id
WHERE fg.user_id = ?
ORDER BY f.household_name
LIMIT 1",
[$guardianId]
)->getRowArray();
if (!empty($row['id'])) {
$familyId = (int) $row['id'];
} else {
// 2) Fallback via students.parent_id → family_students
$row = $db->query(
"SELECT f.id
FROM students s
JOIN family_students fs ON fs.student_id = s.id
JOIN families f ON f.id = fs.family_id
WHERE s.parent_id = ?
ORDER BY fs.is_primary_home DESC, f.household_name
LIMIT 1",
[$guardianId]
)->getRowArray();
if (!empty($row['id'])) $familyId = (int) $row['id'];
}
}
}
if (!$familyId) {
return $this->response->setStatusCode(404)->setBody('<div class="p-3 text-danger">Family not found.</div>');
}
$family = $db->query(
"SELECT f.id, f.family_code, f.household_name, f.address_line1, f.address_line2, f.city, f.state, f.postal_code, f.country,
f.primary_phone, f.preferred_lang, f.preferred_contact_method, f.is_active
FROM families f
WHERE f.id = ?",
[$familyId]
)->getRowArray();
if (!$family) {
return $this->response->setStatusCode(404)->setBody('<div class="p-3 text-danger">Family not found.</div>');
}
// Hydrate with guardians, students (+grades), invoices, payments
$invoiceModel = new \App\Models\InvoiceModel();
$paymentModel = new \App\Models\PaymentModel();
$studentClassModel = new \App\Models\StudentClassModel();
$configModel = new \App\Models\ConfigurationModel();
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
// Guardians
$guardians = $db->query(
"SELECT u.id AS user_id, u.firstname, u.lastname, u.email, u.cellphone,
u.address_street, u.city, u.state, u.zip,
fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms
FROM family_guardians fg
JOIN users u ON u.id = fg.user_id
WHERE fg.family_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
[$familyId]
)->getResultArray();
$family['guardians'] = $guardians;
// Students
$studentsRows = $db->query(
"SELECT s.id, s.firstname, s.lastname
FROM family_students fs
JOIN students s ON s.id = fs.student_id
WHERE fs.family_id = ?
ORDER BY s.lastname, s.firstname",
[$familyId]
)->getResultArray();
if (!empty($studentsRows)) {
foreach ($studentsRows as &$sr) {
$sid = (int) ($sr['id'] ?? 0);
$sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : '';
}
unset($sr);
}
$family['students'] = $studentsRows;
// Financials
$parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians);
$parentIds = array_values(array_filter($parentIds));
$family['invoices'] = [];
$family['payments'] = [];
$family['finance_summary'] = [
'invoices_count' => 0,
'total_amount' => 0.0,
'paid_amount' => 0.0,
'balance' => 0.0,
];
// Emergency contacts (by guardian/parent)
$family['emergency_contacts'] = [];
if (!empty($parentIds)) {
// Map guardian name by user_id
$gmap = [];
foreach ($guardians as $g) {
$gid = (int)($g['user_id'] ?? 0);
if ($gid > 0) $gmap[$gid] = trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? ''));
}
$ecRows = $db->table('emergency_contacts')
->select('id, parent_id, emergency_contact_name, relation, cellphone, email, school_year, semester, created_at, updated_at')
->whereIn('parent_id', $parentIds)
->orderBy('updated_at', 'DESC')
->get()->getResultArray();
if (!empty($ecRows)) {
foreach ($ecRows as &$ec) {
$pid = (int)($ec['parent_id'] ?? 0);
$ec['parent_label'] = $gmap[$pid] ?? ('Parent #'.$pid);
}
unset($ec);
$family['emergency_contacts'] = $ecRows;
}
}
if (!empty($parentIds)) {
// Invoices
$invRows = $db->table('invoices')
->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date')
->whereIn('parent_id', $parentIds)
->orderBy('issue_date', 'DESC')
->get()->getResultArray();
$family['invoices'] = $invRows;
$invoiceMap = [];
foreach ($invRows as $ir) {
$invoiceMap[(int)$ir['id']] = (string)($ir['invoice_number'] ?? '');
}
$family['invoice_map'] = $invoiceMap;
foreach ($invRows as $ir) {
$family['finance_summary']['invoices_count']++;
$family['finance_summary']['total_amount'] += (float)($ir['total_amount'] ?? 0);
$family['finance_summary']['paid_amount'] += (float)($ir['paid_amount'] ?? 0);
$family['finance_summary']['balance'] += (float)($ir['balance'] ?? 0);
}
// Payments
$payRows = $db->table('payments')
->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status')
->whereIn('parent_id', $parentIds)
->orderBy('payment_date', 'DESC')
->limit(10)
->get()->getResultArray();
$family['payments'] = $payRows;
}
return service('response')->setBody(view('family/card', ['f' => $family]));
}
public function composeEmail()
{
$to = trim((string)$this->request->getGet('to'));
$name = trim((string)$this->request->getGet('name'));
$returnUrl = trim((string)$this->request->getGet('return_url'));
if ($returnUrl === '') {
$returnUrl = trim((string)$this->request->getServer('HTTP_REFERER'));
}
if ($returnUrl === '') {
$returnUrl = site_url('family');
}
return view('family/compose_email', [
'to' => $to,
'name' => $name,
'return_url' => $returnUrl,
]);
}
public function sendComposeEmail()
{
if (!$this->request->is('post')) {
return redirect()->to(site_url('family'));
}
$to = trim((string)$this->request->getPost('to'));
$subject = trim((string)$this->request->getPost('subject'));
$html = (string)($this->request->getPost('html') ?? '');
$returnUrl = trim((string)$this->request->getPost('return_url'));
if ($returnUrl === '' || !$this->isLocalReturnUrl($returnUrl)) {
$returnUrl = site_url('family');
}
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
return redirect()->back()->withInput()->with('error', 'Please enter a valid email address.');
}
if ($subject === '') {
return redirect()->back()->withInput()->with('error', 'Subject is required.');
}
if (trim($html) === '') {
return redirect()->back()->withInput()->with('error', 'Email body is required.');
}
$wrapped = view('emails/custom_html', [
'subject' => $subject,
'body_html' => $html,
]);
$mailer = new \App\Controllers\View\EmailController();
$ok = $mailer->sendEmail($to, $subject, $wrapped, 'communication');
if ($ok) {
return redirect()->to($returnUrl)->with('status', 'Email sent.');
}
return redirect()->to($returnUrl)->with('error', 'Unable to send email.');
}
private function isLocalReturnUrl(string $url): bool
{
$url = trim($url);
if ($url === '') return false;
$parts = parse_url($url);
if ($parts === false) return false;
if (!empty($parts['scheme']) || !empty($parts['host'])) {
$base = parse_url(site_url('/'));
if (!$base || empty($base['host'])) return false;
$hostMatch = strcasecmp((string)($parts['host'] ?? ''), (string)$base['host']) === 0;
return $hostMatch;
}
// Relative URL (e.g., /family?x=1 or family?x=1)
return true;
}
}
@@ -1,477 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use CodeIgniter\Database\BaseConnection;
// MODELS you should already have (or create tiny ones if not)
use App\Models\FamilyModel;
use App\Models\FamilyGuardianModel;
use App\Models\FamilyStudentModel;
// Your app's models
use App\Models\StudentModel;
use App\Models\UserModel;
/**
* FamilyController
*
* Handles creating/linking Family ←→ Guardians (users) ←→ Students,
* including bootstrap from existing schema where students.parent_id is the
* “first parent”, and a second parent may be present as a users row
* or only as an email (stub user creation).
*/
class FamilyController extends BaseController
{
protected FamilyModel $families;
protected FamilyGuardianModel $guardians;
protected FamilyStudentModel $familyStudents;
protected StudentModel $students;
protected UserModel $users;
protected BaseConnection $db;
public function __construct()
{
$this->families = new FamilyModel();
$this->guardians = new FamilyGuardianModel();
$this->familyStudents = new FamilyStudentModel();
$this->students = new StudentModel();
$this->users = new UserModel();
$this->db = \Config\Database::connect();
}
/* =========================================================
* SECTION A — APIs your UI uses
* =======================================================*/
// GET /api/students/{id}/families
public function familiesByStudent(int $studentId)
{
$rows = $this->db->query(
"SELECT f.*, fs.is_primary_home
FROM family_students fs
JOIN families f ON f.id = fs.family_id
WHERE fs.student_id = ? AND f.is_active = 1
ORDER BY fs.is_primary_home DESC, f.household_name",
[$studentId]
)->getResultArray();
return $this->response->setJSON(['data' => $rows]);
}
// GET /api/families/{id}/guardians
public function guardiansByFamily(int $familyId)
{
$rows = $this->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
FROM family_guardians fg
JOIN users u ON u.id = fg.user_id
WHERE fg.family_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
[$familyId]
)->getResultArray();
return $this->response->setJSON(['data' => $rows]);
}
/* =========================================================
* SECTION B — Bootstrap & Linking helpers
* =======================================================*/
// POST /families/bootstrap
// Creates/ensures families for every student with parent_id,
// links the student to that family, and (optionally) links a second parent.
public function bootstrap()
{
// Optionally restrict this to admins
// if (! $this->userCan('families.bootstrap')) return $this->deny();
// Guard: ensure required tables exist
foreach (['families','family_students','family_guardians'] as $t) {
if (! $this->db->tableExists($t)) {
return $this->response->setStatusCode(500)->setJSON([
'status' => 'error',
'message' => "Missing required table '{$t}'. Run migrations: php spark migrate",
]);
}
}
$this->db->transStart();
// 1) Get all distinct primary parents from students.parent_id
$primaryParents = $this->db->query(
"SELECT DISTINCT parent_id FROM students WHERE parent_id IS NOT NULL"
)->getResultArray();
$created = 0;
$linkedStudents = 0;
$linkedGuardians = 0;
foreach ($primaryParents as $row) {
$primaryUserId = (int) $row['parent_id'];
if ($primaryUserId <= 0) continue;
$familyId = $this->ensureFamilyForPrimaryParent($primaryUserId, $created);
// Link all students of this primary parent
$students = $this->db->query(
"SELECT id FROM students WHERE parent_id = ?",
[$primaryUserId]
)->getResultArray();
foreach ($students as $s) {
$linkedStudents += $this->attachStudentToFamily((int)$s['id'], $familyId);
}
// Ensure primary parent is guardian on that family
$linkedGuardians += $this->attachGuardianUser($familyId, $primaryUserId, 'primary', true, 1, 0);
}
$this->db->transComplete();
$payload = [
'status' => $this->db->transStatus() ? 'ok' : 'error',
'families_created' => $created,
'students_linked' => $linkedStudents,
'guardians_linked' => $linkedGuardians,
];
// If accessed via GET (browser), redirect back with flash
if (strtolower($this->request->getMethod()) === 'get') {
$msg = json_encode($payload);
return redirect()->to('/family')->with('message', "Bootstrap result: {$msg}");
}
return $this->response->setJSON($payload);
}
// POST /families/attach-second-by-user
// body: student_id, user_id, relation='secondary'
public function attachSecondByUser()
{
$studentId = (int) $this->request->getPost('student_id');
$userId = (int) $this->request->getPost('user_id');
$relation = (string) ($this->request->getPost('relation') ?? 'secondary');
if (!$studentId || !$userId) return $this->bad('student_id and user_id required');
$familyId = $this->familyIdFromStudentPrimary($studentId);
if (!$familyId) return $this->bad('No primary family found for this student');
$rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
return $this->response->setJSON(['status' => 'ok', 'attached' => $rows, 'family_id' => $familyId]);
}
// POST /families/attach-second-by-email
// body: student_id, email, firstname, lastname, relation='secondary'
// Creates a stub user if email not in users, then links.
public function attachSecondByEmail()
{
$studentId = (int) $this->request->getPost('student_id');
$email = trim((string)$this->request->getPost('email'));
$first = trim((string)$this->request->getPost('firstname'));
$last = trim((string)$this->request->getPost('lastname'));
$relation = (string) ($this->request->getPost('relation') ?? 'secondary');
if (!$studentId || !$email) return $this->bad('student_id and email required');
$familyId = $this->familyIdFromStudentPrimary($studentId);
if (!$familyId) return $this->bad('No primary family found for this student');
$user = $this->users->where('email', $email)->first();
if (!$user) {
// Create a minimal/stub user; adjust fields to your schema
$this->users->insert([
'firstname' => $first ?: '',
'lastname' => $last ?: '',
'email' => $email,
'status' => 'Active',
'user_type' => 'secondary', // align with secondary guardian role
]);
$userId = (int) $this->users->getInsertID();
} else {
$userId = (int) $user['id'];
}
$rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
return $this->response->setJSON(['status' => 'ok', 'attached' => $rows, 'family_id' => $familyId, 'user_id' => $userId]);
}
/* =========================================================
* SECTION C — Maintenance actions
* =======================================================*/
// POST /families/set-primary-home
// body: family_id, student_id, is_primary_home (0/1)
public function setPrimaryHome()
{
$familyId = (int) $this->request->getPost('family_id');
$studentId = (int) $this->request->getPost('student_id');
$flag = (int) $this->request->getPost('is_primary_home');
if (!$familyId || !$studentId) return $this->bad('family_id and student_id required');
$this->familyStudents
->where(['family_id' => $familyId, 'student_id' => $studentId])
->set(['is_primary_home' => $flag ? 1 : 0])
->update();
return $this->response->setJSON(['status' => 'ok']);
}
// POST /families/set-guardian-flags
// body: family_id, user_id, receive_emails(0/1), is_primary(0/1), receive_sms(0/1)
public function setGuardianFlags()
{
$familyId = (int) $this->request->getPost('family_id');
$userId = (int) $this->request->getPost('user_id');
if (!$familyId || !$userId) return $this->bad('family_id and user_id required');
$data = [];
foreach (['receive_emails', 'is_primary', 'receive_sms', 'relation'] as $k) {
if (null !== $this->request->getPost($k)) {
$val = $this->request->getPost($k);
$data[$k] = in_array($k, ['receive_emails', 'is_primary', 'receive_sms'])
? (int)$val
: (string)$val;
}
}
if (!$data) return $this->bad('No flags provided');
$this->guardians->where(['family_id' => $familyId, 'user_id' => $userId])->set($data)->update();
return $this->response->setJSON(['status' => 'ok']);
}
// POST /families/unlink-guardian
// body: family_id, user_id
public function unlinkGuardian()
{
$familyId = (int) $this->request->getPost('family_id');
$userId = (int) $this->request->getPost('user_id');
if (!$familyId || !$userId) return $this->bad('family_id and user_id required');
$this->guardians->where(['family_id' => $familyId, 'user_id' => $userId])->delete();
return $this->response->setJSON(['status' => 'ok']);
}
// POST /families/unlink-student
// body: family_id, student_id
public function unlinkStudent()
{
$familyId = (int) $this->request->getPost('family_id');
$studentId = (int) $this->request->getPost('student_id');
if (!$familyId || !$studentId) return $this->bad('family_id and student_id required');
$this->familyStudents->where(['family_id' => $familyId, 'student_id' => $studentId])->delete();
return $this->response->setJSON(['status' => 'ok']);
}
/* =========================================================
* SECTION D — Private helpers
* =======================================================*/
// Ensure there is exactly one family per primary parent (users.id)
// Returns $familyId and increments $created by reference if newly created.
protected function ensureFamilyForPrimaryParent(int $primaryUserId, int &$createdCounter): int
{
$code = 'FAM-' . $primaryUserId;
$row = $this->families->where('family_code', $code)->first();
if ($row) return (int)$row['id'];
$this->families->insert([
'family_code' => $code,
'household_name' => 'Family of User ' . $primaryUserId,
'is_active' => 1,
]);
$createdCounter++;
return (int)$this->families->getInsertID();
}
// Attach a student to a family (idempotent; returns rows affected >0 if inserted)
protected function attachStudentToFamily(int $studentId, int $familyId): int
{
// INSERT IGNORE pattern
$sql = "INSERT IGNORE INTO family_students (family_id, student_id, is_primary_home)
VALUES (?, ?, 1)";
$this->db->query($sql, [$familyId, $studentId]);
return $this->db->affectedRows();
}
// Attach a guardian user to family (idempotent)
protected function attachGuardianUser(
int $familyId,
int $userId,
string $relation = 'primary',
bool $isPrimary = false,
int $receiveEmails = 1,
int $receiveSms = 0
): int {
$sql = "INSERT IGNORE INTO family_guardians (family_id, user_id, relation, is_primary, receive_emails, receive_sms)
VALUES (?, ?, ?, ?, ?, ?)";
$this->db->query($sql, [$familyId, $userId, $relation, $isPrimary ? 1 : 0, $receiveEmails, $receiveSms]);
return $this->db->affectedRows();
}
// Find the “primary” family for a student = family created from parent_id
protected function familyIdFromStudentPrimary(int $studentId): ?int
{
// 1) Get primary parent id for the student
$st = $this->students->select('parent_id')->find($studentId);
if (!$st || empty($st['parent_id'])) return null;
// 2) The canonical family uses code FAM-{parent_id}
$code = 'FAM-' . (int)$st['parent_id'];
$row = $this->families->select('id')->where('family_code', $code)->first();
if ($row) return (int)$row['id'];
// If not found, fall back to any family linked already
$row = $this->db->query(
"SELECT family_id FROM family_students WHERE student_id = ? ORDER BY is_primary_home DESC LIMIT 1",
[$studentId]
)->getRowArray();
return $row ? (int)$row['family_id'] : null;
}
// Simple 400
protected function bad(string $msg)
{
return $this->response->setStatusCode(400)->setJSON(['status' => 'error', 'message' => $msg]);
}
// Example permission check hook (plug your own logic)
protected function userCan(string $perm): bool
{
$perms = (array) (session('permissions') ?? []);
return in_array($perm, $perms, true);
}
protected function deny()
{
return redirect()->to('/access_denied')->setStatusCode(403);
}
// Legacy import helper: map secondary parents from legacy 'parents' table
public function importSecondParentsFromLegacy()
{
// Protect this route (e.g., admin only) via filter in routes
$L = [
'table' => 'parents',
'firstparent_id' => 'firstparent_id', // users.id of primary (first) parent
'second_user_id' => 'secondparent_id', // users.id of second parent (optional)
'second_email' => 'secondparent_email',
'second_firstname' => 'secondparent_firstname',
'second_lastname' => 'secondparent_lastname',
];
if (!$this->db->tableExists($L['table'])) {
return $this->response->setJSON(['status' => 'error', 'message' => "Legacy table '{$L['table']}' not found"]);
}
foreach (['families','family_students','family_guardians'] as $t) {
if (! $this->db->tableExists($t)) {
return $this->response->setStatusCode(500)->setJSON([
'status' => 'error',
'message' => "Missing required table '{$t}'. Run migrations: php spark migrate",
]);
}
}
$rows = $this->db->table($L['table'])
->select(
"{$L['firstparent_id']} AS first_id, " .
"{$L['second_user_id']} AS second_id, " .
"{$L['second_email']} AS email, " .
"{$L['second_firstname']} AS firstname, " .
"{$L['second_lastname']} AS lastname",
false
)
->groupStart()
->where("{$L['second_user_id']} !=", 0)
->orWhere("{$L['second_email']} !=", '')
->groupEnd()
->get()->getResultArray();
$createdUsers = 0;
$linked = 0;
$skipped = 0;
foreach ($rows as $r) {
$firstId = (int)($r['first_id'] ?? 0);
$secondId = (int)($r['second_id'] ?? 0);
$email = trim((string)($r['email'] ?? ''));
if (!$firstId || (!$secondId && $email === '')) {
$skipped++;
continue;
}
// Ensure/find family by primary parent
$code = 'FAM-' . $firstId;
$fam = $this->families->select('id')->where('family_code', $code)->first();
if ($fam) {
$familyId = (int)$fam['id'];
} else {
$tmp = 0; // counter sink
$familyId = $this->ensureFamilyForPrimaryParent($firstId, $tmp);
}
// Resolve/create user
$userId = 0;
if ($secondId > 0) {
$userId = $secondId;
} else {
$user = $this->users->where('email', $email)->first();
if (!$user) {
$this->users->insert([
'firstname' => $r['firstname'] ?? '',
'lastname' => $r['lastname'] ?? '',
'email' => $email,
'status' => 'Active',
'user_type' => 'secondary',
]);
$userId = (int)$this->users->getInsertID();
$createdUsers++;
} else {
$userId = (int)$user['id'];
}
}
// Ensure all students for this primary parent are linked to this family
$stuRows = $this->db->query("SELECT id FROM students WHERE parent_id = ?", [$firstId])->getResultArray();
foreach ($stuRows as $sr) {
$sid = (int)($sr['id'] ?? 0);
if ($sid > 0) {
$this->attachStudentToFamily($sid, $familyId);
}
}
// Link as guardian (idempotent)
$linked += $this->attachGuardianUser($familyId, $userId, 'guardian', false, 1, 0);
}
$payload = [
'status' => 'ok',
'created_users' => $createdUsers,
'guardians_linked' => $linked,
'skipped' => $skipped,
'total_source' => count($rows),
];
if (strtolower($this->request->getMethod()) === 'get') {
$msg = json_encode($payload);
return redirect()->to('/family')->with('message', "Import legacy result: {$msg}");
}
return $this->response->setJSON($payload);
}
}
@@ -1,183 +0,0 @@
<?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,84 +0,0 @@
<?php
namespace App\Controllers\View;
use CodeIgniter\Controller;
class FrontendController extends Controller
{
public function index()
{
return view('/index');
}
public function facility()
{
log_message('debug', 'FrontendController::facility called');
helper('url'); // Ensure URL helper is loaded
return view('/facility');
}
public function team()
{
log_message('debug', 'FrontendController::team called');
return view('/team');
}
public function callToAction()
{
log_message('debug', 'FrontendController::callToAction called');
return view('/call_to_action');
}
public function testimonial()
{
log_message('debug', 'FrontendController::testimonial called');
return view('/testimonial');
}
public function notFound()
{
log_message('debug', 'FrontendController::notFound called');
return view('/notFound');
}
public function fetchUser()
{
header('Content-Type: application/json');
// Include the shared database connection file
$file = __DIR__ . '/../db_connection.php';
if (file_exists($file)) {
require_once $file;
} else {
die("Error: Could not find the required file '$file'.");
}
// Assuming the logged-in user's information is available in the session
session_start();
if (!isset($_SESSION['user_id'])) {
echo json_encode(['error' => 'User not logged in']);
exit;
}
$userId = $_SESSION['user_id'];
// Fetch user data from the database
$sql = "SELECT firstname, lastname FROM users WHERE id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
$user = $result->fetch_assoc();
if ($user) {
echo json_encode($user);
} else {
echo json_encode(['error' => 'User not found']);
}
$stmt->close();
$conn->close();
}
}
@@ -1,75 +0,0 @@
<?php
namespace App\Controllers\View;
use CodeIgniter\Controller;
class HealthController extends Controller
{
private function checkPath(string $label, string $path): array
{
return [
'label' => $label,
'path' => $path,
'exists' => is_dir($path),
'writable' => is_writable($path),
];
}
public function index()
{
$uploadsBase = rtrim(WRITEPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads';
$paths = [
'uploads' => $uploadsBase,
'uploads/reimbursements' => $uploadsBase . DIRECTORY_SEPARATOR . 'reimbursements',
'uploads/receipts' => $uploadsBase . DIRECTORY_SEPARATOR . 'receipts',
'uploads/checks' => $uploadsBase . DIRECTORY_SEPARATOR . 'checks',
'uploads/early_dismissal_signatures' => $uploadsBase . DIRECTORY_SEPARATOR . 'early_dismissal_signatures',
];
$pathsStatus = [];
foreach ($paths as $label => $p) {
$pathsStatus[] = $this->checkPath($label, $p);
}
$db = \Config\Database::connect();
$dbChecks = [
'user_preferences_exists' => $db->tableExists('user_preferences'),
'settings_exists' => $db->tableExists('settings'),
'migrations_exists' => $db->tableExists('migrations'),
];
$dbChecks['user_preferences_has_timezone'] = $dbChecks['user_preferences_exists']
? $db->fieldExists('timezone', 'user_preferences')
: false;
$dbChecks['settings_has_timezone'] = $dbChecks['settings_exists']
? $db->fieldExists('timezone', 'settings')
: false;
$okPaths = array_reduce($pathsStatus, function ($carry, $row) {
return $carry && $row['exists'] && $row['writable'];
}, true);
$okDb = true;
if ($dbChecks['user_preferences_exists'] && !$dbChecks['user_preferences_has_timezone']) {
$okDb = false;
}
if ($dbChecks['settings_exists'] && !$dbChecks['settings_has_timezone']) {
$okDb = false;
}
$ok = $okPaths && $okDb;
$payload = [
'ok' => $ok,
'paths' => $pathsStatus,
'database' => $dbChecks,
'write_path' => WRITEPATH,
'timestamp' => date('c'),
];
$status = $ok ? 200 : 503;
return $this->response->setStatusCode($status)->setJSON($payload);
}
}
@@ -1,39 +0,0 @@
<?php
namespace App\Controllers\View;
use CodeIgniter\Controller;
use App\Models\UserModel;
use CodeIgniter\Database\Exceptions\DatabaseException;
class InfoIconController extends Controller
{
public function profileIcon()
{
$session = session();
$userId = $session->get('user_id');
if (!$userId) {
return view('errors/html/error_401', ['message' => 'User not logged in']);
}
$userModel = new UserModel();
$user = $userModel->getUserInfoById($userId);
if ($user) {
$data = [
'userName' => $user['firstname'] . ' ' . $user['lastname'],
'userInitials' => strtoupper($user['firstname'][0] . $user['lastname'][0])
];
} else {
$data = [
'userName' => 'User not found',
'userInitials' => '??'
];
}
return view('partials/header', $data);
// return view('dashboard', ['content' => view('partials/user_icon', $data)]);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,148 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\IpAttemptModel;
class IpBanController extends BaseController
{
protected $ipAttemptModel;
public function __construct()
{
$this->ipAttemptModel = new IpAttemptModel();
helper(['url', 'form']);
}
public function index()
{
$now = utc_now();
$status = strtolower(trim((string)($this->request->getGet('status') ?? 'all')));
$builder = $this->ipAttemptModel->orderBy('updated_at', 'DESC');
if ($status === 'active') {
$builder = $builder->where('blocked_until >', $now);
}
$banned = $builder->findAll();
return view('administrator/ip_bans', [
'banned' => $banned,
'status' => $status,
'now' => $now,
]);
}
public function unban()
{
$isAjax = $this->request->isAJAX();
$json = function(array $p, int $code=200){
$p['csrfTokenName'] = csrf_token();
$p['csrfHash'] = csrf_hash();
$p[csrf_token()] = csrf_hash();
return $this->response->setStatusCode($code)->setJSON($p);
};
$id = (int) $this->request->getPost('id');
$ip = trim((string) $this->request->getPost('ip'));
$all = (int) $this->request->getPost('all') === 1;
try {
if ($all) {
// Clear all active bans
$now = utc_now();
$builder = $this->ipAttemptModel->builder();
$builder->where('blocked_until >', $now)
->set(['blocked_until' => null, 'attempts' => 0])
->update();
$cnt = $this->ipAttemptModel->db->affectedRows();
$msg = $cnt . ' IP(s) unbanned.';
return $isAjax ? $json(['ok'=>true,'message'=>$msg,'count'=>$cnt])
: redirect()->to(site_url('administrator/ip_bans'))->with('success', $msg);
}
$row = null;
if ($id > 0) {
$row = $this->ipAttemptModel->find($id);
} elseif ($ip !== '') {
$row = $this->ipAttemptModel->where('ip_address', $ip)->first();
}
if (!$row) {
$msg = 'IP record not found.';
return $isAjax ? $json(['ok'=>false,'message'=>$msg],404)
: redirect()->back()->with('error', $msg);
}
$ok = $this->ipAttemptModel->update($row['id'], [
'blocked_until' => null,
'attempts' => 0,
]);
if (!$ok) {
throw new \RuntimeException('Failed to update record.');
}
$msg = 'IP ' . $row['ip_address'] . ' unbanned.';
return $isAjax ? $json(['ok'=>true,'message'=>$msg])
: redirect()->to(site_url('administrator/ip_bans'))->with('success', $msg);
} catch (\Throwable $e) {
$msg = 'Unable to unban: ' . $e->getMessage();
return $isAjax ? $json(['ok'=>false,'message'=>$msg],500)
: redirect()->back()->with('error', $msg);
}
}
public function banNow()
{
$isAjax = $this->request->isAJAX();
$json = function(array $p, int $code=200){
$p['csrfTokenName'] = csrf_token();
$p['csrfHash'] = csrf_hash();
$p[csrf_token()] = csrf_hash();
return $this->response->setStatusCode($code)->setJSON($p);
};
// Accept both POST and GET params
$id = (int) ($this->request->getPost('id') ?? $this->request->getGet('id') ?? 0);
$ip = trim((string) ($this->request->getPost('ip') ?? $this->request->getGet('ip') ?? ''));
$hours = (int) ($this->request->getPost('hours') ?? $this->request->getGet('hours') ?? 24);
if ($hours <= 0) $hours = 24;
try {
$row = null;
if ($id > 0) {
$row = $this->ipAttemptModel->find($id);
} elseif ($ip !== '') {
$row = $this->ipAttemptModel->where('ip_address', $ip)->first();
}
if (!$row) {
$msg = 'IP record not found.';
return $isAjax ? $json(['ok'=>false,'message'=>$msg],404)
: redirect()->to(site_url('administrator/ip_bans'))->with('error', $msg);
}
$blockedUntil = date('Y-m-d H:i:s', strtotime("+{$hours} hours"));
$ok = $this->ipAttemptModel->update($row['id'], [
'blocked_until' => $blockedUntil,
// Optionally set attempts to threshold for clarity
'attempts' => max((int)$row['attempts'], 10),
]);
if (!$ok) {
throw new \RuntimeException('Failed to update record.');
}
$msg = 'IP ' . $row['ip_address'] . ' banned for ' . $hours . 'h.';
return $isAjax ? $json(['ok'=>true,'message'=>$msg])
: redirect()->to(site_url('administrator/ip_bans'))->with('success', $msg);
} catch (\Throwable $e) {
$msg = 'Unable to ban: ' . $e->getMessage();
return $isAjax ? $json(['ok'=>false,'message'=>$msg],500)
: redirect()->back()->with('error', $msg);
}
}
}
@@ -1,882 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\UserRoleModel;
use App\Models\StudentClassModel;
use App\Models\InvoiceModel;
use App\Models\TeacherClassModel;
use App\Models\ClassSectionModel;
use App\Models\SemesterScoreModel;
use App\Models\ScoreCommentModel;
use App\Models\AttendanceRecordModel;
use App\Models\AttendanceDayModel;
use App\Models\CalendarModel;
use \Config\Database;
use DateTimeImmutable;
use DateTimeZone;
use App\Models\ConfigurationModel;
class LandingPageController extends BaseController
{
protected $classSectionId;
protected $db;
protected $schoolYear;
protected $semester;
protected $configModel;
protected $lastDayOfRegistration;
protected $refundDeadline;
protected $studentClassModel;
protected $invoiceModel;
protected $userRoleModel;
protected $teacherClassModel;
protected $classSectionModel;
protected $semesterScoreModel;
protected $scoreCommentModel;
protected $attendanceRecordModel;
protected $attendanceDayModel;
protected $calendarModel;
public function __construct()
{
$this->db = \Config\Database::connect();
$this->configModel = new ConfigurationModel();
$this->studentClassModel = new StudentClassModel();
$this->invoiceModel = new InvoiceModel();
$this->userRoleModel = new UserRoleModel();
$this->teacherClassModel = new TeacherClassModel();
$this->semesterScoreModel = new SemesterScoreModel();
$this->scoreCommentModel = new ScoreCommentModel();
$this->attendanceRecordModel = new AttendanceRecordModel();
$this->attendanceDayModel = new AttendanceDayModel();
$this->classSectionModel = new ClassSectionModel();
$this->calendarModel = new CalendarModel();
// Fetch Enrollment and Refund Deadlines from Configuration
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline') ?? 'Not set';
$this->refundDeadline = $this->configModel->getConfig('refund_deadline') ?? 'Not set';
// Get class_id and store it in session
$this->classSectionId = $this->classSectionId();
session()->set('class_section_id', $this->classSectionId);
}
public function index()
{
$userRole = $this->getUserRole(); // Assume this function gets the user role
switch (strtolower($userRole)) {
case 'administrator':
return $this->administrator();
case 'admin':
return $this->admin();
case 'teacher':
return $this->teacher();
case 'student':
return $this->student();
case 'parent':
case 'authorized_user':
return $this->parentDashboard();
default:
return $this->guest();
}
}
public function classSectionId()
{
// Get user_id from the session
$user_id = session()->get('user_id');
if (!$user_id) {
return null;
}
// Get all class assignments for this teacher in the current term
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
(int)$user_id,
(string)$this->schoolYear,
(string)$this->semester
);
$ids = array_map(static fn($a) => (int)($a['class_section_id'] ?? 0), $assignments);
$ids = array_values(array_filter(array_unique($ids)));
if (empty($ids)) {
log_message('error', "No class section found for user ID: $user_id");
return null;
}
$current = (int)(session()->get('class_section_id') ?? 0);
$chosen = in_array($current, $ids, true) ? $current : $ids[0];
session()->set([
'class_section_id' => $chosen,
'class_section_ids' => $ids,
]);
return $chosen;
}
public function administrator()
{
return view('administrator/administratordashboard');
}
public function admin()
{
return view('/landing_page/admin_dashboard');
}
public function teacher()
{
$userId = (int)(session()->get('user_id') ?? 0);
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
$userId,
(string)$this->schoolYear,
(string)$this->semester
);
$availableIds = array_map(static fn($a) => (int)($a['class_section_id'] ?? 0), $assignments);
$availableIds = array_values(array_filter(array_unique($availableIds)));
$requestedId = (int)($this->request->getGet('class_section_id') ?? 0);
$activeId = null;
if ($requestedId && in_array($requestedId, $availableIds, true)) {
$activeId = $requestedId;
} elseif (!empty($availableIds)) {
$activeId = in_array((int)$this->classSectionId, $availableIds, true)
? (int)$this->classSectionId
: $availableIds[0];
}
if ($activeId) {
session()->set('class_section_id', $activeId);
}
$activeName = null;
foreach ($assignments as $a) {
if ((int)$a['class_section_id'] === (int)$activeId) {
$activeName = $a['class_section_name'];
break;
}
}
$normalizedSemester = ucfirst(strtolower(trim((string)($this->semester ?? ''))));
$classSectionIds = array_values(array_filter(array_unique(array_map(static fn($a) => (int)($a['class_section_id'] ?? 0), $assignments)), static fn($id) => $id > 0));
$dashboardSummary = $this->buildTeacherDashboardSummary($classSectionIds, $normalizedSemester);
return view('/landing_page/teacher_dashboard', [
'class_section_id' => $activeId,
'active_class_name' => $activeName,
'classes' => $assignments,
'jobs' => $assignments,
'jobCount' => count($assignments),
'scoreSummary' => $dashboardSummary['scoreSummary'],
'commentSummary' => $dashboardSummary['commentSummary'],
'attendanceSummary' => $dashboardSummary['attendanceSummary'],
'deadlineEvents' => $dashboardSummary['deadlineEvents'],
'participationSummary' => $dashboardSummary['participationSummary'],
'attendanceStatus' => $dashboardSummary['attendanceStatus'],
'dashboardNotifications' => $dashboardSummary['notifications'],
'school_year' => (string)$this->schoolYear,
'semester' => (string)$this->semester,
]);
}
private function buildTeacherDashboardSummary(array $classSectionIds, string $semester): array
{
$normalizedSemester = ucfirst(strtolower(trim((string)$semester)));
if ($normalizedSemester === '' || !in_array($normalizedSemester, ['Fall', 'Spring'], true)) {
$normalizedSemester = 'Fall';
}
$scoreField = ($normalizedSemester === 'Spring') ? 'final_exam_score' : 'midterm_exam_score';
$scoreLabel = ($normalizedSemester === 'Spring') ? 'Final Exam' : 'Midterm';
$summary = [
'scoreSummary' => [
'completionPct' => 0,
'missing' => 0,
'expected' => 0,
'filled' => 0,
'fieldLabel' => $scoreLabel,
],
'commentSummary' => [
'total' => 0,
'pendingReview' => 0,
'reviewed' => 0,
'byType' => [],
],
'attendanceSummary' => [
'recorded' => 0,
'students' => 0,
'completionPct' => 0,
'avgAbsences' => 0,
],
'participationSummary' => [
'filled' => 0,
'missing' => 0,
'completionPct' => 0,
'expected' => 0,
],
'deadlineEvents' => [],
];
if (!empty($classSectionIds)) {
$studentRows = $this->db->table('student_class sc')
->select('sc.student_id, sc.class_section_id')
->join('students s', 's.id = sc.student_id', 'inner')
->whereIn('sc.class_section_id', $classSectionIds)
->where('s.is_active', 1)
->where('sc.school_year', (string)$this->schoolYear)
->get()
->getResultArray();
$studentIds = [];
foreach ($studentRows as $row) {
$studentId = (int)($row['student_id'] ?? 0);
$sectionId = (int)($row['class_section_id'] ?? 0);
if ($studentId <= 0 || $sectionId <= 0) {
continue;
}
$studentIds[] = $studentId;
}
$uniqueStudentIds = array_values(array_unique($studentIds));
} else {
$uniqueStudentIds = [];
}
$totalStudents = count($uniqueStudentIds);
$summary['attendanceSummary']['students'] = $totalStudents;
$scoreRows = [];
if (!empty($classSectionIds)) {
$scoreRows = $this->semesterScoreModel
->select('student_id, class_section_id, midterm_exam_score, final_exam_score, participation_score')
->whereIn('class_section_id', $classSectionIds)
->where('semester', $normalizedSemester)
->where('school_year', (string)$this->schoolYear)
->findAll();
}
$scoreTypesConfig = [
'participation' => ['label' => 'Participation', 'field' => 'participation_score'],
];
if ($normalizedSemester === 'Fall') {
$scoreTypesConfig = array_merge([
'midterm' => ['label' => 'Midterm', 'field' => 'midterm_exam_score'],
], $scoreTypesConfig);
}
if ($normalizedSemester === 'Spring') {
$scoreTypesConfig = array_merge([
'final' => ['label' => 'Final exam', 'field' => 'final_exam_score'],
], $scoreTypesConfig);
}
$filledCounts = array_fill_keys(array_keys($scoreTypesConfig), 0);
foreach ($scoreRows as $row) {
foreach ($scoreTypesConfig as $key => $cfg) {
$value = trim((string)($row[$cfg['field']] ?? ''));
if ($value !== '') {
$filledCounts[$key]++;
}
}
}
$expectedScoreCount = $totalStudents > 0 ? $totalStudents : count($scoreRows);
$scoreDetails = [];
foreach ($scoreTypesConfig as $key => $cfg) {
$filled = $filledCounts[$key] ?? 0;
$missing = max(0, $expectedScoreCount - $filled);
$completionPct = $expectedScoreCount > 0 ? (int)round(min(100, ($filled / $expectedScoreCount) * 100)) : 0;
$scoreDetails[$key] = [
'label' => $cfg['label'],
'field' => $cfg['field'],
'filled' => $filled,
'missing' => $missing,
'expected' => $expectedScoreCount,
'completionPct' => $completionPct,
];
}
$scoreTypeByField = [];
if ($normalizedSemester === 'Fall') {
$scoreTypeByField['midterm_exam_score'] = 'midterm';
}
if ($normalizedSemester === 'Spring') {
$scoreTypeByField['final_exam_score'] = 'final';
}
$activeScoreType = $scoreTypeByField[$scoreField] ?? 'midterm';
$activeScoreDetails = $scoreDetails[$activeScoreType] ?? $scoreDetails['midterm'];
$summary['scoreSummary'] = [
'completionPct' => $activeScoreDetails['completionPct'],
'missing' => $activeScoreDetails['missing'],
'expected' => $activeScoreDetails['expected'],
'filled' => $activeScoreDetails['filled'],
'fieldLabel' => $scoreLabel,
'types' => $scoreDetails,
];
$participationMetrics = $scoreDetails['participation'];
$summary['participationSummary'] = [
'filled' => $participationMetrics['filled'],
'missing' => $participationMetrics['missing'],
'completionPct' => $participationMetrics['completionPct'],
'expected' => $participationMetrics['expected'],
];
if (!empty($uniqueStudentIds)) {
$commentRows = $this->scoreCommentModel
->select('student_id, score_type, comment, comment_review')
->whereIn('student_id', $uniqueStudentIds)
->where('semester', $normalizedSemester)
->where('school_year', (string)$this->schoolYear)
->findAll();
} else {
$commentRows = [];
}
$expectedCommentTypes = array_values(array_filter([
'ptap',
$normalizedSemester === 'Fall' ? 'midterm' : null,
$normalizedSemester === 'Spring' ? 'final' : null,
]));
$commentStats = [];
$commentsByStudent = [];
foreach ($commentRows as $row) {
$sid = (int)($row['student_id'] ?? 0);
$type = strtolower(trim((string)($row['score_type'] ?? '')));
if ($type === '') {
$type = 'general';
}
if (!isset($commentStats[$type])) {
$commentStats[$type] = ['pending' => 0, 'reviewed' => 0];
}
$commentText = trim((string)($row['comment'] ?? ''));
$reviewValue = trim((string)($row['comment_review'] ?? ''));
if ($sid > 0 && $commentText !== '') {
$commentsByStudent[$sid][$type] = $commentText;
}
if ($commentText === '') {
continue;
}
if ($reviewValue === '') {
$commentStats[$type]['pending']++;
} else {
$commentStats[$type]['reviewed']++;
}
}
foreach ($expectedCommentTypes as $type) {
if (!isset($commentStats[$type])) {
$commentStats[$type] = ['pending' => 0, 'reviewed' => 0];
}
}
$missingComments = 0;
$missingDetail = [];
foreach ($uniqueStudentIds as $sid) {
foreach ($expectedCommentTypes as $type) {
$text = $commentsByStudent[$sid][$type] ?? '';
if ($text === '') {
$missingDetail[$type] = ($missingDetail[$type] ?? 0) + 1;
$missingComments++;
}
}
}
$commentTypes = [];
foreach ($expectedCommentTypes as $type) {
$stats = $commentStats[$type];
$filled = $stats['pending'] + $stats['reviewed'];
$completionPct = $totalStudents > 0 ? (int)round(min(100, ($filled / $totalStudents) * 100)) : 0;
$commentTypes[$type] = [
'label' => ucfirst($type) . ' comments',
'pending' => $stats['pending'],
'reviewed' => $stats['reviewed'],
'filled' => $filled,
'missing' => $missingDetail[$type] ?? 0,
'expected' => $totalStudents,
'completionPct' => $completionPct,
];
}
$totalPending = array_sum(array_column($commentTypes, 'pending'));
$totalReviewed = array_sum(array_column($commentTypes, 'reviewed'));
$totalFilled = array_sum(array_column($commentTypes, 'filled'));
$totalExpected = array_sum(array_column($commentTypes, 'expected'));
$commentCompletionPct = $totalExpected > 0 ? (int)round(min(100, ($totalFilled / $totalExpected) * 100)) : 0;
$summary['commentSummary'] = [
'total' => $totalFilled,
'pendingReview' => $totalPending,
'reviewed' => $totalReviewed,
'missing' => $missingComments,
'missingDetail' => $missingDetail,
'completionPct' => $commentCompletionPct,
'types' => $commentTypes,
];
$attendanceRows = [];
if (!empty($classSectionIds)) {
$attendanceRows = $this->attendanceRecordModel
->select('student_id, class_section_id, total_absence')
->whereIn('class_section_id', $classSectionIds)
->where('semester', $normalizedSemester)
->where('school_year', (string)$this->schoolYear)
->findAll();
}
$attendanceSet = [];
$totalAbsences = 0;
foreach ($attendanceRows as $record) {
$studentId = (int)($record['student_id'] ?? 0);
$sectionId = (int)($record['class_section_id'] ?? 0);
if ($studentId <= 0 || $sectionId <= 0) {
continue;
}
$key = "{$sectionId}:{$studentId}";
$attendanceSet[$key] = true;
$totalAbsences += (int)($record['total_absence'] ?? 0);
}
$attendanceRecorded = count($attendanceSet);
$attendancePct = $totalStudents > 0 ? (int)round(min(100, ($attendanceRecorded / $totalStudents) * 100)) : 0;
$avgAbsence = $attendanceRecorded > 0 ? round($totalAbsences / $attendanceRecorded, 1) : 0;
$summary['attendanceSummary'] = [
'recorded' => $attendanceRecorded,
'students' => $totalStudents,
'completionPct' => $attendancePct,
'avgAbsences' => $avgAbsence,
];
$timezone = new DateTimeZone(date_default_timezone_get() ?: 'UTC');
$today = (new DateTimeImmutable('now', $timezone))->format('Y-m-d');
$attendanceStatus = $this->buildAttendanceSubmissionStatus($classSectionIds, $normalizedSemester, $today);
$summary['deadlineEvents'] = $this->calendarModel
->where('notify_teacher', 1)
->where('school_year', (string)$this->schoolYear)
->where('semester', $normalizedSemester)
->where('date >=', $today)
->orderBy('date', 'ASC')
->limit(5)
->findAll();
$summary['attendanceStatus'] = $attendanceStatus;
if ($attendanceStatus['submitted'] === false && !empty($classSectionIds)) {
$summary['attendanceSummary']['completionPct'] = 0;
$summary['attendanceSummary']['recorded'] = 0;
}
$summary['notifications'] = $this->buildDashboardNotifications($summary);
return $summary;
}
private function buildDashboardNotifications(array $summary): array
{
$notifications = [];
$baseScoreLink = base_url('/teacher/scores');
$buildScoreLink = static function (?string $focus) use ($baseScoreLink) {
if (empty($focus)) {
return $baseScoreLink;
}
return $baseScoreLink . '?focus=' . urlencode($focus);
};
$determineFocus = static function (array $event): string {
$eventTypeText = strtolower(trim((string)($event['event_type'] ?? '')));
if ($eventTypeText !== '' && str_contains($eventTypeText, 'draft')) {
return 'comments';
}
$text = strtolower(trim(($event['title'] ?? '') . ' ' . ($event['description'] ?? '')));
if ($text === '') {
return 'scores';
}
if (str_contains($text, 'comment') || str_contains($text, 'ptap')) {
return 'comments';
}
return 'scores';
};
$calendarLink = base_url('/teacher/calendar');
$attendanceLink = base_url('/teacher/showupdate_attendance');
$timezone = new DateTimeZone(date_default_timezone_get() ?: 'UTC');
$todayDate = (new DateTimeImmutable('now', $timezone))->setTime(0, 0);
$today = $todayDate->format('Y-m-d');
$calendarNotificationConfig = [
'1st Semester Scores' => 14,
'2nd Semester Scores' => 14,
'Final' => 14,
'Final Exam Draft' => 42,
'Midterm' => 14,
'Midterm Exam Draft' => 42,
];
$activeDeadline = false;
foreach ($summary['deadlineEvents'] ?? [] as $event) {
$eventTypeRaw = trim((string)($event['event_type'] ?? ''));
if ($eventTypeRaw === '') {
continue;
}
$matchedType = null;
$thresholdDays = null;
foreach ($calendarNotificationConfig as $type => $days) {
if (strcasecmp($eventTypeRaw, $type) === 0) {
$matchedType = $type;
$thresholdDays = $days;
break;
}
}
if ($matchedType === null || $thresholdDays === null) {
continue;
}
$rawDate = $event['date'] ?? '';
if ($rawDate === '') {
continue;
}
try {
$eventDateTime = new DateTimeImmutable($rawDate, $timezone);
} catch (\Exception $e) {
continue;
}
$eventDateTime = $eventDateTime->setTime(0, 0);
$daysUntil = (int)$todayDate->diff($eventDateTime)->format('%r%a');
if ($daysUntil <= 0) {
$activeDeadline = true;
$eventFocus = $determineFocus($event);
$notifications[] = [
'type' => 'danger',
'message' => sprintf(
"Deadline today: %s is due. Submit the remaining entries before the day ends.",
$matchedType
),
'link' => $buildScoreLink($eventFocus),
'linkText' => 'Submit now',
];
continue;
}
if ($daysUntil > $thresholdDays) {
continue;
}
$relativeText = $daysUntil === 1 ? ' (tomorrow)' : " (in {$daysUntil} days)";
$notifications[] = [
'type' => 'warning',
'message' => sprintf(
"%s is due on %s%s. Review it on the calendar so you dont miss it.",
$matchedType,
$eventDateTime->format('M j, Y'),
$relativeText
),
'link' => $calendarLink,
'linkText' => 'View deadline',
];
}
if (!$activeDeadline) {
$scoreLabel = $summary['scoreSummary']['fieldLabel'] ?? 'Score';
if (!empty($summary['scoreSummary']['missing'] ?? 0)) {
$notifications[] = [
'type' => 'warning',
'message' => sprintf(
"You have %s %s entries missing on the scores page. Please finish submitting them.",
(int)$summary['scoreSummary']['missing'],
$scoreLabel
),
'link' => $buildScoreLink('scores'),
'linkText' => 'Score sheet',
];
}
foreach (['midterm', 'final', 'ptap'] as $type) {
$count = (int)($summary['commentSummary']['missingDetail'][$type] ?? 0);
if ($count <= 0) {
continue;
}
$notifications[] = [
'type' => 'warning',
'message' => sprintf(
"%s comment missing for %s student(s). Leave comments on the scores page.",
ucfirst($type),
$count
),
'link' => $buildScoreLink('comments'),
'linkText' => ucfirst($type) . ' comments',
];
}
if (!empty($summary['participationSummary']['missing'] ?? 0)) {
$notifications[] = [
'type' => 'warning',
'message' => sprintf(
"Participation entries are still missing for %s student(s). Please add them from the scores page.",
(int)$summary['participationSummary']['missing']
),
'link' => $buildScoreLink('scores'),
'linkText' => 'Participation',
];
}
}
if (!empty($summary['attendanceStatus']['missingSections'] ?? [])) {
$missingNames = $summary['attendanceStatus']['missingNames'] ?? [];
$label = '';
if (!empty($missingNames)) {
$label = ' (' . implode(', ', array_slice($missingNames, 0, 3)) . (count($missingNames) > 3 ? '…' : '') . ')';
}
$notifications[] = [
'type' => 'danger',
'message' => "Today's attendance is still unsubmitted for {$summary['attendanceStatus']['date']}{$label}. Please submit it now.",
'link' => $attendanceLink,
'linkText' => 'Record attendance',
];
}
return $notifications;
}
private function buildAttendanceSubmissionStatus(array $classSectionIds, string $semester, string $date): array
{
if (empty($classSectionIds)) {
return [
'date' => $date,
'missingSections' => [],
'missingNames' => [],
'submitted' => true,
];
}
$rows = $this->attendanceDayModel
->select('class_section_id, status')
->whereIn('class_section_id', $classSectionIds)
->where('date', $date)
->where('semester', $semester)
->where('school_year', (string)$this->schoolYear)
->findAll();
$statusMap = [];
foreach ($rows as $row) {
$csId = (int)($row['class_section_id'] ?? 0);
if ($csId <= 0) {
continue;
}
$statusMap[$csId] = strtolower(trim((string)($row['status'] ?? '')));
}
$missingSections = [];
foreach ($classSectionIds as $classSectionId) {
$status = $statusMap[$classSectionId] ?? '';
if (!in_array($status, ['submitted', 'published', 'finalized'], true)) {
$missingSections[] = $classSectionId;
}
}
$missingNames = [];
if (!empty($missingSections)) {
$sectionRows = $this->classSectionModel
->select('class_section_id, class_section_name')
->whereIn('class_section_id', $missingSections)
->findAll();
foreach ($sectionRows as $row) {
$name = trim((string)($row['class_section_name'] ?? ''));
if ($name === '') {
$name = (string)($row['class_section_id'] ?? '');
}
$missingNames[] = $name;
}
}
return [
'date' => $date,
'missingSections' => $missingSections,
'missingNames' => $missingNames,
'submitted' => empty($missingSections),
];
}
public function student()
{
return view('/landing_page/student_dashboard');
}
public function parentDashboard()
{
$parentId = session()->get('user_id');
$userType = $_SESSION['user_type'];
// Map user type to roles
if ($userType === 'primary') {
// If user type is 'Primary', they are the firstparent
$parentId = $parentId;
} elseif ($userType === 'secondary') {
// If user type is 'Secondary', find the parent_id from the parents table
$parentData = $this->db->table('parents')
->select('parent_id')
->where('secondparent_user_id', $parentId)
->get()
->getRowArray();
if ($parentData) {
$parentId = $parentData['parent_id'];
}
} elseif ($userType === 'tertiary') {
// If user type is 'Tertiary', find the parent_id from the authorized_users table
$authUserData = $this->db->table('authorized_users')
->select('user_id as parent_id')
->where('authorized_user_id', $parentId)
->get()
->getRowArray();
if ($authUserData) {
$parentId = $authUserData['parent_id'];
}
}
// If no firstparent ID is found, show an error or redirect
if (!$parentId) {
return redirect()->back()->with(
'error',
'Unable to retrieve student data. Please contact support.'
);
}
// Fetch Notifications (only active, non-expired, non-deleted)
$notifications = $this->db->table('notifications')
->select([
'notifications.id',
'notifications.title',
'notifications.message',
'notifications.target_group',
'notifications.created_at',
'notifications.expires_at',
'user_notifications.user_id',
"CASE
WHEN user_notifications.user_id IS NOT NULL THEN 'personal'
ELSE 'broadcast'
END as notification_type"
])
->join(
'user_notifications',
'user_notifications.notification_id = notifications.id AND user_notifications.user_id = ' . (int) $parentId,
'left'
)
->groupStart()
->where('notifications.target_group', 'parent')
->orWhere('user_notifications.user_id', $parentId)
->groupEnd()
->where('notifications.deleted_at IS NULL') // Exclude soft-deleted notifications
->groupStart()
->where('notifications.expires_at IS NULL')
->orWhere('notifications.expires_at > NOW()') // Exclude expired
->groupEnd()
->orderBy('notifications.created_at', 'DESC')
->get()
->getResultArray();
// Fetch Student Information (no filtering needed by school year or semester)
$students = $this->db->table('students')
->where('parent_id', $parentId)
->get()
->getResultArray();
foreach ($students as &$student) {
// Get class_section_name and treat it as grade
$classSection = $this->studentClassModel->getClassSectionsByStudentId($student['id'], $this->schoolYear);
$student['class_section'] = $classSection;
$student['grade'] = $classSection; // grade same as section
}
unset($student);
// Fetch Attendance Records (filtered by most recent school year and semester)
$attendanceData = $this->db->table('attendance_data')
->select('attendance_data.*, students.firstname, students.lastname')
->join('students', 'students.id = attendance_data.student_id')
->where('students.parent_id', $parentId)
// ->orWhere('students.secondparent_user_id', $parentId)
->where('attendance_data.school_year', $this->schoolYear)
->where('attendance_data.semester', $this->semester)
->orderBy('attendance_data.date', 'DESC')
->get()
->getResultArray();
// Fetch Grades (filtered by most recent school year and semester)
$grades = $this->db->table('final_score') // Correct table name
->select('final_score.*, students.firstname, students.lastname') // Ensure table name is correct
->join('students', 'students.id = final_score.student_id')
->where('students.parent_id', $parentId)
// ->orWhere('students.secondparent_user_id', $parentId)
->where('final_score.school_year', $this->schoolYear) // Ensure correct table column names
->where('final_score.semester', $this->semester)
->orderBy('final_score.created_at', 'DESC')
->get()
->getResultArray();
// Fetch Enrollments (filtered by most recent school year and semester)
$enrollments = $this->db->table('enrollments')
->select('enrollments.*, classes.class_name')
->join('students', 'students.id = enrollments.student_id')
->join('classes', 'classes.id = enrollments.class_section_id') // Ensure this join is correct
->where('students.parent_id', $parentId)
// ->orWhere('students.secondparent_user_id', $parentId)
->where('enrollments.school_year', $this->schoolYear)
->where('enrollments.semester', $this->semester)
->orderBy('enrollments.enrollment_date', 'DESC')
->get()
->getResultArray();
// Fetch latest invoice balance
$paymentBalance = $this->invoiceModel->getLatestInvoiceTotalAmount($parentId);
// Pass data to the view, including the deadlines
return view('/landing_page/parent_dashboard', [
'notifications' => $notifications,
'students' => $students,
'attendance' => $attendanceData,
'grades' => $grades,
'enrollments' => $enrollments,
'lastDayOfRegistration' => $this->lastDayOfRegistration, // Add the enrollment deadline to the view
'withdrawalDeadline' => $this->refundDeadline, // Add the refund deadline to the view
'paymentBalance' => $paymentBalance, // 🔹 New
]);
}
public function guest()
{
return view('/landing_page/guest_dashboard');
}
protected function getUserRole()
{
// Assuming you have a session variable storing the user role
return session()->get('user_role', 'guest'); // Default to guest if not set
}
protected function getUserRoleFromDatabase($user_id)
{
// Fetching user role from the database
$role = $this->userRoleModel->getRoleByUserId($user_id);
return $role ?? 'guest';
}
}
@@ -1,63 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\LateSlipLogModel;
use App\Models\ConfigurationModel;
class LateSlipLogsController extends BaseController
{
private LateSlipLogModel $logModel;
private ConfigurationModel $configModel;
public function __construct()
{
$this->logModel = new LateSlipLogModel();
$this->configModel = new ConfigurationModel();
}
public function index()
{
$req = $this->request;
$defaultYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$defaultSem = (string) ($this->configModel->getConfig('semester') ?? '');
$schoolYear = trim((string) ($req->getGet('school_year') ?? $defaultYear));
$semester = trim((string) ($req->getGet('semester') ?? $defaultSem));
$q = trim((string) ($req->getGet('q') ?? ''));
$dateFrom = trim((string) ($req->getGet('date_from') ?? ''));
$dateTo = trim((string) ($req->getGet('date_to') ?? ''));
// Normalize dates to Y-m-d for filtering
$toDbDate = static function (?string $s): ?string {
$s = trim((string) $s);
if ($s === '') return null;
$ts = strtotime($s);
return $ts ? date('Y-m-d', $ts) : null;
};
$df = $toDbDate($dateFrom);
$dt = $toDbDate($dateTo);
$model = $this->logModel;
if ($schoolYear !== '') $model = $model->where('school_year', $schoolYear);
if ($semester !== '') $model = $model->where('semester', $semester);
if ($q !== '') $model = $model->like('student_name', $q);
if ($df) $model = $model->where('slip_date >=', $df);
if ($dt) $model = $model->where('slip_date <=', $dt);
// Limit to most recent 200 entries
$logs = $model->orderBy('id', 'DESC')->findAll(200);
return view('administrator/late_slip_logs', [
'schoolYear' => $schoolYear,
'semester' => $semester,
'filters' => [
'q' => $q,
'date_from' => $dateFrom,
'date_to' => $dateTo,
],
'logs' => $logs,
]);
}
}
@@ -1,249 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Models\UserModel;
use App\Models\StudentModel;
use App\Models\TeacherClassModel;
use App\Models\StudentClassModel;
use App\Models\UserRoleModel;
use App\Models\MessageModel;
use App\Controllers\BaseController;
class MessagesController extends BaseController
{
protected $messageModel;
public function __construct()
{
//$this->messageModel = new MessageModel();
}
public function inbox()
{
$userId = session()->get('user_id');
$receivedMessages = $this->messageModel->getInboxMessages($userId);
return view('messages/inbox', [
'receivedMessages' => $receivedMessages
]);
}
public function sent()
{
$userId = session()->get('user_id');
$sentMessages = $this->messageModel->getSentMessages($userId);
return view('messages/sent', [
'sentMessages' => $sentMessages
]);
}
public function drafts()
{
$userId = session()->get('user_id');
$draftMessages = $this->messageModel->getDraftMessages($userId);
return view('messages/drafts', [
'draftMessages' => $draftMessages
]);
}
public function trash()
{
$userId = session()->get('user_id');
$trashedMessages = $this->messageModel->getTrashedMessages($userId);
return view('messages/trash', [
'trashedMessages' => $trashedMessages
]);
}
public function index()
{
$userRoleModel = new UserRoleModel();
//$role = session()->get('role');
$userId = session()->get('user_id');
// Fetch the user role from the user_roles and roles tables
$role = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $userId)
->get()
->getRowArray();
// Fetch received messages based on role
$db = \Config\Database::connect();
$builder = $db->table('messages');
$builder->select('messages.*, users.firstname AS sender_name');
$builder->join('users', 'messages.sender_id = users.id');
$builder->where('messages.recipient_id', $userId);
$query = $builder->get();
$receivedMessages = $query->getResultArray();
return view('/messages', [
'receivedMessages' => $receivedMessages,
'role' => $role['name']
]);
}
public function send()
{
$db = \Config\Database::connect();
$userRoleModel = new UserRoleModel();
//$role = session()->get('role');
$userId = session()->get('user_id');
// Fetch the user role from the user_roles and roles tables
$role = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $userId)
->get()
->getRowArray();
// Handle file upload (if any)
$attachmentPath = null;
$file = $this->request->getFile('attachment');
if ($file && $file->isValid() && !$file->hasMoved()) {
$attachmentPath = $file->store();
}
// Determine recipient based on role or input
$recipientId = $this->getRecipientId($role);
// Prepare data for insertion
$data = [
'sender_id' => $userId,
'recipient_id' => $recipientId,
'subject' => $this->request->getPost('subject'),
'message' => $this->request->getPost('message'),
'sent_datetime' => utc_now(),
'message_number' => $this->generateMessageNumber(),
'priority' => $this->request->getPost('priority') ?: 'normal',
'attachment' => $attachmentPath,
'status' => 'sent'
];
$builder = $db->table('messages');
$builder->insert($data);
return redirect()->to(base_url('messages'))->with('status', 'Message sent successfully');
}
public function receive()
{
$userId = session()->get('user_id');
$db = \Config\Database::connect();
$builder = $db->table('messages');
$builder->select('messages.*, users.firstname AS sender_name');
$builder->join('users', 'messages.sender_id = users.id');
$builder->where('messages.recipient_id', $userId);
$builder->orderBy('sent_datetime', 'DESC');
$query = $builder->get();
$receivedMessages = $query->getResultArray();
// Mark messages as read
foreach ($receivedMessages as $message) {
if ($message['read_status'] == 0) {
$this->markAsRead($message['id']);
}
}
// Assuming $role['name'] contains the role name
//$roleName = strtolower($role['name']); // Convert role name to lowercase if needed
// Construct the view path dynamically
// $viewPath = '/' . $roleName . '/' . $roleName . '_messages';
// Return the view
//return view($viewPath, ['receivedMessages' => $receivedMessages]);
}
private function generateMessageNumber()
{
return 'MSG-' . date('YmdHis');
}
private function markAsRead($messageId)
{
$db = \Config\Database::connect();
$builder = $db->table('messages');
$builder->where('id', $messageId);
$builder->update([
'read_status' => 1,
'read_datetime' => utc_now()
]);
}
private function getRecipientId($role)
{
// You can customize this logic to retrieve recipient_id based on the role or input
switch (strtolower($role['name'])) {
case 'teacher':
return 1; // Example: return teacher's ID
case 'parent':
return 2; // Example: return parent's ID
case 'admin':
return 3; // Example: return admin's ID
case 'student':
return 4; // Example: return student's ID
case 'guest':
return 5; // Example: return guest's ID
case 'administrator':
return 6; // Example: return administrator's ID
default:
throw new \Exception('Invalid role: ' . $role['name']);
}
}
public function getRecipients($type)
{
$recipients = [];
$teacherClassModel = new TeacherClassModel();
$studentClassModel = new StudentClassModel();
$studentsModel = new StudentModel();
$teacherClasses = $teacherClassModel->findAll();
if ($type === 'teacher') {
$userModel = new UserModel(); // Assuming the UserModel is in the App\Models namespace
foreach ($teacherClasses as $teacher) {
$user = $userModel->find($teacher['teacher_id']);
if ($user) {
$recipients[] = [
'id' => $teacher['teacher_id'],
'name' => $user['firstname'] . ' ' . $user['lastname']
];
}
}
} elseif ($type === 'parent') {
foreach ($teacherClasses as $class) {
$students = $studentClassModel
->active()
->where('student_class.class_section_id', $class['class_section_id'])
->findAll();
foreach ($students as $student) {
$studentData = $studentsModel
->where('id', $student['student_id'])
->where('is_active', 1)
->first();
if ($studentData) {
if ($studentData['firstparent']) {
$recipients[] = [
'id' => $studentData['parent_id'],
'name' => $studentData['firstparent']
];
}
if ($studentData['secondparent']) {
$recipients[] = [
'id' => $studentData['secondparent_user_id'],
'name' => $studentData['secondparent']
];
}
}
}
}
}
return $this->response->setJSON($recipients);
}
}
@@ -1,303 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\NavItemModel;
use App\Models\RoleNavItemModel;
use App\Services\NavbarService;
class NavBuilderController extends BaseController
{
protected NavItemModel $items;
protected RoleNavItemModel $maps;
protected NavbarService $service;
public function __construct()
{
$this->items = new NavItemModel();
$this->maps = new RoleNavItemModel();
$this->service = new NavbarService();
}
protected function ensureAdmin(): void
{
$sessionRole = session()->get('role'); // could be a string or array in your app
$roleNames = is_array($sessionRole) ? $sessionRole : [$sessionRole];
$roleNames = array_values(array_filter(array_map('strval', $roleNames)));
if (empty($roleNames)) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$db = \Config\Database::connect();
// Map role names -> ids
$roleIdRows = $db->table('roles')->select('id')->whereIn('name', $roleNames)->get()->getResultArray();
$roleIds = array_map('intval', array_column($roleIdRows, 'id'));
if (empty($roleIds)) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
// Is this route allowed for any of the user's roles?
$allowed = $db->table('role_nav_items AS rni')
->select('1')
->join('nav_items AS ni', 'ni.id = rni.nav_item_id')
->where('ni.url', 'nav-builder') // IMPORTANT: your current route path
->whereIn('rni.role_id', $roleIds)
->get(1)->getFirstRow();
if (!$allowed) {
// You can show a nicer "Access Denied" view if you prefer
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
}
public function index(){
$this->ensureAdmin();
helper(['url', 'form']);
return view('nav_builder/index');
}
/** Case-insensitive, natural sort; ignores leading punctuation & articles (“a/an/the”) */
private function labelKey(string $s): string
{
$s = trim(preg_replace('/\s+/', ' ', $s));
$s = preg_replace('/^[^[:alnum:]]+/u', '', $s); // strip leading punctuation
$s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s); // drop leading articles
return mb_strtolower($s, 'UTF-8');
}
private function sortTreeAlpha(array &$nodes): void
{
usort($nodes, fn($a,$b) => strnatcasecmp(
$this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')
));
foreach ($nodes as &$n) {
if (!empty($n['children'])) {
$this->sortTreeAlpha($n['children']);
}
}
unset($n);
}
public function save()
{
$this->ensureAdmin();
$id = (int) $this->request->getPost('id');
// Accept either "menu_parent_id" or "parent_id" from the form
$menuParentRaw = $this->request->getPost('menu_parent_id');
if ($menuParentRaw === null) {
$menuParentRaw = $this->request->getPost('parent_id');
}
$menuParentId = ($menuParentRaw === '' || $menuParentRaw === null) ? null : (int) $menuParentRaw;
if ($id && $menuParentId === $id) {
// item cannot be its own parent
$menuParentId = null;
}
// Validate parent exists (optional but helpful)
if ($menuParentId !== null) {
$parent = $this->items->select('id')->where('id', $menuParentId)->first();
if (!$parent) {
return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput();
}
}
$data = [
'menu_parent_id' => $menuParentId,
'label' => trim((string) $this->request->getPost('label')),
'url' => trim((string) $this->request->getPost('url')) ?: null,
'icon_class' => trim((string) $this->request->getPost('icon_class')) ?: null,
'target' => trim((string) $this->request->getPost('target')) ?: null,
'sort_order' => (int) $this->request->getPost('sort_order'),
'is_enabled' => (int) ($this->request->getPost('is_enabled') ? 1 : 0),
];
// Save (force insert to return ID)
if ($id) {
$this->items->update($id, $data);
} else {
$id = (int) $this->items->insert($data, true); // ensure insertID is returned
}
// Roles
$roleIds = array_values(array_unique(array_filter(
array_map('intval', (array) ($this->request->getPost('roles') ?? [])),
fn ($v) => $v > 0
)));
$this->maps->where('nav_item_id', $id)->delete();
foreach ($roleIds as $rid) {
$this->maps->insert(['role_id' => $rid, 'nav_item_id' => $id]);
}
$this->service->clearCache();
return redirect()->back()->with('success', 'Menu saved.');
}
public function delete($id)
{
$this->ensureAdmin();
$id = (int) $id;
$this->items->delete($id); // children handled by FK (SET NULL on parent)
$this->service->clearCache();
return redirect()->back()->with('success', 'Menu item deleted.');
}
public function reorder()
{
$this->ensureAdmin();
$orders = $this->request->getPost('orders') ?? [];
foreach ($orders as $id => $order) {
$this->items->update((int) $id, ['sort_order' => (int) $order]);
}
$this->service->clearCache();
return $this->response->setJSON(['ok' => true]);
}
protected function distinctRoles(): array
{
$db = \Config\Database::connect();
return $db->table('roles')
->select('id, name')
->orderBy('name')
->get()
->getResultArray();
}
public function data()
{
$this->ensureAdmin();
return $this->response->setJSON($this->buildNavPayload());
}
private function buildNavPayload(): array
{
$all = $this->items
->orderBy('menu_parent_id', 'ASC')
->orderBy('label', 'ASC')
->orderBy('id', 'ASC')
->findAll();
$byId = [];
foreach ($all as $a) {
$a['children'] = [];
$byId[$a['id']] = $a;
}
$tree = [];
foreach ($byId as $id => &$node) {
$pid = $node['menu_parent_id'] ?: null;
if ($pid && isset($byId[$pid])) {
$byId[$pid]['children'][] = &$node;
} else {
$tree[] = &$node;
}
}
unset($node);
$this->sortTreeAlpha($tree);
$flatAlpha = $all;
usort($flatAlpha, fn($a, $b) => strnatcasecmp(
$this->labelKey($a['label'] ?? ''),
$this->labelKey($b['label'] ?? '')
));
$roleAssignments = [];
$roleRows = $this->maps
->select('role_nav_items.nav_item_id, roles.id AS role_id, roles.name AS role_name')
->join('roles', 'roles.id = role_nav_items.role_id', 'left')
->findAll();
foreach ($roleRows as $row) {
$navId = (int) ($row['nav_item_id'] ?? 0);
if ($navId <= 0) continue;
$roleId = (int) ($row['role_id'] ?? 0);
$roleName = $row['role_name'] ?? ($roleId ? ('#' . $roleId) : null);
if ($roleId > 0) {
$roleAssignments[$navId]['ids'][] = $roleId;
}
if ($roleName !== null) {
$roleAssignments[$navId]['names'][] = $roleName;
}
}
$flattened = $this->flattenTreeForResponse($tree, $roleAssignments);
$parentOptions = array_map(static function ($row) {
return [
'id' => (int) ($row['id'] ?? 0),
'label' => (string) ($row['label'] ?? ''),
];
}, $flatAlpha);
$roles = array_map(static function ($role) {
return [
'id' => (int) ($role['id'] ?? 0),
'name' => (string) ($role['name'] ?? ''),
];
}, $this->distinctRoles());
return [
'items' => $flattened,
'roles' => $roles,
'parentOptions' => $parentOptions,
];
}
private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array
{
foreach ($nodes as $node) {
$raw = $node;
unset($raw['children']);
$navId = (int) ($raw['id'] ?? 0);
$roles = $roleAssignments[$navId] ?? ['ids' => [], 'names' => []];
$rows[] = [
'id' => $navId,
'label' => (string) ($raw['label'] ?? ''),
'url' => $raw['url'] ?? null,
'parent_label' => $parentLabel ?? '—',
'parent_id' => isset($raw['menu_parent_id']) && (int) $raw['menu_parent_id'] !== 0
? (int) $raw['menu_parent_id']
: null,
'order' => (int) ($raw['sort_order'] ?? 0),
'enabled' => (int) ($raw['is_enabled'] ?? 0) === 1,
'target' => $raw['target'] ?? null,
'depth' => $depth,
'roles' => [
'ids' => array_values(array_unique($roles['ids'] ?? [])),
'names' => array_values(array_unique($roles['names'] ?? [])),
],
'raw' => $raw,
];
if (!empty($node['children'])) {
$this->flattenTreeForResponse(
$node['children'],
$roleAssignments,
(string) ($raw['label'] ?? ''),
$depth + 1,
$rows
);
}
}
return $rows;
}
}
@@ -1,66 +0,0 @@
<?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();
}
}
@@ -1,39 +0,0 @@
<?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,213 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Controllers\View\EmailController;
use App\Models\NotificationModel;
use App\Models\UserNotificationModel;
use App\Models\UserModel;
class NotificationsController extends BaseController
{
protected $notificationModel;
protected $userNotificationModel;
protected $userModel;
public function __construct()
{
$this->notificationModel = new NotificationModel();
$this->userNotificationModel = new UserNotificationModel();
$this->userModel = new UserModel();
}
public function index()
{
$userId = session()->get('user_id');
$notifications = $this->userNotificationModel
->select('notifications.*, user_notifications.is_read')
->join('notifications', 'notifications.id = user_notifications.notification_id')
->where('user_notifications.user_id', $userId)
->orderBy('notifications.created_at', 'DESC')
->findAll();
return view('notifications/index', ['notifications' => $notifications]);
}
public function create()
{
return view('notifications/create');
}
public function send()
{
$title = $this->request->getPost('title');
$message = $this->request->getPost('message');
$group = $this->request->getPost('target_group');
$channels = $this->request->getPost('channels'); // ['in_app', 'email', 'sms']
$notifId = $this->notificationModel->insert([
'title' => $title,
'message' => $message,
'target_group' => $group,
'delivery_channels' => implode(',', $channels),
'created_at' => utc_now()
]);
$users = $this->userModel->where('role', $group)->findAll();
foreach ($users as $user) {
$this->userNotificationModel->insert([
'notification_id' => $notifId,
'user_id' => $user['id'],
'is_read' => false
]);
if (in_array('email', $channels)) {
$this->sendEmail($user['email'], $title, $message);
}
if (in_array('sms', $channels) && !empty($user['phone'])) {
$this->sendSMS($user['phone'], $message);
}
}
return redirect()->back()->with('success', 'Notification sent successfully.');
}
public function markAsRead($id)
{
$userId = session()->get('user_id');
$this->userNotificationModel->where('notification_id', $id)
->where('user_id', $userId)
->set(['is_read' => 1])
->update();
return redirect()->back();
}
public function listActive()
{
helper('url');
$targetGroup = $this->request->getGet('target_group');
return view('notifications/list_active', [
'notificationsEndpoint' => site_url('api/notifications/active'),
'defaultTargetGroup' => $targetGroup,
]);
}
public function listDeleted()
{
helper(['url', 'form']);
return view('notifications/list_deleted', [
'deletedNotificationsEndpoint' => site_url('api/notifications/deleted'),
'restoreEndpoint' => site_url('notifications/restore'),
'csrfTokenName' => csrf_token(),
'csrfTokenValue' => csrf_hash(),
]);
}
public function activeNotificationsData()
{
$targetGroup = $this->request->getGet('target_group');
return $this->response->setJSON($this->buildActiveNotificationsPayload($targetGroup));
}
public function deletedNotificationsData()
{
return $this->response->setJSON($this->buildDeletedNotificationsPayload());
}
public function restore($id)
{
if ($this->notificationModel->restoreNotification($id)) {
return redirect()->to(base_url('notifications/deleted'))->with('success', 'Notification restored successfully.');
}
return redirect()->to(base_url('notifications/deleted'))->with('error', 'Failed to restore notification.');
}
protected function sendEmail($to, $subject, $message)
{
$mailer = new EmailController();
$mailer->sendEmail($to, $subject, $message);
}
protected function sendSMS($phone, $message)
{
log_message('info', "SMS to {$phone}: {$message}");
}
private function buildActiveNotificationsPayload(?string $targetGroup): array
{
$targetGroup = $targetGroup !== null ? trim($targetGroup) : null;
if ($targetGroup === '') {
$targetGroup = null;
}
$rows = $this->notificationModel->getActiveNotifications($targetGroup);
if (!is_array($rows)) {
$rows = [];
}
$now = time();
$notifications = array_map(static function ($row) use ($now) {
if (!is_array($row)) {
return [];
}
$expiresAt = $row['expires_at'] ?? null;
$expiryTs = $expiresAt ? strtotime((string) $expiresAt) : false;
$isExpired = $expiryTs !== false && $expiryTs <= $now;
return [
'id' => $row['id'] ?? null,
'title' => $row['title'] ?? null,
'message' => $row['message'] ?? null,
'priority' => $row['priority'] ?? null,
'scheduled_at' => $row['scheduled_at'] ?? null,
'expires_at' => $expiresAt,
'created_at' => $row['created_at'] ?? null,
'target_group' => $row['target_group'] ?? null,
'isExpired' => $isExpired,
];
}, $rows);
return [
'notifications' => $notifications,
];
}
private function buildDeletedNotificationsPayload(): array
{
$rows = $this->notificationModel->getDeletedNotifications();
if (!is_array($rows)) {
$rows = [];
}
$notifications = array_map(static function ($row) {
if (!is_array($row)) {
return [];
}
return [
'id' => $row['id'] ?? null,
'title' => $row['title'] ?? null,
'message' => $row['message'] ?? null,
'priority' => $row['priority'] ?? null,
'scheduled_at' => $row['scheduled_at'] ?? null,
'expires_at' => $row['expires_at'] ?? null,
'deleted_at' => $row['deleted_at'] ?? null,
];
}, $rows);
return [
'notifications' => $notifications,
];
}
}
@@ -1,64 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Models\ContactUsModel;
use App\Controllers\BaseController;
class PageController extends BaseController
{
public function privacyPolicy()
{
return $this->response->setBody(file_get_contents(ROOTPATH . 'public/html/privacy_policy.html'))
->setContentType('text/html');
}
public function termsOfService()
{
return $this->response->setBody(file_get_contents(ROOTPATH . 'public/html/terms_of_service.html'))
->setContentType('text/html');
}
public function helpCenter()
{
return $this->response->setBody(file_get_contents(ROOTPATH . 'public/html/help_center.html'))
->setContentType('text/html');
}
public function submitContactForm()
{
try {
$email = strtolower($this->request->getPost('email'));
$message = $this->request->getPost('message');
// Save the message to the database
$contactModel = new ContactUsModel();
$contactData = [
'email' => $email,
'message' => $message,
];
if (!$contactModel->save($contactData)) {
log_message('error', 'Failed to save contact data: ' . implode(', ', $contactModel->errors()));
return redirect()->back()->with('error', 'Failed to save message. Please try again.');
}
// Prepare the email content
$recipient = 'alrahma.isgl@gmail.com'; // Change to the communication head's email
$subject = 'Contact Us Form Submission';
$emailMessage = "You have received a new message from $email:\n\n$message";
// Use EmailController to send the email
$emailController = new \App\Controllers\View\EmailController();
if (!$emailController->sendEmail($recipient, $subject, $emailMessage)) {
log_message('error', 'Failed to send email.');
return redirect()->back()->with('error', 'Failed to send message. Please try again.');
}
return redirect()->to('/thank_you')->with('success', 'Message sent successfully.');
} catch (\Exception $e) {
log_message('error', 'Exception occurred: ' . $e->getMessage());
return redirect()->back()->with('error', 'An unexpected error occurred. Please try again.');
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,31 +0,0 @@
<?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,36 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use CodeIgniter\HTTP\ResponseInterface;
helper('document');
class PolicyController extends BaseController
{
public function index()
{
//
}
public function schoolPicturePolicy()
{
// FIXED path: correct spelling of "policy"
$content = include(APPPATH . 'Views/policy/picture_policy_partial.php');
// Pass the content to the view
return view('/policy/picture_policy', $content);
}
public function schoolPolicy()
{
// FIXED path: correct spelling of "policy"
$content = include(APPPATH . 'Views/policy/school_policy_partial.php');
return view('policy/school_policy', $content);
}
}
@@ -1,144 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\PreferencesModel;
class PreferencesController extends BaseController
{
protected $preferencesModel;
public function __construct()
{
$this->preferencesModel = new PreferencesModel();
}
/**
* Display preferences page
* GET /preferences/{userId}
*/
public function index($userId = null)
{
// Get user ID from parameter or session
$userId = $userId ?? (int) session()->get('user_id');
if (!$userId) {
return redirect()->to('/login')->with('error', 'Please log in to view preferences');
}
// Fetch preferences for the current user
$preferences = $this->preferencesModel->where('user_id', $userId)->first();
// If preferences do not exist, set default preferences
if (!$preferences) {
$preferences = [
'receive_email_notifications' => 1,
'receive_sms_notifications' => 1,
'theme' => 'light',
'language' => 'en',
];
}
// Append style/menu selections from session or defaults
$styleConfig = config('Style');
$preferences['style_color'] = $preferences['style_color'] ?? (session()->get('style_color') ?? ($styleConfig->defaultStyle ?? 'blue'));
$preferences['menu_color'] = $preferences['menu_color'] ?? (session()->get('menu_color') ?? ($styleConfig->defaultMenu ?? 'white'));
// Options for selectors
$styleOptions = array_keys($styleConfig->stylePalettes ?? []);
$menuOptions = array_keys($styleConfig->menuPalettes ?? []);
// Load the view with the user's preferences
return view('/preferences', [
'preferences' => $preferences,
'styleOptions' => $styleOptions,
'menuOptions' => $menuOptions,
'userId' => $userId,
]);
}
/**
* Update preferences
* POST /preferences/update/{userId}
*/
public function updatePreferences($userId = null)
{
// Get user ID from parameter or session
$userId = $userId ?? (int) session()->get('user_id');
if (!$userId) {
return redirect()->to('/login')->with('error', 'Please log in to update preferences');
}
// Validation rules
$validation = \Config\Services::validation();
$styleConfig = config('Style');
$styleOptions = implode(',', array_keys($styleConfig->stylePalettes ?? []));
$menuOptions = implode(',', array_keys($styleConfig->menuPalettes ?? []));
$validation->setRules([
'receive_email_notifications' => 'permit_empty|in_list[0,1]',
'receive_sms_notifications' => 'permit_empty|in_list[0,1]',
'theme' => 'permit_empty|in_list[light,dark]',
'language' => 'permit_empty|in_list[en,fr,es]',
'style_color' => $styleOptions ? 'permit_empty|in_list[' . $styleOptions . ']' : 'permit_empty',
'menu_color' => $menuOptions ? 'permit_empty|in_list[' . $menuOptions . ']' : 'permit_empty',
]);
if (!$validation->run($this->request->getPost())) {
$preferences = $this->preferencesModel->where('user_id', $userId)->first();
if (!$preferences) {
$preferences = [
'receive_email_notifications' => 1,
'receive_sms_notifications' => 1,
'theme' => 'light',
'language' => 'en',
];
}
$preferences['style_color'] = $this->request->getPost('style_color') ?? ($preferences['style_color'] ?? (session()->get('style_color') ?? ($styleConfig->defaultStyle ?? 'blue')));
$preferences['menu_color'] = $this->request->getPost('menu_color') ?? ($preferences['menu_color'] ?? (session()->get('menu_color') ?? ($styleConfig->defaultMenu ?? 'white')));
return view('/preferences', [
'preferences' => $preferences,
'styleOptions' => array_keys($styleConfig->stylePalettes ?? []),
'menuOptions' => array_keys($styleConfig->menuPalettes ?? []),
'validation' => $validation,
'userId' => $userId,
]);
}
// Prepare updated data (map form fields to database fields)
$updateData = [
'receive_email_notifications' => $this->request->getPost('receive_email_notifications') ?? $this->request->getPost('notification_email') ?? 1,
'receive_sms_notifications' => $this->request->getPost('receive_sms_notifications') ?? $this->request->getPost('notification_sms') ?? 1,
'theme' => $this->request->getPost('theme') ?? 'light',
'language' => $this->request->getPost('language') ?? 'en',
];
// Store style/menu selections in session (no DB column dependency)
$styleColor = $this->request->getPost('style_color');
$menuColor = $this->request->getPost('menu_color');
if ($styleColor && isset(($styleConfig->stylePalettes ?? [])[$styleColor])) {
session()->set('style_color', $styleColor);
$updateData['style_color'] = $styleColor;
}
if ($menuColor && isset(($styleConfig->menuPalettes ?? [])[$menuColor])) {
session()->set('menu_color', $menuColor);
$updateData['menu_color'] = $menuColor;
}
// Check if preferences already exist, update if they do, otherwise insert new preferences
$existing = $this->preferencesModel->where('user_id', $userId)->first();
if ($existing) {
$this->preferencesModel->update($existing['id'], $updateData);
} else {
$updateData['user_id'] = $userId;
$this->preferencesModel->insert($updateData);
}
// Redirect back to preferences page with success message
return redirect()->to('/preferences/' . $userId)->with('success', 'Preferences updated successfully');
}
}
@@ -1,120 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\AttendanceRecordModel;
use App\Models\BadgePrintLogModel;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\ScoreCommentModel;
use App\Models\SemesterScoreModel;
use App\Models\StaffModel;
use App\Models\StudentClassModel;
use App\Models\StudentModel;
use App\Models\TeacherClassModel;
use App\Models\UserModel;
require_once APPPATH . 'ThirdParty/fpdf/fpdf.php';
class PrintablesBaseController extends BaseController
{
protected $userModel;
protected $studentModel;
protected $db;
protected $classSectionModel;
protected $configModel;
protected $schoolYear;
protected $semester;
protected $staffModel;
protected $teacherClassModel;
protected $semesterScoreModel;
protected $studentClassModel;
protected $stickerWidth;
protected $stickerHeight;
protected $pageW;
protected $pageH;
protected $marginL;
protected $marginT;
protected $gapX;
protected $gapY;
protected $badgePrintLogModel;
protected $scoreCommentModel;
protected $attendanceRecordModel;
public function __construct()
{
$this->db = \Config\Database::connect();
$this->userModel = new UserModel();
$this->studentModel = new StudentModel();
$this->classSectionModel = new ClassSectionModel();
$this->configModel = new ConfigurationModel();
$this->semesterScoreModel = new SemesterScoreModel();
$this->studentClassModel = new StudentClassModel();
$this->teacherClassModel = new TeacherClassModel();
$this->staffModel = new StaffModel();
$this->badgePrintLogModel = new BadgePrintLogModel();
$this->scoreCommentModel = new ScoreCommentModel();
$this->attendanceRecordModel = new AttendanceRecordModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->stickerWidth = $this->configModel->getConfig('stickerWidth');
$this->stickerHeight = $this->configModel->getConfig('stickerHeight');
$this->pageW = $this->configModel->getConfig('pageWidth');
$this->pageH = $this->configModel->getConfig('pageHeight');
$this->marginL = $this->configModel->getConfig('leftMargin');
$this->marginT = $this->configModel->getConfig('topMargin');
$this->gapX = $this->configModel->getConfig('horizontalGap');
$this->gapY = $this->configModel->getConfig('verticalGap');
}
protected function firstExisting(array $paths)
{
foreach ($paths as $p) {
if (is_string($p) && file_exists($p)) {
return $p;
}
}
return null;
}
protected function resolveClassName($db, $classId)
{
$tables = [
['classSection', 'class_section_id'],
['classSection', 'id'],
['class_sections', 'class_section_id'],
['class_sections', 'id'],
];
foreach ($tables as [$table, $pk]) {
$result = $db->table($table)
->select('class_section_name')
->where($pk, $classId)
->get()
->getRowArray();
if ($result) {
return $result['class_section_name'] ?? null;
}
}
return null;
}
protected function fetchStudentsByClass(int $sectionId, ?string $schoolYear)
{
$b = $this->studentClassModel
->select('s.id, s.firstname, s.lastname, s.registration_grade, s.gender')
->join('students s', 's.id = student_class.student_id', 'inner')
->where('student_class.class_section_id', $sectionId)
->orderBy('s.lastname', 'ASC');
if (!empty($schoolYear)) {
$b->where('student_class.school_year', $schoolYear);
}
return $b->findAll();
}
}
@@ -1,227 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\PurchaseOrderModel;
use App\Models\PurchaseOrderItemModel;
use App\Models\SupplierModel;
use App\Models\SupplyModel;
use App\Models\SupplyTransactionModel;
class PurchaseOrderController extends BaseController
{
protected $poModel;
protected $itemModel;
protected $supplierModel;
protected $supplyModel;
protected $txnModel;
protected $db;
public function __construct()
{
$this->poModel = new PurchaseOrderModel();
$this->itemModel = new PurchaseOrderItemModel();
$this->supplierModel = new SupplierModel();
$this->supplyModel = new SupplyModel();
$this->txnModel = new SupplyTransactionModel();
$this->db = \Config\Database::connect();
}
public function index()
{
$q = trim($this->request->getGet('q') ?? '');
$builder = $this->poModel->select('purchase_orders.*, suppliers.name AS supplier_name')
->join('suppliers', 'suppliers.id = purchase_orders.supplier_id', 'left');
if ($q !== '') {
$builder->groupStart()
->like('po_number', $q)
->orLike('suppliers.name', $q)
->groupEnd();
}
$orders = $builder->orderBy('purchase_orders.created_at', 'DESC')->paginate(20);
return view('inventory/po_index', [
'orders' => $orders,
'pager' => $this->poModel->pager,
'q' => $q,
]);
}
public function create()
{
return view('inventory/po_form', [
'title' => 'Create Purchase Order',
'action' => site_url('inventory/po/store'),
'suppliers' => $this->supplierModel->orderBy('name')->findAll(),
'supplies' => $this->supplyModel->orderBy('name')->findAll(),
]);
}
public function store()
{
$po = $this->request->getPost([
'po_number','supplier_id','order_date','expected_date','notes'
]);
$status = $this->request->getPost('status') ?? 'ordered';
$supply_ids = $this->request->getPost('item_supply_id') ?? [];
$descs = $this->request->getPost('item_description') ?? [];
$qtys = $this->request->getPost('item_quantity') ?? [];
$unit_costs = $this->request->getPost('item_unit_cost') ?? [];
if (empty($supply_ids)) {
return redirect()->back()->withInput()->with('error', 'Add at least one line item.');
}
// compute totals
$subtotal = 0.0;
$items = [];
foreach ($supply_ids as $i => $sid) {
$q = max(0, (int)($qtys[$i] ?? 0));
$uc = (float)($unit_costs[$i] ?? 0);
if ($sid && $q > 0) {
$line = $q * $uc;
$subtotal += $line;
$items[] = [
'supply_id' => (int)$sid,
'description' => trim($descs[$i] ?? ''),
'quantity' => $q,
'unit_cost' => $uc,
'received_qty'=> 0,
];
}
}
if (!$items) {
return redirect()->back()->withInput()->with('error', 'Valid line items required.');
}
$tax = 0.00;
$total = $subtotal + $tax;
$this->db->transStart();
$po['status'] = in_array($status, ['draft','ordered'], true) ? $status : 'ordered';
$po['subtotal'] = $subtotal;
$po['tax'] = $tax;
$po['total'] = $total;
if (!$this->poModel->save($po)) {
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', implode("\n", $this->poModel->errors()));
}
$poId = $this->poModel->getInsertID();
foreach ($items as $it) {
$it['purchase_order_id'] = $poId;
if (!$this->itemModel->insert($it)) {
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', 'Failed to save line items.');
}
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return redirect()->back()->withInput()->with('error', 'Failed to save purchase order.');
}
return redirect()->to(site_url('inventory/po/show/'.$poId))->with('success', 'PO created.');
}
public function show($id)
{
$po = $this->poModel->select('purchase_orders.*, suppliers.name AS supplier_name')
->join('suppliers', 'suppliers.id = purchase_orders.supplier_id', 'left')
->find($id);
if (!$po) return redirect()->to('inventory/po')->with('error', 'PO not found.');
$items = $this->itemModel->select('purchase_order_items.*, supplies.name AS supply_name, supplies.unit as supply_unit')
->join('supplies', 'supplies.id = purchase_order_items.supply_id', 'left')
->where('purchase_order_id', $id)->findAll();
return view('inventory/po_show', [
'po' => $po,
'items' => $items,
]);
}
/**
* Receive items (partial or full)
* POST body: received[item_id] = qty_to_receive
*/
public function receive($id)
{
$po = $this->poModel->find($id);
if (!$po || in_array($po['status'], ['canceled','received'], true)) {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'PO not receivable.');
}
$received = $this->request->getPost('received') ?? []; // [itemId => qty]
if (!$received) {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'No items to receive.');
}
$issuedBy = (string) (session('user.email') ?? session('user.username') ?? 'system');
$this->db->transStart();
$completed = true;
foreach ($received as $itemId => $qty) {
$qty = (int)$qty;
if ($qty <= 0) continue;
$item = $this->itemModel->where('purchase_order_id', $id)->find($itemId);
if (!$item) { $completed = false; continue; }
$remaining = (int)$item['quantity'] - (int)$item['received_qty'];
$toReceive = min($remaining, $qty);
if ($toReceive <= 0) continue;
// Update item received qty
$this->itemModel->update($itemId, [
'received_qty' => (int)$item['received_qty'] + $toReceive
]);
// Update supply on hand
$supply = $this->supplyModel->find($item['supply_id']);
if (!$supply) { $completed = false; continue; }
$newQty = (int)$supply['qty_on_hand'] + $toReceive;
$this->supplyModel->update($supply['id'], ['qty_on_hand' => $newQty]);
// Log transaction IN
$this->txnModel->insert([
'supply_id' => $supply['id'],
'type' => 'in',
'quantity' => $toReceive,
'ref' => 'PO ' . $po['po_number'],
'issued_to' => 'Inventory',
'issued_by' => $issuedBy,
'notes' => 'Received against PO',
]);
if (($item['received_qty'] + $toReceive) < $item['quantity']) {
$completed = false;
}
}
// Set PO status
$this->poModel->update($id, ['status' => $completed ? 'received' : 'ordered']);
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Failed to receive items.');
}
return redirect()->to('inventory/po/show/'.$id)->with('success', $completed ? 'PO fully received.' : 'PO partially received.');
}
public function cancel($id)
{
$po = $this->poModel->find($id);
if (!$po || $po['status'] === 'received') {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Cannot cancel this PO.');
}
$this->poModel->update($id, ['status' => 'canceled']);
return redirect()->to('inventory/po/show/'.$id)->with('success', 'PO canceled.');
}
}
@@ -1,71 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\UserModel;
use App\Models\StudentModel;
use CodeIgniter\I18n\Time;
class RFIDController extends BaseController
{
public function process()
{
$rfidTag = $this->request->getPost('rfid');
// Load the user model
$userModel = new UserModel();
// Find the user by RFID tag
$user = $userModel->where('rfid_tag', $rfidTag)->first();
if ($user) {
// Log the scan (optional: store in a 'scan_log' table)
$db = \Config\Database::connect();
$db->table('scan_log')->insert([
'user_id' => $user->id,
'card_id' => $rfidTag,
'scan_time' => Time::now(),
]);
// Redirect with success message
return redirect()->to('/rfid/log')->with('message', 'RFID recognized: ' . $user->name);
} else {
// Redirect with error message if RFID not found
return redirect()->to('/')->with('error', 'RFID tag not recognized.');
}
}
public function log()
{
$db = \Config\Database::connect();
// Retrieve scan logs and join with users table to display user names
$query = $db->table('scan_log')
->select('users.firstname as user_firstname, users.lastname as user_lastname, students.firstname as student_firstname, students.lastname as student_lastname, scan_log.card_id, scan_log.scan_time')
->join('users', 'users.id = scan_log.user_id', 'left')
->join('students', 'students.rfid_tag = scan_log.card_id', 'left') // Join students table by RFID tag
->orderBy('scan_time', 'DESC')
->get();
// Fetch user and student data for display purposes
$userModel = new UserModel();
$studentModel = new StudentModel();
// Optional: Fetch all users and students (if needed elsewhere in the view)
$users = $userModel->findAll();
$students = $studentModel->findAll();
// Pass scan logs, users, and students data to the view
$data['logs'] = $query->getResult(); // Logs with user and student info
$data['users'] = $users; // All users data
$data['students'] = $students; // All students data
return view('/rfid/rfid_coming_soon', $data);
}
public function rfidComingSoon()
{
return view('rfid/rfid_coming_soon');
}
}
@@ -1,785 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\RefundModel;
use App\Models\UserModel;
use App\Models\PaymentModel;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\EnrollmentModel;
class RefundController extends BaseController
{
protected RefundModel $refundModel;
protected UserModel $userModel;
protected PaymentModel $paymentModel;
protected ConfigurationModel $configModel;
protected InvoiceModel $invoiceModel;
protected EnrollmentModel $enrollmentModel;
protected $db;
// Allowed request types (mapped to your `refunds.request` column)
private const REQUEST_TYPES = ['tuition','overpayment','duplicate','extra'];
// Allowed finalization decisions
private const DECISIONS = ['Approved','Rejected'];
public function __construct()
{
$this->refundModel = new RefundModel();
$this->userModel = new UserModel();
$this->paymentModel = new PaymentModel();
$this->configModel = new ConfigurationModel();
$this->invoiceModel = new InvoiceModel();
$this->enrollmentModel = new EnrollmentModel();
$this->db = \Config\Database::connect();
}
/** Get current term (school_year, semester) from configuration */
private function getCurrentTerm(): array
{
$rows = $this->configModel
->select('config_key, config_value')
->whereIn('config_key', ['school_year','semester'])
->findAll();
$map = [];
foreach ($rows as $r) {
$map[$r['config_key']] = $r['config_value'];
}
return [
'school_year' => $map['school_year'] ?? date('Y') . '-' . (date('Y') + 1),
'semester' => $map['semester'] ?? 'Fall',
];
}
/**
* Detect overpayments per parent for current school_year and create/update Pending refunds.
* When $triggerEvents is true, emits refundPending for newly created rows only.
*
* @return array [created_ids => int[], updated_ids => int[]]
*/
private function recalcOverpayments(bool $triggerEvents = false): array
{
$created = [];
$updated = [];
$term = $this->getCurrentTerm();
$year = (string)$term['school_year'];
$sem = (string)$term['semester'];
// Sum payments by parent for the term
$pm = $this->paymentModel;
$payTable = $pm->table;
$hasStatus = $this->paymentModel->db->fieldExists('status', $payTable);
$hasVoid = $this->paymentModel->db->fieldExists('is_void', $payTable);
$qbPaid = $pm->select('parent_id, COALESCE(SUM(paid_amount),0) AS total_paid')
->where('school_year', $year)
->groupBy('parent_id');
if ($hasStatus) {
$qbPaid->groupStart()
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($hasVoid) {
$qbPaid->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$paidRows = $qbPaid->findAll();
// Sum invoices (charges) by parent for the term
$invRows = $this->invoiceModel
->select('parent_id, COALESCE(SUM(total_amount),0) AS total_invoiced')
->where('school_year', $year)
->groupBy('parent_id')
->findAll();
// Sum refunds PAID (cash out) by parent for the term
$refRows = $this->refundModel
->select('parent_id, COALESCE(SUM(refund_paid_amount),0) AS total_refunded')
->where('school_year', $year)
->whereIn('status', ['Partial','Paid'])
->groupBy('parent_id')
->findAll();
$paidMap = [];
foreach ($paidRows as $r) $paidMap[(int)$r['parent_id']] = (float)($r['total_paid'] ?? 0);
$invMap = [];
foreach ($invRows as $r) $invMap[(int)$r['parent_id']] = (float)($r['total_invoiced'] ?? 0);
$refMap = [];
foreach ($refRows as $r) $refMap[(int)$r['parent_id']] = (float)($r['total_refunded'] ?? 0);
$parentIds = array_unique(array_merge(array_keys($paidMap), array_keys($invMap)));
foreach ($parentIds as $pid) {
$paid = (float)($paidMap[$pid] ?? 0);
$invd = (float)($invMap[$pid] ?? 0);
$rfnd = (float)($refMap[$pid] ?? 0);
$over = round($paid - $invd - $rfnd, 2);
if ($over > 0.00 + 0.0001) {
// Parent-level policy: DO NOT create new overpayment rows; only keep existing parent-level
// overpayment rows in sync if they are still open (Pending/Partial). Prefer per-invoice rows.
$existing = $this->refundModel
->where('parent_id', $pid)
->where('school_year', $year)
->where('request', 'overpayment')
->orderBy('id', 'DESC')
->first();
if ($existing && in_array($existing['status'], ['Pending','Partial'], true)) {
$this->refundModel->update((int)$existing['id'], [
'refund_amount' => $over,
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
$updated[] = (int)$existing['id'];
}
// else: no parent-level row created
}
}
// ===== Stage 2: Per-invoice overpayment detection (current school year) =====
try {
$invRows = $this->invoiceModel
->select('id, parent_id, total_amount, school_year')
->where('school_year', $year)
->findAll();
if ($invRows) {
$invoiceIds = array_map(static fn($r) => (int)$r['id'], $invRows);
// payments by invoice
$qbInvPaid = $this->paymentModel
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id');
$payTable2 = $this->paymentModel->table;
$hasStatus2 = $this->paymentModel->db->fieldExists('status', $payTable2);
$hasVoid2 = $this->paymentModel->db->fieldExists('is_void', $payTable2);
if ($hasStatus2) {
$qbInvPaid->groupStart()
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($hasVoid2) {
$qbInvPaid->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$invPaidRows = $qbInvPaid->findAll();
// discounts by invoice
$invDiscRows = $this->db->table('discount_usages')
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->get()->getResultArray();
// refunds paid by invoice
$invRefRows = $this->refundModel
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_ref')
->whereIn('invoice_id', $invoiceIds)
->whereIn('status', ['Partial','Paid'])
->groupBy('invoice_id')
->findAll();
$paidByInv = [];
foreach ($invPaidRows as $r) { $paidByInv[(int)$r['invoice_id']] = (float)($r['total_paid'] ?? 0); }
$discByInv = [];
foreach ($invDiscRows as $r) { $discByInv[(int)$r['invoice_id']] = (float)($r['total_disc'] ?? 0); }
$refByInv = [];
foreach ($invRefRows as $r) { $refByInv[(int)$r['invoice_id']] = (float)($r['total_ref'] ?? 0); }
foreach ($invRows as $inv) {
$iid = (int)$inv['id'];
$pid = (int)$inv['parent_id'];
$total = (float)($inv['total_amount'] ?? 0);
$paid = (float)($paidByInv[$iid] ?? 0);
$disc = (float)($discByInv[$iid] ?? 0);
$rfnd = (float)($refByInv[$iid] ?? 0);
$rawBalance = round($total - $disc - $paid - $rfnd, 2);
if ($rawBalance < -0.0001) {
$over = abs($rawBalance);
// If ANY open refund exists for this invoice, update it instead of creating another line
$openRow = $this->refundModel
->where('invoice_id', $iid)
->whereIn('status', ['Pending','Approved','Partial'])
->orderBy('id', 'DESC')
->first();
if ($openRow) {
// Prefer merging into 'overpayment' if present, else update the open row
$this->refundModel->update((int)$openRow['id'], [
'refund_amount' => $over,
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
$updated[] = (int)$openRow['id'];
continue;
}
// If there is an open PARENT-LEVEL overpayment (invoice_id IS NULL), migrate it to this invoice
$parentLevelOpen = $this->refundModel
->where('parent_id', $pid)
->where('school_year', $year)
->where('invoice_id', null)
->where('request', 'overpayment')
->whereIn('status', ['Pending','Approved','Partial'])
->orderBy('id', 'DESC')
->first();
if ($parentLevelOpen) {
$this->refundModel->update((int)$parentLevelOpen['id'], [
'invoice_id' => $iid,
'refund_amount' => $over,
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
$updated[] = (int)$parentLevelOpen['id'];
continue; // merged instead of creating new
}
// Existing overpayment refund for this invoice?
$existing = $this->refundModel
->where('invoice_id', $iid)
->where('request', 'overpayment')
->whereIn('status', ['Pending','Approved','Partial','Paid'])
->orderBy('id', 'DESC')
->first();
if (!$existing || $existing['status'] === 'Paid') {
$ok = $this->refundModel->insert([
'parent_id' => $pid,
'school_year' => $year,
'semester' => $sem,
'invoice_id' => $iid,
'refund_amount' => $over,
'refund_paid_amount' => 0.00,
'status' => 'Pending',
'request' => 'overpayment',
'reason' => 'Auto-detected per-invoice overpayment',
'updated_by' => session()->get('user_id'),
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
if ($ok) {
$created[] = (int)$this->refundModel->getInsertID();
if ($triggerEvents) {
$user = (new UserModel())->select('id, email, firstname, lastname')->find($pid) ?: [];
$eventData = [
'user_id' => (int)($user['id'] ?? $pid),
'email' => $user['email'] ?? null,
'firstname' => $user['firstname'] ?? null,
'lastname' => $user['lastname'] ?? null,
'amount' => $over,
'portalLink'=> base_url('/login'),
];
\CodeIgniter\Events\Events::trigger('refundPending', $eventData, []);
}
}
} else {
// Keep Pending/Partial in sync with current overpayment
if (in_array($existing['status'], ['Pending','Partial'], true)) {
$this->refundModel->update((int)$existing['id'], [
'refund_amount' => $over,
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
$updated[] = (int)$existing['id'];
}
}
}
}
}
} catch (\Throwable $e) {
log_message('error', 'recalcOverpayments per-invoice failed: ' . $e->getMessage());
}
return ['created_ids' => $created, 'updated_ids' => $updated];
}
/**
* Parent financial summary for *current term* (adjust if you want all-time).
* Assumes invoices.total_amount and payments.amount (positive=in, negative=out).
*/
private function getParentFinancialSummary(int $parentId, string $schoolYear, string $semester): array
{
// invoices total for term
$inv = $this->invoiceModel
->select('COALESCE(SUM(total_amount),0) AS total_invoiced')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->first();
// payments in (amount > 0) for term
$payIn = $this->paymentModel
->select('COALESCE(SUM(amount),0) AS paid_in')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where('amount >', 0)
->first();
// refunds already paid out (your refunds table)
$refPaid = $this->refundModel
->select('COALESCE(SUM(refund_paid_amount),0) AS refunded_cash')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->whereIn('status', ['Partial','Paid'])
->first();
$totalInvoiced = (float)($inv['total_invoiced'] ?? 0);
$paidIn = (float)($payIn['paid_in'] ?? 0);
$refundedCash = (float)($refPaid['refunded_cash'] ?? 0);
// Unapplied balance = money in invoices cash refunds out
$unapplied = $paidIn - $totalInvoiced - $refundedCash;
return [
'total_invoiced' => $totalInvoiced,
'total_paid_in' => $paidIn,
'total_refunded' => $refundedCash,
'unapplied_balance' => $unapplied,
];
}
/** Optional helper if you want a quick API for balances in UI */
public function parentBalances(int $parentId)
{
$t = $this->getCurrentTerm();
return $this->response->setJSON($this->getParentFinancialSummary($parentId, $t['school_year'], $t['semester']));
}
/**
* Create refund request.
* Stores source/type in `refunds.request` as: tuition|overpayment|duplicate|extra
*
* @param int $parentId
* @param float $amount
* @param string|null $requestType tuition|overpayment|duplicate|extra
* @param int|null $invoiceId required for tuition; useful for duplicate
* @param int|null $paymentId useful for duplicate
* @param string|null $reason
*/
public function requestRefund(
int $parentId,
float $amount,
?string $requestType = 'tuition',
?int $invoiceId = null,
?int $paymentId = null,
?string $reason = null
) {
$requestType = strtolower((string)$requestType);
if (!in_array($requestType, self::REQUEST_TYPES, true)) {
return $this->response->setJSON(['error' => 'Invalid refund request type.']);
}
$parent = $this->userModel->find($parentId);
if (!$parent) {
return $this->response->setJSON(['error' => 'Parent not found.']);
}
if ($amount <= 0) {
return $this->response->setJSON(['error' => 'Refund amount must be > 0.']);
}
$term = $this->getCurrentTerm();
$schoolYear = $term['school_year'];
$semester = $term['semester'];
// Tuition requires an invoice check
if ($requestType === 'tuition') {
if (empty($invoiceId)) {
return $this->response->setJSON(['error' => 'invoice_id is required for tuition refunds.']);
}
$inv = $this->invoiceModel->find($invoiceId);
if (!$inv || (int)$inv['parent_id'] !== $parentId) {
return $this->response->setJSON(['error' => 'Invoice not found for parent.']);
}
// Optionally cap to eligible amount per your policy.
}
// Overpayment/extra must not exceed unapplied balance
if (in_array($requestType, ['overpayment','extra'], true)) {
$sum = $this->getParentFinancialSummary($parentId, $schoolYear, $semester);
if ($sum['unapplied_balance'] <= 0) {
return $this->response->setJSON(['error' => 'No unapplied balance available.']);
}
if ($amount > $sum['unapplied_balance'] + 0.0001) {
return $this->response->setJSON([
'error' => 'Amount exceeds available unapplied balance.',
'available' => number_format($sum['unapplied_balance'], 2)
]);
}
}
// Duplicate: optionally tie to payment; otherwise your staff can fill details in reason/note
if ($requestType === 'duplicate' && empty($paymentId) && empty($invoiceId)) {
// Not hard-failing; but better to guide:
// return $this->response->setJSON(['error' => 'Provide payment_id or invoice_id for duplicate refunds.']);
}
$payload = [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'semester' => $semester,
'invoice_id' => $invoiceId,
'refund_amount' => $amount,
'refund_paid_amount' => 0.00, // IMPORTANT: your column is NOT NULL
'status' => 'Pending',
'reason' => $reason,
'request' => $requestType, // <- store the source/type here
'note' => $paymentId ? ('duplicate-of-payment#' . $paymentId) : null,
'created_at' => utc_now(),
'updated_at' => utc_now(),
];
$ok = $this->refundModel->insert($payload);
if (!$ok) {
return $this->response->setJSON(['error' => 'Failed to create refund request.']);
}
// Fire refundPending notification/event for newly created refunds
try {
$user = $this->userModel->select('id, email, firstname, lastname')->find($parentId) ?: [];
$eventData = [
'user_id' => (int)($user['id'] ?? $parentId),
'email' => $user['email'] ?? null,
'firstname' => $user['firstname'] ?? null,
'lastname' => $user['lastname'] ?? null,
'amount' => (float)$amount,
'portalLink'=> base_url('/login'),
];
\CodeIgniter\Events\Events::trigger('refundPending', $eventData, []);
} catch (\Throwable $e) {
log_message('error', 'requestRefund: failed to trigger refundPending: ' . $e->getMessage());
}
return $this->response->setJSON(['success' => 'Refund requested']);
}
// Approve refund (no money movement)
public function approveRefund(int $refundId)
{
$ok = $this->refundModel->update($refundId, [
'status' => 'Approved',
'approved_at' => utc_now(),
'approved_by' => session()->get('user_id'),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
return $this->response->setJSON($ok ? ['success' => 'Refund approved'] : ['error' => 'Approve failed']);
}
// Reject refund
public function rejectRefund(int $refundId)
{
$ok = $this->refundModel->update($refundId, [
'status' => 'Rejected',
'approved_at' => utc_now(),
'approved_by' => session()->get('user_id'),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
return $this->response->setJSON($ok ? ['success' => 'Refund rejected'] : ['error' => 'Reject failed']);
}
/**
* POST: refund_id, paid_amount, payment_method (Check|Online|Cash), check_number, check_file (optional)
* Marks payout (can be partial) and inserts a negative Payment row to keep balances correct.
*/
public function updatePayment()
{
log_message('debug', 'POST data: ' . print_r($_POST, true));
$refundId = (int)$this->request->getPost('refund_id');
$newPaidAmount = (float)$this->request->getPost('paid_amount');
$refundMethod = $this->request->getPost('payment_method'); // Check|Online|Cash
$checkNbr = $this->request->getPost('check_number');
if ($refundId <= 0) return $this->response->setJSON(['error' => 'Missing refund ID.']);
if ($newPaidAmount <= 0) return $this->response->setJSON(['error' => 'Valid paid amount is required.']);
$refund = $this->refundModel->find($refundId);
if (!$refund) return $this->response->setJSON(['error' => 'Refund not found.']);
if (!in_array($refund['status'], ['Approved','Partial'], true)) {
return $this->response->setJSON(['error' => 'Refund must be Approved/Partial to pay.']);
}
$oldPaid = (float)$refund['refund_paid_amount'];
$target = (float)$refund['refund_amount'];
$total = $oldPaid + $newPaidAmount;
if ($total > $target + 0.0001) {
return $this->response->setJSON(['error' => 'Total paid cannot exceed refund amount.']);
}
// Optional file upload for Check
$checkFileName = $refund['check_file'] ?? null;
if ($refundMethod === 'Check') {
$checkFile = $this->request->getFile('check_file');
if ($checkFile && $checkFile->isValid() && !$checkFile->hasMoved()) {
$checkFileName = $checkFile->getRandomName();
$checkFile->move(WRITEPATH . 'uploads/checks/', $checkFileName);
}
}
$newStatus = ($total < $target) ? 'Partial' : 'Paid';
$db = db_connect();
$db->transStart();
// 0) If this refund is not tied to an invoice yet, assign it to the most overpaid invoice
$assignInvoiceId = null;
if (empty($refund['invoice_id'])) {
try {
// Gather invoices for this parent/year
$invRows = $this->invoiceModel
->select('id, total_amount')
->where('parent_id', (int)$refund['parent_id'])
->where('school_year', $refund['school_year'])
->findAll();
if ($invRows) {
$invoiceIds = array_map(static fn($r) => (int)$r['id'], $invRows);
// Payments per invoice
$payRows = $this->paymentModel
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->findAll();
$paidBy = [];
foreach ($payRows as $r) { $paidBy[(int)$r['invoice_id']] = (float)($r['total_paid'] ?? 0); }
// Discounts per invoice
$discRows = $this->db->table('discount_usages')
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->get()->getResultArray();
$discBy = [];
foreach ($discRows as $r) { $discBy[(int)$r['invoice_id']] = (float)($r['total_disc'] ?? 0); }
// Choose invoice with most negative raw (total - disc - paid)
$minRaw = 0.0; $minId = null;
foreach ($invRows as $ir) {
$iid = (int)$ir['id'];
$raw = (float)($ir['total_amount'] ?? 0) - (float)($discBy[$iid] ?? 0) - (float)($paidBy[$iid] ?? 0);
if ($raw < $minRaw) { $minRaw = $raw; $minId = $iid; }
}
if ($minId !== null) {
$assignInvoiceId = $minId;
} else {
// fallback: latest invoice in this year
$row = $this->invoiceModel
->select('id')
->where('parent_id', (int)$refund['parent_id'])
->where('school_year', $refund['school_year'])
->orderBy('created_at', 'DESC')
->first();
if ($row && !empty($row['id'])) $assignInvoiceId = (int)$row['id'];
}
}
} catch (\Throwable $e) {
log_message('error', 'updatePayment: invoice assignment failed: ' . $e->getMessage());
}
}
// 1) Update refund row
$this->refundModel->update($refundId, [
'refund_paid_amount' => $total,
'status' => $newStatus,
'refunded_at' => utc_now(),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
'refund_method' => $refundMethod,
'check_nbr' => $checkNbr,
'check_file' => $checkFileName,
// tie to invoice if determined
'invoice_id' => $assignInvoiceId ?? $refund['invoice_id'],
]);
// 2) Optional: If you want an accounting journal entry for payouts, write to a dedicated table.
// We no longer insert a negative row into payments to avoid schema/validation conflicts.
$db->transComplete();
if ($db->transStatus() === false) {
return $this->response->setJSON(['error' => 'Failed to update payment.']);
}
return $this->response->setJSON(['success' => 'Payment updated successfully.']);
}
/** Keep your listing; added extra fields for clarity */
public function listRefunds()
{
// NOTE: We no longer auto-create/adjust refunds on page load to avoid duplicate lines
// when staff are recording payouts. Use the "Recalculate" buttons to run detection on demand.
// 2) List refunds with joins
$refunds = $this->refundModel
->select('refunds.*,
u.firstname, u.lastname, u.school_id,
a.firstname AS approved_by_firstname,
a.lastname AS approved_by_lastname')
->join('users u', 'refunds.parent_id = u.id')
->join('users a', 'refunds.approved_by = a.id', 'left')
->orderBy('refunds.created_at', 'DESC')
->findAll();
foreach ($refunds as &$r) {
$r['approved_by_name'] = trim(($r['approved_by_firstname'] ?? '') . ' ' . ($r['approved_by_lastname'] ?? '')) ?: '-';
}
$parentId = !empty($refunds) ? $refunds[0]['parent_id'] : null;
return view('refunds/list', [
'refunds' => $refunds,
'parentId' => $parentId
]);
}
/** Manual endpoint to recalculate/sync overpayments and optionally notify newly created entries. */
public function recalculateOverpayments()
{
// Optional targeted invoice_number to handle cross-year cases
$invoiceNumber = (string)($this->request->getPost('invoice_number') ?? '');
if ($invoiceNumber !== '') {
try {
$inv = $this->invoiceModel->where('invoice_number', $invoiceNumber)->first();
if ($inv) {
$pid = (int)$inv['parent_id'];
$iid = (int)$inv['id'];
$year = (string)$inv['school_year'];
// Compute per-invoice overpayment now (independent of current term)
$paid = (float)($this->paymentModel
->select('COALESCE(SUM(paid_amount),0) AS tot')
->where('invoice_id', $iid)
->first()['tot'] ?? 0);
$disc = (float)($this->db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS tot')
->where('invoice_id', $iid)
->get()->getRowArray()['tot'] ?? 0);
$rfnd = (float)($this->refundModel
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', $iid)
->whereIn('status', ['Partial','Paid'])
->first()['tot'] ?? 0);
$total = (float)($inv['total_amount'] ?? 0);
$raw = round($total - $disc - $paid - $rfnd, 2);
if ($raw < -0.0001) {
$over = abs($raw);
// If there is ANY open refund for this invoice (any request), update it rather than create
$openRow = $this->refundModel
->where('invoice_id', $iid)
->whereIn('status', ['Pending','Approved','Partial'])
->orderBy('id', 'DESC')
->first();
if ($openRow) {
$this->refundModel->update((int)$openRow['id'], [
'refund_amount' => $over,
'request' => 'overpayment',
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
return redirect()->to(site_url('refunds/list'))->with('success', 'Overpayment updated for invoice ' . esc($invoiceNumber));
}
// Else, if no open rows exist, either update existing overpayment (Paid) — skip creating new
$existing = $this->refundModel
->where('invoice_id', $iid)
->where('request', 'overpayment')
->orderBy('id', 'DESC')
->first();
if ($existing) {
// If it's Paid, do not create a new duplicate line
return redirect()->to(site_url('refunds/list'))->with('info', 'Overpayment was already resolved for this invoice.');
}
// As a last resort, create a single overpayment row
$this->refundModel->insert([
'parent_id' => $pid,
'school_year' => $year,
'semester' => $inv['semester'] ?? null,
'invoice_id' => $iid,
'refund_amount' => $over,
'refund_paid_amount' => 0.00,
'status' => 'Pending',
'request' => 'overpayment',
'reason' => 'Auto-detected per-invoice overpayment (manual)',
'updated_by' => session()->get('user_id'),
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
$user = $this->userModel->select('id, email, firstname, lastname')->find($pid) ?: [];
$eventData = [
'user_id' => (int)($user['id'] ?? $pid),
'email' => $user['email'] ?? null,
'firstname' => $user['firstname'] ?? null,
'lastname' => $user['lastname'] ?? null,
'amount' => $over,
'portalLink'=> base_url('/login'),
];
\CodeIgniter\Events\Events::trigger('refundPending', $eventData, []);
return redirect()->to(site_url('refunds/list'))->with('success', 'Overpayment detected and refund created for invoice ' . esc($invoiceNumber));
}
return redirect()->to(site_url('refunds/list'))->with('info', 'No overpayment detected for invoice ' . esc($invoiceNumber));
}
return redirect()->to(site_url('refunds/list'))->with('error', 'Invoice not found: ' . esc($invoiceNumber));
} catch (\Throwable $e) {
return redirect()->to(site_url('refunds/list'))->with('error', 'Failed to recalc for invoice: ' . $e->getMessage());
}
}
$res = $this->recalcOverpayments(true);
$nNew = count($res['created_ids'] ?? []);
$nUpd = count($res['updated_ids'] ?? []);
return redirect()->to(site_url('refunds/list'))
->with('success', "Recalculated overpayments: created {$nNew}, updated {$nUpd}.");
}
/** Decision endpoint (Approved/Rejected) with reason */
public function updateStatus()
{
log_message('debug', 'POST data: ' . print_r($_POST, true));
$refundId = (int)$this->request->getPost('refund_id');
$status = $this->request->getPost('status');
$reason = $this->request->getPost('reason');
if ($refundId <= 0 || !in_array($status, self::DECISIONS, true)) {
return $this->response->setJSON(['error' => 'Invalid status update request.']);
}
if (empty($reason)) {
return $this->response->setJSON(['error' => 'Reason is required for approval or rejection.']);
}
$refund = $this->refundModel->find($refundId);
if (!$refund) {
return $this->response->setJSON(['error' => 'Refund not found.']);
}
$ok = $this->refundModel->update($refundId, [
'status' => $status,
'reason' => $reason,
'approved_at' => utc_now(),
'approved_by' => session()->get('user_id'),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
return $this->response->setJSON($ok ? ['success' => 'Refund status updated successfully.']
: ['error' => 'Failed to update refund status.']);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,723 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Models\RoleModel;
use App\Models\PermissionModel;
use App\Models\RolePermissionModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use App\Models\ConfigurationModel;
use App\Models\StaffModel;
use CodeIgniter\Controller;
class RolePermissionController extends Controller
{
protected $roleModel;
protected $userModel;
protected $userRoleModel;
protected $staffModel;
protected $configModel;
protected $permissionModel;
protected $rolePermissionModel;
protected $request;
protected $db;
protected $schoolYear;
protected $semester;
public function __construct()
{
$this->roleModel = new RoleModel();
$this->userModel = new UserModel();
$this->userRoleModel = new UserRoleModel();
$this->staffModel = new StaffModel();
$this->configModel = new ConfigurationModel();
$this->permissionModel = new PermissionModel();
$this->rolePermissionModel = new RolePermissionModel();
$this->request = \Config\Services::request();
$this->db = \Config\Database::connect();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
}
public function index()
{
helper('url');
return view('rolepermission/list_roles', [
'rolesEndpoint' => site_url('api/rolepermission/roles'),
]);
}
public function assignRole()
{
helper('url');
return view('rolepermission/assign_role', [
'assignRoleEndpoint' => site_url('api/rolepermission/users'),
'rolesEndpoint' => site_url('api/rolepermission/roles'),
]);
}
public function saveRole()
{
if ($this->request->getMethod(true) === 'POST') {
$userId = (int) $this->request->getPost('user_id');
$roleIdsRaw = $this->request->getPost('role_ids');
$roleIdsArray = is_array($roleIdsRaw) ? $roleIdsRaw : ($roleIdsRaw !== null ? [$roleIdsRaw] : []);
$roleIds = array_values(array_unique(array_filter(array_map('intval', $roleIdsArray), fn($id) => $id > 0)));
$adminId = session()->get('user_id');
if ($userId <= 0) {
return redirect()->to('rolepermission/assign_role')->with('error', 'Invalid user selection.');
}
try {
$this->db->transStart();
// Remove all existing roles for the user
$this->userRoleModel->where('user_id', $userId)->delete();
// Assign new roles
if (!empty($roleIds)) {
foreach ($roleIds as $roleId) {
$this->userRoleModel->insert([
'user_id' => $userId,
'role_id' => $roleId,
'created_at' => utc_now(),
'updated_by' => $adminId
]);
}
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
throw new \RuntimeException('Transaction failed.');
}
// ✅ Load role names after saving
$assignedRoles = !empty($roleIds)
? $this->roleModel->whereIn('id', $roleIds)->findAll()
: [];
$roleNames = array_map(fn($r) => strtolower($r['name']), $assignedRoles ?? []);
// ✅ Update staff record if needed
$this->updateStaffRecord($userId, $roleNames);
return redirect()->to('rolepermission/assign_role')->with('success', 'Roles updated successfully.');
} catch (\Exception $e) {
$this->db->transRollback();
log_message('error', 'Error updating roles: ' . $e->getMessage());
return redirect()->to('rolepermission/assign_role')->with('error', 'Failed to update roles.');
}
}
return redirect()->to('rolepermission/assign_role')->with('error', 'Invalid request.');
}
public function usersData()
{
return $this->response->setJSON($this->buildUsersWithRolesPayload());
}
public function rolesData()
{
$rows = $this->roleModel
->select('roles.id, roles.name, roles.description, COUNT(DISTINCT user_roles.user_id) AS user_count')
->join('user_roles', 'user_roles.role_id = roles.id', 'left')
->groupBy('roles.id, roles.name, roles.description')
->orderBy('roles.name', 'ASC')
->findAll();
$normalized = array_map(static function ($row) {
if (!is_array($row)) {
return [];
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'description' => $row['description'] ?? null,
'user_count' => (int) ($row['user_count'] ?? 0),
];
}, $rows ?? []);
return $this->response->setJSON(['roles' => $normalized]);
}
private function buildUsersWithRolesPayload(): array
{
$rows = $this->userModel
->select('users.id, users.firstname, users.lastname, users.email, users.account_id, roles.name AS role_name')
->join('user_roles', 'users.id = user_roles.user_id', 'left')
->join('roles', 'user_roles.role_id = roles.id', 'left')
->orderBy('users.id', 'ASC')
->findAll();
$users = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
if (!isset($users[$id])) {
$users[$id] = [
'id' => $id,
'firstname' => $row['firstname'] ?? '',
'lastname' => $row['lastname'] ?? '',
'email' => $row['email'] ?? '',
'account_id' => $row['account_id'] ?? '',
'roles' => [],
];
}
$roleName = $row['role_name'] ?? null;
if ($roleName) {
$users[$id]['roles'][] = $roleName;
}
}
$normalized = array_map(static function ($user) {
$roles = array_values(array_unique(array_filter($user['roles'] ?? [], 'strlen')));
return [
'id' => $user['id'],
'firstname' => $user['firstname'] ?? '',
'lastname' => $user['lastname'] ?? '',
'email' => $user['email'] ?? '',
'account_id' => $user['account_id'] ?? '',
'roles' => $roles,
];
}, array_values($users));
return ['users' => $normalized];
}
private function updateStaffRecord(int $userId, array $newRoleNames): void
{
$excludedRoles = ['parent', 'student', 'guest'];
$loweredNewRoles = array_map('strtolower', $newRoleNames);
$user = $this->userModel->find($userId);
if (!$user) {
log_message('error', "updateStaffRecord: user_id {$userId} not found");
return;
}
// Fetch existing staff record if it exists
$existingStaff = $this->staffModel->where('user_id', $userId)->first();
// Extract existing roles from DB if any
$existingRoles = [];
if (!empty($existingStaff['role_name'])) {
$existingRoles = array_map('strtolower', array_map('trim', explode(',', $existingStaff['role_name'])));
}
// Merge and deduplicate roles
$allRoles = array_unique(array_merge($existingRoles, $loweredNewRoles));
// Determine staff roles (excluding parent/student/guest)
$staffRoles = array_filter($newRoleNames, fn($role) => !in_array($role, $excludedRoles));
// Determine active role
$activeRole = !empty($staffRoles) ? $staffRoles[0] : 'inactive';
$email = $this->generateUniqueStaffEmail($user['firstname'], $user['lastname'], $userId);
$now = utc_now();
$row = [
'user_id' => $userId,
'firstname' => $user['firstname'],
'lastname' => $user['lastname'],
'email' => $email,
'phone' => $user['cellphone'],
'role_name' => implode(', ', $allRoles), // historical roles
'active_role' => $activeRole,
'school_year' => $this->schoolYear,
'updated_at' => $now,
];
$this->staffModel->upsert($row);
}
private function generateUniqueStaffEmail(string $firstname, string $lastname, int $userId): string
{
$domain = '@alrahmaisgl.org';
// Sanitize names: lowercase, letters only
$firstname = strtolower(preg_replace('/[^a-z]/i', '', $firstname));
$lastname = strtolower(preg_replace('/[^a-z]/i', '', $lastname));
// Use at least 1 letter from firstname
$min = 1;
$max = strlen($firstname);
$base = '';
$email = '';
for ($i = $min; $i <= $max; $i++) {
$base = substr($firstname, 0, $i) . $lastname;
$email = $base . $domain;
// If email not taken or belongs to same user, use it
$exists = $this->staffModel->where('email', $email)
->where('user_id !=', $userId)
->first();
if (!$exists) {
return $email;
}
}
// If all combinations used, fallback to adding user ID
return $base . $userId . $domain;
}
public function listRoles()
{
helper('url');
return view('rolepermission/list_roles', [
'rolesEndpoint' => site_url('api/rolepermission/roles'),
]);
}
public function editRolePermissions($roleId)
{
// Fetch roles, permissions, and role permissions data
$roles = $this->roleModel->findAll();
$permissions = $this->permissionModel->findAll();
$rolePermissions = $this->rolePermissionModel->where('role_id', $roleId)->findAll();
// Convert existing role permissions to a simple array for comparison
$existingPermissions = [];
foreach ($rolePermissions as $rolePermission) {
$existingPermissions[$rolePermission['permission_id']] = [
'can_create' => $rolePermission['can_create'],
'can_read' => $rolePermission['can_read'],
'can_update' => $rolePermission['can_update'],
'can_delete' => $rolePermission['can_delete'],
];
}
// Handle POST request
if (strtolower($this->request->getMethod()) === 'post') {
// Get the posted permissions data
$postPermissions = $this->request->getPost('permissions');
// Call saveRolePermissions with the correct data
return $this->saveRolePermissions($roleId, $postPermissions);
}
return view('rolepermission/edit_role_permissions', [
'roles' => $roles,
'roleId' => $roleId,
'permissions' => $permissions,
'rolePermissions' => $rolePermissions,
'existingPermissions' => $existingPermissions
]);
}
public function saveRolePermissions($roleId = null, $permissions = null)
{
// If $roleId and $permissions are not passed, retrieve them from POST
if (!$roleId) {
$roleId = $this->request->getPost('role_id');
}
if (!$permissions) {
$permissions = $this->request->getPost('permissions');
}
if (!$roleId) {
return redirect()->back()->with('error', 'Role ID is missing.');
}
// Fetch existing permissions for the role
$rolePermissions = $this->rolePermissionModel->where('role_id', $roleId)->findAll();
$existingPermissions = [];
foreach ($rolePermissions as $rolePermission) {
$existingPermissions[$rolePermission['permission_id']] = $rolePermission;
}
try {
$this->rolePermissionModel->transStart();
// Process the new permissions if provided
if ($permissions && is_array($permissions)) {
foreach ($permissions as $permissionId => $crudValues) {
// Ensure CRUD values are correctly set, defaulting to 0 if not provided
$permissionData = [
'can_create' => isset($crudValues['create']) ? 1 : 0,
'can_read' => isset($crudValues['read']) ? 1 : 0,
'can_update' => isset($crudValues['update']) ? 1 : 0,
'can_delete' => isset($crudValues['delete']) ? 1 : 0,
'updated_at' => utc_now(),
];
if (isset($existingPermissions[$permissionId])) {
// Update existing permission
$this->rolePermissionModel->where('role_id', $roleId)
->where('permission_id', $permissionId)
->set($permissionData)
->update();
} else {
// Insert new permission
$permissionData['role_id'] = $roleId;
$permissionData['permission_id'] = $permissionId;
$permissionData['created_at'] = utc_now();
$this->rolePermissionModel->insert($permissionData);
}
}
}
// Remove permissions that are no longer present
foreach ($existingPermissions as $permissionId => $permissionData) {
if (!isset($permissions[$permissionId])) {
$this->rolePermissionModel->where('role_id', $roleId)
->where('permission_id', $permissionId)
->delete();
}
}
$this->rolePermissionModel->transComplete();
if ($this->rolePermissionModel->transStatus() === false) {
throw new \RuntimeException('Transaction failed.');
}
return redirect()->to('/rolepermission/edit_role_permissions/' . $roleId)
->with('status', 'Permissions saved successfully.');
} catch (\Exception $e) {
$this->rolePermissionModel->transRollback();
log_message('error', 'Failed to save permissions: ' . $e->getMessage());
return redirect()->back()->with('error', 'Failed to save permissions: ' . $e->getMessage());
}
}
// Role management functions
public function rolesList()
{
return $this->listRoles();
}
public function addRole()
{
return view('rolepermission/add_role');
}
public function storeRole()
{
$validation = \Config\Services::validation();
// Validation rules
$validation->setRules([
'name' => 'required|min_length[3]|max_length[255]|is_unique[roles.name]',
'slug' => 'permit_empty|max_length[64]|is_unique[roles.slug]',
'description' => 'permit_empty|max_length[500]',
'dashboard_route' => [
'rules' => 'required|min_length[1]|max_length[255]|regex_match[/^[a-z0-9_\\/\\-]+$/]',
'errors' => [
'regex_match' => 'Route may contain lowercase letters, numbers, /, -, and _.'
]
],
'priority' => 'required|integer|greater_than_equal_to[0]|less_than_equal_to[100000]',
'is_active' => 'required|in_list[0,1]',
]);
if (!$validation->withRequest($this->request)->run()) {
return redirect()->back()->withInput()->with('errors', $validation->getErrors());
}
// Normalize + sanitize inputs
$rawName = trim((string) $this->request->getPost('name'));
$rawSlug = trim((string) $this->request->getPost('slug'));
$route = trim((string) $this->request->getPost('dashboard_route'));
$desc = trim((string) $this->request->getPost('description'));
$priority = (int) $this->request->getPost('priority');
$isActive = (string) $this->request->getPost('is_active') === '1' ? 1 : 0;
// Force lowercase for consistency
$name = strtolower($rawName);
$dashboard_route = strtolower($route);
$description = strtolower($desc);
// Build slug: from input if provided, else from name
$slug = $rawSlug !== '' ? $rawSlug : $name;
// convert spaces / invalid chars to underscores and lowercase
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $slug));
$slug = trim($slug, '_');
// Ensure slug uniqueness (in case collision after normalization)
$slug = $this->ensureUniqueSlug($slug);
$data = [
'name' => $name,
'slug' => $slug,
'description' => $description,
'dashboard_route' => $dashboard_route,
'priority' => $priority,
'is_active' => $isActive,
'created_at' => utc_now(),
'updated_at' => utc_now(),
];
if ($this->roleModel->insert($data)) {
return redirect()->to('rolepermission/roles')->with('success', 'Role created successfully!');
}
return redirect()->back()->withInput()->with('error', 'Failed to create role.');
}
/**
* Ensure slug is unique in roles.slug; if exists, append -2, -3, ...
*/
/**
* Ensure slug is unique in the roles table.
* - If $ignoreId is provided, that record is excluded (for updates).
* - Appends _2, _3, ... if duplicates exist.
*/
private function ensureUniqueSlug(string $baseSlug, ?int $ignoreId = null): string
{
$slug = $baseSlug;
$i = 2;
while (true) {
$builder = $this->roleModel->where('slug', $slug);
if ($ignoreId !== null) {
$builder->where('id !=', $ignoreId);
}
$exists = $builder->countAllResults() > 0;
if (!$exists) {
break;
}
$slug = $baseSlug . '_' . $i;
$i++;
if ($i > 100000) { // safety stop
break;
}
}
return $slug;
}
public function editRole($id)
{
if (!$role = $this->roleModel->find($id)) {
return redirect()->to('rolepermission/roles')->with('error', 'Role not found.');
}
return view('rolepermission/edit_role', ['role' => $role]);
}
public function updateRole($id)
{
$role = $this->roleModel->find($id);
if (!$role) {
return redirect()->to('rolepermission/roles')->with('error', 'Role not found.');
}
$validation = \Config\Services::validation();
// CI4 is_unique format: is_unique[table.field,ignore_field,ignore_value]
$validation->setRules([
'name' => "required|min_length[3]|max_length[255]|is_unique[roles.name,id,{$id}]",
'slug' => "permit_empty|max_length[64]|is_unique[roles.slug,id,{$id}]",
'description' => 'permit_empty|max_length[500]',
'dashboard_route' => [
'rules' => 'required|min_length[1]|max_length[255]|regex_match[/^[a-z0-9_\\/\\-]+$/]',
'errors' => [
'regex_match' => 'Route may contain lowercase letters, numbers, /, -, and _.'
]
],
'priority' => 'required|integer|greater_than_equal_to[0]|less_than_equal_to[100000]',
'is_active' => 'required|in_list[0,1]',
]);
if (!$validation->withRequest($this->request)->run()) {
return redirect()->back()->withInput()->with('errors', $validation->getErrors());
}
// Normalize inputs (lowercase storage)
$rawName = trim((string) $this->request->getPost('name'));
$rawSlug = trim((string) $this->request->getPost('slug'));
$route = trim((string) $this->request->getPost('dashboard_route'));
$desc = trim((string) $this->request->getPost('description'));
$priority = (int) $this->request->getPost('priority');
$isActive = (string) $this->request->getPost('is_active') === '1' ? 1 : 0;
$name = strtolower($rawName);
$dashboard_route = strtolower($route);
$description = strtolower($desc);
// slug: if blank, derive from name; else normalize
$slug = $rawSlug !== '' ? $rawSlug : $name;
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $slug));
$slug = trim($slug, '_');
// Ensure unique slug if changed or blank turned into new value
if ($slug !== $role['slug']) {
$slug = $this->ensureUniqueSlug($slug, (int) $id);
}
$data = [
'name' => $name,
'slug' => $slug,
'description' => $description,
'dashboard_route' => $dashboard_route,
'priority' => $priority,
'is_active' => $isActive,
'updated_at' => utc_now(),
];
if ($this->roleModel->update($id, $data)) {
return redirect()->to('rolepermission/roles')->with('success', 'Role updated successfully!');
}
return redirect()->back()->withInput()->with('error', 'Failed to update role.');
}
public function deleteRole($id)
{
// Check if role exists
$role = $this->roleModel->find($id);
if (!$role) {
return redirect()->to(site_url('rolepermission/roles'))
->with('error', 'Role not found.');
}
// Try deleting
if ($this->roleModel->delete($id)) {
return redirect()->to(site_url('rolepermission/roles'))
->with('success', 'Role deleted successfully.');
} else {
return redirect()->to(site_url('rolepermission/roles'))
->with('error', 'Failed to delete role.');
}
}
// ADD: show form + handle submit
public function addPermission()
{
if (strtolower($this->request->getMethod()) === 'post') {
$name = trim((string) $this->request->getPost('name'));
$desc = trim((string) $this->request->getPost('description'));
// Basic guard
if ($name === '') {
return redirect()->back()
->with('error', 'Permission name is required.')
->withInput();
}
// Check existence (collation is case-insensitive: utf8mb4_unicode_ci)
$existing = $this->permissionModel->where('name', $name)->first();
if ($existing) {
// Idempotent: do nothing if already exists
return redirect()->to(site_url('rolepermission/list_permissions'))
->with('success', 'Permission already exists. No changes made.');
}
// Create new
$data = [
'name' => $name,
'description' => $desc,
];
if (!$this->permissionModel->save($data)) {
return redirect()->back()
->with('error', implode(' ', $this->permissionModel->errors()))
->withInput();
}
return redirect()->to(site_url('rolepermission/list_permissions'))
->with('success', 'Permission added successfully.');
}
// GET -> form
return view('rolepermission/add_permission');
}
// EDIT: show form + handle submit
public function editPermission($id = null)
{
$permission = $this->permissionModel->find($id);
if (!$permission) {
return redirect()->to(site_url('roles'))->with('error', 'Permission not found.');
}
if (strtolower($this->request->getMethod()) === 'post') {
$data = [
'id' => $id, // needed for is_unique ignore
'name' => trim($this->request->getPost('name')),
'description' => trim($this->request->getPost('description')),
];
if (!$this->permissionModel->save($data)) {
return redirect()->back()
->with('error', implode(' ', $this->permissionModel->errors()))
->withInput();
}
return redirect()->to(site_url('rolepermission/list_permissions'))
->with('success', 'Permission updated successfully.');
}
return view('rolepermission/edit_permission', ['permission' => $permission]);
}
// Optional delete
public function deletePermission($id = null)
{
if (!$this->permissionModel->find($id)) {
return redirect()->to(site_url('rolepermission/list_permissions'))->with('error', 'Permission not found.');
}
$this->permissionModel->delete($id);
return redirect()->to(site_url('rolepermission/list_permissions'))->with('success', 'Permission deleted.');
}
public function listPermissions()
{
helper('url');
return view('rolepermission/permissionList', [
'permissionsEndpoint' => site_url('api/rolepermission/permissions'),
]);
}
public function permissionsData()
{
$rows = $this->permissionModel
->select('id, name, description, created_at, updated_at')
->orderBy('id', 'ASC')
->findAll();
$normalized = array_map(static function ($row) {
if (!is_array($row)) {
return [];
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'description' => $row['description'] ?? null,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
}, $rows ?? []);
return $this->response->setJSON(['permissions' => $normalized]);
}
}
-21
View File
@@ -1,21 +0,0 @@
<?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';
}
}
@@ -1,94 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\UserRoleModel;
use App\Models\ConfigurationModel;
use CodeIgniter\Controller;
use App\Models\RoleModel;
class RoleSwitcherController extends BaseController
{
protected $db;
protected $configModel;
protected $semester;
protected $schoolYear;
protected $userRoleModel;
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->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
public function index()
{
$userId = session()->get('user_id');
if (!$userId) {
return redirect()->to('/login')->with('error', 'Please log in first.');
}
$roles = $this->userRoleModel
->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $userId)
->findAll();
if (empty($roles)) {
return redirect()->back()->with('error', 'No roles assigned to this user.');
}
$roleNames = array_column($roles, 'name');
if (count($roleNames) === 1) {
return $this->redirectToDashboard($roleNames[0]);
}
return view('account/choose_account', ['roles' => $roleNames]);
}
public function switchTo($role)
{
session()->set('active_role', $role);
return $this->redirectToDashboard($role);
}
/*
private function redirectToDashboard($role)
{
return match ($role) {
'administrator' => redirect()->to('administrator/administratordashboard'),
'administrative staff' => redirect()->to('/landing_page/admin_dashboard'),
'teacher' => redirect()->to('/teacher_dashboard'),
'student' => redirect()->to('/landing_page/student_dashboard'),
'parent' => redirect()->to('/parent_dashboard'),
'head_of_department' => redirect()->to('/hod/dashboard'),
default => redirect()->to('/')->with('error', 'Unknown role.'),
};
}
*/
private function redirectToDashboard($role)
{
$key = is_string($role) ? $role : (string) $role;
$route = (new RoleModel())->getRouteByNameOrSlug($key)
?? '/landing_page/guest_dashboard';
log_message('debug', 'Redirecting user to: ' . $route);
return redirect()->to($route);
}
}
@@ -1,658 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\CalendarModel;
use App\Models\ConfigurationModel;
use App\Models\TeacherModel;
use App\Models\UserModel;
use App\Models\ParentMeetingScheduleModel;
use App\Services\EmailService;
class SchoolCalendarController extends BaseController
{
protected $calendarModel;
protected $configModel;
protected $semester;
protected $schoolYear;
protected $db;
protected $eventTypes = [
'1st Semester Scores',
'2nd Semester Scores',
'Break',
'Event',
'Final',
'Final Exam Draft',
'Midterm',
'Midterm Exam Draft',
];
protected EmailService $emailService;
protected UserModel $userModel;
protected TeacherModel $teacherModel;
protected ParentMeetingScheduleModel $meetingModel;
public function __construct()
{
// Load the models
$this->calendarModel = new CalendarModel();
$this->configModel = new ConfigurationModel();
$this->userModel = new UserModel();
$this->teacherModel = new TeacherModel();
$this->emailService = new EmailService();
$this->meetingModel = new ParentMeetingScheduleModel();
$this->db = \Config\Database::connect();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
// Load helpers
helper(['form', 'url']);
}
public function index()
{
// Get school_year / semester from query string or defaults
$syParam = $this->request->getGet('school_year');
$semParam = $this->request->getGet('semester');
$schoolYear = $syParam ?: $this->schoolYear;
$semester = $semParam ?: '';
if ($semester !== '') {
$events = $this->calendarModel->getEventsBySchoolYearAndSemester($schoolYear, $semester);
} else {
$events = $this->calendarModel->getEventsBySchoolYear($schoolYear);
}
return view('administrator/calendar_view', [
'events' => $events,
'schoolYear' => $schoolYear,
'semester' => $semester,
]);
}
public function edit($id)
{
$event = $this->calendarModel->find($id);
if (!$event) {
return redirect()->to('/administrator/calendar_view')->with('error', 'Event not found');
}
return view('administrator/calendar_edit', [
'event' => $event,
'eventTypes' => $this->eventTypes,
]);
}
public function update($id)
{
// Fetch the event by ID
$event = $this->calendarModel->find($id);
if (!$event) {
return redirect()->to('/administrator/calendar_view')->with('error', 'Event not found.');
}
if (strtolower($this->request->getMethod()) === 'post') {
$validation = $this->validate([
'title' => 'required|min_length[3]',
'start' => 'required|valid_date[Y-m-d]',
]);
if (!$validation) {
return view('administrator/calendar_edit', [
'validation' => $this->validator,
'event' => $event,
'eventTypes' => $this->eventTypes,
]);
}
$emailTargets = $this->emailNotificationTargetsFromRequest();
$data = [
'title' => $this->request->getPost('title'),
'description' => $this->request->getPost('description'),
'event_type' => $this->request->getPost('event_type') ?: null,
'date' => $this->request->getPost('start'),
'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0,
'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0,
'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0,
'no_school' => $this->request->getPost('no_school') ? 1 : 0,
'school_year' => $this->request->getPost('school_year') ?: $this->schoolYear,
'semester' => $this->request->getPost('semester') ?: $this->semester,
];
if (!$this->calendarModel->supportsEventType()) {
unset($data['event_type']);
}
if ($this->calendarModel->update($id, $data)) {
$updatedEvent = $this->calendarModel->find($id);
$emailStatus = $this->dispatchCalendarNotificationEmails($updatedEvent, $emailTargets);
$filters = [
'school_year' => $data['school_year'],
'semester' => $data['semester'],
];
$redirect = redirect()->to($this->buildCalendarViewUrl($filters))->with('success', 'Event updated successfully');
if ($emailFlash = $this->buildEmailFlash($emailStatus)) {
$redirect = $redirect
->with('email_status', $emailFlash['message'])
->with('email_status_class', $emailFlash['class']);
}
return $redirect;
} else {
return redirect()->back()->with('error', 'Failed to update the event.');
}
}
return view('administrator/calendar_edit', [
'event' => $event,
'eventTypes' => $this->eventTypes,
]);
}
public function addEvent()
{
if (strtolower($this->request->getMethod()) === 'post') {
$validation = $this->validate([
'title' => 'required|min_length[3]',
'start' => 'required|valid_date[Y-m-d]',
]);
if (!$validation) {
return view('administrator/calendar_add_event', [
'validation' => $this->validator,
'eventTypes' => $this->eventTypes,
]);
}
$emailTargets = $this->emailNotificationTargetsFromRequest();
// Prepare data
$data = [
'title' => $this->request->getPost('title'),
'description' => $this->request->getPost('description'),
'event_type' => $this->request->getPost('event_type') ?: null,
'date' => $this->request->getPost('start'),
'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0,
'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0,
'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0,
'no_school' => $this->request->getPost('no_school') ? 1 : 0,
'school_year' => $this->request->getPost('school_year') ?: $this->schoolYear,
'semester' => $this->request->getPost('semester') ?: $this->semester,
];
if (!$this->calendarModel->supportsEventType()) {
unset($data['event_type']);
}
if ($this->calendarModel->save($data)) {
$insertedId = $this->calendarModel->getInsertID();
$savedEvent = $this->calendarModel->find($insertedId);
$emailStatus = $this->dispatchCalendarNotificationEmails($savedEvent, $emailTargets);
log_message('info', 'Event saved successfully: ' . print_r($data, true));
$filters = [
'school_year' => $data['school_year'],
'semester' => $data['semester'],
];
$redirect = redirect()->to($this->buildCalendarViewUrl($filters))->with('success', 'Event added successfully');
if ($emailFlash = $this->buildEmailFlash($emailStatus)) {
$redirect = $redirect
->with('email_status', $emailFlash['message'])
->with('email_status_class', $emailFlash['class']);
}
return $redirect;
} else {
log_message('error', 'Failed to save event: ' . print_r($data, true));
return redirect()->back()->with('error', 'Failed to save the event.');
}
}
return view('administrator/calendar_add_event', [
'eventTypes' => $this->eventTypes,
]);
}
protected function emailNotificationTargetsFromRequest(): array
{
return [
'parents' => (bool) $this->request->getPost('send_email_parent'),
'teachers' => (bool) $this->request->getPost('send_email_teacher'),
'admins' => (bool) $this->request->getPost('send_email_admin'),
];
}
protected function dispatchCalendarNotificationEmails(array $event = null, array $targets = []): array
{
if (empty($event) || empty(array_filter($targets))) {
return [];
}
$subject = $this->buildCalendarEmailSubject($event);
$innerBody = view('emails/calendar_notification', [
'event' => $event,
'calendarUrl' => base_url('administrator/calendar_view'),
]);
$body = view('emails/_wrap_layout', [
'title' => $subject,
'subject' => $subject,
'body_html' => $innerBody,
]);
$results = [];
foreach ($targets as $group => $enabled) {
if (!$enabled) {
continue;
}
$emails = $this->collectEmailsForGroup($group);
if (empty($emails)) {
log_message('warning', "SchoolCalendarController: no recipient emails for {$group}.");
$results[$group] = null;
continue;
}
$ok = $this->emailService->sendBcc($emails, $subject, $body, 'general');
if (!$ok) {
log_message('warning', "SchoolCalendarController: failed to send calendar notification to {$group}.");
}
$results[$group] = $ok;
}
return $results;
}
protected function collectEmailsForGroup(string $group): array
{
switch ($group) {
case 'parents':
return $this->normalizeEmailAddresses($this->userModel->getParents());
case 'teachers':
return $this->normalizeEmailAddresses($this->teacherModel->getAllTeachers());
case 'admins':
return $this->normalizeEmailAddresses($this->fetchAdminRows());
default:
return [];
}
}
protected function normalizeEmailAddresses(array $rows): array
{
$emails = [];
foreach ($rows as $row) {
$email = trim((string) ($row['email'] ?? ''));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$emails[$email] = true;
}
return array_values(array_keys($emails));
}
protected function fetchAdminRows(): array
{
$excluded = ['guest', 'teacher', 'teacher_assistant', 'parent'];
$escaped = array_map(static fn($value) => "'" . addslashes($value) . "'", $excluded);
$excludedList = implode(', ', $escaped);
$builder = $this->db->table('users u')
->select("u.id, u.firstname, u.lastname, u.email, MAX(CASE WHEN LOWER(COALESCE(r.slug, r.name)) NOT IN({$excludedList}) THEN 1 ELSE 0 END) AS is_admin", false)
->join('user_roles ur', 'ur.user_id = u.id')
->join('roles r', 'r.id = ur.role_id')
->where('r.is_active', 1)
->where('u.email IS NOT NULL')
->where('u.email !=', '')
->groupBy('u.id, u.firstname, u.lastname, u.email')
->having('is_admin', 1);
return $builder->get()->getResultArray();
}
protected function buildCalendarEmailSubject(array $event): string
{
$title = trim((string) ($event['title'] ?? 'School Calendar Update'));
$typeSegment = !empty($event['event_type']) ? ' (' . $event['event_type'] . ')' : '';
return 'School Calendar: ' . $title . $typeSegment;
}
protected function buildEmailFlash(array $status): ?array
{
if (empty($status)) {
return null;
}
$sent = [];
$failed = [];
$missing = [];
foreach ($status as $group => $result) {
if ($result === true) {
$sent[] = $group;
} elseif ($result === false) {
$failed[] = $group;
} elseif ($result === null) {
$missing[] = $group;
}
}
$parts = [];
if (!empty($sent)) {
$parts[] = 'Email sent to ' . $this->humanizeGroupList($sent) . '.';
}
if (!empty($failed)) {
$parts[] = 'Sending failed for ' . $this->humanizeGroupList($failed) . '.';
}
if (!empty($missing)) {
$parts[] = 'No recipient emails for ' . $this->humanizeGroupList($missing) . '.';
}
if (empty($parts)) {
return null;
}
return [
'message' => implode(' ', $parts),
'class' => empty($failed) && empty($missing) ? 'info' : 'warning',
];
}
protected function humanizeGroupList(array $groups): string
{
$groups = array_map('strval', $groups);
if (empty($groups)) {
return '';
}
if (count($groups) === 1) {
return $groups[0];
}
$last = array_pop($groups);
return implode(', ', $groups) . ' and ' . $last;
}
protected function buildCalendarViewUrl(array $filters = []): string
{
$allowed = ['school_year', 'semester'];
$params = [];
foreach ($allowed as $key) {
$value = $filters[$key] ?? '';
if ($value !== '') {
$params[$key] = $value;
}
}
$query = http_build_query($params);
return '/administrator/calendar_view' . ($query !== '' ? ('?' . $query) : '');
}
public function delete($id)
{
// Check if the event exists
$event = $this->calendarModel->find($id);
if (!$event) {
return redirect()->to('/administrator/calendar_view')->with('error', 'Event not found.');
}
// Delete the event
if ($this->calendarModel->delete($id)) {
return redirect()->to('/administrator/calendar_view')->with('success', 'Event deleted successfully');
} else {
return redirect()->to('/administrator/calendar_view')->with('error', 'Failed to delete the event.');
}
}
public function calendarParentView()
{
// Transform the events into the format FullCalendar expects
$formattedEvents = $this->calendarView('parent');
// Pass the formatted events to the view
return view('/parent/calendar', ['events' => $formattedEvents]);
}
public function calendarTeacherView()
{
// Transform the events into the format FullCalendar expects
$formattedEvents = $this->calendarView('teacher');
// Pass the formatted events to the view
return view('/teacher/calendar', ['events' => $formattedEvents]);
}
public function calendarAdminView()
{
// Transform the events into the format FullCalendar expects
$formattedEvents = $this->calendarView('admin');
// Pass the formatted events to the view
return view('administrator/calendar', ['events' => $formattedEvents]);
}
private function calendarView(?string $audience = null)
{
// Filter events by school year
$events = $this->calendarModel->getEventsBySchoolYear($this->schoolYear);
// Apply audience visibility rules:
// - If no checkboxes selected (all notify_* = 0) => visible to everyone
// - Otherwise visible only to the selected audience
if (in_array($audience, ['parent', 'teacher', 'admin'], true)) {
$events = array_values(array_filter($events, function ($event) use ($audience) {
$p = (int) ($event['notify_parent'] ?? 0);
$t = (int) ($event['notify_teacher'] ?? 0);
$a = (int) ($event['notify_admin'] ?? 0);
$ns = (int) ($event['no_school'] ?? 0);
$hasAny = ($p + $t + $a) > 0;
// If marked as no_school, it should be visible to all audiences
if ($ns === 1) {
return true;
}
if (!$hasAny) {
// No audience explicitly selected: show to everyone
return true;
}
// Audience explicitly selected: restrict to matching audience
if ($audience === 'parent') { return $p === 1; }
if ($audience === 'teacher') { return $t === 1; }
if ($audience === 'admin') { return $a === 1; }
return true;
}));
}
// Format for FullCalendar
$aud = $audience; // capture for closure
$formattedEvents = array_map(function ($event) use ($aud) {
$p = (int) ($event['notify_parent'] ?? 0);
$t = (int) ($event['notify_teacher'] ?? 0);
$a = (int) ($event['notify_admin'] ?? 0);
$ns = (int) ($event['no_school'] ?? 0);
$backgroundColor = null;
// Color no-school events yellow on teacher and parent calendars
if (in_array($aud, ['teacher', 'parent'], true) && $ns === 1) {
$backgroundColor = '#ffc107'; // yellow
}
// Highlight events dedicated to staff (teachers/admins) only in green
// Definition: visible to staff but not to parents, and not a no-school marker
if (in_array($aud, ['teacher', 'admin'], true) && $backgroundColor === null) {
$isStaffOnly = ($p === 0 && ($t === 1 || $a === 1) && $ns === 0);
if ($isStaffOnly) {
// Use a distinct green to differentiate from the default blue
$backgroundColor = '#28a745';
}
}
// Title-casing for "no school" phrase when marked as no_school
$title = $event['title'] ?? 'Untitled';
if ($ns === 1 && is_string($title)) {
// Replace common variants with Title Case
$title = preg_replace('/\bno\s*[- ]\s*school\b/i', 'No School', $title);
}
return [
'id' => $event['id'],
'title' => $title,
'start' => $event['date'] ?? '', // FullCalendar expects 'start'
'description' => $event['description'] ?? '',
'extendedProps' => [
'semester' => $event['semester'] ?? '',
'school_year' => $event['school_year'] ?? '',
'notify_parent' => $p,
'notify_teacher' => $t,
'notify_admin' => $a,
'no_school' => $ns,
'backgroundColor' => $backgroundColor,
]
];
}, $events);
// Append parent meeting schedules
$meetingEvents = $this->formatMeetingEvents($audience);
if (!empty($meetingEvents)) {
$formattedEvents = array_values(array_merge($formattedEvents, $meetingEvents));
}
return $formattedEvents;
}
private function formatMeetingEvents(?string $audience = null): array
{
$aud = $audience ?? '';
$builder = $this->meetingModel
->where('school_year', $this->schoolYear)
->groupStart()
->where('status', 'scheduled')
->orWhere('status', 'Scheduled')
->orWhere('status', '')
->orWhere('status', null)
->groupEnd();
if ($aud === 'parent') {
$parentUserId = (int) (session()->get('user_id') ?? 0);
if ($parentUserId <= 0) {
return [];
}
$studentIds = $this->getStudentIdsForParent($parentUserId);
if (!empty($studentIds)) {
$builder = $builder->groupStart()
->where('parent_user_id', $parentUserId)
->orWhereIn('student_id', $studentIds)
->groupEnd();
} else {
$builder = $builder->where('parent_user_id', $parentUserId);
}
}
$rows = $builder->orderBy('date', 'ASC')->findAll();
if (empty($rows)) {
// Fallback: show any meetings if school_year was missing/mismatched
$fallback = $this->meetingModel
->groupStart()
->where('status', 'scheduled')
->orWhere('status', 'Scheduled')
->orWhere('status', '')
->orWhere('status', null)
->groupEnd();
if ($aud === 'parent') {
$parentUserId = (int) (session()->get('user_id') ?? 0);
if ($parentUserId > 0) {
$studentIds = $this->getStudentIdsForParent($parentUserId);
if (!empty($studentIds)) {
$fallback = $fallback->groupStart()
->where('parent_user_id', $parentUserId)
->orWhereIn('student_id', $studentIds)
->groupEnd();
} else {
$fallback = $fallback->where('parent_user_id', $parentUserId);
}
}
}
$rows = $fallback->orderBy('date', 'ASC')->findAll();
}
if (empty($rows)) {
return [];
}
return array_map(function ($row) use ($aud) {
$parentName = (string)($row['parent_name'] ?? 'Parent');
$studentName = (string)($row['student_name'] ?? 'Student');
$time = trim((string)($row['time'] ?? ''));
$timeLabel = $time !== '' ? (' ' . $time) : '';
if ($aud === 'admin') {
$title = 'Admin Meeting';
} else {
$title = 'Parent Meeting';
}
$descParts = [];
$descParts[] = 'Student: ' . $studentName;
$descParts[] = 'Parent: ' . $parentName;
if (!empty($row['class_section_name'])) {
$descParts[] = 'Section: ' . $row['class_section_name'];
}
$descParts[] = 'Date/Time: ' . ($row['date'] ?? '') . $timeLabel;
if (!empty($row['notes'])) {
$descParts[] = 'Notes: ' . $row['notes'];
}
$start = (string)($row['date'] ?? '');
if ($start !== '' && $time !== '') {
$start = $start . 'T' . $time;
}
return [
'id' => 'pm-' . (int)($row['id'] ?? 0),
'title' => $title,
'start' => $start,
'description' => implode("\n", $descParts),
'extendedProps' => [
'semester' => $row['semester'] ?? '',
'school_year' => $row['school_year'] ?? '',
'notify_parent' => 1,
'notify_admin' => 1,
'notify_teacher' => 0,
'no_school' => 0,
'backgroundColor' => '#6f42c1',
],
];
}, $rows);
}
private function getStudentIdsForParent(int $parentUserId): array
{
try {
$rows = $this->db->query(
"SELECT DISTINCT fs.student_id
FROM family_guardians fg
JOIN family_students fs ON fs.family_id = fg.family_id
WHERE fg.user_id = ?",
[$parentUserId]
)->getResultArray();
$ids = array_map(static fn($r) => (int)($r['student_id'] ?? 0), $rows);
// Legacy fallback: students.parent_id
$rows2 = $this->db->query(
"SELECT id AS student_id
FROM students
WHERE parent_id = ?",
[$parentUserId]
)->getResultArray();
foreach ($rows2 as $r) {
$ids[] = (int)($r['student_id'] ?? 0);
}
return array_values(array_filter($ids, static fn($id) => $id > 0));
} catch (\Throwable $e) {
return [];
}
}
}
@@ -1,74 +0,0 @@
<?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,54 +0,0 @@
Major U.S. Providers:
AT&T
SMS: <10-digit-number>@txt.att.net
MMS: <10-digit-number>@mms.att.net
Verizon
SMS: <10-digit-number>@vtext.com
MMS: <10-digit-number>@vzwpix.com
T-Mobile
SMS: <10-digit-number>@tmomail.net
MMS: <10-digit-number>@tmomail.net
Sprint (now merged with T-Mobile, but some legacy users may still use Sprint domains)
SMS: <10-digit-number>@messaging.sprintpcs.com
MMS: <10-digit-number>@pm.sprint.com
Boost Mobile
SMS: <10-digit-number>@sms.myboostmobile.com
MMS: <10-digit-number>@myboostmobile.com
U.S. Cellular
SMS: <10-digit-number>@email.uscc.net
MMS: <10-digit-number>@mms.uscc.net
Metro by T-Mobile (formerly MetroPCS)
SMS: <10-digit-number>@mymetropcs.com
MMS: <10-digit-number>@mymetropcs.com
Cricket Wireless
SMS: <10-digit-number>@sms.cricketwireless.net
MMS: <10-digit-number>@mms.cricketwireless.net
Google Fi
SMS/MMS: <10-digit-number>@msg.fi.google.com
Virgin Mobile USA
SMS: <10-digit-number>@vmobl.com
MMS: <10-digit-number>@vmpix.com
Carrier SMS Gateway MMS Gateway
AT&T number@txt.att.net number@mms.att.net
Verizon number@vtext.com number@vzwpix.com
T-Mobile number@tmomail.net number@tmomail.net
Sprint number@messaging.sprintpcs.com number@pm.sprint.com
Boost Mobile number@sms.myboostmobile.com number@myboostmobile.com
Cricket number@sms.cricketwireless.net number@mms.cricketwireless.net
US Cellular number@email.uscc.net number@mms.uscc.net
Google Fi number@msg.fi.google.com number@msg.fi.google.com
Virgin Mobile number@vmobl.com number@vmpix.com
Metro by T-Mobile number@mymetropcs.com number@mymetropcs.com
@@ -1,92 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use Config\SessionTimeout;
use CodeIgniter\Controller;
use CodeIgniter\API\ResponseTrait;
class SessionTimeoutController extends BaseController
{
use ResponseTrait;
public function getTimeoutConfig()
{
return $this->response->setJSON([
'success' => true,
'timeout' => SessionTimeout::TIMEOUT_DURATION,
'warning_time' => SessionTimeout::WARNING_THRESHOLD,
'check_interval' => SessionTimeout::CHECK_INTERVAL,
'logout_url' => site_url('auth/logout'),
'keep_alive_url' => site_url('session/pingActivity'),
'check_url' => site_url('session/checkTimeout')
]);
}
public function checkTimeout()
{
$session = session();
// Verify session exists and has last_activity
if (!$session->has('last_activity')) {
return $this->expireSession();
}
$lastActivity = $session->get('last_activity');
$elapsed = time() - $lastActivity;
if ($elapsed > SessionTimeout::TIMEOUT_DURATION) {
return $this->expireSession();
} elseif ($elapsed > SessionTimeout::WARNING_THRESHOLD) {
return $this->response->setJSON([
'status' => 'warning',
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed
]);
}
return $this->response->setJSON([
'status' => 'active',
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed
]);
}
public function pingActivity()
{
$session = session();
// Only update if session is still valid
if (!$session->has('last_activity') ||
(time() - $session->get('last_activity') > SessionTimeout::TIMEOUT_DURATION)) {
return $this->expireSession();
}
$session->set('last_activity', time());
return $this->response->setJSON([
'status' => 'active',
'time_remaining' => SessionTimeout::TIMEOUT_DURATION
]);
}
private function expireSession()
{
$this->destroySession();
return $this->response->setJSON([
'status' => 'expired',
'redirect' => site_url('login'),
'message' => 'Your session has expired due to inactivity.'
]);
}
private function destroySession()
{
$session = session();
$session->setFlashdata('error', 'Your session has expired due to inactivity.');
// Clear session data
$session->remove('last_activity');
$session->destroy();
// Regenerate session ID for security
session_regenerate_id(true);
}
}
@@ -1,38 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\SettingsModel;
class SettingsController extends BaseController
{
protected $settingsModel;
public function __construct()
{
$this->settingsModel = new SettingsModel();
}
public function index()
{
// Retrieve current settings from the database
$settings = $this->settingsModel->getSettings();
$timezones = timezone_identifiers_list();
// Pass the settings and timezones to the view
return view('administrator/settings', ['settings' => $settings, 'timezones' => $timezones]);
}
public function update()
{
// Handle the form submission to update settings
$data = [
'site_name' => $this->request->getPost('site_name'),
'administrator_email' => strtolower($this->request->getPost('administrator_email')),
'timezone' => $this->request->getPost('timezone'),
];
$this->settingsModel->updateSettings($data);
return redirect()->to('/administrator/settings');
}
}
?>
@@ -1,691 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use CodeIgniter\HTTP\ResponseInterface;
use App\Models\UserModel;
use App\Models\LateSlipLogModel;
use App\Models\ConfigurationModel;
use Mike42\Escpos\PrintConnectors\WindowsPrintConnector;
// ESC/POS
use Mike42\Escpos\Printer;
use Mike42\Escpos\EscposImage;
use Mike42\Escpos\PrintConnectors\NetworkPrintConnector;
use Mike42\Escpos\PrintConnectors\UsbPrintConnector;
use Dompdf\Dompdf;
use Dompdf\Options;
class SlipPrinterController extends BaseController
{
protected $configModel;
protected $semester;
protected $schoolYear;
public function __construct()
{
helper(['form', 'url']);
$this->configModel = new ConfigurationModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
/**
* POST /slips/print
* Expected fields (POST): school_year, student_name, date, time_in, grade, reason, admin_name
* You can wire this to your own form/data source; all fields are strings.
*/
public function print(): ResponseInterface
{
$req = $this->request;
$data = [
'school_year' => trim((string) $req->getVar('school_year') ?: ''),
'student_name' => trim((string) $req->getVar('student_name') ?: ''),
'date' => trim((string) $req->getVar('date') ?: date('m/d/Y')),
'time_in' => trim((string) $req->getVar('time_in') ?: date('h:i A')),
'grade' => trim((string) $req->getVar('grade') ?: ''),
'reason' => trim((string) $req->getVar('reason') ?: ''),
'admin_name' => trim((string) $req->getVar('admin_name') ?: ''),
];
// Always prefer current logged-in user's full name from DB
$adminFromDb = $this->currentAdminName();
if ($adminFromDb !== '') {
$data['admin_name'] = $adminFromDb;
}
// Validate minimal fields
if ($data['student_name'] === '') {
return $this->response->setStatusCode(422)->setJSON(['error' => 'Student name is required.']);
}
try {
// Persist a log (best-effort) before generating PDF
try {
$printedBy = (int) (session()->get('user_id') ?? 0) ?: null;
$cfg = new ConfigurationModel();
$semester = (string)($cfg->getConfig('semester') ?? '');
$logData = [
'school_year' => $data['school_year'],
'semester' => $semester,
'student_name' => $data['student_name'],
'slip_date' => $this->toDbDate($data['date']),
'time_in' => $this->toDbTime($data['time_in']),
'grade' => $data['grade'],
'reason' => $data['reason'],
'admin_name' => $data['admin_name'],
];
(new LateSlipLogModel())->logSlip($logData, $printedBy);
} catch (\Throwable $e) {
log_message('error', 'Late slip log insert failed: ' . $e->getMessage());
}
// Generate and stream a PDF instead of direct printer output
return $this->renderSlipPdfResponse($data);
} catch (\Throwable $e) {
log_message('error', 'Late slip PDF generation failed: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'error' => 'Printing failed.',
'details' => $e->getMessage(),
]);
}
}
/**
* Show or print preview of late slips.
*/
public function preview(): ResponseInterface
{
$req = $this->request;
try {
// ---- Step 1: Resolve current school year ----
$currentYear = trim((string) ($this->schoolYear ?? ''));
if ($currentYear === '') {
$latest = (new LateSlipLogModel())
->select('school_year')
->orderBy('id', 'DESC')
->first();
$currentYear = $latest['school_year'] ?? (date('Y') . '-' . (date('Y') + 1));
}
$selectedYear = trim((string) $req->getVar('school_year') ?: $currentYear);
$selectedSemester = strtolower(trim((string) $req->getVar('semester') ?: ''));
$data = [
'school_year' => $selectedYear,
'student_name' => trim((string) $req->getVar('student_name') ?: ''),
'date' => trim((string) $req->getVar('date') ?: date('m/d/Y')),
'time_in' => trim((string) $req->getVar('time_in') ?: date('h:i A')),
'grade' => trim((string) $req->getVar('grade') ?: ''),
'reason' => trim((string) $req->getVar('reason') ?: ''),
'admin_name' => trim((string) $req->getVar('admin_name') ?: ''),
];
// Prefer logged-in admin name
$adminFromDb = $this->currentAdminName();
if ($adminFromDb !== '') {
$data['admin_name'] = $adminFromDb;
}
// ---- Handle GET (HTML preview) ----
if (strtolower($req->getMethod()) === 'get') {
$rows = [];
$logModel = new LateSlipLogModel();
$builder = $logModel;
// Filter by school year if not empty
if ($selectedYear !== '') {
$builder->where('school_year', $selectedYear);
}
// Normalize and apply semester filter
$hasFilter = false;
if ($selectedSemester !== '') {
$hasFilter = true;
if ($selectedSemester === 'fall') {
$builder->groupStart()
->where('semester', 'fall')
->orWhere('semester', '1')
->orWhere('semester', 1)
->groupEnd();
} elseif ($selectedSemester === 'spring') {
$builder->groupStart()
->where('semester', 'spring')
->orWhere('semester', '2')
->orWhere('semester', 2)
->groupEnd();
}
}
// ---- Execute query ----
$logs = $builder->orderBy('id', 'DESC')->findAll(50);
// 🟢 Only use fallback when *no filters are selected*
if (empty($logs) && !$hasFilter && $selectedYear === '') {
$logs = (new LateSlipLogModel())->orderBy('id', 'DESC')->findAll(50);
}
// ---- Format rows ----
foreach ($logs as $r) {
$dispDate = '';
$slipDateRaw = trim((string)($r['slip_date'] ?? ''));
if ($slipDateRaw !== '' && $slipDateRaw !== '0000-00-00') {
$ts = strtotime($slipDateRaw);
$dispDate = $ts ? date('m/d/Y', $ts) : '';
}
if ($dispDate === '') {
$printedAt = trim((string)($r['printed_at'] ?? ''));
if ($printedAt !== '') {
try {
$dispDate = local_date($printedAt, 'm/d/Y');
} catch (\Throwable $e) {
$ts = strtotime($printedAt);
$dispDate = $ts ? date('m/d/Y', $ts) : '';
}
}
}
$dispTime = '';
if (!empty($r['time_in'])) {
$t = strtotime($r['time_in']) ?: strtotime('today ' . $r['time_in']);
$dispTime = $t ? date('h:i A', $t) : '';
}
$rows[] = [
'school_year' => (string)($r['school_year'] ?? ''),
'semester' => (string)($r['semester'] ?? ''),
'student_name' => (string)($r['student_name'] ?? ''),
'date' => $dispDate,
'time_in' => $dispTime,
'grade' => (string)($r['grade'] ?? ''),
'reason' => (string)($r['reason'] ?? ''),
'admin_name' => (string)($r['admin_name'] ?? ''),
];
}
// ---- Render the view ----
$html = view('slips/preview_list', [
'rows' => $rows,
'school_year' => $selectedYear,
'semester' => $selectedSemester,
]);
return $this->response
->setStatusCode(200)
->setHeader('Content-Type', 'text/html; charset=UTF-8')
->setBody($html);
}
// ---- POST mode (JSON preview for printer) ----
$cfg = $this->printerConfig();
$lineWidth = (int) ($cfg['chars_per_line'] ?? 48);
$feedLines = (int) ($cfg['feed_lines'] ?? 3);
$header = "Al Rahma School at ISGL\nSchool Year: {$data['school_year']}\n\n";
$body = implode("\n", $this->buildSlipLines($data, $lineWidth));
$footer = str_repeat("\n", $feedLines);
$full = $header . $body . $footer;
return $this->response->setJSON([
'ok' => true,
'text' => $full,
'width' => $lineWidth,
]);
} catch (\Throwable $e) {
log_message('error', 'Late slip preview failed: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'ok' => false,
'error' => 'Preview failed.',
'details' => $e->getMessage(),
]);
}
}
/**
* POST /administrator/late_slips/reprint/{id}
* Reprint a late slip from a saved log row, then redirect back with status.
*/
public function reprint($id = null): ResponseInterface
{
$id = (int) ($id ?? 0);
if ($id <= 0) {
return redirect()->back()->with('error', 'Invalid slip id.');
}
$row = null;
try {
$row = (new LateSlipLogModel())->find($id);
} catch (\Throwable $e) {
$row = null;
}
if (!$row) {
return redirect()->back()->with('error', 'Slip not found.');
}
// Map DB row to print payload
$dateDisplay = '';
if (!empty($row['slip_date'])) {
$ts = strtotime($row['slip_date']);
$dateDisplay = $ts ? date('m/d/Y', $ts) : '';
}
if ($dateDisplay === '') $dateDisplay = date('m/d/Y');
$timeDisplay = '';
if (!empty($row['time_in'])) {
$ts = strtotime($row['time_in']);
if ($ts === false) {
// Try combining with today
$ts = strtotime('today ' . $row['time_in']);
}
$timeDisplay = $ts ? date('h:i A', $ts) : '';
}
if ($timeDisplay === '') $timeDisplay = date('h:i A');
$data = [
'school_year' => (string) ($row['school_year'] ?? ''),
'student_name' => (string) ($row['student_name'] ?? ''),
'date' => $dateDisplay,
'time_in' => $timeDisplay,
'grade' => (string) ($row['grade'] ?? ''),
'reason' => (string) ($row['reason'] ?? ''),
'admin_name' => (string) ($row['admin_name'] ?? ''),
];
// Always prefer current logged-in user's full name from DB
$adminFromDb = $this->currentAdminName();
if ($adminFromDb !== '') {
$data['admin_name'] = $adminFromDb;
}
try {
// Format lines and print
$lines = $this->buildSlipLines($data);
$printer = $this->makePrinter();
$printer->setJustification(Printer::JUSTIFY_CENTER);
$printer->setEmphasis(true);
$printer->text("Al Rahma School at ISGL\n");
$printer->setEmphasis(false);
$printer->text("School Year: {$data['school_year']}\n");
$printer->feed();
$printer->setJustification(Printer::JUSTIFY_LEFT);
foreach ($lines as $ln) {
$printer->text($ln . "\n");
}
$printer->feed((int) ($this->printerConfig()['feed_lines'] ?? 3));
$printer->cut();
$printer->close();
// Log again (best-effort) for audit trail
try {
$printedBy = (int) (session()->get('user_id') ?? 0) ?: null;
$cfg = new ConfigurationModel();
$semester = (string)($cfg->getConfig('semester') ?? '');
$logData = [
'school_year' => $data['school_year'],
'semester' => $semester,
'student_name' => $data['student_name'],
'slip_date' => $this->toDbDate($data['date']),
'time_in' => $this->toDbTime($data['time_in']),
'grade' => $data['grade'],
'reason' => $data['reason'],
'admin_name' => $data['admin_name'],
];
(new LateSlipLogModel())->logSlip($logData, $printedBy);
} catch (\Throwable $e) {
log_message('error', 'Late slip reprint log insert failed: ' . $e->getMessage());
}
return redirect()->back()->with('success', 'Late slip sent to printer.');
} catch (\Throwable $e) {
return redirect()->back()->with('error', 'Reprint failed: ' . $e->getMessage());
}
}
/**
* Build monospaced slip lines for an 80mm (typically 48 chars) receipt.
*
* Layout required:
* Al Rahma School at ISGL "SchoolYear"
* Student Name ___________________________
* Date ______________ Time In ______________
* Grade _________________________________
* Reason _________________________________
* _______________________________________
* Admin Name ____________________________
*/
private function buildSlipLines(array $data, ?int $lineWidth = null): array
{
// Choose a sane default for 58mm rolls (~2432 chars). Fall back to env config.
$cfgWidth = (int) ($this->printerConfig()['chars_per_line'] ?? 0);
$w = $lineWidth ?? ($cfgWidth > 0 ? $cfgWidth : 32);
$lines = [];
$lines[] = $this->fitLine('Student Name: ' . (string)($data['student_name'] ?? ''), $w);
$lines[] = $this->fitLine('Date: ' . (string)($data['date'] ?? '') . ' ' . 'Time In: ' . (string)($data['time_in'] ?? ''), $w);
$lines[] = $this->fitLine('Grade: ' . (string)($data['grade'] ?? ''), $w);
$lines[] = $this->fitLine('Reason: ' . (string)($data['reason'] ?? ''), $w);
$lines[] = $this->fitLine('Admin: ' . (string)($data['admin_name'] ?? ''), $w);
return $lines;
}
/**
* Create and return a configured Mike42\Escpos\Printer instance.
*/
private function makePrinter(): Printer
{
$cfg = $this->printerConfig();
$mode = strtolower((string) ($cfg['mode'] ?? 'network'));
if ($mode === 'windows') {
if (stripos(PHP_OS_FAMILY, 'Windows') !== false) {
$name = (string) ($cfg['windows_name'] ?? 'POS-80');
$connector = new \Mike42\Escpos\PrintConnectors\WindowsPrintConnector($name);
return new Printer($connector);
}
log_message('warning', 'PRINTER_MODE=windows on non-Windows OS; falling back to network.');
$mode = 'network';
}
if ($mode === 'usb') {
if (stripos(PHP_OS_FAMILY, 'Windows') !== false) {
$name = (string) ($cfg['windows_name'] ?? 'POS-80');
$connector = new \Mike42\Escpos\PrintConnectors\WindowsPrintConnector($name);
return new Printer($connector);
}
$vidStr = (string) ($cfg['usb_vid'] ?? '');
$pidStr = (string) ($cfg['usb_pid'] ?? '');
if ($vidStr === '' || $pidStr === '') {
throw new \RuntimeException('USB mode requires PRINTER_USB_VID and PRINTER_USB_PID');
}
$vid = $this->hexToInt($vidStr);
$pid = $this->hexToInt($pidStr);
$connector = new \Mike42\Escpos\PrintConnectors\UsbPrintConnector($vid, $pid);
return new Printer($connector);
}
// Default: network
$host = (string) ($cfg['host'] ?? '192.168.1.100');
$port = (int) ($cfg['port'] ?? 9100);
$connector = new \Mike42\Escpos\PrintConnectors\NetworkPrintConnector($host, $port);
return new Printer($connector);
}
/**
* Build a "Label: ________VALUE" style line, using underscores to fill remaining space.
* If $value is empty, just render a long blank line after the label.
*/
private function labelWithLine(string $label, string $value, int $width, ?int $fillCount = null): string
{
$label = rtrim($label, ':') . ': ';
$maxFill = $fillCount ?? max(0, $width - strlen($label));
$filled = $value !== ''
? $this->fixedField($value, $maxFill, '_')
: str_repeat('_', $maxFill);
$line = $label . $filled;
// Ensure total width, pad or trim
return $this->fitLine($line, $width);
}
/**
* Create a single field like "Date: ________" (or with value).
*/
private function fieldWithBlanks(string $label, string $value, int $fieldWidth): string
{
$label = rtrim($label, ':') . ': ';
$fill = max(0, $fieldWidth - strlen($label));
$content = $value !== '' ? $this->fixedField($value, $fill, '_') : str_repeat('_', $fill);
return $this->fitLine($label . $content, $fieldWidth);
}
/**
* Join two fixed-width fields into one line (e.g., Date field + Time In field).
*/
private function joinTwoFields(string $left, string $right, int $totalWidth): string
{
$gap = ' ';
$line = $left . $gap . $right;
return $this->fitLine($line, $totalWidth);
}
/**
* Pad/truncate a value to an exact width using a fill character (for underlines).
*/
private function fixedField(string $val, int $width, string $fillChar = '_'): string
{
$val = (string) $val;
if (strlen($val) > $width) {
return substr($val, 0, $width);
}
return $val . str_repeat($fillChar, max(0, $width - strlen($val)));
}
/**
* Ensure any line is exactly $width characters (trim or pad spaces).
*/
private function fitLine(string $line, int $width): string
{
$line = (string) $line;
if (strlen($line) > $width) {
return substr($line, 0, $width);
}
return $line . str_repeat(' ', $width - strlen($line));
}
/**
* "0x1a86" -> 0x1a86 (int). Accepts plain decimal too.
*/
private function hexToInt(string $hexOrDec): int
{
$hexOrDec = trim($hexOrDec);
if ($hexOrDec === '') return 0;
if (stripos($hexOrDec, '0x') === 0) {
return intval($hexOrDec, 16);
}
return (int) $hexOrDec;
}
/**
* Consolidated printer configuration. Supports both dotted keys (printer.*)
* and uppercase PRINTER_* keys from .env.
*/
private function printerConfig(): array
{
$get = function ($keys, $default = null) {
$keys = is_array($keys) ? $keys : [$keys];
foreach ($keys as $k) {
$v = env($k);
if ($v !== null && $v !== '') return $v;
}
return $default;
};
return [
'mode' => strtolower((string) $get(['printer.mode', 'PRINTER_MODE'], 'network')),
'host' => (string) $get(['printer.host', 'PRINTER_HOST'], '192.168.1.100'),
'port' => (int) $get(['printer.port', 'PRINTER_PORT'], 9100),
'usb_vid' => (string) $get(['printer.usb.vid', 'PRINTER_USB_VID'], ''),
'usb_pid' => (string) $get(['printer.usb.pid', 'PRINTER_USB_PID'], ''),
'windows_name' => (string) $get(['printer.windows.name', 'PRINTER_WINDOWS_NAME'], 'POS-80'),
'chars_per_line' => (int) $get(['printer.chars_per_line', 'PRINTER_CHARS_PER_LINE'], 48),
'feed_lines' => (int) $get(['printer.feed_lines', 'PRINTER_FEED_LINES'], 3),
];
}
/**
* Convert user-provided date/time strings into DB formats.
*/
private function toDbDate(?string $s): ?string
{
$s = trim((string)$s);
if ($s === '') return null;
// Explicitly parse common US formats to avoid day/month swaps.
if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $s)) {
return $s;
}
if (preg_match('/^\\d{1,2}\\/\\d{1,2}\\/\\d{4}$/', $s)) {
$dt = \DateTime::createFromFormat('!m/d/Y', $s);
return $dt ? $dt->format('Y-m-d') : null;
}
if (preg_match('/^\\d{1,2}-\\d{1,2}-\\d{4}$/', $s)) {
$dt = \DateTime::createFromFormat('!m-d-Y', $s);
return $dt ? $dt->format('Y-m-d') : null;
}
$ts = strtotime($s);
return $ts ? date('Y-m-d', $ts) : null;
}
private function toDbTime(?string $s): ?string
{
$s = trim((string)$s);
if ($s === '') return null;
$ts = strtotime($s);
return $ts ? date('H:i:s', $ts) : null;
}
/**
* Compute current admin full name using session user_id and users table.
* Falls back to session firstname/lastname, then user_name, else ''.
*/
private function currentAdminName(): string
{
try {
$uid = (int) (session()->get('user_id') ?? 0);
if ($uid > 0) {
$user = (new UserModel())
->select('firstname, lastname')
->find($uid);
if ($user) {
$full = trim((string)($user['firstname'] ?? '') . ' ' . (string)($user['lastname'] ?? ''));
if ($full !== '') return $full;
}
}
} catch (\Throwable $e) {
// ignore and fall back
}
$first = trim((string)(session()->get('firstname') ?? ''));
$last = trim((string)(session()->get('lastname') ?? ''));
$full = trim($first . ' ' . $last);
if ($full !== '') return $full;
return (string)(session()->get('user_name') ?? '');
}
/**
* Render the slip as a PDF and stream it inline to the browser.
*/
private function renderSlipPdfResponse(array $data): ResponseInterface
{
// Build HTML from a dedicated view
// Determine paper size (mm)
$paper = strtolower((string) ($this->request->getVar('paper') ?: env('SLIP_PAPER', 'card')));
// Estimate dynamic height based on the PDF view font and line-height
// Title + seven content lines = 8 rows total
// Defaults match the current view: 13pt font, 2.0 line-height
$fontPt = (float) ($this->request->getVar('font_pt') ?? env('SLIP_FONT_PT', 13.0));
$lineH = (float) ($this->request->getVar('line_h') ?? env('SLIP_LINE_H', 2.0));
$linesCount = (int) ($this->request->getVar('rows') ?? 8);
if ($linesCount < 1) {
$linesCount = 8;
}
$ptPerLine = $fontPt * $lineH;
$mmPerPt = 25.4 / 72.0; // 1pt in mm
$contentMm = $ptPerLine * $linesCount * $mmPerPt; // content only
$pageMarginsMm = 3.0 * 2; // top+bottom from CSS
// Allow caller to increase safety buffer if their viewer/driver adds spacing
// Slightly bigger safety buffer to avoid second-page spillovers from drivers
$fudgeMm = (float) ($this->request->getVar('fudge_mm') ?? env('SLIP_FUDGE_MM', 12.0));
$dynHeightMm = $contentMm + $pageMarginsMm + $fudgeMm;
switch ($paper) {
case 'cardp':
case 'card-portrait':
$wMm = 53.98;
$hMm = max($dynHeightMm, 40.0); // portrait width, dynamic height
$wChars = 30;
break;
case 'receipt80':
$wMm = 72.0;
$hMm = 90.0; // 80mm roll (printable ~72mm) — taller to fit larger font
$wChars = 30;
break;
case 'receipt58':
$wMm = 58.0;
$hMm = 80.0; // 58mm roll — taller to fit larger font
$wChars = 24;
break;
case 'card':
default:
$wMm = 85.60;
$hMm = max($dynHeightMm, 40.0); // credit card width, dynamic height
$wChars = 40;
}
// Optional manual override via query: height_mm (or h)
$hOverride = $this->request->getVar('height_mm') ?? $this->request->getVar('h');
if (is_numeric($hOverride)) {
$hMm = max(30.0, min(200.0, (float) $hOverride));
}
// Build exactly six lines in monospace formatting
$w = $wChars; // characters per line for layout
$lines = [];
$lines[] = $this->fitLine('Al Rahma School at ISGL ' . ($data['school_year'] ?? ''), $w);
// Helper to compose "Label: value_____" without truncating value first
$compose = function (string $label, string $value, int $padUnderscore = 0) use ($w) {
$prefix = rtrim($label, ':') . ': ';
$line = $prefix . $value;
$len = strlen($line);
if ($padUnderscore > 0 && $len < $w) {
$line .= str_repeat('_', min($padUnderscore, $w - $len));
}
return $this->fitLine($line, $w);
};
// Student Name (no trailing underscores)
$lines[] = $compose('Student Name', (string)($data['student_name'] ?? ''), 0);
// Date and Time In on separate lines
$lines[] = $this->fitLine('Date: ' . (string)($data['date'] ?? ''), $w);
$lines[] = $this->fitLine('Time In: ' . (string)($data['time_in'] ?? ''), $w);
// Grade and Reason (no trailing underscores)
$lines[] = $compose('Grade', (string)($data['grade'] ?? ''), 0);
$lines[] = $compose('Reason', (string)($data['reason'] ?? ''), 0);
// Admin: do not underline, just value
$lines[] = $this->fitLine('Admin: ' . (string)($data['admin_name'] ?? ''), $w);
$html = view('slips/slip_pdf', ['entry' => $data, 'lines' => $lines, 'page' => ['w_mm' => $wMm, 'h_mm' => $hMm]]);
$options = new Options();
$options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'Courier');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
// Ensure minimum height is enough for current content calculation
if ($hMm < $dynHeightMm) {
$hMm = $dynHeightMm;
}
// Set paper size from selected mm values -> points
$mmToPt = 72 / 25.4;
$wPt = $wMm * $mmToPt;
$hPt = $hMm * $mmToPt;
$dompdf->setPaper([0, 0, $wPt, $hPt]);
$dompdf->render();
$filename = 'LateSlip_' . preg_replace('/[^A-Za-z0-9]+/', '_', (string)($data['student_name'] ?? 'slip')) . '_' . date('Ymd_His') . '.pdf';
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->setBody($dompdf->output());
}
}
@@ -1,156 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\StaffModel;
use App\Models\UserModel;
use App\Models\TeacherClassModel;
use App\Models\ConfigurationModel;
class StaffController extends BaseController
{
protected $configModel;
protected $semester;
protected $schoolYear;
protected $teacherClassModel;
protected $userModel;
protected $staffModel;
public function __construct()
{
// Initialize the config model
$this->configModel = new ConfigurationModel(); // Assuming ConfigModel is the model handling configurations
$this->teacherClassModel = new TeacherClassModel();
$this->userModel = new UserModel();
$this->staffModel = new StaffModel();
// Retrieve the configuration values
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
public function index()
{
// roles we never show
$excludedRoles = ['student', 'parent', 'guest', 'inactive']; // ← add inactive here
$staffList = $this->staffModel
->whereNotIn('LOWER(active_role)', array_map('strtolower', $excludedRoles))
->orderBy('created_at', 'DESC')
->findAll();
// Preload assignments for the selected school year (across all semesters),
// mirroring the logic used in teacher_class_assignment.
$assignRows = $this->teacherClassModel
->select('teacher_class.teacher_id, teacher_class.position, classSection.class_section_name')
->join('classSection', 'classSection.class_section_id = teacher_class.class_section_id', 'left')
->where('teacher_class.school_year', (string)$this->schoolYear)
->findAll();
$assignByTeacher = [];
foreach ($assignRows as $r) {
$tid = (int)($r['teacher_id'] ?? 0);
if ($tid <= 0) { continue; }
$name = trim((string)($r['class_section_name'] ?? ''));
if ($name === '') { continue; }
$pos = strtolower((string)($r['position'] ?? ''));
if (!in_array($pos, ['main','ta'], true)) { continue; }
$assignByTeacher[$tid][] = $name . ' (' . $pos . ')';
}
$issuesCount = 0;
foreach ($staffList as &$staff) {
// attach school_id
$staff['school_id'] = $this->userModel->getSchoolIdByUserId($staff['user_id'] ?? null);
// Verify and attach class assignments for teacher/TA roles for the selected school year
$role = strtolower((string)($staff['active_role'] ?? ''));
if (in_array($role, ['teacher', 'teacher_assistant'], true)) {
$tid = (int)($staff['user_id'] ?? 0);
$labels = $assignByTeacher[$tid] ?? [];
if (!empty($labels)) {
$staff['class_section'] = implode(', ', array_unique($labels));
$staff['verification_issue'] = false;
} else {
$staff['class_section'] = 'No class assigned';
$staff['verification_issue'] = true;
$issuesCount++;
}
} else {
$staff['class_section'] = '—';
$staff['verification_issue'] = false;
}
}
unset($staff); // break reference
return view('staff/index', [
'staff' => $staffList,
'issues_count' => $issuesCount,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
]);
}
public function create()
{
return view('staff/create');
}
public function store()
{
$now = utc_now();
$payload = [
'firstname' => $this->request->getPost('firstname'),
'lastname' => $this->request->getPost('lastname'),
'email' => $this->request->getPost('email'),
'phone' => $this->request->getPost('phone'),
'role_name' => $this->request->getPost('role_name'),
'school_year' => $this->request->getPost('school_year') ?: $this->schoolYear,
'status' => $this->request->getPost('status') ?? 'Active',
'created_at' => $now,
'updated_at' => $now,
];
if (!$this->staffModel->insert($payload)) {
return redirect()->back()->withInput()->with('error', 'Failed to create staff.');
}
return redirect()->to('/staff')->with('success', 'Staff member added.');
}
public function edit($id)
{
$user = $this->staffModel->find($id);
if (!$user) {
return redirect()->to('staff')->with('error', 'Staff member not found.');
}
return view('staff/edit', ['user' => $user]);
}
public function update($id)
{
// Only update fields provided by the form to avoid wiping data
$data = [];
foreach (['firstname','lastname','email','phone','role_name','school_year','status'] as $field) {
$val = $this->request->getPost($field);
if ($val !== null && $val !== '') {
$data[$field] = $val;
}
}
// Always bump updated_at
$data['updated_at'] = utc_now();
if (empty($data)) {
return redirect()->back()->with('error', 'No changes detected.');
}
if (!$this->staffModel->update($id, $data)) {
return redirect()->back()->withInput()->with('error', 'Failed to update staff.');
}
return redirect()->to('/staff')->with('success', 'Staff member updated.');
}
}
@@ -1,32 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Models\StatsModel;
use App\Controllers\BaseController;
class StatsController extends BaseController
{
public function index()
{
$model = new StatsModel();
$data['stats'] = $model->getStats();
return view('stats_view', $data);
}
public function calendar()
{
return view('/calendars');
}
public function support()
{
return view('/support');
}
public function contactUs()
{
return view('/contact_us');
}
}
@@ -1,390 +0,0 @@
<?php
namespace App\Controllers\View;
class StickersController extends PrintablesBaseController
{
public function stickerForm()
{
$data['students'] = $this->studentModel->findAll();
$data['classes'] = $this->classSectionModel->findAll();
$data['stickers'] = $this->stickerPresets();
return view('printables_reports/sticker_form', $data);
}
public function getByClass($classId)
{
$builder = $this->db->table('student_class sc')
->select('s.id, s.firstname, s.lastname, s.registration_grade, s.gender')
->join('students s', 's.id = sc.student_id')
->where('sc.class_section_id', $classId)
->where('sc.school_year', $this->schoolYear)
->orderBy('s.lastname', 'asc');
$students = $builder->get()->getResultArray();
return $this->response->setJSON($students);
}
/** Preset sticker sizes for the view (robust keys) */
private function stickerPresets(): array
{
$rows = [
['value' => '100x24.5', 'label' => 'Xsmall - 100x24.5 mm', 'ppg' => 20],
['value' => '60x30', 'label' => 'Small - 60x30 mm', 'ppg' => 24],
['value' => '102x34', 'label' => 'Medium - 102x34 mm', 'ppg' => 14],
['value' => '90x45', 'label' => 'Large - 90x45 mm', 'ppg' => 10],
];
// Add aliases so different views will not break (title/text/per_page)
return array_map(static function ($r) {
$r['title'] = $r['title'] ?? ($r['label'] ?? ($r['value'] . ' mm'));
$r['text'] = $r['text'] ?? $r['title'];
$r['per_page'] = $r['per_page'] ?? ($r['ppg'] ?? null);
return $r;
}, $rows);
}
/**
* Generate name stickers as a PDF.
*
* Can be used for different sticker sizes/sheets by passing parameters
* via function args (from routes) or via GET/POST form fields.
*
* Accepted request params (POST takes precedence over GET):
* - class_id : int
* - student_id : int
* - print_all : 0|1
* - sticker_size : string like "102x34" (accepts upper/lower case X or the unicode multiply sign)
* - sticker_width : number (mm)
* - sticker_height : number (mm)
* - stickers_per_page : int (optional cap; grid still derived from size/margins)
* - page_size : "A4" (default) | "Letter" | any FPDF size token
* - orientation : "P" (default) | "L"
* - margin_left : number (mm)
* - margin_top : number (mm)
* - margin_right : number (mm; defaults to margin_left)
* - margin_bottom : number (mm; default 10)
* - gap_x : number (mm; horizontal gap between stickers)
* - gap_y : number (mm; vertical gap between stickers)
* - copies[<student_id>] : int per-student copies in batch mode
* - single_copies : int copies for single student mode
*/
public function generateStickers(
?int $studentId = null,
?float $stickerWidthArg = null,
?float $stickerHeightArg = null,
?int $stickersPerPageArg = null,
?string $pageSizeArg = null,
?string $orientationArg = null,
?float $marginLeftArg = null,
?float $marginTopArg = null,
?float $marginRightArg = null,
?float $marginBottomArg = null,
?float $gapXArg = null,
?float $gapYArg = null
) {
$request = service('request');
$method = strtolower($request->getMethod());
$classId = $request->getPost('class_id') ?: $request->getGet('class_id');
$studentId = $studentId ?? ($request->getPost('student_id') ?: $request->getGet('student_id'));
// Safety cast
$classId = $classId !== null ? (int) $classId : null;
$studentId = $studentId !== null ? (int) $studentId : null;
// Sanity log for school year
if (empty($this->schoolYear)) {
log_message('error', 'generateStickers(): $this->schoolYear is empty. Load it from ConfigurationModel.');
} else {
log_message('debug', 'generateStickers(): schoolYear=' . $this->schoolYear);
}
// Load classes that actually have students in this school year
$classes = $this->classSectionModel
->select('classSection.class_section_id, classSection.class_section_name')
->join('student_class', 'student_class.class_section_id = classSection.class_section_id', 'inner')
->where('student_class.school_year', $this->schoolYear)
->groupBy('classSection.class_section_id, classSection.class_section_name')
->orderBy('classSection.class_section_name', 'ASC')
->findAll();
// ---------- GET: render form ----------
if ($method === 'get') {
$students = [];
if (!empty($classId)) {
$students = $this->fetchStudentsByClass((int) $classId, $this->schoolYear);
} else {
$students = $this->studentModel
->select('students.id, students.firstname, students.lastname')
->join('student_class', 'student_class.student_id = students.id', 'inner')
->where('student_class.school_year', $this->schoolYear)
->groupBy('students.id, students.firstname, students.lastname')
->orderBy('students.firstname', 'ASC')
->orderBy('students.lastname', 'ASC')
->findAll(500);
}
// Presets (now with robust keys expected by the view)
$presetRows = [
['value' => '60x30', 'label' => 'Small - 60x30 mm', 'ppg' => 24],
['value' => '102x34', 'label' => 'Medium - 102x34 mm', 'ppg' => 14],
['value' => '90x45', 'label' => 'Large - 90x45 mm', 'ppg' => 10],
];
// Normalize to include 'title', 'text', and 'per_page' so views using any of those will not error.
$stickers = array_map(static function (array $r): array {
$r['title'] = $r['title'] ?? ($r['label'] ?? ($r['value'] . ' mm'));
$r['text'] = $r['text'] ?? $r['title'];
$r['per_page'] = $r['per_page'] ?? ($r['ppg'] ?? null);
return $r;
}, $presetRows);
return view('printables_reports/sticker_form', [
'classes' => $classes,
'students' => $students,
'stickers' => $stickers,
]);
}
// ---------- POST: build student list ----------
$printAll = (int) ($request->getPost('print_all') ?? $request->getGet('print_all') ?? 0) === 1;
$students = [];
if (!empty($studentId)) {
$student = $this->studentModel->find((int) $studentId);
if (!$student) {
return 'Student not found.';
}
$students = [$student];
} elseif (!empty($classId)) {
$students = $this->fetchStudentsByClass((int) $classId, $this->schoolYear);
if (empty($students)) {
return 'No students found in this class.';
}
} elseif ($printAll) {
$students = $this->studentModel
->select('students.id, students.firstname, students.lastname, sc.class_section_id, cs.class_section_name, c.id AS class_id')
->join('student_class sc', 'sc.student_id = students.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', $this->schoolYear)
->groupBy('students.id, students.firstname, students.lastname, sc.class_section_id, cs.class_section_name, c.id')
->orderBy('c.id', 'ASC')
->orderBy('cs.class_section_name', 'ASC')
->orderBy('students.firstname', 'ASC')
->orderBy('students.lastname', 'ASC')
->findAll(5000);
// Exclude Youth and KG
$students = array_values(array_filter($students, static function ($s) {
$g = trim((string) ($s['class_section_name'] ?? ''));
return $g !== '' && !preg_match('/^(youth|kg)(?:\b|-)/i', $g);
}));
if (empty($students)) {
return 'No students found for this school year.';
}
} else {
return 'Please select a student or class.';
}
// ---- Expand list according to per-student copy counts ----
$copiesPosted = $request->getPost('copies'); // array|null
$singleCopies = (int) ($request->getPost('single_copies') ?? $request->getGet('single_copies') ?? 1);
if ($singleCopies < 0) $singleCopies = 0;
$printList = [];
if (!empty($studentId)) {
$qty = max(0, $singleCopies);
for ($k = 0; $k < $qty; $k++) {
$printList[] = $students[0];
}
} else {
$hasCopies = is_array($copiesPosted);
$defaultQty = $hasCopies ? 0 : 1; // if copies[] present, only print IDs provided
foreach ($students as $stu) {
$id = (int) ($stu['id'] ?? 0);
$qty = $defaultQty;
if ($hasCopies && array_key_exists((string)$id, $copiesPosted)) {
$qty = (int) $copiesPosted[(string)$id];
}
$qty = max(0, $qty);
for ($k = 0; $k < $qty; $k++) {
$printList[] = $stu;
}
}
}
if (empty($printList)) {
return 'Nothing to print. All quantities are zero.';
}
// ---------- Layout parameters (request/args/defaults) ----------
$sizeStrReq = trim((string) ($request->getPost('sticker_size') ?? $request->getGet('sticker_size') ?? ''));
$stickerWidth = $stickerWidthArg ?? (float) ($request->getPost('sticker_width') ?? $request->getGet('sticker_width') ?? 0);
$stickerHeight = $stickerHeightArg ?? (float) ($request->getPost('sticker_height') ?? $request->getGet('sticker_height') ?? 0);
// parse size string if provided (supports decimals; accepts x/X/unicode multiply)
if (($stickerWidth <= 0 || $stickerHeight <= 0) && $sizeStrReq !== '') {
if (preg_match('/^\\s*(\\d+(?:\\.\\d+)?)\\s*[xX\\x{00D7}]\\s*(\\d+(?:\\.\\d+)?)\\s*$/u', $sizeStrReq, $m)) {
$stickerWidth = (float) $m[1];
$stickerHeight = (float) $m[2];
}
}
// fallbacks to controller properties or sane defaults (mm)
if ($stickerWidth <= 0) {
$stickerWidth = isset($this->stickerWidth) ? (float)$this->stickerWidth : 102.0;
}
if ($stickerHeight <= 0) {
$stickerHeight = isset($this->stickerHeight) ? (float)$this->stickerHeight : 34.0;
}
$stickersPerPageReq = $stickersPerPageArg ?? (int) ($request->getPost('stickers_per_page') ?? $request->getGet('stickers_per_page') ?? 0);
$pageSize = $pageSizeArg ?? (string) ($request->getPost('page_size') ?? $request->getGet('page_size') ?? 'A4');
$orientation = $orientationArg ?? (string) ($request->getPost('orientation') ?? $request->getGet('orientation') ?? 'P');
$marginLeftProvided = $marginLeftArg !== null || $request->getPost('margin_left') !== null || $request->getGet('margin_left') !== null;
$marginTopProvided = $marginTopArg !== null || $request->getPost('margin_top') !== null || $request->getGet('margin_top') !== null;
$marginRightProvided = $marginRightArg !== null || $request->getPost('margin_right') !== null || $request->getGet('margin_right') !== null;
$marginBottomProvided = $marginBottomArg !== null || $request->getPost('margin_bottom') !== null || $request->getGet('margin_bottom') !== null;
$gapXProvided = $gapXArg !== null || $request->getPost('gap_x') !== null || $request->getGet('gap_x') !== null;
$gapYProvided = $gapYArg !== null || $request->getPost('gap_y') !== null || $request->getGet('gap_y') !== null;
$marginLeft = $marginLeftArg ?? (float) ($request->getPost('margin_left') ?? $request->getGet('margin_left') ?? ($this->marginL ?? 6));
$marginTop = $marginTopArg ?? (float) ($request->getPost('margin_top') ?? $request->getGet('margin_top') ?? ($this->marginT ?? 6));
$marginRight = $marginRightArg ?? (float) ($request->getPost('margin_right') ?? $request->getGet('margin_right') ?? $marginLeft);
$marginBottom = $marginBottomArg ?? (float) ($request->getPost('margin_bottom') ?? $request->getGet('margin_bottom') ?? 10);
$gapX = $gapXArg ?? (float) ($request->getPost('gap_x') ?? $request->getGet('gap_x') ?? ($this->gapX ?? 0));
$gapY = $gapYArg ?? (float) ($request->getPost('gap_y') ?? $request->getGet('gap_y') ?? ($this->gapY ?? 0));
// If using the 100x24.5 preset (small labels) and nothing custom was provided,
// default to tight margins and zero gaps to fit 20 per page (2 columns * 10+ rows).
$normalizedSize = strtolower(str_replace(['×', ' '], ['x', ''], $sizeStrReq));
$isXSmallPreset = $normalizedSize === '100x24.5' || (abs($stickerWidth - 100.0) < 0.6 && abs($stickerHeight - 24.5) < 0.6);
if ($isXSmallPreset) {
if (!$marginLeftProvided) {
$marginLeft = 4.0;
if (!$marginRightProvided) {
$marginRight = 4.0;
}
}
if (!$marginRightProvided && $marginRight < 4.0) {
$marginRight = 4.0;
}
if (!$gapXProvided) {
$gapX = 0.0;
}
if (!$gapYProvided) {
$gapY = 0.0;
}
}
// ---------- FPDF setup ----------
$pdf = new \FPDF($orientation, 'mm', $pageSize);
$pdf->SetMargins($marginLeft, $marginTop, $marginRight);
$pdf->SetAutoPageBreak(true, $marginBottom);
$pdf->AddPage();
$pdf->SetFont('Arial', '', 12);
// Printable area
$pageW = (float) $pdf->GetPageWidth();
$pageH = (float) $pdf->GetPageHeight();
$usableW = max(0.0, $pageW - ($marginLeft + $marginRight));
$usableH = max(0.0, $pageH - ($marginTop + $marginBottom));
// Compute columns & rows from physical dimensions and gaps.
$cols = (int) floor(($usableW + max(0.0, $gapX)) / (max(0.1, $stickerWidth) + max(0.0, $gapX)));
$rows = (int) floor(($usableH + max(0.0, $gapY)) / (max(0.1, $stickerHeight) + max(0.0, $gapY)));
$cols = max(1, $cols);
$rows = max(1, $rows);
// Optional cap: stickers_per_page
$capacity = $cols * $rows;
if ($stickersPerPageReq > 0 && $stickersPerPageReq < $capacity) {
$rows = (int) ceil($stickersPerPageReq / $cols);
$maxRowsFit = (int) floor(($usableH + max(0.0, $gapY)) / (max(0.1, $stickerHeight) + max(0.0, $gapY)));
$rows = max(1, min($rows, max(1, $maxRowsFit)));
$capacity = $cols * $rows;
}
// Starting positions
$xStart = $marginLeft;
$yStart = $marginTop;
$x = $xStart;
$y = $yStart;
$pt2mm = static function (float $pt): float {
return $pt * 0.352778;
};
// Draw loop
$i = 0;
$perPage = $capacity;
foreach ($printList as $stu) {
if ($i > 0 && $i % $perPage === 0) {
$pdf->AddPage();
$x = $xStart;
$y = $yStart;
}
// Background sticker rectangle (optional; fill white)
$pdf->SetFillColor(255, 255, 255);
$pdf->Rect($x, $y, $stickerWidth, $stickerHeight, 'F');
// Centered name with dynamic font size
$fontFamily = 'Arial';
$fontStyle = 'B';
$maxPt = 16;
$minPt = 8;
$hPad = 2;
$fullName = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? '')) ?: 'Student';
$bestPt = $maxPt;
$textW = 0.0;
while ($bestPt >= $minPt) {
$pdf->SetFont($fontFamily, $fontStyle, $bestPt);
$textW = (float) $pdf->GetStringWidth($fullName);
if ($textW <= ($stickerWidth - 2 * $hPad)) break;
$bestPt -= 0.5;
}
if ($bestPt < $minPt) {
$bestPt = $minPt;
$pdf->SetFont($fontFamily, $fontStyle, $bestPt);
$textW = (float) $pdf->GetStringWidth($fullName);
}
$textH = $pt2mm($bestPt);
$cellX = $x + (($stickerWidth - $textW) / 2);
$cellY = $y + (($stickerHeight - $textH) / 2);
$pdf->SetXY($cellX, $cellY);
$pdf->Cell($textW, $textH, $fullName, 0, 0, 'L');
// Advance cell position within the grid
$i++;
$posInRow = ($i - 1) % $cols;
if ($posInRow === ($cols - 1)) {
$x = $xStart;
$y += $stickerHeight + $gapY;
} else {
$x += $stickerWidth + $gapX;
}
}
// Output
$pdfContent = $pdf->Output('S');
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="Student_Stickers.pdf"')
->setHeader('Cache-Control', 'private, max-age=0, must-revalidate')
->setHeader('Pragma', 'public')
->setHeader('Content-Length', (string) strlen($pdfContent))
->setBody($pdfContent);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,143 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ClassModel;
use App\Models\SubjectCurriculumModel;
use CodeIgniter\Exceptions\PageNotFoundException;
class SubjectCurriculumController extends BaseController
{
private const SUBJECT_OPTIONS = [
'islamic' => 'Islamic Studies',
'quran' => 'Quran/Arabic',
];
protected SubjectCurriculumModel $curriculumModel;
protected ClassModel $classModel;
public function __construct()
{
helper(['form']);
$this->curriculumModel = new SubjectCurriculumModel();
$this->classModel = new ClassModel();
}
public function index()
{
$data = $this->buildViewData();
$data['editEntry'] = null;
return view('administrator/subject_curriculum', $data);
}
public function store()
{
if (! $this->validate($this->getValidationRules())) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$data = $this->buildEntryDataFromInput();
$insertedId = $this->curriculumModel->insert($data);
if ($insertedId === false) {
return redirect()->back()->withInput()->with('error', 'Unable to save the curriculum entry. Please try again.');
}
return redirect()->to('administrator/subject-curriculum')->with('success', 'Curriculum entry added.');
}
public function edit(int $id)
{
$entry = $this->curriculumModel->find($id);
if (! $entry) {
throw new PageNotFoundException('Curriculum entry not found.');
}
$data = $this->buildViewData();
$data['editEntry'] = $entry;
return view('administrator/subject_curriculum', $data);
}
public function update(int $id)
{
$entry = $this->curriculumModel->find($id);
if (! $entry) {
throw new PageNotFoundException('Curriculum entry not found.');
}
$redirectBack = redirect()->to('administrator/subject-curriculum/edit/' . $id)->withInput();
if (! $this->validate($this->getValidationRules())) {
return $redirectBack->with('errors', $this->validator->getErrors());
}
$data = $this->buildEntryDataFromInput();
if ($this->curriculumModel->update($id, $data) === false) {
return $redirectBack->with('error', 'Unable to update the curriculum entry.');
}
return redirect()->to('administrator/subject-curriculum')->with('success', 'Curriculum entry updated.');
}
public function delete(int $id)
{
$entry = $this->curriculumModel->find($id);
if (! $entry) {
throw new PageNotFoundException('Curriculum entry not found.');
}
$this->curriculumModel->delete($id);
return redirect()->to('administrator/subject-curriculum')->with('success', 'Curriculum entry deleted.');
}
private function buildViewData(): array
{
$classes = $this->classModel->orderBy('class_name', 'ASC')->findAll();
$entries = $this->curriculumModel
->builder()
->select('subject_curriculum_items.*, classes.class_name')
->join('classes', 'classes.id = subject_curriculum_items.class_id', 'left')
->orderBy('classes.class_name', 'ASC')
->orderBy('subject', 'ASC')
->orderBy('unit_number', 'ASC')
->orderBy('chapter_name', 'ASC')
->get()
->getResultArray();
return [
'classes' => $classes,
'entries' => $entries,
'subjectLabels' => self::SUBJECT_OPTIONS,
];
}
private function buildEntryDataFromInput(): array
{
$unitNumber = $this->request->getPost('unit_number');
$unitNumber = is_numeric($unitNumber) ? (int) $unitNumber : null;
if ($unitNumber === 0) {
$unitNumber = null;
}
$unitTitle = trim((string) $this->request->getPost('unit_title'));
$chapterName = trim((string) $this->request->getPost('chapter_name'));
return [
'class_id' => (int) $this->request->getPost('class_id'),
'subject' => (string) $this->request->getPost('subject'),
'unit_number' => $unitNumber,
'unit_title' => $unitTitle !== '' ? $unitTitle : null,
'chapter_name' => $chapterName,
];
}
private function getValidationRules(): array
{
return [
'class_id' => 'required|is_natural_no_zero',
'subject' => 'required|in_list[islamic,quran]',
'chapter_name' => 'required|string|max_length[255]',
'unit_number' => 'permit_empty|integer',
'unit_title' => 'permit_empty|string|max_length[255]',
];
}
}
@@ -1,77 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\SupplierModel;
class SupplierController extends BaseController
{
protected $model;
public function __construct()
{
$this->model = new SupplierModel();
}
public function index()
{
$q = trim($this->request->getGet('q') ?? '');
$builder = $this->model;
if ($q !== '') {
$builder = $builder->groupStart()
->like('name', $q)->orLike('email', $q)->orLike('phone', $q)
->groupEnd();
}
return view('inventory/suppliers_index', [
'suppliers' => $builder->orderBy('name')->paginate(20),
'pager' => $this->model->pager,
'q' => $q,
]);
}
public function create()
{
return view('inventory/suppliers_form', [
'title' => 'Add Supplier',
'action' => site_url('inventory/suppliers/store'),
'supplier' => null,
]);
}
public function store()
{
$data = $this->request->getPost(['name','email','phone','address','notes']);
if (!$this->model->save($data)) {
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
}
return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier saved.');
}
public function edit($id)
{
$s = $this->model->find($id);
if (!$s) return redirect()->to('inventory/suppliers')->with('error', 'Not found.');
return view('inventory/suppliers_form', [
'title' => 'Edit Supplier',
'action' => site_url('inventory/suppliers/update/'.$id),
'supplier' => $s,
]);
}
public function update($id)
{
$data = $this->request->getPost(['name','email','phone','address','notes']);
$data['id'] = $id;
if (!$this->model->save($data)) {
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
}
return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier updated.');
}
public function delete($id)
{
$this->model->delete($id);
return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier deleted.');
}
}
@@ -1,69 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\SupplyCategoryModel;
class SupplyCategoryController extends BaseController
{
protected $model;
public function __construct()
{
$this->model = new SupplyCategoryModel();
}
public function index()
{
return view('inventory/categories_index', [
'categories' => $this->model->orderBy('name')->findAll(),
]);
}
public function create()
{
return view('inventory/categories_form', [
'title' => 'Add Category',
'action' => site_url('inventory/categories/store'),
'category' => null,
]);
}
public function store()
{
$data = $this->request->getPost(['name']);
if (!$this->model->save($data)) {
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
}
return redirect()->to(site_url('inventory/categories'))->with('success', 'Category saved.');
}
public function edit($id)
{
$cat = $this->model->find($id);
if (!$cat) return redirect()->to('inventory/categories')->with('error', 'Not found.');
return view('inventory/categories_form', [
'title' => 'Edit Category',
'action' => site_url('inventory/categories/update/'.$id),
'category' => $cat,
]);
}
public function update($id)
{
$data = $this->request->getPost(['name']);
$data['id'] = (int) $id;
if (!$this->model->save($data)) {
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
}
return redirect()->to(site_url('inventory/categories'))->with('success', 'Category updated.');
}
public function delete($id)
{
$this->model->delete($id);
return redirect()->to(site_url('inventory/categories'))->with('success', 'Category deleted.');
}
}
@@ -1,133 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Controllers\View\EmailController;
use App\Models\SupportRequestModel;
use App\Models\UserModel;
class SupportController extends BaseController
{
public function __construct()
{
helper(['form']);
}
public function index()
{
return view('/support');
}
public function submit()
{
// Retrieve user_id from session
$session = session();
$user_id = $session->get('user_id');
// If no user_id is found in the session, redirect with an error
if (!$user_id) {
return redirect()->back()->with('error', 'You must be logged in to submit a support request.');
}
// Load the UserModel to retrieve user information
$userModel = new UserModel();
$user = $userModel->find($user_id);
// If the user does not exist, redirect with an error
if (!$user) {
return redirect()->back()->with('error', 'User not found. Please contact support.');
}
// Retrieve form data
$subject = $this->request->getPost('subject');
$message = $this->request->getPost('message');
// Get the user's full name using firstname and lastname
$full_name = $user['firstname'] . ' ' . $user['lastname'];
// Call the function to send an email using SMTP (configured via Email.php or .env)
$sendStatus = $this->sendSupportEmail($user['email'], $full_name, $subject, $message);
if ($sendStatus === true) {
return redirect()->back()->with('success', 'Your support request has been submitted successfully.');
} else {
// If email fails, log the error and display a message
log_message('error', $sendStatus);
return redirect()->back()->with('error', 'Failed to send your support request. Please try again later.');
}
}
/**
* Function to send an email via SMTP from user to support@domain.org
*
* @param string $user_email - The email address of the user sending the request.
* @param string $user_name - The name of the user sending the request.
* @param string $subject - The subject of the support request.
* @param string $message - The body of the support request.
* @return bool|string - Returns true if email sent successfully, otherwise error message.
*/
public function sendSupportEmail($user_email, $full_name, $subject, $message)
{
$mailer = new EmailController();
$ok = $mailer->sendEmail(
'support@alrahmaisgl.org',
$subject,
nl2br($message),
null,
$user_email,
$full_name
);
return $ok === true ? true : 'Failed to send support email.';
}
public function requests()
{
$supportModel = new SupportRequestModel();
$userId = session()->get('user_id');
$data['requests'] = $supportModel->where('user_id', $userId)->findAll();
return view('/support_requests', $data);
}
public function supportTeacher()
{
return view('/teacher/teacher_support');
}
}
/*
public function submit()
{
$validation = \Config\Services::validation();
// Define validation rules
$validation->setRules([
'subject' => 'required|min_length[3]',
'message' => 'required|min_length[10]',
]);
if (!$validation->withRequest($this->request)->run()) {
return view('/support', [
'validation' => $validation
]);
}
$supportModel = new SupportRequestModel();
$supportModel->save([
'user_id' => session()->get('user_id'),
'subject' => $this->request->getPost('subject'),
'message' => $this->request->getPost('message'),
'status' => 'open'
]);
session()->setFlashdata('success', 'Your support request has been submitted.');
return redirect()->to('/support');
}
*/
File diff suppressed because it is too large Load Diff
@@ -1,27 +0,0 @@
<?php
namespace App\Controllers\View;
use CodeIgniter\Controller;
use CodeIgniter\Database\Exceptions\DatabaseException;
class TestDBController extends Controller
{
public function index()
{
$db = \Config\Database::connect();
try {
$query = $db->query('SELECT 1');
$result = $query->getResult();
if ($result) {
echo "Database connection is successful.";
} else {
echo "Database connection failed.";
}
} catch (DatabaseException $e) {
echo "Database connection failed: " . $e->getMessage();
}
}
}
-222
View File
@@ -1,222 +0,0 @@
<?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);
}
}
-102
View File
@@ -1,102 +0,0 @@
<?php
namespace App\Controllers\View;
use CodeIgniter\Controller;
use App\Models\PreferencesModel;
class UiController extends Controller
{
public function style()
{
$styleCfg = config('Style');
$accent = (string) $this->request->getGet('accent');
$menu = (string) $this->request->getGet('menu');
$menuBg = (string) $this->request->getGet('menu_bg');
$menuTx = (string) $this->request->getGet('menu_text');
$menuMd = (string) $this->request->getGet('menu_mode'); // optional: 'light'|'dark'|'auto'
$updates = [];
if ($accent && isset(($styleCfg->stylePalettes ?? [])[$accent])) {
session()->set('style_color', $accent);
$updates['style_color'] = $accent;
}
if ($menu && isset(($styleCfg->menuPalettes ?? [])[$menu])) {
session()->set('menu_color', $menu);
$updates['menu_color'] = $menu;
$updates['menu_custom_bg'] = null;
$updates['menu_custom_text'] = null;
$updates['menu_custom_mode'] = null;
} elseif (strtolower($menu) === 'custom' || ($menuBg !== '' || $menuTx !== '')) {
// Allow custom menu colors via GET: menu=custom&menu_bg=#hex&menu_text=#hex&menu_mode=dark|light|auto
$bg = $this->normalizeHex($menuBg);
$tx = $this->normalizeHex($menuTx);
if ($bg === '' && $tx === '') {
// no valid values; ignore
} else {
if ($bg === '') $bg = '#0f172a';
if ($tx === '') $tx = '#ffffff';
$mode = in_array($menuMd, ['light','dark','auto'], true) ? $menuMd : 'auto';
session()->set('menu_color', 'custom');
session()->set('menu_custom_bg', $bg);
session()->set('menu_custom_text', $tx);
session()->set('menu_custom_mode', $mode);
$updates['menu_color'] = 'custom';
$updates['menu_custom_bg'] = $bg;
$updates['menu_custom_text'] = $tx;
$updates['menu_custom_mode'] = $mode;
}
}
$userId = (int) session()->get('user_id');
if ($userId && !empty($updates)) {
$prefsModel = new PreferencesModel();
$existing = $prefsModel->where('user_id', $userId)->first();
if ($existing) {
$prefsModel->update($existing['id'], $updates);
} else {
$updates['user_id'] = $userId;
$prefsModel->insert($updates);
}
}
// Redirect back to referrer or home
$back = (string) $this->request->getGet('back');
if ($back) return redirect()->to($back);
$ref = $this->request->getServer('HTTP_REFERER');
if ($ref) return redirect()->to($ref);
return redirect()->to(site_url('/'));
}
/**
* Normalize a CSS hex color (#RGB or #RRGGBB). Returns '' on failure.
*/
private function normalizeHex(string $hex): string
{
$h = trim($hex);
if ($h === '') return '';
if ($h[0] !== '#') $h = '#' . $h;
if (preg_match('/^#([0-9a-fA-F]{3})$/', $h, $m)) {
// Expand #RGB to #RRGGBB
$r = $m[1][0]; $g = $m[1][1]; $b = $m[1][2];
return '#' . $r . $r . $g . $g . $b . $b;
}
if (preg_match('/^#([0-9a-fA-F]{6})$/', $h)) return strtoupper($h);
return '';
}
/**
* Choose navbar mode based on background luminance: 'dark' for dark BG, else 'light'.
*/
private function autoModeForBg(string $hexBg): string
{
$hex = ltrim($hexBg, '#');
if (strlen($hex) !== 6) return 'dark';
$r = hexdec(substr($hex,0,2));
$g = hexdec(substr($hex,2,2));
$b = hexdec(substr($hex,4,2));
// Relative luminance approximation
$lum = (0.2126*$r + 0.7152*$g + 0.0722*$b) / 255.0;
return ($lum < 0.5) ? 'dark' : 'light';
}
}
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,39 @@ use Illuminate\Validation\Rule;
class UpdateEnrollmentStatusesRequest extends ApiFormRequest
{
public function validationData(): array
{
$data = $this->all();
if (array_key_exists('enrollment_status', $data)) {
return $data;
}
$raw = $this->getContent() ?: '';
if ($raw !== '') {
$json = json_decode($raw, true);
if (is_array($json)) {
return array_merge($data, $json);
}
parse_str($raw, $parsed);
if (is_array($parsed)) {
return array_merge($data, $parsed);
}
}
$queryString = (string) $this->server->get('QUERY_STRING', '');
if ($queryString !== '') {
$parsedQuery = [];
parse_str($queryString, $parsedQuery);
if (is_array($parsedQuery)) {
return array_merge($data, $parsedQuery);
}
}
return $data;
}
public function authorize(): bool
{
return auth()->check();
@@ -1,10 +1,10 @@
<?php
namespace App\Http\Requests;
namespace App\Http\Requests\Assignment;
use Illuminate\Foundation\Http\FormRequest;
use App\Http\Requests\ApiFormRequest;
class StoreAssignmentRequest extends FormRequest
class StoreAssignmentRequest extends ApiFormRequest
{
public function authorize(): bool
{
@@ -15,10 +15,10 @@ class StoreAssignmentRequest extends FormRequest
{
return [
'student_id' => ['required', 'integer', 'exists:students,id'],
'class_section_id' => ['required', 'integer', 'exists:class_sections,id'],
'class_section_id' => ['required', 'integer', 'exists:classSection,class_section_id'],
'semester' => ['required', 'string', 'max:50'],
'school_year' => ['required', 'string', 'max:50'],
'description' => ['nullable', 'string'],
];
}
}
}
@@ -2,9 +2,9 @@
namespace App\Http\Requests\AttendanceTracking;
use Illuminate\Foundation\Http\FormRequest;
use App\Http\Requests\ApiFormRequest;
class ComposeAttendanceEmailRequest extends FormRequest
class ComposeAttendanceEmailRequest extends ApiFormRequest
{
public function authorize(): bool
{
@@ -20,4 +20,4 @@ class ComposeAttendanceEmailRequest extends FormRequest
'incident_date' => ['nullable', 'date_format:Y-m-d'],
];
}
}
}
@@ -2,9 +2,9 @@
namespace App\Http\Requests\AttendanceTracking;
use Illuminate\Foundation\Http\FormRequest;
use App\Http\Requests\ApiFormRequest;
class ParentsInfoRequest extends FormRequest
class ParentsInfoRequest extends ApiFormRequest
{
public function authorize(): bool
{
@@ -24,4 +24,4 @@ class ParentsInfoRequest extends FormRequest
'student_id' => $this->query('student_id'),
]);
}
}
}
@@ -2,9 +2,9 @@
namespace App\Http\Requests\AttendanceTracking;
use Illuminate\Foundation\Http\FormRequest;
use App\Http\Requests\ApiFormRequest;
class RecordAttendanceTrackingRequest extends FormRequest
class RecordAttendanceTrackingRequest extends ApiFormRequest
{
public function authorize(): bool
{
@@ -23,4 +23,4 @@ class RecordAttendanceTrackingRequest extends FormRequest
'school_year' => ['nullable', 'string'],
];
}
}
}
@@ -2,9 +2,9 @@
namespace App\Http\Requests\AttendanceTracking;
use Illuminate\Foundation\Http\FormRequest;
use App\Http\Requests\ApiFormRequest;
class SaveAttendanceNotificationNoteRequest extends FormRequest
class SaveAttendanceNotificationNoteRequest extends ApiFormRequest
{
public function authorize(): bool
{
@@ -23,4 +23,4 @@ class SaveAttendanceNotificationNoteRequest extends FormRequest
'variant' => ['nullable', 'string'],
];
}
}
}
@@ -2,9 +2,9 @@
namespace App\Http\Requests\AttendanceTracking;
use Illuminate\Foundation\Http\FormRequest;
use App\Http\Requests\ApiFormRequest;
class SendAttendanceManualEmailRequest extends FormRequest
class SendAttendanceManualEmailRequest extends ApiFormRequest
{
public function authorize(): bool
{
@@ -23,4 +23,4 @@ class SendAttendanceManualEmailRequest extends FormRequest
'incident_date' => ['nullable', 'date_format:Y-m-d'],
];
}
}
}
@@ -1,6 +1,6 @@
<?php
namespace App\Http\Resources;
namespace App\Http\Resources\Assignment;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -19,4 +19,4 @@ class AssignmentOverviewResource extends JsonResource
'current_school_year' => $this->current_school_year ?? null,
];
}
}
}
@@ -1,6 +1,6 @@
<?php
namespace App\Http\Resources;
namespace App\Http\Resources\Assignment;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -20,4 +20,4 @@ class AssignmentSectionResource extends JsonResource
'description' => $this['description'] ?? '',
];
}
}
}