reconstruction of the project
This commit is contained in:
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\SaveAdminNotificationSubjectsRequest;
|
||||
use App\Http\Requests\Admin\SavePrintNotificationRecipientsRequest;
|
||||
use App\Http\Resources\Admin\AdminNotificationAlertsResource;
|
||||
use App\Http\Resources\Admin\PrintRecipientsResource;
|
||||
use App\Services\Admin\AdminNotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class AdminNotificationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private AdminNotificationService $service
|
||||
) {}
|
||||
|
||||
public function alerts(): AdminNotificationAlertsResource
|
||||
{
|
||||
return new AdminNotificationAlertsResource($this->service->alertsPayload());
|
||||
}
|
||||
|
||||
public function saveSubjects(SaveAdminNotificationSubjectsRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->saveSubjects($request->validated('subjects') ?? []);
|
||||
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
|
||||
}
|
||||
|
||||
public function printRecipients(): PrintRecipientsResource
|
||||
{
|
||||
return new PrintRecipientsResource($this->service->printRecipientsPayload());
|
||||
}
|
||||
|
||||
public function savePrintRecipients(SavePrintNotificationRecipientsRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->savePrintRecipients($request->validated('notify') ?? []);
|
||||
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ClassProgressAttachment;
|
||||
use App\Models\ClassProgressReport;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class AdminProgressAttachmentController extends Controller
|
||||
{
|
||||
use HandlesProgressAttachments;
|
||||
|
||||
/**
|
||||
* Download legacy attachment from class_progress_reports.attachment_path
|
||||
*/
|
||||
public function downloadLegacyByReport(int $reportId): BinaryFileResponse
|
||||
{
|
||||
$row = ClassProgressReport::query()->find($reportId);
|
||||
|
||||
if (! $row || empty($row->attachment_path)) {
|
||||
abort(404, 'Attachment not found.');
|
||||
}
|
||||
|
||||
$file = $this->resolveAttachmentFile($row->toArray());
|
||||
if (! $file) {
|
||||
abort(404, 'Attachment missing.');
|
||||
}
|
||||
|
||||
return response()->download($file, basename($file));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file from normalized attachments table
|
||||
*/
|
||||
public function downloadAttachment(int $attachmentId): BinaryFileResponse
|
||||
{
|
||||
$attachment = ClassProgressAttachment::query()->find($attachmentId);
|
||||
|
||||
if (! $attachment || empty($attachment->file_path)) {
|
||||
abort(404, 'Attachment not found.');
|
||||
}
|
||||
|
||||
$file = $this->resolveAttachmentPath((string) $attachment->file_path);
|
||||
if (! $file) {
|
||||
abort(404, 'Attachment missing.');
|
||||
}
|
||||
|
||||
$downloadName = $attachment->original_name ?: basename($file);
|
||||
|
||||
return response()->download($file, $downloadName);
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\Admin\IndexAdminProgressRequest;
|
||||
use App\Http\Resources\Admin\Progress\ProgressGroupResource;
|
||||
use App\Http\Resources\Admin\Progress\ProgressReportDetailResource;
|
||||
use App\Models\ClassProgressReport;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\StudentClass;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminProgressReportController extends Controller
|
||||
{
|
||||
use HandlesProgressAttachments;
|
||||
|
||||
public function index(IndexAdminProgressRequest $request): JsonResponse
|
||||
{
|
||||
$filters = $request->validated();
|
||||
|
||||
// Ensure all keys exist (same shape as CI)
|
||||
$filters = array_merge([
|
||||
'from' => '',
|
||||
'to' => '',
|
||||
'class_section_id' => '',
|
||||
'status' => '',
|
||||
], $filters);
|
||||
|
||||
$query = ClassProgressReport::query()
|
||||
->from('class_progress_reports')
|
||||
->select([
|
||||
'class_progress_reports.*',
|
||||
'cs.class_section_name',
|
||||
])
|
||||
->selectRaw("CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS teacher_name")
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id')
|
||||
->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id');
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
$rows = $query
|
||||
->orderByDesc('week_start')
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
$arr = $row->toArray();
|
||||
$arr['status_label'] = config('progress.status_options.' . ($arr['status'] ?? ''), 'Unknown');
|
||||
return $arr;
|
||||
})
|
||||
->all();
|
||||
|
||||
$reportGroups = $this->groupReportsByWeekAndSection($rows);
|
||||
|
||||
$classSections = $this->getClassSections();
|
||||
$studentCounts = $this->getStudentCountsBySection();
|
||||
|
||||
$filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts) {
|
||||
$sectionId = (int) ($section['class_section_id'] ?? 0);
|
||||
return isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0;
|
||||
}));
|
||||
|
||||
return response()->json([
|
||||
'data' => ProgressGroupResource::collection(collect(array_values($reportGroups))),
|
||||
'meta' => [
|
||||
'filters' => $filters,
|
||||
'class_sections' => $filteredSections,
|
||||
'status_options' => config('progress.status_options', []),
|
||||
'subject_sections' => config('progress.subject_sections', []),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$row = ClassProgressReport::query()
|
||||
->from('class_progress_reports')
|
||||
->select([
|
||||
'class_progress_reports.*',
|
||||
'cs.class_section_name',
|
||||
])
|
||||
->selectRaw("CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS teacher_name")
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id')
|
||||
->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id')
|
||||
->where('class_progress_reports.id', $id)
|
||||
->first();
|
||||
|
||||
if (! $row) {
|
||||
return response()->json(['message' => 'Progress report not found.'], 404);
|
||||
}
|
||||
|
||||
$rowData = $row->toArray();
|
||||
$rowData['status_label'] = config('progress.status_options.' . ($rowData['status'] ?? ''), 'Unknown');
|
||||
$rowData['flags'] = $this->decodeFlags($rowData['flags_json'] ?? null);
|
||||
|
||||
$weeklyReports = ClassProgressReport::query()
|
||||
->where('class_section_id', $rowData['class_section_id'])
|
||||
->where('week_start', $rowData['week_start'])
|
||||
->orderBy('subject', 'asc')
|
||||
->get()
|
||||
->map(fn ($r) => $r->toArray())
|
||||
->all();
|
||||
|
||||
$attachmentMap = $this->loadAttachmentsForReports(array_column($weeklyReports, 'id'));
|
||||
|
||||
foreach ($weeklyReports as &$report) {
|
||||
$reportId = (int) ($report['id'] ?? 0);
|
||||
$report['attachments'] = $attachmentMap[$reportId] ?? [];
|
||||
|
||||
// Legacy fallback attachment (same behavior as CI)
|
||||
if (empty($report['attachments']) && ! empty($report['attachment_path'])) {
|
||||
$report['attachments'][] = [
|
||||
'id' => $reportId, // pseudo-id for legacy
|
||||
'name' => basename((string) $report['attachment_path']),
|
||||
'legacy' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
unset($report);
|
||||
|
||||
return response()->json([
|
||||
'data' => new ProgressReportDetailResource([
|
||||
'report' => $rowData,
|
||||
'weekly_reports' => $weeklyReports,
|
||||
'subject_sections' => config('progress.subject_sections', []),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function applyFilters(Builder $query, array $filters): void
|
||||
{
|
||||
if (! empty($filters['from'])) {
|
||||
$query->where('week_start', '>=', $filters['from']);
|
||||
}
|
||||
|
||||
if (! empty($filters['to'])) {
|
||||
$query->where('week_end', '<=', $filters['to']);
|
||||
}
|
||||
|
||||
if (! empty($filters['class_section_id'])) {
|
||||
$query->where('class_progress_reports.class_section_id', (int) $filters['class_section_id']);
|
||||
}
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('class_progress_reports.status', $filters['status']);
|
||||
}
|
||||
}
|
||||
|
||||
private function groupReportsByWeekAndSection(array $rows): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$key = ($row['week_start'] ?? '') . '_' . ($row['class_section_id'] ?? '');
|
||||
if ($key === '_') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'week_start' => $row['week_start'] ?? null,
|
||||
'week_end' => $row['week_end'] ?? null,
|
||||
'class_section_id' => $row['class_section_id'] ?? null,
|
||||
'class_section_name' => $row['class_section_name'] ?? '',
|
||||
'reports' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$subject = (string) ($row['subject'] ?? 'unknown');
|
||||
$groups[$key]['reports'][$subject] = $row;
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace with relation/scopes/service if you already have them.
|
||||
*/
|
||||
private function getClassSections(): array
|
||||
{
|
||||
if (! class_exists(ClassSection::class)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ClassSection::query()
|
||||
->orderBy('class_section_name')
|
||||
->get()
|
||||
->map(fn ($r) => $r->toArray())
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected shape: [class_section_id => count]
|
||||
*/
|
||||
private function getStudentCountsBySection(): array
|
||||
{
|
||||
if (! class_exists(StudentClass::class)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return StudentClass::query()
|
||||
->selectRaw('class_section_id, COUNT(*) as total')
|
||||
->groupBy('class_section_id')
|
||||
->pluck('total', 'class_section_id')
|
||||
->map(fn ($v) => (int) $v)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\SendTeacherSubmissionNotificationsRequest;
|
||||
use App\Http\Resources\Admin\TeacherSubmissionReportResource;
|
||||
use App\Services\Admin\TeacherSubmissionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class AdminTeacherSubmissionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherSubmissionService $service
|
||||
) {}
|
||||
|
||||
public function report(): TeacherSubmissionReportResource
|
||||
{
|
||||
return new TeacherSubmissionReportResource($this->service->report());
|
||||
}
|
||||
|
||||
public function sendNotifications(SendTeacherSubmissionNotificationsRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->sendNotifications(
|
||||
(int) $request->user()->id,
|
||||
$request->validated()
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Models\ClassProgressAttachment;
|
||||
|
||||
trait HandlesProgressAttachments
|
||||
{
|
||||
protected function resolveAttachmentFile(array $row): ?string
|
||||
{
|
||||
$path = trim((string) ($row['attachment_path'] ?? ''));
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveAttachmentPath($path);
|
||||
}
|
||||
|
||||
protected function resolveAttachmentPath(string $path): ?string
|
||||
{
|
||||
// CI stored paths may look like: writable/uploads/...
|
||||
$relative = preg_replace('#^writable/uploads/#', '', $path);
|
||||
|
||||
// Laravel equivalent target
|
||||
$absolute = storage_path('app/uploads/' . ltrim((string) $relative, '/'));
|
||||
|
||||
return is_file($absolute) ? $absolute : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns map: [report_id => [ ['id'=>.., 'name'=>..], ... ]]
|
||||
*/
|
||||
protected function loadAttachmentsForReports(array $reportIds): array
|
||||
{
|
||||
$reportIds = array_values(array_filter(array_map('intval', $reportIds)));
|
||||
if (empty($reportIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = ClassProgressAttachment::query()
|
||||
->whereIn('report_id', $reportIds)
|
||||
->orderBy('id', 'asc')
|
||||
->get();
|
||||
|
||||
$map = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$reportId = (int) ($row->report_id ?? 0);
|
||||
if ($reportId === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$reportId][] = [
|
||||
'id' => (int) $row->id,
|
||||
'name' => $row->original_name ?: basename((string) $row->file_path),
|
||||
];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
protected function decodeFlags(?string $json): array
|
||||
{
|
||||
if (! $json) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$flags = json_decode($json, true);
|
||||
return is_array($flags) ? $flags : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
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\Support\Facades\Auth;
|
||||
|
||||
class AdministratorAbsenceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdministratorAbsenceService $service
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$userId = (int) Auth::id();
|
||||
if ($userId <= 0) {
|
||||
return response()->json(['message' => 'Please log in first.'], 401);
|
||||
}
|
||||
|
||||
return response()->json($this->service->getAbsenceFormData($userId));
|
||||
}
|
||||
|
||||
public function store(SubmitAdministratorAbsenceRequest $request): JsonResponse
|
||||
{
|
||||
$userId = (int) Auth::id();
|
||||
if ($userId <= 0) {
|
||||
return response()->json(['message' => 'Please log in first.'], 401);
|
||||
}
|
||||
|
||||
$result = $this->service->submit($request, $userId);
|
||||
|
||||
return response()->json([
|
||||
'message' => $result['message'],
|
||||
'saved' => $result['saved'] ?? 0,
|
||||
'dates' => $result['dates'] ?? [],
|
||||
], $result['status']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Administrator;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Administrator\AdministratorDashboardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdministratorDashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdministratorDashboardService $service
|
||||
) {
|
||||
}
|
||||
|
||||
public function metrics(): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->metrics());
|
||||
}
|
||||
|
||||
public function userSearch(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$this->service->userSearch((string) $request->query('query', ''))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
class AdministratorEnrollmentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdministratorEnrollmentService $service
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$this->service->enrollmentWithdrawalData(
|
||||
(string) $request->query('schoolYear', '')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function newStudents(): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->showNewStudentsData());
|
||||
}
|
||||
|
||||
public function updateStatuses(UpdateEnrollmentStatusesRequest $request): JsonResponse
|
||||
{
|
||||
$editorUserId = (int) Auth::id();
|
||||
if ($editorUserId <= 0) {
|
||||
return response()->json(['message' => 'Please log in first.'], 401);
|
||||
}
|
||||
|
||||
$data = $request->validated();
|
||||
$result = $this->service->updateStatuses(
|
||||
(array) ($data['enrollment_status'] ?? []),
|
||||
$editorUserId
|
||||
);
|
||||
|
||||
return response()->json(['message' => $result['message']], $result['status']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
class AdministratorNotificationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdministratorNotificationService $service
|
||||
) {
|
||||
}
|
||||
|
||||
public function alerts(): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->notificationsAlertsData());
|
||||
}
|
||||
|
||||
public function saveAlerts(SaveAdminNotificationSubjectsRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$result = $this->service->saveNotificationSubjects(
|
||||
(array) ($data['subjects'] ?? [])
|
||||
);
|
||||
|
||||
return response()->json(['message' => $result['message']], $result['status']);
|
||||
}
|
||||
|
||||
public function printRecipients(): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->printNotificationRecipientsData());
|
||||
}
|
||||
|
||||
public function savePrintRecipients(SavePrintRecipientsRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$result = $this->service->savePrintNotificationRecipients(
|
||||
(array) ($data['notify'] ?? [])
|
||||
);
|
||||
|
||||
return response()->json(['message' => $result['message']], $result['status']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
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\Support\Facades\Auth;
|
||||
|
||||
class AdministratorTeacherSubmissionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdministratorTeacherSubmissionService $service
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->report());
|
||||
}
|
||||
|
||||
public function notify(SendTeacherSubmissionNotificationsRequest $request): JsonResponse
|
||||
{
|
||||
$adminId = (int) Auth::id();
|
||||
if ($adminId <= 0) {
|
||||
return response()->json(['message' => 'Please log in first.'], 401);
|
||||
}
|
||||
|
||||
$result = $this->service->sendNotifications($request, $adminId);
|
||||
|
||||
return response()->json([
|
||||
'message' => $result['message'],
|
||||
'sent' => $result['sent'] ?? 0,
|
||||
'failed' => $result['failed'] ?? 0,
|
||||
], $result['status']);
|
||||
}
|
||||
}
|
||||
@@ -1,844 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Api\BaseApiController;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\LoginActivity;
|
||||
use App\Models\Refund;
|
||||
use App\Models\StaffAttendance;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRole;
|
||||
use App\Services\FeeCalculationService;
|
||||
|
||||
class AdministratorController extends BaseApiController
|
||||
{
|
||||
protected $user;
|
||||
protected $student;
|
||||
protected $config;
|
||||
protected $loginActivity;
|
||||
protected $enrollment;
|
||||
protected $refund;
|
||||
protected $invoice;
|
||||
protected $classSection;
|
||||
protected $userRole;
|
||||
protected $studentClass;
|
||||
protected $staffAttendance;
|
||||
protected $db;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
// Use CI4's model() to allow tests to inject mocks
|
||||
$this->user = model(User::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->loginActivity = model(LoginActivity::class);
|
||||
$this->enrollment = model(Enrollment::class);
|
||||
$this->refund = model(Refund::class);
|
||||
$this->invoice = model(Invoice::class);
|
||||
$this->classSection = model(ClassSection::class);
|
||||
$this->userRole = model(UserRole::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->staffAttendance= model(StaffAttendance::class);
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->schoolYear = $this->config->getConfig('school_year');
|
||||
$this->semester = $this->config->getConfig('semester');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard metrics
|
||||
* GET /api/v1/administrator/dashboard
|
||||
*/
|
||||
public function dashboard()
|
||||
{
|
||||
try {
|
||||
$recentActivities = $this->loginActivity->getLastActivities(4);
|
||||
if (!is_array($recentActivities)) {
|
||||
$recentActivities = [];
|
||||
}
|
||||
|
||||
$totalAdmins = (int) ($this->user->countAdminsBySchoolYear($this->schoolYear) ?? 0);
|
||||
|
||||
$teachers = $this->user->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear);
|
||||
$totalTeachers = count(array_unique(array_column($teachers, 'id')));
|
||||
|
||||
$teacherAssistants = $this->user->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear);
|
||||
$totalTeacherAssistants = count(array_unique(array_column($teacherAssistants, 'id')));
|
||||
|
||||
$parents = $this->user->getUsersByRoleAndSchoolYear('parent', $this->schoolYear);
|
||||
$totalParents = count(array_unique(array_column($parents, 'id')));
|
||||
|
||||
$totalStudents = (int) (
|
||||
$this->db->table('student_class')
|
||||
->select('COUNT(DISTINCT student_class.student_id) AS cnt')
|
||||
->where('student_class.school_year', $this->schoolYear)
|
||||
->where('student_class.class_section_id IS NOT NULL', null, false)
|
||||
->get()
|
||||
->getRow('cnt')
|
||||
?? 0
|
||||
);
|
||||
|
||||
// Align shape with View\AdministratorController::buildDashboardMetrics()
|
||||
$data = [
|
||||
'counts' => [
|
||||
'students' => $totalStudents,
|
||||
'teachers' => $totalTeachers,
|
||||
'teacherAssistants' => $totalTeacherAssistants,
|
||||
'admins' => $totalAdmins,
|
||||
'parents' => $totalParents,
|
||||
],
|
||||
'recentActivities' => array_map(static function ($activity) {
|
||||
if (!is_array($activity)) { return []; }
|
||||
return [
|
||||
'login_time' => $activity['login_time'] ?? null,
|
||||
'email' => $activity['email'] ?? null,
|
||||
];
|
||||
}, $recentActivities),
|
||||
'meta' => [
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
],
|
||||
];
|
||||
|
||||
return $this->success($data, 'Dashboard metrics retrieved successfully');
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'Dashboard metrics error: ' . $e->getMessage());
|
||||
return $this->error('Failed to retrieve dashboard metrics', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute allowed absence dates (future Sundays within Sep..May for configured school year)
|
||||
*/
|
||||
private function allowedAbsenceDates(): array
|
||||
{
|
||||
$todayStr = local_date(utc_now(), 'Y-m-d');
|
||||
try { $today = new \DateTimeImmutable($todayStr); } catch (\Throwable $e) { $today = new \DateTimeImmutable(); }
|
||||
|
||||
$sy = (string)($this->schoolYear ?? '');
|
||||
$startYear = null; $endYear = null;
|
||||
if (preg_match('/^(\d{4})\D+(\d{4})$/', $sy, $m)) {
|
||||
$startYear = (int)$m[1];
|
||||
$endYear = (int)$m[2];
|
||||
} else {
|
||||
$cy = (int)date('Y');
|
||||
$cm = (int)date('n');
|
||||
if ($cm >= 9) { // Sep-Dec
|
||||
$startYear = $cy;
|
||||
$endYear = $cy + 1;
|
||||
} else {
|
||||
$startYear = $cy - 1;
|
||||
$endYear = $cy;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$start = new \DateTimeImmutable(sprintf('%04d-09-01', $startYear));
|
||||
$end = new \DateTimeImmutable(sprintf('%04d-05-31', $endYear));
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($start < $today) {
|
||||
$start = $today;
|
||||
}
|
||||
|
||||
$dates = [];
|
||||
for ($cursor = $start; $cursor <= $end; $cursor = $cursor->modify('+1 day')) {
|
||||
if ((int)$cursor->format('w') === 0) { // Sunday
|
||||
$dates[] = $cursor->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
return $dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/administrator/absence
|
||||
* Return current admin absence context and allowed dates
|
||||
*/
|
||||
public function absenceInfo()
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return $this->error('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$admin = $this->user->find($userId);
|
||||
$displayName = $admin ? trim(($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? '')) : 'Administrator';
|
||||
|
||||
$semester = (string)($this->semester ?? '');
|
||||
$schoolYear = (string)($this->schoolYear ?? '');
|
||||
|
||||
$existing = $this->staffAttendance
|
||||
->where('user_id', $userId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('date', 'DESC')
|
||||
->findAll();
|
||||
|
||||
return $this->success([
|
||||
'admin_name' => $displayName,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'existing' => $existing,
|
||||
'available_dates'=> $this->allowedAbsenceDates(),
|
||||
], 'Absence info retrieved');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/administrator/absence
|
||||
* Submit absence entries for current admin
|
||||
* Payload: { dates: ["YYYY-MM-DD",...], reason_type?: string, reason: string }
|
||||
*/
|
||||
public function submitAbsence()
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return $this->error('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$dates = (array)($payload['dates'] ?? []);
|
||||
$reasonType = trim((string)($payload['reason_type'] ?? ''));
|
||||
$reasonText = trim((string)($payload['reason'] ?? ''));
|
||||
|
||||
$semester = (string)($this->semester ?? '');
|
||||
$schoolYear = (string)($this->schoolYear ?? '');
|
||||
|
||||
if ($semester === '' || $schoolYear === '') {
|
||||
return $this->error('Semester or school year not configured', 500);
|
||||
}
|
||||
if ($reasonText === '') {
|
||||
return $this->error('Reason is required', 422);
|
||||
}
|
||||
|
||||
$reasonBase = ($reasonType !== '') ? strtolower($reasonType) : '';
|
||||
$reason = $reasonBase !== '' ? ($reasonBase . ': ' . $reasonText) : $reasonText;
|
||||
|
||||
$allowedDates = $this->allowedAbsenceDates();
|
||||
$allowedSet = array_fill_keys($allowedDates, true);
|
||||
|
||||
$roleName = $this->user->getUserRole($userId) ?: null;
|
||||
|
||||
$saved = 0; $invalid = []; $savedDates = [];
|
||||
$dates = array_values(array_unique(array_map('strval', $dates)));
|
||||
foreach ($dates as $d) {
|
||||
$d = trim((string)$d);
|
||||
if ($d === '') continue;
|
||||
$dt = date_create_from_format('Y-m-d', $d);
|
||||
if (!$dt || $dt->format('Y-m-d') !== $d || empty($allowedSet[$d])) {
|
||||
$invalid[] = $d;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = $this->staffAttendance->upsertOne(
|
||||
userId: $userId,
|
||||
roleName: $roleName,
|
||||
date: $d,
|
||||
semester: $semester,
|
||||
schoolYear: $schoolYear,
|
||||
status: StaffAttendance::STATUS_ABSENT,
|
||||
reason: $reason,
|
||||
editorId: $userId
|
||||
);
|
||||
if ($ok) { $saved++; $savedDates[] = $d; }
|
||||
}
|
||||
|
||||
// Best-effort principal email
|
||||
try {
|
||||
$user = $this->user->find($userId) ?: [];
|
||||
$fullName = trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')) ?: 'Administrator';
|
||||
$userEmail = $user['email'] ?? '';
|
||||
$role = $roleName ?: 'admin';
|
||||
$dateList = !empty($savedDates) ? implode(', ', $savedDates) : implode(', ', $dates);
|
||||
|
||||
$subject = sprintf('TimeOff Request: %s (%s) — %s', $fullName, ucfirst((string)$role), $dateList ?: 'No dates');
|
||||
|
||||
$body = '<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">'
|
||||
. '<h3 style="margin:0 0 8px;">New TimeOff Request</h3>'
|
||||
. '<p style="margin:0 0 12px;">A staff time-off request was submitted from the administrator portal.</p>'
|
||||
. '<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;">'
|
||||
. '<tr><td><strong>Name</strong></td><td>' . esc($fullName) . '</td></tr>'
|
||||
. '<tr><td><strong>Email</strong></td><td>' . esc($userEmail) . '</td></tr>'
|
||||
. '<tr><td><strong>Role</strong></td><td>' . esc((string)$role) . '</td></tr>'
|
||||
. '<tr><td><strong>Semester</strong></td><td>' . esc($semester) . '</td></tr>'
|
||||
. '<tr><td><strong>School Year</strong></td><td>' . esc($schoolYear) . '</td></tr>'
|
||||
. '<tr><td><strong>Reason Type</strong></td><td>' . esc($reasonType ?: '-') . '</td></tr>'
|
||||
. '<tr><td><strong>Reason</strong></td><td>' . esc($reasonText) . '</td></tr>'
|
||||
. '<tr><td><strong>Dates</strong></td><td>' . esc($dateList ?: '-') . '</td></tr>'
|
||||
. '<tr><td><strong>Submitted At</strong></td><td>' . esc(utc_now()) . '</td></tr>'
|
||||
. '</table>'
|
||||
. '</div>';
|
||||
|
||||
$mailer = \Config\Services::emailService();
|
||||
$principalEmail = env('PRINCIPAL_EMAIL') ?: 'principal@alrahmaisgl.org';
|
||||
$mailer->send($principalEmail, $subject, $body, 'notifications');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to send TimeOff email (api admin): ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'saved' => $saved,
|
||||
'invalid' => $invalid,
|
||||
'saved_dates' => $savedDates,
|
||||
], $saved . ' day(s) saved as absent.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search users, students, parents, staff, emergency contacts
|
||||
* GET /api/v1/administrator/search
|
||||
*/
|
||||
public function search()
|
||||
{
|
||||
$q = trim((string)($this->request->getGet('q') ?? ''));
|
||||
if ($q === '') {
|
||||
return $this->error('Search query is required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$db = $this->db;
|
||||
|
||||
// 1) Tokenize
|
||||
$rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$tokens = array_values(array_filter(array_map('trim', $rawTokens)));
|
||||
|
||||
// 2) Phone variants per token
|
||||
$phoneMap = [];
|
||||
foreach ($tokens as $t) {
|
||||
$digits = preg_replace('/\D+/', '', $t);
|
||||
if ($digits === '') continue;
|
||||
$v = [];
|
||||
if (strlen($digits) >= 7) {
|
||||
$v[] = $digits;
|
||||
if (strlen($digits) === 10) {
|
||||
$v[] = sprintf('(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
|
||||
$v[] = sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
|
||||
$v[] = sprintf('%s %s %s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
|
||||
$v[] = '1' . $digits;
|
||||
$v[] = '+1' . $digits;
|
||||
$v[] = '+1 ' . sprintf('(%s) %s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
|
||||
$v[] = '+1-' . sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
|
||||
} elseif (strlen($digits) === 11 && str_starts_with($digits, '1')) {
|
||||
$ten = substr($digits, 1);
|
||||
$v[] = $ten;
|
||||
$v[] = sprintf('(%s)-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
|
||||
$v[] = sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
|
||||
$v[] = sprintf('%s %s %s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
|
||||
$v[] = '+1' . $ten;
|
||||
$v[] = '+1 ' . sprintf('(%s) %s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
|
||||
$v[] = '+1-' . sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
|
||||
} else {
|
||||
$v[] = $digits; // partials
|
||||
}
|
||||
}
|
||||
if (!empty($v)) $phoneMap[$t] = array_values(array_unique($v));
|
||||
}
|
||||
|
||||
// Helper
|
||||
$applyMultiTokenLike = function ($qb, array $columns, array $tokens, array $phoneCols = []) use ($phoneMap) {
|
||||
foreach ($tokens as $t) {
|
||||
$qb->groupStart();
|
||||
foreach ($columns as $i => $col) {
|
||||
if ($i === 0) $qb->like($col, $t); else $qb->orLike($col, $t);
|
||||
}
|
||||
if (!empty($phoneMap[$t]) && !empty($phoneCols)) {
|
||||
foreach ($phoneMap[$t] as $pv) {
|
||||
foreach ($phoneCols as $pcol) { $qb->orLike($pcol, $pv); }
|
||||
}
|
||||
}
|
||||
$qb->groupEnd();
|
||||
}
|
||||
return $qb;
|
||||
};
|
||||
|
||||
// USERS (phone: cellphone)
|
||||
$uCols = ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state'];
|
||||
$uQB = $db->table('users')->select('id, firstname, lastname, email, cellphone, school_id, city, state, school_year, semester');
|
||||
$applyMultiTokenLike($uQB, $uCols, $tokens, ['cellphone']);
|
||||
$users = $uQB->limit(150)->get()->getResultArray();
|
||||
|
||||
// STUDENTS (no phone field)
|
||||
$sCols = ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender'];
|
||||
$sQB = $db->table('students')->select('id, parent_id, school_id, firstname, lastname, dob, gender, school_year, semester, rfid_tag');
|
||||
$applyMultiTokenLike($sQB, $sCols, $tokens, []);
|
||||
$students = $sQB->limit(150)->get()->getResultArray();
|
||||
|
||||
// PARENTS (phone: secondparent_phone)
|
||||
$pCols = ['secondparent_firstname', 'secondparent_lastname', 'secondparent_email', 'secondparent_phone'];
|
||||
$pQB = $db->table('parents')->select('id, firstparent_id, secondparent_firstname, secondparent_lastname, secondparent_email, secondparent_phone, school_year, semester');
|
||||
$applyMultiTokenLike($pQB, $pCols, $tokens, ['secondparent_phone']);
|
||||
foreach ($tokens as $t) { if (ctype_digit($t)) { $pQB->orWhere('firstparent_id', (int)$t)->orWhere('id', (int)$t); } }
|
||||
$parents = $pQB->limit(150)->get()->getResultArray();
|
||||
|
||||
// STAFF (phone: phone)
|
||||
$stCols = ['firstname', 'lastname', 'email', 'role_name', 'phone'];
|
||||
$stQB = $db->table('staff')->select('id, user_id, firstname, lastname, email, phone, role_name, school_year, active_role');
|
||||
$applyMultiTokenLike($stQB, $stCols, $tokens, ['phone']);
|
||||
$staff = $stQB->limit(150)->get()->getResultArray();
|
||||
|
||||
// EMERGENCY CONTACTS (phone: cellphone)
|
||||
$ecCols = ['emergency_contact_name', 'relation', 'email', 'cellphone'];
|
||||
$ecQB = $db->table('emergency_contacts')->select('id, parent_id, emergency_contact_name, relation, cellphone, email, school_year, semester');
|
||||
$applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']);
|
||||
$emergency = $ecQB->limit(150)->get()->getResultArray();
|
||||
|
||||
$results = [
|
||||
'users' => $users,
|
||||
'students' => $students,
|
||||
'parents' => $parents,
|
||||
'staff' => $staff,
|
||||
'emergency_contacts' => $emergency,
|
||||
'total' => count($users) + count($students) + count($parents) + count($staff) + count($emergency),
|
||||
];
|
||||
|
||||
return $this->success($results, 'Search completed successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Search error: ' . $e->getMessage());
|
||||
return $this->error('Failed to perform search', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enrollment/withdrawal data
|
||||
* GET /api/v1/administrator/enrollment-withdrawal
|
||||
*/
|
||||
public function enrollmentWithdrawal()
|
||||
{
|
||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
||||
$perPage = min(100, (int) ($this->request->getGet('per_page') ?? 20));
|
||||
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
|
||||
try {
|
||||
$query = $this->enrollment
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('created_at', 'DESC');
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
return $this->success($result, 'Enrollment/withdrawal data retrieved successfully');
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'Enrollment withdrawal error: ' . $e->getMessage());
|
||||
return $this->error('Failed to retrieve enrollment/withdrawal data', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/administrator/enrollment-withdrawal/data
|
||||
* Rich data used by admin UI: students, classes, school years
|
||||
*/
|
||||
public function enrollmentWithdrawalData()
|
||||
{
|
||||
try {
|
||||
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? ''));
|
||||
if ($selectedYear === '') { $selectedYear = (string)$this->schoolYear; }
|
||||
|
||||
// Distinct school years
|
||||
$yearsRows = $this->db->table('enrollments')->distinct()->select('school_year')->orderBy('school_year', 'DESC')->get()->getResultArray();
|
||||
$schoolYears = array_values(array_filter(array_map(static fn($r) => isset($r['school_year']) ? (string)$r['school_year'] : null, $yearsRows)));
|
||||
|
||||
$students = $this->student->getStudentsWithClassAndEnrollment();
|
||||
|
||||
foreach ($students as &$s) {
|
||||
$s['student_id'] = (int)($s['id'] ?? 0);
|
||||
if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
|
||||
$s['parent_id'] = (int)$s['secondparent_user_id'];
|
||||
} else {
|
||||
$s['parent_id'] = (int)($s['parent_id'] ?? 0);
|
||||
}
|
||||
|
||||
$pf = trim((string)($s['parent_firstname'] ?? ''));
|
||||
$pl = trim((string)($s['parent_lastname'] ?? ''));
|
||||
if ($pf === '' && $pl === '' && !empty($s['parent_fullname'])) {
|
||||
$parts = preg_split('/\s+/', trim((string)$s['parent_fullname']), 2);
|
||||
$pf = $parts[0] ?? '';
|
||||
$pl = $parts[1] ?? '';
|
||||
}
|
||||
$s['parent_label'] = trim($pf . ' ' . $pl) ?: 'Unknown Parent';
|
||||
$s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf) ?: 'ZZZ Unknown Parent';
|
||||
|
||||
$s['is_new'] = (int) ($s['is_new'] ?? 0);
|
||||
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
|
||||
|
||||
$statusForYear = $this->enrollment->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
|
||||
if (!empty($statusForYear)) {
|
||||
$s['enrollment_status'] = $statusForYear;
|
||||
} elseif (($s['admission_status'] ?? null) === 'denied') {
|
||||
$s['enrollment_status'] = 'denied';
|
||||
}
|
||||
|
||||
$className = $this->studentClass->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
|
||||
$s['class_section'] = $className ?: 'Class not Assigned';
|
||||
|
||||
$s['registration_date_order'] = !empty($s['registration_date']) ? date('Y-m-d', strtotime($s['registration_date'])) : '';
|
||||
}
|
||||
unset($s);
|
||||
|
||||
usort($students, function (array $a, array $b) {
|
||||
$pa = $a['parent_sort'] ?? '';
|
||||
$pb = $b['parent_sort'] ?? '';
|
||||
if (strcasecmp($pa, $pb) === 0) {
|
||||
$la = $a['lastname'] ?? '';
|
||||
$lb = $b['lastname'] ?? '';
|
||||
$cmp = strcasecmp($la, $lb);
|
||||
if ($cmp !== 0) return $cmp;
|
||||
return strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? '');
|
||||
}
|
||||
return strcasecmp($pa, $pb);
|
||||
});
|
||||
|
||||
$classes = $this->classSection
|
||||
->select('id, class_section_id, class_section_name, school_year, semester')
|
||||
->where('school_year', (string)$selectedYear)
|
||||
->where('semester', (string)$this->semester)
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
if (empty($classes)) {
|
||||
$classes = $this->classSection
|
||||
->select('id, class_section_id, class_section_name')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'students' => $students,
|
||||
'classes' => $classes,
|
||||
'semester' => (string)$this->semester,
|
||||
'school_year' => (string)$selectedYear,
|
||||
'schoolYears' => $schoolYears,
|
||||
], 'Enrollment/withdrawal data');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'enrollmentWithdrawalData error: {msg}', ['msg' => $e->getMessage()]);
|
||||
return $this->error('Server error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/administrator/enrollment-withdrawal/update
|
||||
* Update enrollment statuses in batch and trigger notifications/refunds
|
||||
* Payload: { enrollment_status: { student_id: status, ... } }
|
||||
*/
|
||||
public function updateEnrollmentStatuses()
|
||||
{
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$enrollmentStatuses = $payload['enrollment_status'] ?? null;
|
||||
if (empty($enrollmentStatuses) || !is_array($enrollmentStatuses)) {
|
||||
return $this->error('No enrollment statuses were submitted.', 422);
|
||||
}
|
||||
|
||||
$refundService = new FeeCalculationService();
|
||||
$this->db->transStart();
|
||||
|
||||
try {
|
||||
$errors = [];
|
||||
$groupsByParentStatus = [];
|
||||
$parentInfo = [];
|
||||
$refundParents = [];
|
||||
$refundAmountByParent = [];
|
||||
|
||||
$validStatuses = [
|
||||
'admission under review', 'payment pending', 'enrolled',
|
||||
'withdraw under review', 'refund pending', 'withdrawn', 'denied', 'waitlist',
|
||||
];
|
||||
|
||||
foreach ($enrollmentStatuses as $studentId => $newEnrollmentStatus) {
|
||||
if (!in_array($newEnrollmentStatus, $validStatuses, true)) {
|
||||
$errors[] = "Invalid enrollment status '$newEnrollmentStatus' for student ID $studentId.";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($newEnrollmentStatus === 'denied') { $admissionStatus = 'denied'; }
|
||||
elseif (in_array($newEnrollmentStatus, ['enrolled', 'payment pending'], true)) { $admissionStatus = 'accepted'; }
|
||||
else { $admissionStatus = 'pending'; }
|
||||
|
||||
$enrollmentRow = $this->db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (!$enrollmentRow) {
|
||||
$stu = $this->student->find((int)$studentId) ?? [];
|
||||
$parentId = (int)($stu['parent_id'] ?? ($stu['secondparent_user_id'] ?? 0));
|
||||
if (!$parentId) { $errors[] = "No parent ID found for student ID $studentId."; continue; }
|
||||
|
||||
$isWithdrawn = in_array($newEnrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true) ? 1 : 0;
|
||||
|
||||
$ok = $this->db->table('enrollments')->insert([
|
||||
'student_id' => (int)$studentId,
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => (string)$this->schoolYear,
|
||||
'semester' => (string)$this->semester,
|
||||
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
|
||||
'is_withdrawn' => $isWithdrawn,
|
||||
'enrollment_status' => $newEnrollmentStatus,
|
||||
'admission_status' => $admissionStatus,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
|
||||
if (!$ok) { $errors[] = "Failed to create enrollment for student ID $studentId."; continue; }
|
||||
|
||||
$studentRow = $this->student->find($studentId) ?? [];
|
||||
$studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}";
|
||||
|
||||
if (!isset($parentInfo[$parentId])) {
|
||||
$p = $this->user->find($parentId) ?? [];
|
||||
$parentInfo[$parentId] = [
|
||||
'user_id' => $p['id'] ?? $parentId,
|
||||
'email' => $p['email'] ?? null,
|
||||
'firstname' => $p['firstname'] ?? '',
|
||||
'lastname' => $p['lastname'] ?? '',
|
||||
];
|
||||
}
|
||||
$groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'student_name' => $studentName,
|
||||
];
|
||||
|
||||
if ($newEnrollmentStatus === 'refund pending') { $refundParents[$parentId] = true; }
|
||||
continue;
|
||||
}
|
||||
|
||||
$oldStatus = $enrollmentRow['enrollment_status'] ?? null;
|
||||
$parentId = $enrollmentRow['parent_id'] ?? null;
|
||||
if (!$parentId) { $errors[] = "No parent ID found for student ID $studentId."; continue; }
|
||||
if ($oldStatus === $newEnrollmentStatus) { continue; }
|
||||
|
||||
$updated = $this->db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->update([
|
||||
'enrollment_status' => $newEnrollmentStatus,
|
||||
'admission_status' => $admissionStatus,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
if (!$updated) { $errors[] = "Failed to update enrollment for student ID $studentId."; continue; }
|
||||
|
||||
$studentRow = $this->student->find($studentId);
|
||||
$studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}";
|
||||
|
||||
if (!isset($parentInfo[$parentId])) {
|
||||
$p = $this->user->find($parentId) ?? [];
|
||||
$parentInfo[$parentId] = [
|
||||
'user_id' => $p['id'] ?? $parentId,
|
||||
'email' => $p['email'] ?? null,
|
||||
'firstname' => $p['firstname'] ?? '',
|
||||
'lastname' => $p['lastname'] ?? '',
|
||||
];
|
||||
}
|
||||
$groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'student_name' => $studentName,
|
||||
];
|
||||
if ($newEnrollmentStatus === 'refund pending') { $refundParents[$parentId] = true; }
|
||||
}
|
||||
|
||||
// Refunds
|
||||
foreach (array_keys($refundParents) as $pid) {
|
||||
$students = $this->enrollment
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->findAll();
|
||||
if (empty($students)) { continue; }
|
||||
|
||||
$invoice = $this->invoice->where('parent_id', $pid)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first();
|
||||
if (!$invoice) { $errors[] = "No invoice found for parent ID $pid (for refund calc)."; continue; }
|
||||
|
||||
$refundAmount = $refundService->calculateRefund($students, $pid);
|
||||
$refundAmountByParent[$pid] = $refundAmount;
|
||||
|
||||
$existingRefund = $this->refund->where('invoice_id', $invoice['id'])->first();
|
||||
if ($existingRefund) {
|
||||
$this->refund->update($existingRefund['id'], [
|
||||
'refund_amount' => $refundAmount,
|
||||
'status' => 'Pending',
|
||||
'updated_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
} else {
|
||||
$this->refund->insert([
|
||||
'parent_id' => $pid,
|
||||
'school_year' => $invoice['school_year'],
|
||||
'invoice_id' => $invoice['id'],
|
||||
'refund_amount' => $refundAmount,
|
||||
'refund_paid_amount' => 0.0,
|
||||
'status' => 'Pending',
|
||||
'requested_at' => utc_now(),
|
||||
'updated_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
if (!$this->db->transStatus()) {
|
||||
return $this->error('A database error occurred. Changes were rolled back.', 500);
|
||||
}
|
||||
|
||||
// Build response summary
|
||||
return $this->success([
|
||||
'errors' => $errors,
|
||||
'groupsByParentStatus' => $groupsByParentStatus,
|
||||
], empty($errors) ? 'Enrollment statuses updated.' : 'Enrollment updated with some errors.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Enrollment withdrawal error: ' . $e->getMessage());
|
||||
return $this->error('An unexpected error occurred while processing enrollments.', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/administrator/students
|
||||
* Return student profiles with parent/emergency and class section name
|
||||
*/
|
||||
public function studentProfiles()
|
||||
{
|
||||
$db = $this->db;
|
||||
$isPg = ($db->getPlatform() === 'Postgre');
|
||||
|
||||
if (!$isPg) { $db->query('SET SESSION group_concat_max_len = 8192'); }
|
||||
$b = $db->table('students');
|
||||
|
||||
if ($isPg) {
|
||||
$students = $b->select([
|
||||
'students.*',
|
||||
'users.firstname AS parent_firstname',
|
||||
'users.lastname AS parent_lastname',
|
||||
'users.email AS parent_email',
|
||||
'users.cellphone AS parent_phone',
|
||||
"(SELECT ec.emergency_contact_name FROM emergency_contacts ec WHERE ec.parent_id = students.parent_id ORDER BY ec.id ASC LIMIT 1) AS emergency_name",
|
||||
"(SELECT ec.relation FROM emergency_contacts ec WHERE ec.parent_id = students.parent_id ORDER BY ec.id ASC LIMIT 1) AS emergency_relationship",
|
||||
"(SELECT ec.cellphone FROM emergency_contacts ec WHERE ec.parent_id = students.parent_id ORDER BY ec.id ASC LIMIT 1) AS emergency_phone",
|
||||
"(SELECT ec.email FROM emergency_contacts ec WHERE ec.parent_id = students.parent_id ORDER BY ec.id ASC LIMIT 1) AS emergency_email",
|
||||
"(SELECT STRING_AGG(DISTINCT sa.allergy, ', ' ORDER BY sa.allergy) FROM student_allergies sa WHERE sa.student_id = students.id) AS allergies",
|
||||
"(SELECT STRING_AGG(DISTINCT smc.condition_name, ', ' ORDER BY smc.condition_name) FROM student_medical_conditions smc WHERE smc.student_id = students.id) AS medical_conditions",
|
||||
])->join('users', 'users.id = students.parent_id', 'left')
|
||||
->orderBy('students.lastname', 'ASC')->orderBy('students.firstname', 'ASC')
|
||||
->get()->getResultArray();
|
||||
} else {
|
||||
$students = $b->select([
|
||||
'students.*',
|
||||
'users.firstname AS parent_firstname',
|
||||
'users.lastname AS parent_lastname',
|
||||
'users.email AS parent_email',
|
||||
'users.cellphone AS parent_phone',
|
||||
'MIN(emergency_contacts.emergency_contact_name) AS emergency_name',
|
||||
'MIN(emergency_contacts.relation) AS emergency_relationship',
|
||||
'MIN(emergency_contacts.cellphone) AS emergency_phone',
|
||||
'MIN(emergency_contacts.email) AS emergency_email',
|
||||
"GROUP_CONCAT(DISTINCT student_allergies.allergy ORDER BY student_allergies.allergy SEPARATOR ', ') AS allergies",
|
||||
"GROUP_CONCAT(DISTINCT student_medical_conditions.condition_name ORDER BY student_medical_conditions.condition_name SEPARATOR ', ') AS medical_conditions",
|
||||
])->join('users', 'users.id = students.parent_id', 'left')
|
||||
->join('emergency_contacts', 'emergency_contacts.parent_id = students.parent_id', 'left')
|
||||
->join('student_allergies', 'student_allergies.student_id = students.id', 'left')
|
||||
->join('student_medical_conditions', 'student_medical_conditions.student_id = students.id', 'left')
|
||||
->groupBy('students.id')
|
||||
->orderBy('students.lastname', 'ASC')->orderBy('students.firstname', 'ASC')
|
||||
->get()->getResultArray();
|
||||
}
|
||||
|
||||
foreach ($students as $i => $row) {
|
||||
$sid = (int) ($row['id'] ?? 0);
|
||||
$students[$i]['class_section_name'] = $sid > 0 ? (string) ($this->studentClass->getClassSectionNameByStudentId($sid) ?? '') : '';
|
||||
}
|
||||
|
||||
return $this->success(['students' => $students]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/administrator/parents
|
||||
* Return parent profiles with latest invoice paid/balance
|
||||
*/
|
||||
public function parentProfiles()
|
||||
{
|
||||
$allUsers = $this->user->findAll();
|
||||
$parents = [];
|
||||
|
||||
foreach ($allUsers as $user) {
|
||||
$roles = $this->userRole->getRolesByUserId($user['id']);
|
||||
$isParent = false;
|
||||
if (is_array($roles)) {
|
||||
foreach ($roles as $role) {
|
||||
if (isset($role['role_name']) && $role['role_name'] === 'parent') { $isParent = true; break; }
|
||||
}
|
||||
}
|
||||
if ($isParent) {
|
||||
$paidAmount = $this->invoice->getLatestInvoicePaidAmount($user['id']) ?? 0;
|
||||
$balance = $this->invoice->getLatestInvoiceBalance($user['id']) ?? 0;
|
||||
$parents[] = [
|
||||
'id' => $user['id'],
|
||||
'school_id' => $user['school_id'],
|
||||
'firstname' => $user['firstname'],
|
||||
'lastname' => $user['lastname'],
|
||||
'email' => $user['email'],
|
||||
'cellphone' => $user['cellphone'],
|
||||
'gender' => $user['gender'],
|
||||
'created_at' => $user['created_at'],
|
||||
'school_year' => $user['school_year'],
|
||||
'paid_amount' => $paidAmount,
|
||||
'balance' => $balance,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success(['parents' => $parents]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/administrator/enrollment/new-students
|
||||
* Return students marked as new with parent/emergency + enrollment metadata.
|
||||
*/
|
||||
public function newStudents()
|
||||
{
|
||||
try {
|
||||
$rows = $this->student->getStudentsWithParentsAndEmergency($this->schoolYear, 1);
|
||||
$newStudents = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$studentId = (int) ($row['id'] ?? 0);
|
||||
$classSection = $studentId > 0
|
||||
? ($this->studentClass->getClassSectionsByStudentId($studentId, $this->schoolYear) ?: 'Class not Assigned')
|
||||
: 'Class not Assigned';
|
||||
$enrollmentStatus = $studentId > 0
|
||||
? $this->enrollment->getEnrollmentStatus($studentId, $this->schoolYear)
|
||||
: null;
|
||||
|
||||
$registrationDate = $row['registration_date'] ?? null;
|
||||
if (!empty($registrationDate)) {
|
||||
try {
|
||||
$registrationDate = (new \DateTime($registrationDate))->format('Y-m-d');
|
||||
} catch (\Throwable $e) {
|
||||
$registrationDate = null;
|
||||
}
|
||||
}
|
||||
|
||||
$newStudents[] = array_merge($row, [
|
||||
'student_id' => $studentId,
|
||||
'class_section' => $classSection,
|
||||
'enrollment_status' => $enrollmentStatus,
|
||||
'new_student' => ((int) ($row['is_new'] ?? 0) === 1) ? 'Yes' : 'No',
|
||||
'modalIdContact' => 'contact_' . $studentId,
|
||||
'registration_date' => $registrationDate,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'new_students' => $newStudents,
|
||||
'total_new' => count($newStudents),
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
], 'New students retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'newStudents error: ' . $e->getMessage());
|
||||
return $this->error('Failed to fetch new students', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
class AssignmentApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AssignmentService $assignmentService
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$data = $this->assignmentService->getAssignmentsOverview(
|
||||
schoolYear: $request->query('school_year'),
|
||||
semester: $request->query('semester'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Assignments retrieved successfully.',
|
||||
'data' => new AssignmentOverviewResource((object) $data),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreAssignmentRequest $request): JsonResponse
|
||||
{
|
||||
$assignment = $this->assignmentService->storeAssignment(
|
||||
data: $request->validated(),
|
||||
updatedBy: $request->user()?->id
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Assignment saved successfully.',
|
||||
'data' => $assignment,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function classAssignmentData(): JsonResponse
|
||||
{
|
||||
$data = $this->assignmentService->getClassAssignmentData();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Class assignment data retrieved successfully.',
|
||||
'data' => [
|
||||
'classSections' => AssignmentSectionResource::collection(collect($data['classSections'])),
|
||||
'semester' => $data['semester'],
|
||||
'school_year' => $data['school_year'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreStudentAssignmentRequest;
|
||||
use App\Services\AssignmentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AssignmentController extends Controller
|
||||
{
|
||||
public function index(Request $request, AssignmentService $service): JsonResponse
|
||||
{
|
||||
$payload = $service->buildClassAssignments([
|
||||
'semester' => (string) $request->query('semester', ''),
|
||||
'school_year' => (string) $request->query('school_year', ''),
|
||||
'for_api' => true,
|
||||
]);
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save / update a student-class assignment
|
||||
*/
|
||||
public function store(StoreStudentAssignmentRequest $request, AssignmentService $service): JsonResponse
|
||||
{
|
||||
$assignment = $service->saveStudentAssignment(
|
||||
$request->validated(),
|
||||
$request->user()?->id // fallback handled in service
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Assignment saved successfully',
|
||||
'data' => $assignment,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional endpoint if you want old `classAssignmentData()` naming preserved
|
||||
*/
|
||||
public function classAssignmentData(Request $request, AssignmentService $service): JsonResponse
|
||||
{
|
||||
return $this->index($request, $service);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Attendance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Attendance\AdminAddAttendanceEntryRequest;
|
||||
use App\Http\Requests\Attendance\UpdateAttendanceManagementRequest;
|
||||
use App\Http\Resources\Attendance\DailyAttendanceResource;
|
||||
use App\Services\Attendance\AttendanceQueryService;
|
||||
use App\Services\Attendance\AttendanceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Throwable;
|
||||
|
||||
class AdminAttendanceApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AttendanceQueryService $queryService,
|
||||
protected AttendanceService $attendanceService
|
||||
) {}
|
||||
|
||||
public function dailyData(Request $request): DailyAttendanceResource
|
||||
{
|
||||
return new DailyAttendanceResource(
|
||||
$this->queryService->buildDailyAttendanceData(
|
||||
(string)$request->query('semester', $this->attendanceService->currentSemester()),
|
||||
(string)$request->query('school_year', $this->attendanceService->currentSchoolYear())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function updateManagement(UpdateAttendanceManagementRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json(
|
||||
$this->attendanceService->updateAttendanceManagement(auth()->user(), $request->validated())
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function addEntry(AdminAddAttendanceEntryRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json(
|
||||
$this->attendanceService->adminAddEntry(auth()->user(), $request->validated())
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-57
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
namespace App\Http\Controllers\Api\Attendance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AttendanceCommentTemplate\StoreAttendanceCommentTemplateRequest;
|
||||
@@ -12,90 +12,71 @@ use Illuminate\Http\Request;
|
||||
class AttendanceCommentTemplateController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AttendanceCommentTemplateService $service
|
||||
) {}
|
||||
protected AttendanceCommentTemplateService $service
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/attendance-comment-templates
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$activeOnly = $request->boolean('active_only', false);
|
||||
|
||||
$templates = $this->service->list($activeOnly);
|
||||
$activeOnly = filter_var(
|
||||
$request->query('active_only'),
|
||||
FILTER_VALIDATE_BOOL
|
||||
) === true;
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $templates,
|
||||
'data' => $this->service->list($activeOnly),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/attendance-comment-templates
|
||||
*/
|
||||
public function store(StoreAttendanceCommentTemplateRequest $request): JsonResponse
|
||||
public function listData(Request $request): JsonResponse
|
||||
{
|
||||
$template = $this->service->create($request->validated());
|
||||
$activeOnly = filter_var(
|
||||
$request->query('active_only'),
|
||||
FILTER_VALIDATE_BOOL
|
||||
) === true;
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'templates' => $this->service->list($activeOnly),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $this->service->findOrFail($id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreAttendanceCommentTemplateRequest $request): JsonResponse
|
||||
{
|
||||
$created = $this->service->create($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Template created successfully.',
|
||||
'data' => $template,
|
||||
'data' => $created,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/attendance-comment-templates/{id}
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
public function update(UpdateAttendanceCommentTemplateRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$template = $this->service->findOrFail($id);
|
||||
$updated = $this->service->update($id, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $template,
|
||||
'data' => $updated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT/PATCH /api/attendance-comment-templates/{id}
|
||||
*/
|
||||
public function update(UpdateAttendanceCommentTemplateRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$template = $this->service->update($id, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Template updated successfully.',
|
||||
'data' => $template,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/attendance-comment-templates/{id}
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$this->service->delete($id);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'status' => 'success',
|
||||
'message' => 'Template deleted successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional compatibility endpoint with your old CI shape:
|
||||
* GET /api/attendance-comment-templates/list-data
|
||||
*/
|
||||
public function listData(Request $request): JsonResponse
|
||||
{
|
||||
$activeOnly = $request->boolean('active_only', false);
|
||||
|
||||
$templates = $this->service->list($activeOnly);
|
||||
|
||||
return response()->json([
|
||||
'templates' => $templates,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Attendance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Attendance\SaveAdminAttendanceRequest;
|
||||
use App\Http\Requests\Attendance\SaveStaffAttendanceCellRequest;
|
||||
use App\Http\Resources\Attendance\AdminAttendanceResource;
|
||||
use App\Http\Resources\Attendance\StaffMonthResource;
|
||||
use App\Services\Attendance\StaffAttendanceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
class StaffAttendanceApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected StaffAttendanceService $staffAttendanceService
|
||||
) {}
|
||||
|
||||
public function monthData(Request $request): StaffMonthResource
|
||||
{
|
||||
return new StaffMonthResource(
|
||||
$this->staffAttendanceService->monthData(
|
||||
(string)$request->query('semester'),
|
||||
(string)$request->query('school_year')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function admins(Request $request): AdminAttendanceResource
|
||||
{
|
||||
return new AdminAttendanceResource(
|
||||
$this->staffAttendanceService->adminsAttendanceData(
|
||||
(string)$request->query('date'),
|
||||
(string)$request->query('semester'),
|
||||
(string)$request->query('school_year')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function saveAdmins(SaveAdminAttendanceRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json(
|
||||
$this->staffAttendanceService->saveAdmins(auth()->user(), $request->validated())
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveCell(SaveStaffAttendanceCellRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json(
|
||||
$this->staffAttendanceService->saveCell(auth()->user(), $request->validated())
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function monthCsv(Request $request): StreamedResponse
|
||||
{
|
||||
return $this->staffAttendanceService->monthCsv(
|
||||
(string)$request->query('semester'),
|
||||
(string)$request->query('school_year'),
|
||||
(string)$request->query('scope')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Attendance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Attendance\TeacherSubmitAttendanceRequest;
|
||||
use App\Http\Resources\Attendance\TeacherAttendanceFormResource;
|
||||
use App\Http\Resources\Attendance\TeacherGridResource;
|
||||
use App\Services\Attendance\AttendanceQueryService;
|
||||
use App\Services\Attendance\AttendanceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Throwable;
|
||||
|
||||
class TeacherAttendanceApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AttendanceQueryService $queryService,
|
||||
protected AttendanceService $attendanceService
|
||||
) {}
|
||||
|
||||
public function grid(Request $request): TeacherGridResource
|
||||
{
|
||||
$semester = (string)($request->query('semester') ?: $this->attendanceService->currentSemester());
|
||||
$schoolYear = (string)($request->query('school_year') ?: $this->attendanceService->currentSchoolYear());
|
||||
$date = (string)($request->query('date') ?: now()->toDateString());
|
||||
$sectionCode = (int)$request->query('class_section_id', 0);
|
||||
|
||||
return new TeacherGridResource(
|
||||
$this->queryService->teacherGrid($semester, $schoolYear, $date, $sectionCode)
|
||||
);
|
||||
}
|
||||
|
||||
public function form(Request $request): JsonResponse|TeacherAttendanceFormResource
|
||||
{
|
||||
try {
|
||||
return new TeacherAttendanceFormResource(
|
||||
$this->queryService->teacherAttendanceFormData(
|
||||
auth()->id(),
|
||||
(int)$request->query('class_section_id', 0)
|
||||
)
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit(TeacherSubmitAttendanceRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json(
|
||||
$this->attendanceService->submitTeacherAttendance(auth()->user(), $request->validated())
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\AttendanceData;
|
||||
use App\Models\AttendanceDay;
|
||||
use App\Models\Calendar;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\StaffAttendance;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AttendanceController extends BaseApiController
|
||||
{
|
||||
protected AttendanceData $attendance;
|
||||
protected StudentClass $studentClass;
|
||||
protected ClassSection $classSection;
|
||||
protected Configuration $config;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected StaffAttendance $staffAttendance;
|
||||
protected AttendanceDay $attendanceDay;
|
||||
protected Student $student;
|
||||
protected Calendar $calendar;
|
||||
protected User $user;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
protected int $enableAttendance;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->attendance = model(AttendanceData::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->classSection = model(ClassSection::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->staffAttendance = model(StaffAttendance::class);
|
||||
$this->attendanceDay = model(AttendanceDay::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->calendar = model(Calendar::class);
|
||||
$this->user = model(User::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
$this->enableAttendance = (int) ($this->config->getConfig('enable_attendance') ?? 0);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
||||
$perPage = min(100, (int) ($this->request->getGet('per_page') ?? 20));
|
||||
$studentId = $this->request->getGet('student_id');
|
||||
$date = $this->request->getGet('date');
|
||||
|
||||
$builder = $this->attendance;
|
||||
|
||||
if ($studentId) {
|
||||
$builder = $builder->where('student_id', $studentId);
|
||||
}
|
||||
|
||||
if ($date) {
|
||||
$builder = $builder->where('date', $date);
|
||||
}
|
||||
|
||||
$data = $builder->paginate($perPage, 'default', $page);
|
||||
$pager = $builder->pager;
|
||||
|
||||
return $this->respondSuccess([
|
||||
'data' => $data,
|
||||
'pagination' => [
|
||||
'current_page' => $pager->getCurrentPage(),
|
||||
'per_page' => $pager->getPerPage(),
|
||||
'total' => $pager->getTotal(),
|
||||
'total_pages' => $pager->getPageCount(),
|
||||
],
|
||||
], 'Attendance records retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$record = $this->attendance->find($id);
|
||||
|
||||
if (!$record) {
|
||||
return $this->respondError('Attendance record not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->respondSuccess($record, 'Attendance record retrieved successfully');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$data = $this->request->getJSON(true);
|
||||
|
||||
$rules = [
|
||||
'student_id' => 'required|integer',
|
||||
'date' => 'required|valid_date[Y-m-d]',
|
||||
'status' => 'required|in_list[present,absent,late]',
|
||||
'notes' => 'permit_empty|string',
|
||||
];
|
||||
|
||||
if (!$this->validate($rules)) {
|
||||
return $this->respondValidationError($this->validator->getErrors());
|
||||
}
|
||||
|
||||
$existing = $this->attendance
|
||||
->where('student_id', $data['student_id'])
|
||||
->where('date', $data['date'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $this->respondError('Attendance already recorded for this student and date', 409);
|
||||
}
|
||||
|
||||
$this->attendance->insert($data);
|
||||
$id = $this->attendance->getInsertID();
|
||||
|
||||
return $this->respondCreated([
|
||||
'id' => $id,
|
||||
'data' => $data,
|
||||
], 'Attendance record created successfully');
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$record = $this->attendance->find($id);
|
||||
|
||||
if (!$record) {
|
||||
return $this->respondError('Attendance record not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
|
||||
$rules = [
|
||||
'status' => 'permit_empty|in_list[present,absent,late]',
|
||||
'notes' => 'permit_empty|string',
|
||||
];
|
||||
|
||||
if (!$this->validate($rules)) {
|
||||
return $this->respondValidationError($this->validator->getErrors());
|
||||
}
|
||||
|
||||
$this->attendance->update($id, $data);
|
||||
|
||||
return $this->respondSuccess($this->attendance->find($id), 'Attendance record updated successfully');
|
||||
}
|
||||
|
||||
public function delete($id = null)
|
||||
{
|
||||
$record = $this->attendance->find($id);
|
||||
|
||||
if (!$record) {
|
||||
return $this->respondError('Attendance record not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$this->attendance->delete($id);
|
||||
return $this->respondDeleted(null, 'Attendance record deleted successfully');
|
||||
}
|
||||
|
||||
public function student($id = null)
|
||||
{
|
||||
$records = $this->attendance
|
||||
->where('student_id', $id)
|
||||
->orderBy('date', 'DESC')
|
||||
->findAll();
|
||||
|
||||
return $this->respondSuccess($records, 'Student attendance retrieved successfully');
|
||||
}
|
||||
|
||||
public function classRoster($classSectionId)
|
||||
{
|
||||
$date = $this->request->getGet('date') ?: date('Y-m-d');
|
||||
$db = $this->getDatabaseConnection();
|
||||
|
||||
$rows = $db->table('student_class sc')
|
||||
->select('s.id AS student_id, s.firstname, s.lastname')
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('sc.class_section_id', (int)$classSectionId)
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->orderBy('s.lastname', 'ASC')->orderBy('s.firstname', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $r) {
|
||||
$att = $this->attendance->getAttendance((int)$r['student_id'], (int)$classSectionId, $date);
|
||||
$list[] = [
|
||||
'student_id' => (int)$r['student_id'],
|
||||
'firstname' => $r['firstname'],
|
||||
'lastname' => $r['lastname'],
|
||||
'status' => $att['status'] ?? null,
|
||||
'reason' => $att['reason'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'class_section_id' => (int)$classSectionId,
|
||||
'date' => $date,
|
||||
'students' => $list,
|
||||
], 'Class roster attendance retrieved');
|
||||
}
|
||||
|
||||
public function recordClass($classSectionId)
|
||||
{
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$date = isset($payload['date']) ? trim((string)$payload['date']) : '';
|
||||
$records = isset($payload['records']) && is_array($payload['records']) ? $payload['records'] : [];
|
||||
|
||||
if ($date === '' || empty($records)) {
|
||||
return $this->respondError('Invalid payload: date and records are required.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowedStatuses = ['present', 'absent', 'late'];
|
||||
$now = utc_now();
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($records as $record) {
|
||||
$studentId = (int) ($record['student_id'] ?? 0);
|
||||
$status = strtolower(trim((string) ($record['status'] ?? '')));
|
||||
$reason = isset($record['reason']) ? trim((string) $record['reason']) : null;
|
||||
|
||||
if ($studentId <= 0 || !in_array($status, $allowedStatuses, true)) {
|
||||
DB::rollBack();
|
||||
return $this->respondError('Invalid record provided.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$existing = $this->attendance
|
||||
->where('student_id', $studentId)
|
||||
->where('class_section_id', (int)$classSectionId)
|
||||
->where('date', $date)
|
||||
->first();
|
||||
|
||||
$payload = [
|
||||
'class_section_id' => (int) $classSectionId,
|
||||
'student_id' => $studentId,
|
||||
'status' => $status,
|
||||
'reason' => $reason ?: null,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'date' => $date,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$payload['modified_by'] = $this->getCurrentUserId();
|
||||
$this->attendance->update($existing['id'], $payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$payload['modified_by'] = $this->getCurrentUserId();
|
||||
$this->attendance->insert($payload);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
return $this->respondError('Failed to record attendance.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->success(null, 'Class attendance recorded successfully');
|
||||
}
|
||||
|
||||
public function teacherSectionGrid(): JsonResponse
|
||||
{
|
||||
$semester = (string) ($this->request->get('semester') ?? $this->semester);
|
||||
$schoolYear = (string) ($this->request->get('school_year') ?? $this->schoolYear);
|
||||
$date = (string) ($this->request->get('date') ?? local_date(utc_now(), 'Y-m-d'));
|
||||
$sectionCode = (int) ($this->request->get('class_section_id') ?? 0);
|
||||
|
||||
$assignedBySection = (array) ($this->teacherClass->assignedBySectionForTerm($semester, $schoolYear) ?? []);
|
||||
$sectionCodes = array_keys($assignedBySection);
|
||||
$labels = [];
|
||||
|
||||
if (!empty($sectionCodes)) {
|
||||
$labelRows = $this->classSection
|
||||
->select('class_section_id, class_section_name')
|
||||
->whereIn('class_section_id', $sectionCodes)
|
||||
->orderBy('class_section_id', 'ASC')
|
||||
->findAll();
|
||||
|
||||
foreach ($labelRows as $row) {
|
||||
$code = (int) ($row['class_section_id'] ?? 0);
|
||||
if ($code <= 0) continue;
|
||||
$name = trim((string) ($row['class_section_name'] ?? ''));
|
||||
$labels[$code] = $name !== '' ? $name : ('Section #' . $code);
|
||||
}
|
||||
|
||||
foreach ($sectionCodes as $code) {
|
||||
if (!isset($labels[$code])) {
|
||||
$labels[$code] = 'Section #' . $code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$grid = [];
|
||||
$locked = false;
|
||||
|
||||
if ($sectionCode > 0) {
|
||||
$assigned = (array) ($this->teacherClass->assignedForSectionTerm($sectionCode, $semester, $schoolYear) ?? []);
|
||||
$teacherIds = array_map(static fn($row) => (int) ($row['teacher_id'] ?? 0), $assigned);
|
||||
$teacherIds = array_values(array_filter($teacherIds));
|
||||
|
||||
$statusMap = [];
|
||||
if (!empty($teacherIds)) {
|
||||
$rows = DB::table('staff_attendance as sa')
|
||||
->select('sa.user_id', 'sa.status', 'sa.reason')
|
||||
->where('sa.semester', $semester)
|
||||
->where('sa.school_year', $schoolYear)
|
||||
->where('sa.date', $date)
|
||||
->whereIn('sa.user_id', $teacherIds)
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$statusMap[(int) $row->user_id] = [
|
||||
'status' => $row->status ?? null,
|
||||
'reason' => $row->reason ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($assigned as $teacherRow) {
|
||||
$tid = (int) ($teacherRow['teacher_id'] ?? 0);
|
||||
$name = trim(($teacherRow['firstname'] ?? '') . ' ' . ($teacherRow['lastname'] ?? ''));
|
||||
$name = $name !== '' ? $name : 'User #' . $tid;
|
||||
$posRaw = strtolower((string) ($teacherRow['position'] ?? 'main'));
|
||||
$pos = in_array($posRaw, ['main', 'ta'], true) ? $posRaw : 'main';
|
||||
$cell = $statusMap[$tid] ?? ['status' => null, 'reason' => null];
|
||||
|
||||
$grid[] = [
|
||||
'teacher_id' => $tid,
|
||||
'name' => $name,
|
||||
'position' => $pos,
|
||||
'status' => $cell['status'],
|
||||
'reason' => $cell['reason'],
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$locked = (bool) $this->attendanceDay->isFinalized($sectionCode, $date, $semester, $schoolYear);
|
||||
} catch (\Throwable $e) {
|
||||
$locked = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'date' => $date,
|
||||
'class_section_id' => $sectionCode,
|
||||
'sections' => $labels,
|
||||
'grid' => $grid,
|
||||
'locked' => $locked,
|
||||
], 'Teacher attendance grid retrieved');
|
||||
}
|
||||
|
||||
public function teacherUpdateData(): JsonResponse
|
||||
{
|
||||
$teacherId = (int) ($this->request->get('teacher_id') ?? $this->getCurrentUserId() ?? 0);
|
||||
if ($teacherId <= 0) {
|
||||
return $this->respondError('Teacher not found or unauthorized.', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$classSectionParam = (int) ($this->request->get('class_section_id') ?? 0);
|
||||
if ($classSectionParam <= 0) {
|
||||
$classSection = $this->teacherClass->getClassSectionIdByUserId($teacherId);
|
||||
if (empty($classSection)) {
|
||||
return $this->respondError('No class assigned to this teacher.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
$classSectionId = (int) ($classSection['class_section_id'] ?? 0);
|
||||
} else {
|
||||
$classSectionId = $classSectionParam;
|
||||
}
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Invalid class section.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$semester = (string) ($this->request->get('semester') ?? $this->semester);
|
||||
$schoolYear = (string) ($this->request->get('school_year') ?? $this->schoolYear);
|
||||
$today = local_date(utc_now(), 'Y-m-d');
|
||||
|
||||
$recentDatesRows = $this->attendance
|
||||
->select('date')
|
||||
->distinct()
|
||||
->orderBy('date', 'DESC')
|
||||
->limit(3)
|
||||
->findAll();
|
||||
|
||||
$recentDates = array_map(static fn($row) => (string) ($row['date'] ?? ''), $recentDatesRows);
|
||||
$recentDates = array_values(array_filter($recentDates, static fn($d) => $d !== ''));
|
||||
$allDates = array_values(array_unique(array_merge([$today], $recentDates)));
|
||||
|
||||
$dateSlots = [
|
||||
'day_before_before_yesterday' => $allDates[3] ?? null,
|
||||
'day_before_yesterday' => $allDates[2] ?? null,
|
||||
'yesterday' => $allDates[1] ?? null,
|
||||
'today' => $allDates[0] ?? $today,
|
||||
];
|
||||
|
||||
$teacher = $this->user->find($teacherId);
|
||||
$teacherName = $teacher ? trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')) : 'Teacher';
|
||||
|
||||
$students = (array) ($this->student->getByClassAndYear($classSectionId, $schoolYear) ?? []);
|
||||
$studentIds = array_map(static fn($row) => (int) ($row['id'] ?? 0), $students);
|
||||
$studentIds = array_values(array_filter($studentIds));
|
||||
|
||||
$studentAttendance = [];
|
||||
if (!empty($studentIds) && !empty($allDates)) {
|
||||
$rows = $this->attendance
|
||||
->newQuery()
|
||||
->whereIn('student_id', $studentIds)
|
||||
->whereIn('date', $allDates)
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$studentAttendance[(int) $row['student_id']][$row['date']] = $row['status'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$studentsPayload = [];
|
||||
foreach ($students as $student) {
|
||||
$sid = (int) ($student['id'] ?? 0);
|
||||
$slots = [];
|
||||
foreach ($dateSlots as $key => $date) {
|
||||
$slots[$key] = $date ? ($studentAttendance[$sid][$date] ?? 'N/A') : 'N/A';
|
||||
}
|
||||
|
||||
$studentsPayload[] = [
|
||||
'id' => $sid,
|
||||
'firstname' => $student['firstname'] ?? '',
|
||||
'lastname' => $student['lastname'] ?? '',
|
||||
'school_id' => $student['school_id'] ?? '',
|
||||
'statuses' => $slots,
|
||||
];
|
||||
}
|
||||
|
||||
$assignedTeachers = (array) ($this->teacherClass->assignedForSectionTerm($classSectionId, $semester, $schoolYear) ?? []);
|
||||
$teacherIds = array_map(static fn($row) => (int) ($row['teacher_id'] ?? 0), $assignedTeachers);
|
||||
$teacherIds = array_values(array_filter($teacherIds));
|
||||
|
||||
$teacherStatusMap = [];
|
||||
if (!empty($teacherIds) && !empty($allDates)) {
|
||||
$rows = DB::table('staff_attendance')
|
||||
->select('user_id', 'date', 'status')
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('user_id', $teacherIds)
|
||||
->whereIn('date', $allDates)
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$teacherStatusMap[(int) $row->user_id][$row->date] = $row->status ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$teacherRows = [];
|
||||
foreach ($assignedTeachers as $row) {
|
||||
$tid = (int) ($row['teacher_id'] ?? 0);
|
||||
$posRaw = strtolower((string) ($row['position'] ?? 'main'));
|
||||
$position = in_array($posRaw, ['main', 'ta'], true) ? $posRaw : 'main';
|
||||
$name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = 'User #' . $tid;
|
||||
}
|
||||
|
||||
$slots = [];
|
||||
foreach ($dateSlots as $key => $date) {
|
||||
$slots[$key] = $date ? ($teacherStatusMap[$tid][$date] ?? 'N/A') : 'N/A';
|
||||
}
|
||||
|
||||
$teacherRows[] = [
|
||||
'teacher_id' => $tid,
|
||||
'name' => $name,
|
||||
'position' => $position,
|
||||
'statuses' => $slots,
|
||||
];
|
||||
}
|
||||
|
||||
$wantedDates = array_values(array_filter(array_unique(array_filter($dateSlots))));
|
||||
$noSchool = [];
|
||||
if (!empty($wantedDates)) {
|
||||
$events = $this->calendar
|
||||
->where('no_school', 1)
|
||||
->whereIn('date', $wantedDates)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
foreach ($events as $event) {
|
||||
$d = (string) ($event['date'] ?? '');
|
||||
if ($d === '') continue;
|
||||
$noSchool[$d] = [
|
||||
'title' => $event['title'] ?? 'No School',
|
||||
'description' => $event['description'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'teacher' => [
|
||||
'id' => $teacherId,
|
||||
'name' => $teacherName,
|
||||
],
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'dates' => $dateSlots,
|
||||
'students' => $studentsPayload,
|
||||
'teachers' => $teacherRows,
|
||||
'no_school_days' => $noSchool,
|
||||
'enable_attendance'=> $this->enableAttendance,
|
||||
], 'Teacher attendance update data retrieved');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
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\AttendanceTrackingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AttendanceTrackingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AttendanceTrackingService $service
|
||||
) {
|
||||
}
|
||||
|
||||
public function pendingViolations(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->getPendingViolations(
|
||||
$request->query('school_year'),
|
||||
$request->query('semester')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function notifiedViolations(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->getNotifiedViolations(
|
||||
$request->query('school_year'),
|
||||
$request->query('semester')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function studentCase(int $studentId, Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->getStudentCase(
|
||||
$studentId,
|
||||
$request->query('code'),
|
||||
$request->query('incident_date'),
|
||||
filter_var($request->query('include_notified'), FILTER_VALIDATE_BOOL) === true,
|
||||
filter_var($request->query('pending_only'), FILTER_VALIDATE_BOOL) === true,
|
||||
$request->query('school_year'),
|
||||
$request->query('semester'),
|
||||
);
|
||||
|
||||
return response()->json(
|
||||
$result,
|
||||
$result['status'] ?? 200
|
||||
);
|
||||
}
|
||||
|
||||
public function record(RecordAttendanceTrackingRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->record($request->validated());
|
||||
|
||||
return response()->json(
|
||||
$result,
|
||||
$result['status'] ?? 200
|
||||
);
|
||||
}
|
||||
|
||||
public function sendAutoEmails(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->sendAutoEmails($request->input('variant'));
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function compose(ComposeAttendanceEmailRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->compose(
|
||||
(int) $request->validated('student_id'),
|
||||
(string) $request->validated('code', 'ABS_1'),
|
||||
(string) $request->validated('variant', 'default'),
|
||||
$request->validated('incident_date'),
|
||||
);
|
||||
|
||||
return response()->json(
|
||||
$result,
|
||||
$result['status'] ?? 200
|
||||
);
|
||||
}
|
||||
|
||||
public function sendManualEmail(SendAttendanceManualEmailRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->sendEmailManual($request->validated());
|
||||
|
||||
return response()->json(
|
||||
$result,
|
||||
$result['status'] ?? 200
|
||||
);
|
||||
}
|
||||
|
||||
public function parentsInfo(ParentsInfoRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->parentsInfo((int) $request->validated('student_id'));
|
||||
|
||||
return response()->json(
|
||||
$result,
|
||||
$result['status'] ?? 200
|
||||
);
|
||||
}
|
||||
|
||||
public function saveNotificationNote(SaveAttendanceNotificationNoteRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->saveNotificationNote($request->validated());
|
||||
|
||||
return response()->json(
|
||||
$result,
|
||||
$result['status'] ?? 200
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function login(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$credentials = $validator->validated();
|
||||
$email = strtolower($credentials['email']);
|
||||
$user = User::whereRaw('LOWER(email) = ?', [$email])->first();
|
||||
|
||||
if (!$user) {
|
||||
log_message('warning', 'Login failed, user not found', ['email' => $credentials['email']]);
|
||||
return response()->json(['error' => 'Invalid login'], 401);
|
||||
}
|
||||
|
||||
$hashPreview = substr((string) $user->password, 0, 32);
|
||||
log_message('debug', 'Login hash preview for {email}: {hash}', [
|
||||
'email' => $credentials['email'],
|
||||
'hash' => $hashPreview,
|
||||
]);
|
||||
|
||||
if (!ci_password_verify($credentials['password'], $user->password)) {
|
||||
log_message('warning', 'Login failed, password mismatch', [
|
||||
'email' => $credentials['email'],
|
||||
'hash' => $hashPreview,
|
||||
]);
|
||||
return response()->json(['error' => 'Invalid login'], 401);
|
||||
}
|
||||
|
||||
$token = JWTAuth::fromUser($user);
|
||||
return response()->json($this->respondWithToken($token));
|
||||
}
|
||||
|
||||
public function me()
|
||||
{
|
||||
try {
|
||||
$user = JWTAuth::parseToken()->authenticate();
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
|
||||
public function logout()
|
||||
{
|
||||
try {
|
||||
$token = JWTAuth::getToken();
|
||||
if ($token) {
|
||||
JWTAuth::invalidate($token);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore invalid token
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Logged out']);
|
||||
}
|
||||
|
||||
protected function respondWithToken($token)
|
||||
{
|
||||
return [
|
||||
'access_token' => $token,
|
||||
'token_type' => 'bearer',
|
||||
'expires_in' => auth()->factory()->getTTL() * 60,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Controllers\View\EmailController;
|
||||
use App\Models\AuthorizedUser;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthorizedUsersController extends BaseApiController
|
||||
{
|
||||
protected AuthorizedUser $authorizedUsers;
|
||||
protected User $user;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->authorizedUsers = new AuthorizedUser();
|
||||
$this->user = model(User::class);
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$users = $this->authorizedUsers->newQuery()
|
||||
->where('user_id', $userId)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success($users, 'Authorized users retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null): JsonResponse
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$record = $this->authorizedUsers->newQuery()
|
||||
->where('user_id', $userId)
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if (!$record) {
|
||||
return $this->respondError('Authorized user not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($record, 'Authorized user retrieved successfully');
|
||||
}
|
||||
|
||||
public function store(): JsonResponse
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
$rules = [
|
||||
'email' => 'required|valid_email',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$email = strtolower(trim($payload['email']));
|
||||
$user = $this->user->where('email', $email)->first();
|
||||
|
||||
if (!$user) {
|
||||
return $this->respondError('No user found with this email.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$mainUserId = $this->getCurrentUserId();
|
||||
if (!$mainUserId) {
|
||||
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$existing = $this->authorizedUsers->newQuery()
|
||||
->where('user_id', $mainUserId)
|
||||
->where('authorized_user_id', $user['id'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $this->respondError('This user is already authorized.', Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(48));
|
||||
|
||||
$record = $this->authorizedUsers->newQuery()->create([
|
||||
'user_id' => $mainUserId,
|
||||
'authorized_user_id' => $user['id'],
|
||||
'firstname' => $user['firstname'] ?? null,
|
||||
'lastname' => $user['lastname'] ?? null,
|
||||
'email' => $email,
|
||||
'token' => $token,
|
||||
'status' => 'Pending',
|
||||
]);
|
||||
|
||||
$this->sendAuthorizedUserConfirmationEmail($email, $token);
|
||||
|
||||
return $this->respondCreated([
|
||||
'id' => $record->id,
|
||||
'message' => 'Authorized user added. A confirmation email has been sent.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update($id = null): JsonResponse
|
||||
{
|
||||
$mainUserId = $this->getCurrentUserId();
|
||||
if (!$mainUserId) {
|
||||
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$record = $this->authorizedUsers->newQuery()
|
||||
->where('user_id', $mainUserId)
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if (!$record) {
|
||||
return $this->respondError('Authorized user not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$rules = ['email' => 'permit_empty|valid_email'];
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
if (!empty($payload['email'])) {
|
||||
$record->email = strtolower(trim($payload['email']));
|
||||
}
|
||||
|
||||
$record->save();
|
||||
|
||||
return $this->success($record->fresh(), 'Authorized user updated successfully');
|
||||
}
|
||||
|
||||
public function destroy($id = null): JsonResponse
|
||||
{
|
||||
$mainUserId = $this->getCurrentUserId();
|
||||
if (!$mainUserId) {
|
||||
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$record = $this->authorizedUsers->newQuery()
|
||||
->where('user_id', $mainUserId)
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if (!$record) {
|
||||
return $this->respondError('Authorized user not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
return $this->respondDeleted(null, 'Authorized user deleted successfully');
|
||||
}
|
||||
|
||||
public function confirm(): JsonResponse
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
$token = (string) ($payload['token'] ?? $this->request->get('token') ?? '');
|
||||
if ($token === '') {
|
||||
return $this->respondError('Invalid confirmation token.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$record = $this->authorizedUsers->newQuery()
|
||||
->where('token', $token)
|
||||
->first();
|
||||
|
||||
if (!$record) {
|
||||
return $this->respondError('Invalid or expired confirmation token.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$record->status = 'Active';
|
||||
$record->token = null;
|
||||
$record->save();
|
||||
|
||||
return $this->success([
|
||||
'authorized_user_id' => $record->authorized_user_id,
|
||||
'message' => 'Authorized user confirmed.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function setPassword($authorizedUserId): JsonResponse
|
||||
{
|
||||
$user = $this->user->find($authorizedUserId);
|
||||
if (!$user) {
|
||||
return $this->respondError('User not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'user_id' => $authorizedUserId,
|
||||
'email' => $user['email'] ?? null,
|
||||
], 'Authorized user retrieved.');
|
||||
}
|
||||
|
||||
public function savePassword($authorizedUserId): JsonResponse
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
$rules = [
|
||||
'password' => 'required|min_length[8]',
|
||||
'password_confirm' => 'required|matches[password]',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$user = $this->user->find($authorizedUserId);
|
||||
if (!$user) {
|
||||
return $this->respondError('User not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$hashed = pbkdf2_hash($payload['password']);
|
||||
$this->user->update($authorizedUserId, ['password' => $hashed]);
|
||||
|
||||
return $this->success(['message' => 'Password has been set.']);
|
||||
}
|
||||
|
||||
private function sendAuthorizedUserConfirmationEmail(string $email, string $token): void
|
||||
{
|
||||
$confirmLink = url('/confirm_authorized_user?token=' . $token);
|
||||
try {
|
||||
$message = view('emails/authorized_user_confirmation', [
|
||||
'confirmLink' => $confirmLink,
|
||||
], ['saveData' => false]);
|
||||
} catch (\Throwable $e) {
|
||||
$message = '<p>You have been added as an authorized user for another account.</p>'
|
||||
. '<p><a href="' . e($confirmLink) . '">Confirm Access</a></p>'
|
||||
. '<p>If you did not request this, please ignore this email.</p>';
|
||||
}
|
||||
|
||||
$mailer = new EmailController();
|
||||
$subject = 'Authorized User Confirmation';
|
||||
|
||||
if ($mailer->sendEmail($email, $subject, $message)) {
|
||||
log_message('info', 'Authorized user confirmation email sent to ' . $email);
|
||||
} else {
|
||||
log_message('error', 'Failed to send authorized user confirmation email to ' . $email);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
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 Throwable;
|
||||
|
||||
class BadgeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BadgePdfService $badgePdfService,
|
||||
protected BadgePrintLogService $badgePrintLogService,
|
||||
protected BadgeFormDataService $badgeFormDataService
|
||||
) {
|
||||
}
|
||||
|
||||
public function generatePdf(GenerateBadgePdfRequest $request)
|
||||
{
|
||||
return $this->badgePdfService->generate(
|
||||
userIds: $request->input('user_ids', []),
|
||||
schoolYear: $request->input('school_year'),
|
||||
rolesMap: $request->input('roles', []),
|
||||
classesMap: $request->input('classes', []),
|
||||
actorId: optional($request->user())->id
|
||||
);
|
||||
}
|
||||
|
||||
public function printStatus(BadgePrintStatusRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->badgePrintLogService->getStatus(
|
||||
$request->normalizedUserIds(),
|
||||
$request->input('school_year')
|
||||
),
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => 'Failed to query status',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function logPrint(LogBadgePrintRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$inserted = $this->badgePrintLogService->log(
|
||||
userIds: $request->input('user_ids', []),
|
||||
actorId: optional($request->user())->id,
|
||||
schoolYear: $request->input('school_year'),
|
||||
rolesMap: $request->input('roles', []),
|
||||
classesMap: $request->input('classes', [])
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'inserted' => $inserted,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function formData(BadgePrintStatusRequest $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->badgeFormDataService->build(
|
||||
schoolYear: $request->input('school_year'),
|
||||
selectedUserIds: $request->normalizedUserIds(),
|
||||
activeRole: $request->input('active_role')
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\EmailService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class BroadcastEmailController extends BaseApiController
|
||||
{
|
||||
protected User $user;
|
||||
protected EmailService $mailer;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->user = model(User::class);
|
||||
$this->mailer = app(EmailService::class);
|
||||
}
|
||||
|
||||
public function metadata(): JsonResponse
|
||||
{
|
||||
$parents = array_values(array_filter(
|
||||
$this->user->getParents(),
|
||||
static fn ($parent) => !empty($parent['email'])
|
||||
));
|
||||
|
||||
return $this->success([
|
||||
'parents' => $parents,
|
||||
'fromOptions' => $this->senderOptionsFromEnv(),
|
||||
], 'Broadcast email metadata retrieved');
|
||||
}
|
||||
|
||||
public function send(): JsonResponse
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$rules = [
|
||||
'subject' => 'required|string',
|
||||
'body_html' => 'required|string',
|
||||
'mode' => 'permit_empty|string',
|
||||
'from_key' => 'permit_empty|string',
|
||||
'wrap_layout'=> 'permit_empty',
|
||||
'preheader' => 'permit_empty|string',
|
||||
'cta_text' => 'permit_empty|string',
|
||||
'cta_url' => 'permit_empty|string',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$isTest = !empty($data['send_test_only']);
|
||||
$mode = (string) ($data['mode'] ?? 'standard');
|
||||
$subject = trim((string) $data['subject']);
|
||||
$fromKey = trim((string) ($data['from_key'] ?? 'general'));
|
||||
$wrapLayout = !empty($data['wrap_layout']);
|
||||
$preheader = (string) ($data['preheader'] ?? '');
|
||||
$ctaText = (string) ($data['cta_text'] ?? '');
|
||||
$ctaUrl = (string) ($data['cta_url'] ?? '');
|
||||
$body = $this->sanitizeEmailHtml((string) $data['body_html']);
|
||||
|
||||
if ($subject === '' || $body === '') {
|
||||
return $this->respondError('Subject and body are required.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($isTest) {
|
||||
$testEmail = trim((string) ($data['test_email'] ?? ''));
|
||||
if ($testEmail === '' || !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
return $this->respondError('Provide a valid test email address.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$html = $this->composeEmailHtml(
|
||||
$wrapLayout,
|
||||
$subject,
|
||||
$body,
|
||||
'Parent',
|
||||
$preheader,
|
||||
$ctaText,
|
||||
$ctaUrl,
|
||||
$mode === 'personalized'
|
||||
);
|
||||
|
||||
$ok = $this->mailer->send($testEmail, '[TEST] ' . $subject, $html, $fromKey);
|
||||
return $ok
|
||||
? $this->success(['message' => "Test email sent to {$testEmail}."], 'Test email sent')
|
||||
: $this->respondError('Test email failed to send. Check logs.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$parentIds = $this->parseParentIds($data['parent_ids'] ?? []);
|
||||
if (empty($parentIds)) {
|
||||
return $this->respondError('Please select at least one parent.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rows = $this->user->newQuery()
|
||||
->select('users.id', 'users.email', 'users.firstname', 'users.lastname')
|
||||
->whereIn('users.id', $parentIds)
|
||||
->whereNotNull('users.email')
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
return $this->respondError('No valid parent emails found.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$stats = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'mode' => $mode];
|
||||
$personalized = ($mode === 'personalized');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$stats['attempted']++;
|
||||
$recipientName = trim(($row->firstname ?? '') . ' ' . ($row->lastname ?? '')) ?: 'Parent';
|
||||
$html = $this->composeEmailHtml(
|
||||
$wrapLayout,
|
||||
$subject,
|
||||
$body,
|
||||
$recipientName,
|
||||
$preheader,
|
||||
$ctaText,
|
||||
$ctaUrl,
|
||||
$personalized
|
||||
);
|
||||
|
||||
$ok = $this->mailer->send($row->email, $subject, $html, $fromKey);
|
||||
$ok ? $stats['sent']++ : $stats['failed']++;
|
||||
}
|
||||
|
||||
$message = "Broadcast finished. Mode: {$stats['mode']}. Sent: {$stats['sent']}/{$stats['attempted']}. Failures: {$stats['failed']}.";
|
||||
$status = $stats['failed'] > 0 ? Response::HTTP_OK : Response::HTTP_OK;
|
||||
|
||||
return $this->success(array_merge($stats, ['message' => $message]), 'Broadcast completed', $status);
|
||||
}
|
||||
|
||||
public function uploadImage(): JsonResponse
|
||||
{
|
||||
if (!$this->request->is('post')) {
|
||||
return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
$file = request()->file('image');
|
||||
if (!$file || !$file->isValid()) {
|
||||
return $this->respondError('No image uploaded.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$mime = strtolower((string) $file->getMimeType());
|
||||
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
|
||||
if (!isset($allowed[$mime])) {
|
||||
return $this->respondError('Unsupported image type.', Response::HTTP_UNSUPPORTED_MEDIA_TYPE);
|
||||
}
|
||||
if ($file->getSize() > 5 * 1024 * 1024) {
|
||||
return $this->respondError('Image too large (max 5MB).', Response::HTTP_REQUEST_ENTITY_TOO_LARGE);
|
||||
}
|
||||
|
||||
$targetDir = public_path('uploads/email');
|
||||
if (!is_dir($targetDir)) {
|
||||
@mkdir($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
$newName = uniqid('em_', true) . '.' . $allowed[$mime];
|
||||
$file->move($targetDir, $newName);
|
||||
|
||||
$url = url('uploads/email/' . $newName);
|
||||
|
||||
return $this->success([
|
||||
'success' => true,
|
||||
'url' => $url,
|
||||
], 'Image uploaded successfully');
|
||||
}
|
||||
|
||||
private function composeEmailHtml(
|
||||
bool $wrap,
|
||||
string $subject,
|
||||
string $body,
|
||||
string $recipientName,
|
||||
string $preheader,
|
||||
string $ctaText,
|
||||
string $ctaUrl,
|
||||
bool $doPersonalize
|
||||
): string {
|
||||
$content = $doPersonalize ? str_replace('{{name}}', $recipientName, $body) : $body;
|
||||
|
||||
if (!$wrap) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
try {
|
||||
return view('emails/broadcast_wrapper', [
|
||||
'subject' => $subject,
|
||||
'content' => $content,
|
||||
'preheader' => $preheader,
|
||||
'cta_text' => $ctaText,
|
||||
'cta_url' => $ctaUrl,
|
||||
], ['saveData' => false]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('broadcast_wrapper view missing: ' . $e->getMessage());
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
|
||||
private function sanitizeEmailHtml(string $html): string
|
||||
{
|
||||
$html = preg_replace('#<(script|iframe)[^>]*>.*?</\\1>#is', '', $html);
|
||||
$html = preg_replace('/\son\w+="[^"]*"/i', '', $html);
|
||||
$html = preg_replace("/\son\w+='[^']*'/i", '', $html);
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function senderOptionsFromEnv(): array
|
||||
{
|
||||
$json = env('MAIL_SENDERS', '{}');
|
||||
$arr = json_decode($json, true);
|
||||
if (!is_array($arr) || empty($arr)) {
|
||||
$smtpUser = env('SMTP_USER', '');
|
||||
$name = 'Al Rahma Sunday School';
|
||||
return [[
|
||||
'key' => 'general',
|
||||
'label' => trim($name . ($smtpUser ? " <{$smtpUser}>" : '')),
|
||||
]];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($arr as $key => $info) {
|
||||
$nm = $info['name'] ?? 'Sender';
|
||||
$em = $info['email'] ?? '';
|
||||
$out[] = ['key' => (string) $key, 'label' => trim($nm . ($em ? " <{$em}>" : ''))];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function parseParentIds($raw): array
|
||||
{
|
||||
$raw = is_array($raw) ? $raw : [$raw];
|
||||
$ids = [];
|
||||
foreach ($raw as $value) {
|
||||
if (is_string($value) && str_contains($value, ',')) {
|
||||
$ids = array_merge($ids, array_map('intval', explode(',', $value)));
|
||||
} else {
|
||||
$ids[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($ids)));
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Calendar;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CalendarController extends BaseApiController
|
||||
{
|
||||
protected Calendar $calendar;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->calendar = model(Calendar::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$month = $this->request->getGet('month');
|
||||
$year = $this->request->getGet('year');
|
||||
|
||||
$query = $this->calendar->newQuery();
|
||||
|
||||
if ($month && $year) {
|
||||
$query->whereMonth('start_date', $month)->whereYear('start_date', $year);
|
||||
} elseif ($year) {
|
||||
$query->whereYear('start_date', $year);
|
||||
}
|
||||
|
||||
$query->orderBy('start_date', 'ASC');
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
return $this->success($result, 'Calendar events retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$event = $this->calendar->find($id);
|
||||
if (!$event) {
|
||||
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($event, 'Calendar event retrieved successfully');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$rules = [
|
||||
'title' => 'required|string|max_length[255]',
|
||||
'description' => 'permit_empty|string',
|
||||
'start_date' => 'required|valid_date[Y-m-d]',
|
||||
'end_date' => 'permit_empty|valid_date[Y-m-d]',
|
||||
'location' => 'permit_empty|string|max_length[255]',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$event = $this->calendar->create($data);
|
||||
|
||||
return $this->respondCreated([
|
||||
'id' => $event->id,
|
||||
'data' => $event,
|
||||
], 'Calendar event created successfully');
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$event = $this->calendar->find($id);
|
||||
if (!$event) {
|
||||
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
$rules = [
|
||||
'title' => 'permit_empty|string|max_length[255]',
|
||||
'description' => 'permit_empty|string',
|
||||
'start_date' => 'permit_empty|valid_date[Y-m-d]',
|
||||
'end_date' => 'permit_empty|valid_date[Y-m-d]',
|
||||
'location' => 'permit_empty|string|max_length[255]',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$event->fill($data);
|
||||
$event->save();
|
||||
|
||||
return $this->success($event->fresh(), 'Calendar event updated successfully');
|
||||
}
|
||||
|
||||
public function delete($id = null)
|
||||
{
|
||||
$event = $this->calendar->find($id);
|
||||
if (!$event) {
|
||||
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$event->delete();
|
||||
return $this->respondDeleted(null, 'Calendar event deleted successfully');
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\ClassModel;
|
||||
use App\Models\StudentClass;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ClassController extends BaseApiController
|
||||
{
|
||||
protected Class $class;
|
||||
protected StudentClass $studentClass;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->class = model(ClassModel::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$grade = $this->request->getGet('grade');
|
||||
$teacher = $this->request->getGet('teacher_id');
|
||||
|
||||
$query = $this->class->newQuery();
|
||||
if ($grade) {
|
||||
$query->where('grade', $grade);
|
||||
}
|
||||
if ($teacher) {
|
||||
$query->where('teacher_id', $teacher);
|
||||
}
|
||||
|
||||
$query->orderBy('grade', 'ASC');
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
return $this->success($result, 'Classes retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$class = $this->class->find($id);
|
||||
if (!$class) {
|
||||
return $this->respondError('Class not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
return $this->success($class, 'Class details retrieved successfully');
|
||||
}
|
||||
|
||||
public function students($id = null)
|
||||
{
|
||||
$students = $this->studentClass->newQuery()
|
||||
->select('students.id', 'students.firstname', 'students.lastname', 'students.school_id')
|
||||
->join('students', 'students.id', '=', 'student_class.student_id')
|
||||
->where('student_class.class_id', $id)
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success($students, 'Students retrieved successfully for class');
|
||||
}
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\ClassPrepAdjustment;
|
||||
use App\Models\ClassPreparationLog;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\StudentClass;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClassPreparationController extends BaseApiController
|
||||
{
|
||||
protected StudentClass $studentClass;
|
||||
protected ClassPreparationLog $prepLog;
|
||||
protected ClassSection $classSection;
|
||||
protected Configuration $config;
|
||||
protected ClassPrepAdjustment $adjustment;
|
||||
|
||||
protected ?string $schoolYear;
|
||||
protected ?string $semester;
|
||||
|
||||
private array $allowedPrepCategories = [
|
||||
'Grade Box',
|
||||
'Large Table',
|
||||
'Regular Chair',
|
||||
'Small Chair',
|
||||
'Small Table',
|
||||
'Teacher Chair',
|
||||
'Trash Bin',
|
||||
'White Board',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
StudentClass $studentClass,
|
||||
ClassPreparationLog $prepLog,
|
||||
ClassSection $classSection,
|
||||
Configuration $config,
|
||||
ClassPrepAdjustment $adjustment
|
||||
) {
|
||||
parent::__construct();
|
||||
|
||||
$this->studentClass = $studentClass;
|
||||
$this->prepLog = $prepLog;
|
||||
$this->classSection = $classSection;
|
||||
$this->config = $config;
|
||||
$this->adjustment = $adjustment;
|
||||
|
||||
$this->schoolYear = $this->config->getConfig('school_year');
|
||||
$this->semester = $this->config->getConfig('semester');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
|
||||
$semester = (string) ($this->request->getGet('semester') ?? $this->semester ?? '');
|
||||
$allowed = $this->allowedPrepCategories;
|
||||
|
||||
$classSections = $this->studentClass->newQuery()
|
||||
->selectRaw('class_section_id, COUNT(DISTINCT student_id) AS student_count')
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->groupBy('class_section_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$inventoryMap = $this->buildInventoryMap($schoolYear, $semester);
|
||||
$requiredTotals = array_fill_keys($allowed, 0);
|
||||
$results = [];
|
||||
|
||||
foreach ($classSections as $section) {
|
||||
$classSectionId = (string) ($section['class_section_id'] ?? '');
|
||||
$studentCount = (int) ($section['student_count'] ?? 0);
|
||||
$classLevel = $this->getClassLevelBySection($classSectionId);
|
||||
$className = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||
|
||||
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
||||
|
||||
$rawAdjustments = $this->adjustment->newQuery()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('adjustable', 1)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
||||
|
||||
foreach ($rawAdjustments as $adjustment) {
|
||||
$item = $adjustment['item_name'] ?? '';
|
||||
$delta = (int) ($adjustment['adjustment'] ?? 0);
|
||||
|
||||
if (array_key_exists($item, $adjMap)) {
|
||||
$adjMap[$item] = $delta;
|
||||
}
|
||||
|
||||
if (isset($baseItems[$item])) {
|
||||
$baseItems[$item] = max(0, (int) $baseItems[$item] + $delta);
|
||||
} elseif (in_array($item, $allowed, true)) {
|
||||
$baseItems[$item] = max(0, $delta);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($allowed as $category) {
|
||||
$requiredTotals[$category] += (int) ($baseItems[$category] ?? 0);
|
||||
}
|
||||
|
||||
$oldSnap = $this->prepLog->newQuery()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first();
|
||||
|
||||
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'] ?? '[]', true) : [];
|
||||
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
|
||||
|
||||
$results[] = [
|
||||
'class_section' => $className,
|
||||
'class_section_id' => $classSectionId,
|
||||
'student_count' => $studentCount,
|
||||
'class_level' => $classLevel,
|
||||
'prep_items' => $baseItems,
|
||||
'needs_print' => $hasChanged,
|
||||
'last_printed_at' => $oldSnap['created_at'] ?? null,
|
||||
'adjustments' => $adjMap,
|
||||
];
|
||||
}
|
||||
|
||||
$shortages = $this->calculateShortages($requiredTotals, $inventoryMap);
|
||||
|
||||
return $this->success([
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'results' => $results,
|
||||
'totals' => $requiredTotals,
|
||||
'shortages' => $shortages,
|
||||
'available' => $inventoryMap,
|
||||
], 'Class preparation data retrieved');
|
||||
}
|
||||
|
||||
public function saveAdjustment()
|
||||
{
|
||||
$data = $this->request->all();
|
||||
$sectionId = (string) ($data['class_section_id'] ?? '');
|
||||
$schoolYear = (string) ($data['school_year'] ?? $this->schoolYear ?? '');
|
||||
$adjustments = is_array($data['adjustments'] ?? null) ? $data['adjustments'] : [];
|
||||
|
||||
$updated = 0;
|
||||
|
||||
foreach ($adjustments as $itemName => $adjustment) {
|
||||
$row = $this->adjustment->newQuery()
|
||||
->where('class_section_id', $sectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('item_name', $itemName)
|
||||
->first();
|
||||
|
||||
$payload = [
|
||||
'class_section_id' => $sectionId,
|
||||
'item_name' => $itemName,
|
||||
'adjustment' => (int) $adjustment,
|
||||
'school_year' => $schoolYear,
|
||||
'adjustable' => 1,
|
||||
];
|
||||
|
||||
if ($row) {
|
||||
$this->adjustment->newQuery()
|
||||
->where('id', $row['id'])
|
||||
->update(['adjustment' => $payload['adjustment'], 'adjustable' => 1]);
|
||||
} else {
|
||||
$payload['created_at'] = utc_now();
|
||||
$this->adjustment->insert($payload);
|
||||
}
|
||||
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return $this->success(['updated' => $updated], 'Adjustments saved successfully');
|
||||
}
|
||||
|
||||
public function markPrinted()
|
||||
{
|
||||
$schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear ?? '');
|
||||
$ids = $this->request->getPost('class_section_ids') ?? [];
|
||||
if (!is_array($ids)) {
|
||||
$ids = $ids ? [$ids] : [];
|
||||
}
|
||||
$ids = array_values(array_unique(array_filter(array_map('strval', $ids))));
|
||||
|
||||
$now = utc_now();
|
||||
$count = 0;
|
||||
|
||||
foreach ($ids as $classSectionId) {
|
||||
$studentRow = $this->studentClass->newQuery()
|
||||
->selectRaw('COUNT(DISTINCT student_id) AS cnt')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $this->semester)
|
||||
->first();
|
||||
|
||||
$studentCount = (int) ($studentRow['cnt'] ?? 0);
|
||||
$classLevel = $this->getClassLevelBySection((string) $classSectionId);
|
||||
$className = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||
|
||||
$items = $this->calculatePrepItems($studentCount, $classLevel, (string) $classSectionId);
|
||||
|
||||
$rawAdjustments = $this->adjustment->newQuery()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('adjustable', 1)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
foreach ($rawAdjustments as $adjustment) {
|
||||
$item = $adjustment['item_name'] ?? '';
|
||||
$delta = (int) ($adjustment['adjustment'] ?? 0);
|
||||
if (isset($items[$item])) {
|
||||
$items[$item] = max(0, (int) $items[$item] + $delta);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->prepLog->insert([
|
||||
'class_section_id' => (string) $classSectionId,
|
||||
'class_section' => $className,
|
||||
'school_year' => $schoolYear,
|
||||
'prep_data' => json_encode($items),
|
||||
'created_at' => $now,
|
||||
]);
|
||||
$count++;
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and continue
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success(['updated' => $count], 'Marked sections as printed');
|
||||
}
|
||||
|
||||
private function calculateShortages(array $requiredTotals, array $inventoryMap): array
|
||||
{
|
||||
$shortages = [];
|
||||
foreach ($requiredTotals as $item => $reqQty) {
|
||||
$have = (int) ($inventoryMap[$item] ?? 0);
|
||||
if ($have < (int) $reqQty) {
|
||||
$shortages[$item] = (int) $reqQty - $have;
|
||||
}
|
||||
}
|
||||
return $shortages;
|
||||
}
|
||||
|
||||
private function getTeacherCountForSection(string $classSectionId, ?string $schoolYear = null): int
|
||||
{
|
||||
$schoolYear = $schoolYear ?? $this->schoolYear;
|
||||
|
||||
$row = DB::table('teacher_class')
|
||||
->selectRaw('COUNT(*) AS cnt')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('position', ['main', 'ta'])
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
return (int) ($row->cnt ?? 0);
|
||||
}
|
||||
|
||||
private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array
|
||||
{
|
||||
$allowed = $this->allowedPrepCategories;
|
||||
$items = array_fill_keys($allowed, 0);
|
||||
|
||||
$categories = DB::table('inventory_categories')
|
||||
->select('name', 'grade_min', 'grade_max')
|
||||
->where('type', 'classroom')
|
||||
->whereIn('name', $allowed)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$isLowerGrades = in_array($classLevel, [1, 2], true);
|
||||
$teacherCount = $this->getTeacherCountForSection($classSectionId);
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$name = $category['name'] ?? '';
|
||||
$gradeMin = $category['grade_min'];
|
||||
$gradeMax = $category['grade_max'];
|
||||
|
||||
if ($gradeMin !== null && $classLevel < (int) $gradeMin) {
|
||||
$items[$name] = 0;
|
||||
continue;
|
||||
}
|
||||
if ($gradeMax !== null && $classLevel > (int) $gradeMax) {
|
||||
$items[$name] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
$qty = 0;
|
||||
switch (strtolower($name)) {
|
||||
case 'small table':
|
||||
$qty = $isLowerGrades ? (int) ceil($students / 3) : 0;
|
||||
break;
|
||||
case 'large table':
|
||||
$qty = $isLowerGrades ? 0 : (int) ceil($students / 4);
|
||||
break;
|
||||
case 'small chair':
|
||||
$qty = $isLowerGrades ? $students : 0;
|
||||
break;
|
||||
case 'regular chair':
|
||||
$qty = $isLowerGrades ? 0 : $students;
|
||||
break;
|
||||
case 'teacher chair':
|
||||
$qty = $teacherCount;
|
||||
break;
|
||||
case 'trash bin':
|
||||
case 'white board':
|
||||
case 'grade box':
|
||||
$qty = 1;
|
||||
break;
|
||||
default:
|
||||
$qty = 0;
|
||||
}
|
||||
|
||||
if (array_key_exists($name, $items)) {
|
||||
$items[$name] = $qty;
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function hasPrepChanged(array $new, array $old): bool
|
||||
{
|
||||
ksort($new);
|
||||
ksort($old);
|
||||
|
||||
return json_encode($new) !== json_encode($old);
|
||||
}
|
||||
|
||||
private function buildInventoryMap(string $schoolYear, string $semester): array
|
||||
{
|
||||
$allowed = $this->allowedPrepCategories;
|
||||
$inventoryMap = array_fill_keys($allowed, 0);
|
||||
|
||||
$queries = [
|
||||
DB::table('inventory_items as ii')
|
||||
->selectRaw('ic.name AS item_name, COALESCE(SUM(ii.good_qty),0) AS available')
|
||||
->join('inventory_categories as ic', 'ic.id', '=', 'ii.category_id')
|
||||
->where('ii.type', 'classroom')
|
||||
->where('ii.school_year', $schoolYear)
|
||||
->where('ii.semester', $semester)
|
||||
->whereIn('ic.name', $allowed)
|
||||
->groupBy('ic.name'),
|
||||
DB::table('inventory_items')
|
||||
->selectRaw('name AS item_name, COALESCE(SUM(good_qty),0) AS available')
|
||||
->where('type', 'classroom')
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->whereIn('name', $allowed)
|
||||
->groupBy('name'),
|
||||
DB::table('inventory_items as ii')
|
||||
->selectRaw('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available')
|
||||
->join('inventory_categories as ic', 'ic.id', '=', 'ii.category_id')
|
||||
->where('ii.type', 'classroom')
|
||||
->where('ii.school_year', $schoolYear)
|
||||
->where('ii.semester', $semester)
|
||||
->whereIn('ic.name', $allowed)
|
||||
->groupBy('ic.name'),
|
||||
DB::table('inventory_items')
|
||||
->selectRaw('name AS item_name, COALESCE(SUM(CASE WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available')
|
||||
->where('type', 'classroom')
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->whereIn('name', $allowed)
|
||||
->groupBy('name'),
|
||||
];
|
||||
|
||||
foreach ($queries as $query) {
|
||||
foreach ($query->get() as $row) {
|
||||
$row = (array) $row;
|
||||
$name = (string) ($row['item_name'] ?? $row['name'] ?? '');
|
||||
$val = (int) ($row['available'] ?? 0);
|
||||
|
||||
if ($name !== '' && isset($inventoryMap[$name])) {
|
||||
$inventoryMap[$name] = max($inventoryMap[$name], $val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $inventoryMap;
|
||||
}
|
||||
|
||||
private function getClassLevelBySection(string $classSectionId): int
|
||||
{
|
||||
if (preg_match('/^(kg|k)(\b|[^a-z0-9])/i', $classSectionId)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$numeric = (int) preg_replace('/\D/', '', $classSectionId);
|
||||
if ($numeric > 0 && $numeric < 30) {
|
||||
$firstDigit = (int) substr((string) $numeric, 0, 1);
|
||||
return match ($firstDigit) {
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
default => 3,
|
||||
};
|
||||
}
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\CommunicationLog;
|
||||
use App\Models\Communication;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\FamilyStudent;
|
||||
use App\Models\Student;
|
||||
use Illuminate\Mail\Message;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CommunicationController extends BaseApiController
|
||||
{
|
||||
protected Communication $communication;
|
||||
protected Student $student;
|
||||
protected FamilyStudent $familyStudent;
|
||||
protected EmailTemplate $template;
|
||||
protected CommunicationLog $log;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->communication = model(Communication::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->familyStudent = model(FamilyStudent::class);
|
||||
$this->template = model(EmailTemplate::class);
|
||||
$this->log = model(CommunicationLog::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$senderId = $this->request->getGet('sender_id');
|
||||
$receiverId = $this->request->getGet('receiver_id');
|
||||
|
||||
$query = $this->communication->newQuery();
|
||||
if ($senderId) {
|
||||
$query->where('sender_id', $senderId);
|
||||
}
|
||||
if ($receiverId) {
|
||||
$query->where('receiver_id', $receiverId);
|
||||
}
|
||||
$query->orderBy('created_at', 'DESC');
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
return $this->success($result, 'Communications retrieved successfully');
|
||||
}
|
||||
|
||||
public function send()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$rules = [
|
||||
'sender_id' => 'required|integer',
|
||||
'receiver_id' => 'required|integer',
|
||||
'subject' => 'required|string|max_length[255]',
|
||||
'message' => 'required|string',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$communication = $this->communication->create([
|
||||
'sender_id' => $data['sender_id'],
|
||||
'receiver_id' => $data['receiver_id'],
|
||||
'subject' => $data['subject'],
|
||||
'message' => $data['message'],
|
||||
]);
|
||||
|
||||
return $this->respondCreated([
|
||||
'id' => $communication->id,
|
||||
'data' => $communication,
|
||||
], 'Message sent successfully');
|
||||
}
|
||||
|
||||
public function conversation()
|
||||
{
|
||||
$senderId = $this->request->getGet('sender_id');
|
||||
$receiverId = $this->request->getGet('receiver_id');
|
||||
|
||||
if (!$senderId || !$receiverId) {
|
||||
return $this->respondValidationError([
|
||||
'sender_id' => 'sender_id is required',
|
||||
'receiver_id' => 'receiver_id is required',
|
||||
]);
|
||||
}
|
||||
|
||||
$conversation = $this->communication->newQuery()
|
||||
->where(function ($query) use ($senderId, $receiverId) {
|
||||
$query->where('sender_id', $senderId)
|
||||
->where('receiver_id', $receiverId);
|
||||
})
|
||||
->orWhere(function ($query) use ($senderId, $receiverId) {
|
||||
$query->where('sender_id', $receiverId)
|
||||
->where('receiver_id', $senderId);
|
||||
})
|
||||
->orderBy('created_at', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success($conversation, 'Conversation retrieved successfully');
|
||||
}
|
||||
|
||||
public function delete($id = null)
|
||||
{
|
||||
$message = $this->communication->find($id);
|
||||
if (!$message) {
|
||||
return $this->respondError('Message not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$message->delete();
|
||||
return $this->respondDeleted(null, 'Message deleted successfully');
|
||||
}
|
||||
|
||||
public function metadata()
|
||||
{
|
||||
$students = $this->student->newQuery()
|
||||
->selectRaw('id, firstname, lastname, COALESCE(registration_grade, \'\') AS grade')
|
||||
->orderBy('lastname', 'ASC')
|
||||
->orderBy('firstname', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$templates = $this->template->getActiveTemplates();
|
||||
|
||||
return $this->success([
|
||||
'students' => $students,
|
||||
'templates' => $templates,
|
||||
], 'Communication metadata retrieved');
|
||||
}
|
||||
|
||||
public function families(int $studentId)
|
||||
{
|
||||
$families = $this->familyStudent->getFamiliesForStudent($studentId);
|
||||
return $this->success(['data' => $families], 'Families retrieved');
|
||||
}
|
||||
|
||||
public function guardians(int $familyId)
|
||||
{
|
||||
$rows = DB::table('family_guardians as fg')
|
||||
->select('u.id AS user_id', 'u.firstname', 'u.lastname', 'u.email', 'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms')
|
||||
->join('users as u', 'u.id', '=', 'fg.user_id')
|
||||
->where('fg.family_id', $familyId)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
return $this->success(['data' => $rows], 'Guardians retrieved');
|
||||
}
|
||||
|
||||
public function preview()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$rules = [
|
||||
'template_key' => 'required|string',
|
||||
'student_id' => 'required|integer',
|
||||
'family_id' => 'required|integer',
|
||||
'vars' => 'permit_empty',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$template = $this->template->findByKey((string) ($data['template_key'] ?? ''));
|
||||
if (!$template) {
|
||||
return $this->respondError('Template not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$student = $this->student->find($data['student_id']);
|
||||
if (!$student) {
|
||||
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$guardianRows = DB::table('family_guardians as fg')
|
||||
->select('u.firstname', 'u.lastname')
|
||||
->join('users as u', 'u.id', '=', 'fg.user_id')
|
||||
->where('fg.family_id', $data['family_id'])
|
||||
->where('fg.receive_emails', 1)
|
||||
->orderByDesc('fg.is_primary')
|
||||
->orderBy('u.lastname')
|
||||
->orderBy('u.firstname')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$salutation = 'Parent/Guardian';
|
||||
if (!empty($guardianRows)) {
|
||||
$names = array_map(fn ($row) => trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')), $guardianRows);
|
||||
$names = array_filter($names, fn ($name) => $name !== '');
|
||||
if (!empty($names)) {
|
||||
$salutation = implode(' & ', $names);
|
||||
}
|
||||
}
|
||||
|
||||
$varsPost = $data['vars'] ?? null;
|
||||
$vars = $this->normalizeVars($varsPost);
|
||||
|
||||
$autoVars = [
|
||||
'student_fullname' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
||||
'student_grade' => $student['grade'] ?? $student['registration_grade'] ?? '',
|
||||
'parent_salutation'=> $salutation,
|
||||
'date' => local_date(utc_now(), 'Y-m-d'),
|
||||
'school_name' => 'Al Rahma Sunday School',
|
||||
'teacher_name' => (string) (session('display_name') ?? 'Teacher'),
|
||||
];
|
||||
|
||||
$context = array_merge($autoVars, $vars);
|
||||
$subject = $this->renderTwig($template['subject'] ?? '', $context);
|
||||
$body = nl2br($this->renderTwig($template['body'] ?? '', $context));
|
||||
|
||||
return $this->success([
|
||||
'subject' => $subject,
|
||||
'html' => $body,
|
||||
], 'Template preview generated');
|
||||
}
|
||||
|
||||
public function sendEmail()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$rules = [
|
||||
'student_id' => 'required|integer',
|
||||
'family_id' => 'required|integer',
|
||||
'template_key' => 'required|string',
|
||||
'subject' => 'required|string',
|
||||
'body' => 'required|string',
|
||||
'recipients' => 'required',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$student = $this->student->find($data['student_id']);
|
||||
if (!$student) {
|
||||
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$recipients = $this->normalizeRecipients($data['recipients']);
|
||||
$cc = $this->normalizeRecipients($data['cc'] ?? null);
|
||||
$bcc = $this->normalizeRecipients($data['bcc'] ?? null);
|
||||
|
||||
if (empty($recipients)) {
|
||||
return $this->respondError('At least one recipient is required');
|
||||
}
|
||||
|
||||
$bodyHtml = (string) ($data['body'] ?? '');
|
||||
$subject = (string) ($data['subject'] ?? '');
|
||||
$fromAddress = config('mail.from.address', 'no-reply@alrahmaisgl.org');
|
||||
$fromName = config('mail.from.name', config('app.name', 'Al Rahma'));
|
||||
|
||||
$status = 'failed';
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
Mail::send([], [], function (Message $message) use ($fromAddress, $fromName, $subject, $bodyHtml, $recipients, $cc, $bcc) {
|
||||
$message->from($fromAddress, $fromName);
|
||||
$message->to($recipients);
|
||||
|
||||
if (!empty($cc)) {
|
||||
$message->cc($cc);
|
||||
}
|
||||
if (!empty($bcc)) {
|
||||
$message->bcc($bcc);
|
||||
}
|
||||
|
||||
$message->subject($subject);
|
||||
$message->setBody($bodyHtml, 'text/html');
|
||||
$message->addPart(strip_tags($bodyHtml), 'text/plain');
|
||||
});
|
||||
$status = 'sent';
|
||||
} catch (\Throwable $t) {
|
||||
$error = $t->getMessage();
|
||||
}
|
||||
|
||||
$studentName = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''));
|
||||
|
||||
$this->log->insert([
|
||||
'student_id' => $data['student_id'],
|
||||
'family_id' => $data['family_id'],
|
||||
'student_name' => $studentName,
|
||||
'template_key' => $data['template_key'],
|
||||
'subject' => $subject,
|
||||
'body' => $bodyHtml,
|
||||
'recipients' => json_encode(array_values($recipients)),
|
||||
'cc' => json_encode(array_values($cc)),
|
||||
'bcc' => json_encode(array_values($bcc)),
|
||||
'status' => $status,
|
||||
'error_message' => $error,
|
||||
'sent_by' => (int) (session('user_id') ?? 0),
|
||||
'metadata' => null,
|
||||
]);
|
||||
|
||||
if ($status === 'sent') {
|
||||
return $this->success(['status' => 'sent'], 'Email sent successfully');
|
||||
}
|
||||
|
||||
return $this->error('Failed to send email: ' . ($error ?? 'Unknown error'), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
private function normalizeVars($value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$decoded = json_decode($value, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function normalizeRecipients($input): array
|
||||
{
|
||||
$values = [];
|
||||
if (is_string($input)) {
|
||||
$decoded = json_decode($input, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$values = $decoded;
|
||||
} elseif ($input !== '') {
|
||||
$values = [$input];
|
||||
}
|
||||
} elseif (is_array($input)) {
|
||||
$values = $input;
|
||||
}
|
||||
|
||||
$sanitized = array_values(array_filter(array_map(fn ($item) => trim((string) $item), $values)));
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
private function renderTwig(string $template, array $vars): string
|
||||
{
|
||||
return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}/', function ($match) use ($vars) {
|
||||
$key = $match[1];
|
||||
return htmlspecialchars((string) ($vars[$key] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
}, $template);
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ConfigurationController extends BaseApiController
|
||||
{
|
||||
protected Configuration $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->config = model(Configuration::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$key = $this->request->getGet('key');
|
||||
|
||||
$query = $this->config->newQuery()->orderBy('id', 'ASC');
|
||||
if ($key) {
|
||||
$query->where('config_key', 'LIKE', '%' . $key . '%');
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
return $this->success($result, 'Configurations retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$config = $this->config->find($id);
|
||||
if (!$config) {
|
||||
return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($config, 'Configuration retrieved successfully');
|
||||
}
|
||||
|
||||
public function getByKey($key = null)
|
||||
{
|
||||
$config = $this->config->where('config_key', $key)->first();
|
||||
if (!$config) {
|
||||
return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($config, 'Configuration retrieved successfully');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'config_key' => 'required|string|max_length[255]|is_unique[configuration.config_key]',
|
||||
'config_value' => 'required',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$config = $this->config->create([
|
||||
'config_key' => $data['config_key'],
|
||||
'config_value' => $data['config_value'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Configuration creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create configuration', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->respondCreated([
|
||||
'id' => $config->id,
|
||||
'data' => $config,
|
||||
], 'Configuration created successfully');
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$config = $this->config->find($id);
|
||||
if (!$config) {
|
||||
return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$updateData = array_intersect_key($data, array_flip(['config_key', 'config_value']));
|
||||
if (empty($updateData)) {
|
||||
return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$config->fill($updateData);
|
||||
$config->save();
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Configuration update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update configuration', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->success($config->fresh(), 'Configuration updated successfully');
|
||||
}
|
||||
|
||||
public function delete($id = null)
|
||||
{
|
||||
$config = $this->config->find($id);
|
||||
if (!$config) {
|
||||
return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$config->delete();
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Configuration deletion error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete configuration', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->success(null, 'Configuration deleted successfully');
|
||||
}
|
||||
|
||||
public function listData()
|
||||
{
|
||||
$configs = $this->config->newQuery()
|
||||
->orderBy('id', 'ASC')
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
return [
|
||||
'id' => (int) ($row->id ?? 0),
|
||||
'config_key' => (string) ($row->config_key ?? ''),
|
||||
'config_value' => (string) ($row->config_value ?? ''),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return $this->success(['configs' => $configs], 'Configuration list retrieved');
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Services\EmailService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ContactController extends BaseApiController
|
||||
{
|
||||
public function submit()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[3]',
|
||||
'email' => 'required|valid_email',
|
||||
'subject' => 'required|min_length[3]',
|
||||
'message' => 'required|min_length[10]',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
$email = strtolower(trim((string) ($data['email'] ?? '')));
|
||||
$subject = trim((string) ($data['subject'] ?? ''));
|
||||
$message = trim((string) ($data['message'] ?? ''));
|
||||
|
||||
$body = "<p><strong>From:</strong> {$name} ({$email})</p>"
|
||||
. "<p><strong>Subject:</strong> {$subject}</p>"
|
||||
. "<p>{$message}</p>";
|
||||
|
||||
$supportEmail = env('SUPPORT_EMAIL', 'support@alrahmaisgl.org');
|
||||
|
||||
try {
|
||||
/** @var EmailService $mailer */
|
||||
$mailer = app(EmailService::class);
|
||||
$sent = $mailer->setFrom($email, $name)
|
||||
->setTo($supportEmail)
|
||||
->setSubject($subject)
|
||||
->setMessage($body)
|
||||
->send();
|
||||
|
||||
if (!$sent) {
|
||||
return $this->respondError('There was an error sending your message. Please try again later.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->success(null, 'Thank you for contacting us! We will get back to you soon.');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Contact form submission error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to submit contact form', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\View;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class DashboardRedirectController extends Controller
|
||||
{
|
||||
public function __construct(protected Role $role)
|
||||
{
|
||||
}
|
||||
|
||||
public function dashboard(): RedirectResponse
|
||||
{
|
||||
$session = session();
|
||||
$roles = $session->get('roles') ?? [];
|
||||
|
||||
if (empty($roles) && $session->has('role')) {
|
||||
$roles = [$session->get('role')];
|
||||
}
|
||||
|
||||
return $this->redirectToDashboard($roles);
|
||||
}
|
||||
|
||||
private function redirectToDashboard(array $roles): RedirectResponse
|
||||
{
|
||||
if (empty($roles)) {
|
||||
log_message('error', 'Empty roles array passed to redirectToDashboard.');
|
||||
return redirect()->to('/landing_page/guest_dashboard');
|
||||
}
|
||||
|
||||
$rows = $this->role->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,647 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\DiscountUsage;
|
||||
use App\Models\DiscountVoucher;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DiscountController extends BaseApiController
|
||||
{
|
||||
protected DiscountVoucher $discount;
|
||||
protected DiscountUsage $discountUsage;
|
||||
protected Invoice $invoice;
|
||||
protected User $user;
|
||||
protected Payment $payment;
|
||||
protected Configuration $config;
|
||||
|
||||
protected string $schoolYear;
|
||||
protected ?string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->discount = model(DiscountVoucher::class);
|
||||
$this->discountUsage = model(DiscountUsage::class);
|
||||
$this->invoice = model(Invoice::class);
|
||||
$this->user = model(User::class);
|
||||
$this->payment = model(Payment::class);
|
||||
$this->config = model(Configuration::class);
|
||||
|
||||
$this->schoolYear = $this->config->getConfig('school_year') ?? '';
|
||||
$sem = $this->config->getConfig('semester');
|
||||
$this->semester = is_string($sem) && $sem !== '' ? $sem : null;
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$activeOnly = $this->request->getGet('active') === 'true';
|
||||
$query = $this->discount->newQuery();
|
||||
|
||||
if ($activeOnly) {
|
||||
$today = now()->toDateString();
|
||||
$query->where('is_active', 1)
|
||||
->where(function ($q) use ($today) {
|
||||
$q->whereNull('valid_from')->orWhere('valid_from', '<=', $today);
|
||||
})
|
||||
->where(function ($q) use ($today) {
|
||||
$q->whereNull('valid_until')->orWhere('valid_until', '>=', $today);
|
||||
});
|
||||
}
|
||||
|
||||
$discounts = $query->orderBy('created_at', 'DESC')->get()->toArray();
|
||||
return $this->success($discounts, 'Discount vouchers retrieved successfully');
|
||||
}
|
||||
|
||||
public function getByCode($code = null)
|
||||
{
|
||||
$discount = $this->findActiveDiscount($code);
|
||||
|
||||
if (!$discount) {
|
||||
return $this->respondError('Discount voucher not found or expired', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($this->isUsageLimitReached($discount)) {
|
||||
return $this->respondError('Discount voucher has reached maximum usage limit', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return $this->success($discount->toArray(), 'Discount voucher retrieved successfully');
|
||||
}
|
||||
|
||||
public function apply()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'code' => 'required',
|
||||
'invoice_id' => 'required|integer',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$code = strtoupper(trim((string) ($data['code'] ?? '')));
|
||||
$discount = $this->findActiveDiscount($code);
|
||||
if (!$discount) {
|
||||
return $this->respondError('Invalid or expired discount voucher', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($this->isUsageLimitReached($discount)) {
|
||||
return $this->respondError('Discount voucher has reached maximum usage limit', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$exists = $this->discountUsage->newQuery()
|
||||
->where('voucher_id', $discount->id)
|
||||
->where('invoice_id', $data['invoice_id'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return $this->respondError('Discount voucher already applied to this invoice', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$usage = $this->discountUsage->create([
|
||||
'voucher_id' => $discount->id,
|
||||
'invoice_id' => $data['invoice_id'],
|
||||
'used_by' => $user->id,
|
||||
'used_at' => now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$discount->increment('times_used');
|
||||
|
||||
return $this->success([
|
||||
'discount' => $discount->fresh()->toArray(),
|
||||
'applied' => true,
|
||||
], 'Discount voucher applied successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Discount apply error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to apply discount voucher', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function parents()
|
||||
{
|
||||
$parents = $this->user->getParents();
|
||||
$result = [];
|
||||
foreach ($parents as $parent) {
|
||||
$discount = DB::table('discount_usages as du')
|
||||
->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_discount')
|
||||
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
|
||||
->where('i.parent_id', $parent['id'])
|
||||
->where('i.school_year', $this->schoolYear)
|
||||
->first();
|
||||
|
||||
$totalDiscount = (float) ($discount->total_discount ?? 0);
|
||||
$result[] = [
|
||||
'id' => $parent['id'],
|
||||
'firstname' => $parent['firstname'],
|
||||
'lastname' => $parent['lastname'],
|
||||
'email' => $parent['email'],
|
||||
'school_id' => $parent['school_id'] ?? null,
|
||||
'total_discount' => $totalDiscount,
|
||||
'has_discount' => $totalDiscount > 0 ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success($result, 'Parent discounts retrieved');
|
||||
}
|
||||
|
||||
public function applyVoucher()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$rules = [
|
||||
'voucher_id' => 'required|integer',
|
||||
'parent_ids' => 'required',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$voucherId = (int) $data['voucher_id'];
|
||||
$parentIds = $this->normalizeParentIds($data['parent_ids']);
|
||||
if (empty($parentIds)) {
|
||||
return $this->respondError('At least one parent is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$voucher = $this->discount->find($voucherId);
|
||||
if (!$voucher) {
|
||||
return $this->respondError('Voucher not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$remainingUses = max(0, (int) ($voucher['max_uses'] ?? 1) - (int) ($voucher['times_used'] ?? 0));
|
||||
if ($remainingUses <= 0) {
|
||||
return $this->respondError('Voucher has reached its maximum allowed uses', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$description = $this->normalizeDescription($voucher['description'] ?? '');
|
||||
$now = utc_now();
|
||||
$fullyCoveredInvoiceIds = [];
|
||||
$appliedInvoices = [];
|
||||
$errors = [];
|
||||
$usedCount = 0;
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($parentIds as $parentId) {
|
||||
$invoices = $this->invoice->getInvoicesByUserId($parentId, $this->schoolYear);
|
||||
if (empty($invoices)) {
|
||||
$errors[] = "Parent {$parentId} has no invoices for {$this->schoolYear}.";
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
if ($invoice['balance'] <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($remainingUses <= 0) {
|
||||
break 2;
|
||||
}
|
||||
|
||||
$initialPreBalance = $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
||||
|
||||
$already = $this->discountUsage->newQuery()
|
||||
->where('voucher_id', $voucherId)
|
||||
->where('invoice_id', $invoice['id'])
|
||||
->exists();
|
||||
if ($already) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rawDiscount = ($voucher['discount_type'] === 'percent')
|
||||
? round(((float) $invoice['total_amount'] * (float) $voucher['discount_value']) / 100, 2)
|
||||
: (float) $voucher['discount_value'];
|
||||
|
||||
$discountAmount = min($rawDiscount, $initialPreBalance);
|
||||
if ($discountAmount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->db->table('discount_usages')->insert([
|
||||
'voucher_id' => $voucherId,
|
||||
'invoice_id' => $invoice['id'],
|
||||
'parent_id' => $parentId,
|
||||
'discount_amount' => $discountAmount,
|
||||
'description' => $description,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'updated_by' => $this->getCurrentUserId() ?? 0,
|
||||
'used_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$newBalance = max(0.0, round(((float) $invoice['balance']) - $discountAmount, 2));
|
||||
$this->invoice->update($invoice['id'], [
|
||||
'balance' => $newBalance,
|
||||
'has_discount' => 1,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$postBalance = max(0.0, round($initialPreBalance - $discountAmount, 2));
|
||||
$currentBalance = $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
||||
|
||||
$this->discount->newQuery()
|
||||
->where('id', $voucherId)
|
||||
->set('times_used', 'times_used + 1', false)
|
||||
->update();
|
||||
|
||||
[$eventData, $studentData] = $this->buildPaymentEventData(
|
||||
$invoice['id'],
|
||||
(string) $voucherId,
|
||||
$discountAmount,
|
||||
'discount',
|
||||
$now,
|
||||
'',
|
||||
0,
|
||||
$initialPreBalance,
|
||||
$postBalance
|
||||
);
|
||||
event('paymentReceived', [$eventData, $studentData]);
|
||||
|
||||
$epsilon = 0.01;
|
||||
$fullyCovered = (abs($discountAmount - $initialPreBalance) <= $epsilon)
|
||||
|| ($postBalance <= $epsilon)
|
||||
|| ($currentBalance <= $epsilon);
|
||||
|
||||
if ($fullyCovered) {
|
||||
$fullyCoveredInvoiceIds[] = (int) $invoice['id'];
|
||||
}
|
||||
|
||||
$appliedInvoices[] = (int) $invoice['id'];
|
||||
$remainingUses--;
|
||||
$usedCount++;
|
||||
|
||||
if ($remainingUses <= 0) {
|
||||
$this->discount->update($voucherId, [
|
||||
'is_active' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Voucher application failed: ' . $e->getMessage());
|
||||
return $this->respondError('Voucher application failed. Transaction rolled back.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$enrollmentUpdates = 0;
|
||||
foreach (array_unique($fullyCoveredInvoiceIds) as $invoiceId) {
|
||||
try {
|
||||
$enrollmentUpdates += $this->updateEnrollmentStatusIfPaid($invoiceId);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [
|
||||
'iid' => $invoiceId,
|
||||
'err' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'applied_invoices' => array_values(array_unique($appliedInvoices)),
|
||||
'errors' => $errors,
|
||||
'enrollments' => $enrollmentUpdates,
|
||||
'used_count' => $usedCount,
|
||||
], 'Voucher applied successfully');
|
||||
}
|
||||
|
||||
public function createVoucher()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$rules = [
|
||||
'code' => 'required|string',
|
||||
'discount_type' => 'required|in:percent,fixed',
|
||||
'discount_value' => 'required|numeric',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim((string) $data['code'])));
|
||||
if ($code === '') {
|
||||
return $this->respondError('Voucher code is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'code' => $code,
|
||||
'discount_type' => strtolower((string) $data['discount_type']),
|
||||
'discount_value' => (float) $data['discount_value'],
|
||||
'max_uses' => isset($data['max_uses']) && $data['max_uses'] !== '' ? (int) $data['max_uses'] : null,
|
||||
'valid_from' => $this->normalizeDate($data['valid_from'] ?? null),
|
||||
'valid_until' => $this->normalizeDate($data['valid_until'] ?? null),
|
||||
'is_active' => isset($data['is_active']) ? (bool) $data['is_active'] : true,
|
||||
'description' => trim((string) ($data['description'] ?? '')),
|
||||
];
|
||||
|
||||
if ($payload['valid_from'] && $payload['valid_until'] && $payload['valid_from'] > $payload['valid_until']) {
|
||||
return $this->respondError('Valid until must be the same as or after valid from', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$voucher = $this->discount->create($payload);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Voucher creation failed: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to save voucher: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->respondCreated($voucher->toArray(), 'Voucher created successfully');
|
||||
}
|
||||
|
||||
public function editVoucher($id)
|
||||
{
|
||||
$voucher = $this->discount->find($id);
|
||||
if (!$voucher) {
|
||||
return $this->respondError('Voucher not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$update = [];
|
||||
foreach (['code', 'discount_type', 'discount_value', 'max_uses', 'valid_from', 'valid_until', 'is_active', 'description'] as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$value = $data[$field];
|
||||
if (in_array($field, ['valid_from', 'valid_until'], true)) {
|
||||
$value = $this->normalizeDate($value);
|
||||
}
|
||||
$update[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($update)) {
|
||||
$this->discount->update($id, $update);
|
||||
}
|
||||
|
||||
return $this->success($this->discount->find($id), 'Voucher updated successfully');
|
||||
}
|
||||
|
||||
protected function findActiveDiscount(?string $code)
|
||||
{
|
||||
if (empty($code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$today = now()->toDateString();
|
||||
return $this->discount->newQuery()
|
||||
->where('code', $code)
|
||||
->where('is_active', 1)
|
||||
->where(function ($q) use ($today) {
|
||||
$q->whereNull('valid_from')->orWhere('valid_from', '<=', $today);
|
||||
})
|
||||
->where(function ($q) use ($today) {
|
||||
$q->whereNull('valid_until')->orWhere('valid_until', '>=', $today);
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
protected function isUsageLimitReached($discount): bool
|
||||
{
|
||||
if (!$discount->max_uses) {
|
||||
return false;
|
||||
}
|
||||
return isset($discount->times_used) && $discount->times_used >= $discount->max_uses;
|
||||
}
|
||||
|
||||
private function normalizeParentIds($input): array
|
||||
{
|
||||
$values = [];
|
||||
if (is_string($input)) {
|
||||
$decoded = json_decode($input, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$values = $decoded;
|
||||
} else {
|
||||
$values = [$input];
|
||||
}
|
||||
} elseif (is_array($input)) {
|
||||
$values = $input;
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('intval', $values), fn($id) => $id > 0));
|
||||
}
|
||||
|
||||
private function normalizeDescription(?string $description): string
|
||||
{
|
||||
if ($description === null) {
|
||||
return '';
|
||||
}
|
||||
$description = trim($description);
|
||||
return preg_replace(
|
||||
'/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i',
|
||||
'',
|
||||
$description
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeDate($value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = preg_replace('/[^0-9\-]/', '', (string) $value);
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) ? $date : null;
|
||||
}
|
||||
|
||||
private function getCurrentInvoiceBalance($invoiceId, $schoolYear): float
|
||||
{
|
||||
$invoice = $this->invoice->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$qb = $this->payment
|
||||
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
$table = $this->payment->table;
|
||||
if ($this->db->getSchemaBuilder()->hasColumn($table, 'status')) {
|
||||
$qb->where(function ($q) {
|
||||
$q->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
|
||||
->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
if ($this->db->getSchemaBuilder()->hasColumn($table, 'is_void')) {
|
||||
$qb->where(function ($q) {
|
||||
$q->where('is_void', 0)
|
||||
->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
|
||||
$rowPaid = $qb->first();
|
||||
$totalPaid = (float) ($rowPaid['total_paid'] ?? 0);
|
||||
|
||||
$rowDisc = DB::table('discount_usages as du')
|
||||
->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_disc')
|
||||
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
|
||||
->where('du.invoice_id', $invoiceId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->first();
|
||||
$totalDisc = (float) ($rowDisc->total_disc ?? 0);
|
||||
|
||||
$rowRefund = DB::table('refunds')
|
||||
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->first();
|
||||
$totalRefundPaid = (float) ($rowRefund->total_refund_paid ?? 0);
|
||||
|
||||
$total = (float) ($invoice['total_amount'] ?? 0);
|
||||
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
|
||||
}
|
||||
|
||||
private function updateEnrollmentStatusIfPaid(int $invoiceId): int
|
||||
{
|
||||
$invoice = $this->invoice->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = (float) ($invoice['total_amount'] ?? 0);
|
||||
$balance = (float) ($invoice['balance'] ?? 0);
|
||||
if (!($total > 0 && $balance < $total)) {
|
||||
log_message('info', 'No payment yet. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||
if ($parentId <= 0 || $this->schoolYear === '') {
|
||||
log_message('warning', 'Missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$paidEnrollmentIds = [];
|
||||
try {
|
||||
$rows = DB::table('invoice_items')
|
||||
->select('enrollment_id')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereNotNull('enrollment_id')
|
||||
->get()
|
||||
->pluck('enrollment_id')
|
||||
->map('intval')
|
||||
->filter(fn($eid) => $eid > 0)
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
$paidEnrollmentIds = $rows;
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
$builder = DB::table('enrollments')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where(function ($query) {
|
||||
$query->where('enrollment_status', 'payment pending')
|
||||
->orWhere('enrollment_status', 'Payment pending')
|
||||
->orWhere('enrollment_status', 'Payment Pending')
|
||||
->orWhere('enrollment_status', 'PAYMENT PENDING')
|
||||
->orWhereRaw("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'");
|
||||
});
|
||||
|
||||
if ($this->semester !== null) {
|
||||
$builder->where('semester', $this->semester);
|
||||
}
|
||||
if (!empty($paidEnrollmentIds)) {
|
||||
$builder->whereIn('id', $paidEnrollmentIds);
|
||||
}
|
||||
|
||||
$toUpdateIds = $builder->pluck('id')->map('intval')->toArray();
|
||||
if (empty($toUpdateIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$updated = DB::table('enrollments')
|
||||
->whereIn('id', $toUpdateIds)
|
||||
->update([
|
||||
'enrollment_status' => 'enrolled',
|
||||
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
|
||||
]);
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
private function buildPaymentEventData(
|
||||
int $invoiceId,
|
||||
string $transactionId,
|
||||
float $amount,
|
||||
string $paymentMethod,
|
||||
string $paymentDate,
|
||||
?string $checkNumber,
|
||||
int $installmentSeq,
|
||||
float $preBalance,
|
||||
float $postBalance
|
||||
): array {
|
||||
$invoice = $this->invoice->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
throw new \RuntimeException("Invoice {$invoiceId} not found");
|
||||
}
|
||||
|
||||
$parentRow = DB::table('users')
|
||||
->select('id', 'firstname', 'lastname', 'email')
|
||||
->where('id', $invoice['parent_id'])
|
||||
->first();
|
||||
|
||||
if (!$parentRow) {
|
||||
throw new \RuntimeException("Parent not found for invoice {$invoiceId}");
|
||||
}
|
||||
|
||||
$studentRows = DB::table('students as s')
|
||||
->select('s.id', 's.firstname', 's.lastname', 'sc.class_section_id')
|
||||
->join('student_class as sc', 'sc.student_id', '=', 's.id')
|
||||
->where('s.parent_id', $invoice['parent_id'])
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->when($this->semester !== null, fn($q) => $q->where('sc.semester', $this->semester))
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$data = [
|
||||
'user_id' => (int) $parentRow->id,
|
||||
'email' => $parentRow->email,
|
||||
'firstname' => $parentRow->firstname,
|
||||
'lastname' => $parentRow->lastname,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'invoice_id' => $invoiceId,
|
||||
'transaction_id' => $transactionId,
|
||||
'payment_date' => $paymentDate,
|
||||
'amount' => $amount,
|
||||
'method' => $paymentMethod,
|
||||
'check_number' => $checkNumber,
|
||||
'installment_seq' => $installmentSeq,
|
||||
'invoice_total' => (float) $invoice['total_amount'],
|
||||
'pre_balance' => $preBalance,
|
||||
'post_balance' => $postBalance,
|
||||
'portalLink' => URL::to('/parent/invoices/' . $invoiceId),
|
||||
];
|
||||
|
||||
return [$data, $studentRows];
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EmailExtractorController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* GET /api/emails
|
||||
* Returns JSON: { users: string[], parents: string[] }
|
||||
* Pulls emails from users.email and parents.secondparent_email
|
||||
*/
|
||||
public function getEmails()
|
||||
{
|
||||
try {
|
||||
$users = [];
|
||||
$parents = [];
|
||||
|
||||
// Fetch users.email (non-null, non-empty)
|
||||
$userRows = DB::table('users')->select('email')->get();
|
||||
foreach ($userRows as $row) {
|
||||
$email = strtolower(trim((string)($row->email ?? '')));
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$users[] = $email;
|
||||
}
|
||||
}
|
||||
// De-duplicate
|
||||
$users = array_values(array_unique($users));
|
||||
|
||||
// Fetch parents.secondparent_email (non-null, non-empty)
|
||||
$parentRows = DB::table('parents')->select('secondparent_email')->get();
|
||||
foreach ($parentRows as $row) {
|
||||
$email = strtolower(trim((string)($row->secondparent_email ?? '')));
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$parents[] = $email;
|
||||
}
|
||||
}
|
||||
// De-duplicate
|
||||
$parents = array_values(array_unique($parents));
|
||||
|
||||
return $this->success([
|
||||
'users' => $users,
|
||||
'parents' => $parents,
|
||||
], 'Emails retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Email extraction error: ' . $e->getMessage());
|
||||
return $this->respondError('Database error: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/compare
|
||||
* Accepts multipart/form-data with a CSV file named 'file' (optional),
|
||||
* or JSON body with { csvEmails: string[] } (optional).
|
||||
* Compares against DB and returns:
|
||||
* {
|
||||
* existed: string[], // in CSV AND in DB
|
||||
* needToAdd: string[], // in DB BUT NOT in CSV
|
||||
* counts: { csv: number, db: number, users: number, parents: number }
|
||||
* }
|
||||
*/
|
||||
public function compare()
|
||||
{
|
||||
$csvEmails = [];
|
||||
|
||||
// 1) Try read from uploaded file
|
||||
if ($this->laravelRequest->hasFile('file')) {
|
||||
$file = $this->laravelRequest->file('file');
|
||||
if ($file->isValid()) {
|
||||
$contents = file_get_contents($file->getRealPath());
|
||||
$csvEmails = $this->extractEmailsFromText($contents);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Or from JSON body
|
||||
if (empty($csvEmails)) {
|
||||
$data = $this->payloadData();
|
||||
if (isset($data['csvEmails']) && is_array($data['csvEmails'])) {
|
||||
$csvEmails = $this->normalizeEmailArray($data['csvEmails']);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Compare with DB
|
||||
try {
|
||||
// Fetch DB emails
|
||||
$users = [];
|
||||
$parents = [];
|
||||
|
||||
$userRows = DB::table('users')->select('email')->get();
|
||||
foreach ($userRows as $row) {
|
||||
$e = strtolower(trim((string)($row->email ?? '')));
|
||||
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) {
|
||||
$users[] = $e;
|
||||
}
|
||||
}
|
||||
$users = array_values(array_unique($users));
|
||||
|
||||
$parentRows = DB::table('parents')->select('secondparent_email')->get();
|
||||
foreach ($parentRows as $row) {
|
||||
$e = strtolower(trim((string)($row->secondparent_email ?? '')));
|
||||
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) {
|
||||
$parents[] = $e;
|
||||
}
|
||||
}
|
||||
$parents = array_values(array_unique($parents));
|
||||
|
||||
// Sets for comparison
|
||||
$csvSet = array_flip(array_values(array_unique($csvEmails)));
|
||||
$dbUnion = array_values(array_unique(array_merge($users, $parents)));
|
||||
$dbSet = array_flip($dbUnion);
|
||||
|
||||
// existed: in CSV and in DB
|
||||
$existed = [];
|
||||
foreach ($csvSet as $email => $_) {
|
||||
if (isset($dbSet[$email])) {
|
||||
$existed[] = $email;
|
||||
}
|
||||
}
|
||||
sort($existed);
|
||||
|
||||
// needToAdd: in DB but NOT in CSV
|
||||
$needToAdd = [];
|
||||
foreach ($dbSet as $email => $_) {
|
||||
if (!isset($csvSet[$email])) {
|
||||
$needToAdd[] = $email;
|
||||
}
|
||||
}
|
||||
sort($needToAdd);
|
||||
|
||||
return $this->success([
|
||||
'existed' => $existed,
|
||||
'needToAdd' => $needToAdd,
|
||||
'counts' => [
|
||||
'csv' => count($csvEmails),
|
||||
'db' => count($dbUnion),
|
||||
'users' => count($users),
|
||||
'parents' => count($parents),
|
||||
],
|
||||
], 'Comparison completed successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Email comparison error: ' . $e->getMessage());
|
||||
return $this->respondError('Database error: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
private function normalizeEmailArray(array $arr): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($arr as $e) {
|
||||
$email = strtolower(trim((string)$e));
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$out[] = $email;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
private function extractEmailsFromText(string $text): array
|
||||
{
|
||||
$pattern = '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i';
|
||||
preg_match_all($pattern, $text, $matches);
|
||||
$emails = $matches[0] ?? [];
|
||||
return $this->normalizeEmailArray($emails);
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\EmergencyContact;
|
||||
use App\Models\Student;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EmergencyContactController extends BaseApiController
|
||||
{
|
||||
protected EmergencyContact $emergencyContact;
|
||||
protected Configuration $config;
|
||||
protected Student $student;
|
||||
protected User $user;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->emergencyContact = model(EmergencyContact::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->user = model(User::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$parentId = $this->request->getGet('parent_id');
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$query = $this->emergencyContact->newQuery();
|
||||
if ($parentId) {
|
||||
$query->where('parent_id', $parentId);
|
||||
}
|
||||
if ($schoolYear) {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($semester) {
|
||||
$query->where('semester', $semester);
|
||||
}
|
||||
|
||||
$contacts = $query->get()->toArray();
|
||||
|
||||
return $this->success($contacts, 'Emergency contacts retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$contact = $this->emergencyContact->find($id);
|
||||
if (!$contact) {
|
||||
return $this->respondError('Emergency contact not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($contact, 'Emergency contact retrieved successfully');
|
||||
}
|
||||
|
||||
public function getByParent($id = null)
|
||||
{
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$contacts = $this->emergencyContact->newQuery()
|
||||
->where('parent_id', $id);
|
||||
if ($schoolYear) {
|
||||
$contacts->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($semester) {
|
||||
$contacts->where('semester', $semester);
|
||||
}
|
||||
|
||||
return $this->success($contacts->get()->toArray(), 'Emergency contacts retrieved successfully');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'parent_id' => 'required|integer',
|
||||
'emergency_contact_name' => 'required|max_length[255]',
|
||||
'cellphone' => 'required|max_length[20]',
|
||||
'relation' => 'required|max_length[50]',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'parent_id' => $data['parent_id'],
|
||||
'emergency_contact_name'=> $data['emergency_contact_name'],
|
||||
'cellphone' => $data['cellphone'],
|
||||
'email' => $data['email'] ?? null,
|
||||
'relation' => $data['relation'],
|
||||
'school_year' => $this->config->getConfig('school_year'),
|
||||
'semester' => $this->config->getConfig('semester'),
|
||||
];
|
||||
|
||||
try {
|
||||
$contact = $this->emergencyContact->create($payload);
|
||||
return $this->respondCreated([
|
||||
'id' => $contact->id,
|
||||
'data' => $contact,
|
||||
], 'Emergency contact created successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Emergency contact creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create emergency contact', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$contact = $this->emergencyContact->find($id);
|
||||
if (!$contact) {
|
||||
return $this->respondError('Emergency contact not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowed = ['emergency_contact_name', 'cellphone', 'email', 'relation'];
|
||||
$updateData = [];
|
||||
foreach ($allowed as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$contact->fill($updateData);
|
||||
$contact->save();
|
||||
return $this->success($contact->fresh(), 'Emergency contact updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Emergency contact update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update emergency contact', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete($id = null)
|
||||
{
|
||||
$contact = $this->emergencyContact->find($id);
|
||||
if (!$contact) {
|
||||
return $this->respondError('Emergency contact not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$contact->delete();
|
||||
return $this->success(null, 'Emergency contact deleted successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Emergency contact deletion error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete emergency contact', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/emergency-contacts/data
|
||||
* Returns JSON payload for emergency contacts grouped by parent with parent info, students, and contacts.
|
||||
*/
|
||||
public function data()
|
||||
{
|
||||
try {
|
||||
// Get distinct parent IDs from emergency contacts
|
||||
$parentRows = $this->emergencyContact->newQuery()
|
||||
->distinct()
|
||||
->select('parent_id')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$groups = [];
|
||||
|
||||
foreach ($parentRows as $row) {
|
||||
$parentId = (int)($row['parent_id'] ?? 0);
|
||||
if ($parentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get parent info
|
||||
$parent = $this->user->find($parentId);
|
||||
$parentName = 'Unknown Parent';
|
||||
$parentPhone = '';
|
||||
|
||||
if ($parent) {
|
||||
$parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')) ?: 'Unknown Parent';
|
||||
$parentPhone = (string)($parent['cellphone'] ?? '');
|
||||
}
|
||||
|
||||
// Try to load second parent phone from parents table
|
||||
$secondPhone = '';
|
||||
try {
|
||||
$secondParentRow = DB::table('parents')
|
||||
->select('secondparent_phone')
|
||||
->where('firstparent_id', $parentId)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->first();
|
||||
|
||||
if ($secondParentRow && !empty($secondParentRow->secondparent_phone)) {
|
||||
$secondPhone = (string)$secondParentRow->secondparent_phone;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('EmergencyContactController: could not load second parent phone: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Get students for this parent
|
||||
$students = $this->student->newQuery()
|
||||
->select('id', 'firstname', 'lastname', 'school_id')
|
||||
->where('parent_id', $parentId)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
// Get emergency contacts for this parent
|
||||
$contacts = $this->emergencyContact->newQuery()
|
||||
->where('parent_id', $parentId)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
// Filter out empty phone numbers
|
||||
$parentPhones = array_values(array_filter([$parentPhone, $secondPhone], static fn($v) => (string)$v !== ''));
|
||||
|
||||
$groups[] = [
|
||||
'parent_id' => $parentId,
|
||||
'parent_name' => $parentName,
|
||||
'students' => $students,
|
||||
'contacts' => $contacts,
|
||||
'parent_phones' => $parentPhones,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'groups' => $groups,
|
||||
], 'Emergency contacts grouped by parent retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Emergency contact data error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve emergency contact data', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCharges;
|
||||
use App\Models\Student;
|
||||
use App\Models\User;
|
||||
use App\Http\Controllers\Api\InvoiceController;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EventController extends BaseApiController
|
||||
{
|
||||
protected Event $event;
|
||||
protected Configuration $config;
|
||||
protected EventCharges $eventCharges;
|
||||
protected Student $student;
|
||||
protected User $user;
|
||||
protected InvoiceController $invoiceController;
|
||||
protected ?string $schoolYear;
|
||||
protected ?string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->event = model(Event::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->eventCharges = model(EventCharges::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->user = model(User::class);
|
||||
$this->invoiceController = app(InvoiceController::class);
|
||||
|
||||
$this->schoolYear = $this->config->getConfig('school_year');
|
||||
$this->semester = $this->config->getConfig('semester');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
$upcomingOnly = $this->request->getGet('upcoming') === 'true';
|
||||
|
||||
$query = $this->event->newQuery();
|
||||
if ($schoolYear) {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($upcomingOnly) {
|
||||
$today = Carbon::now()->toDateString();
|
||||
$query->where('expiration_date', '>=', $today);
|
||||
}
|
||||
|
||||
$events = $query->orderBy('created_at', 'DESC')->get()->toArray();
|
||||
|
||||
// Count active events
|
||||
$today = Carbon::now()->toDateString();
|
||||
$activeEventCount = $this->event->newQuery()
|
||||
->where('expiration_date', '>=', $today)
|
||||
->count();
|
||||
|
||||
return $this->success([
|
||||
'events' => $events,
|
||||
'active_event_count' => $activeEventCount,
|
||||
], 'Events retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$event = $this->event->find($id);
|
||||
if (!$event) {
|
||||
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($event, 'Event retrieved successfully');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'event_name' => 'required|max_length[255]',
|
||||
'description' => 'nullable',
|
||||
'amount' => 'required|numeric',
|
||||
'expiration_date' => 'required|date',
|
||||
'semester' => 'nullable',
|
||||
'school_year' => 'nullable',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$flyerPath = null;
|
||||
|
||||
// Handle file upload if present
|
||||
if ($this->laravelRequest->hasFile('flyer')) {
|
||||
$file = $this->laravelRequest->file('flyer');
|
||||
if ($file->isValid()) {
|
||||
$path = $file->store('event_flyers', 'public');
|
||||
$flyerPath = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'event_name' => $data['event_name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'amount' => $data['amount'],
|
||||
'flyer' => $flyerPath,
|
||||
'expiration_date' => $data['expiration_date'],
|
||||
'semester' => $data['semester'] ?? $this->semester,
|
||||
'school_year' => $data['school_year'] ?? $this->schoolYear,
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
];
|
||||
|
||||
try {
|
||||
$event = $this->event->create($payload);
|
||||
return $this->respondCreated($event->toArray(), 'Event created successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Event creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create event', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$event = $this->event->find($id);
|
||||
if (!$event) {
|
||||
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$flyerPath = $event['flyer']; // Keep existing flyer by default
|
||||
|
||||
// Handle file upload if present
|
||||
if ($this->laravelRequest->hasFile('flyer')) {
|
||||
$file = $this->laravelRequest->file('flyer');
|
||||
if ($file->isValid()) {
|
||||
// Delete old flyer if exists
|
||||
if ($flyerPath && Storage::disk('public')->exists($flyerPath)) {
|
||||
Storage::disk('public')->delete($flyerPath);
|
||||
}
|
||||
$path = $file->store('event_flyers', 'public');
|
||||
$flyerPath = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$updateData = [];
|
||||
$allowed = ['event_name', 'description', 'amount', 'expiration_date', 'semester', 'school_year'];
|
||||
foreach ($allowed as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
$updateData['flyer'] = $flyerPath;
|
||||
|
||||
try {
|
||||
$this->event->update($id, $updateData);
|
||||
$updated = $this->event->find($id);
|
||||
return $this->success($updated->toArray(), 'Event updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Event update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update event', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete($id = null)
|
||||
{
|
||||
$event = $this->event->find($id);
|
||||
if (!$event) {
|
||||
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete related charges and collect parent IDs
|
||||
$charges = $this->eventCharges->newQuery()
|
||||
->where('event_id', $id)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$parentIds = [];
|
||||
foreach ($charges as $charge) {
|
||||
$this->eventCharges->delete($charge['id']);
|
||||
$parentIds[] = $charge['parent_id'];
|
||||
}
|
||||
|
||||
// Delete event
|
||||
$this->event->delete($id);
|
||||
|
||||
$parentIds = array_unique($parentIds);
|
||||
|
||||
// Regenerate invoices for affected parents
|
||||
// Note: generateInvoice method may need to be implemented in InvoiceController
|
||||
foreach ($parentIds as $parentId) {
|
||||
// This would need to be implemented or called differently
|
||||
// $this->invoiceController->generateInvoice($parentId);
|
||||
}
|
||||
|
||||
return $this->success(null, 'Event, charges, and invoices updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Event deletion error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete event', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/events/charges
|
||||
* Returns event charges with parent and student information
|
||||
*/
|
||||
public function charges()
|
||||
{
|
||||
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
$semester = $this->request->getGet('semester') ?? $this->semester;
|
||||
|
||||
try {
|
||||
$parents = $this->user->getParents();
|
||||
$events = $this->event->getActiveEvents($this->schoolYear);
|
||||
|
||||
$charges = $this->eventCharges->newQuery()
|
||||
->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'
|
||||
])
|
||||
->leftJoin('users', 'users.id', '=', 'event_charges.parent_id')
|
||||
->leftJoin('students', 'students.id', '=', 'event_charges.student_id')
|
||||
->leftJoin('events', 'events.id', '=', 'event_charges.event_id')
|
||||
->where('event_charges.school_year', $schoolYear)
|
||||
->where('event_charges.semester', $semester)
|
||||
->orderBy('event_charges.created_at', 'DESC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success([
|
||||
'charges' => $charges,
|
||||
'parents' => $parents,
|
||||
'events' => $events,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
], 'Event charges retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Event charges retrieval error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve event charges', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/events/charges/update
|
||||
* Updates event charges for a parent and students
|
||||
*/
|
||||
public function updateCharges()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$schoolYear = $data['school_year'] ?? $this->schoolYear;
|
||||
$semester = $data['semester'] ?? $this->semester;
|
||||
$parentId = $data['parent_id'] ?? null;
|
||||
$eventId = $data['event_id'] ?? null;
|
||||
$participations = $data['participation'] ?? [];
|
||||
|
||||
if (!$parentId || !$eventId || empty($participations)) {
|
||||
return $this->respondError('Missing required information: parent_id, event_id, and participation are required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$userId = $this->getCurrentUserId();
|
||||
$event = $this->event->getEvent($eventId, $schoolYear);
|
||||
|
||||
if (!$event) {
|
||||
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
foreach ($participations as $studentId => $value) {
|
||||
$existing = $this->eventCharges->newQuery()
|
||||
->where('parent_id', $parentId)
|
||||
->where('student_id', $studentId)
|
||||
->where('event_id', $eventId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->first();
|
||||
|
||||
if ($value === 'no') {
|
||||
if ($existing) {
|
||||
$this->eventCharges->delete($existing['id']);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// value is 'yes'
|
||||
$chargeData = [
|
||||
'parent_id' => $parentId,
|
||||
'student_id' => $studentId,
|
||||
'event_id' => $eventId,
|
||||
'participation' => 'yes',
|
||||
'charged' => $event['amount'],
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$chargeData['updated_by'] = $userId;
|
||||
$this->eventCharges->update($existing['id'], $chargeData);
|
||||
} else {
|
||||
$chargeData['updated_by'] = $userId;
|
||||
$this->eventCharges->insert($chargeData);
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate invoice for parent
|
||||
// Note: generateInvoice method may need to be implemented
|
||||
// $this->invoiceController->generateInvoice($parentId);
|
||||
|
||||
return $this->success(null, 'Event charges updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Event charges update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update event charges', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/events/students-with-charges
|
||||
* Returns students for a parent with their charge status
|
||||
*/
|
||||
public function getStudentsWithCharges()
|
||||
{
|
||||
$parentId = $this->request->getGet('parent_id');
|
||||
$semester = $this->request->getGet('semester') ?? $this->semester;
|
||||
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
|
||||
if (!$parentId) {
|
||||
return $this->respondError('parent_id is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get students for parent
|
||||
$students = $this->student->newQuery()
|
||||
->where('parent_id', $parentId)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
// Get student_ids that already have charges
|
||||
$chargedStudentIds = $this->eventCharges->newQuery()
|
||||
->where('parent_id', $parentId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('student_id')
|
||||
->pluck('student_id')
|
||||
->toArray();
|
||||
|
||||
$data = [];
|
||||
foreach ($students as $student) {
|
||||
$data[] = [
|
||||
'id' => $student['id'],
|
||||
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
||||
'charged' => in_array($student['id'], $chargedStudentIds),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success($data, 'Students with charges retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Get students with charges error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve students with charges', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Expense;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ExpenseController extends BaseApiController
|
||||
{
|
||||
protected Expense $expense;
|
||||
protected User $user;
|
||||
protected Configuration $config;
|
||||
protected ?string $schoolYear;
|
||||
protected ?string $semester;
|
||||
protected array $retailors;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->expense = model(Expense::class);
|
||||
$this->user = model(User::class);
|
||||
$this->config = model(Configuration::class);
|
||||
|
||||
$this->schoolYear = $this->config->getConfig('school_year');
|
||||
$this->semester = $this->config->getConfig('semester');
|
||||
|
||||
// Default list of common retailors; adjust as needed
|
||||
$this->retailors = [
|
||||
'Amazon',
|
||||
'Walmart',
|
||||
'Costco',
|
||||
'BJ\'s',
|
||||
'Market Basket',
|
||||
'Aldi',
|
||||
'Hannaford',
|
||||
'Sam\'s Club',
|
||||
'HomeGoods',
|
||||
'Hostinger',
|
||||
'Wicked Cheesy',
|
||||
'Shatila',
|
||||
'Brothers Pizzeria',
|
||||
'Paradise Biryani Pointe',
|
||||
'Emad Leiman',
|
||||
'Nova Trampoline Park',
|
||||
'Lubin\'s Awards',
|
||||
'Dollar Tree',
|
||||
'Stop & Shop',
|
||||
'Dunkin\' Donuts',
|
||||
'Giovanni\'s Pizza'
|
||||
];
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$status = $this->request->getGet('status');
|
||||
$category = $this->request->getGet('category');
|
||||
|
||||
$query = $this->expense
|
||||
->select("
|
||||
expenses.*,
|
||||
u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname,
|
||||
approver.firstname AS approver_firstname, approver.lastname AS approver_lastname
|
||||
")
|
||||
->join('users u', 'u.id = expenses.purchased_by', 'left')
|
||||
->join('users approver', 'approver.id = expenses.approved_by', 'left');
|
||||
|
||||
if ($this->schoolYear) {
|
||||
$query->where('expenses.school_year', $this->schoolYear);
|
||||
}
|
||||
if ($this->semester) {
|
||||
$query->where('expenses.semester', $this->semester);
|
||||
}
|
||||
if ($status) {
|
||||
$query->where('expenses.status', $status);
|
||||
}
|
||||
if ($category) {
|
||||
$query->where('expenses.category', $category);
|
||||
}
|
||||
|
||||
$expenses = $query->orderBy('expenses.created_at', 'DESC')->findAll();
|
||||
|
||||
// Enrich each row with a URL that goes through Files::receipt($name)
|
||||
$expenses = array_map(function ($row) {
|
||||
$name = $row['receipt_path'] ?? null;
|
||||
$row['receipt_url'] = $this->receiptUrl($name);
|
||||
return $row;
|
||||
}, $expenses);
|
||||
|
||||
// Apply pagination manually for array results
|
||||
$total = count($expenses);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$paginatedExpenses = array_slice($expenses, $offset, $perPage);
|
||||
|
||||
$result = [
|
||||
'data' => $paginatedExpenses,
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'total_pages' => (int) ceil($total / $perPage),
|
||||
],
|
||||
];
|
||||
|
||||
return $this->success($result, 'Expenses retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$expense = $this->expense
|
||||
->select("
|
||||
expenses.*,
|
||||
u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname,
|
||||
approver.firstname AS approver_firstname, approver.lastname AS approver_lastname
|
||||
")
|
||||
->join('users u', 'u.id = expenses.purchased_by', 'left')
|
||||
->join('users approver', 'approver.id = expenses.approved_by', 'left')
|
||||
->where('expenses.id', $id)
|
||||
->first();
|
||||
|
||||
if (!$expense) {
|
||||
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Add receipt URL
|
||||
$expense['receipt_url'] = $this->receiptUrl($expense['receipt_path'] ?? null);
|
||||
|
||||
return $this->success($expense, 'Expense retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for creating an expense (users list and retailors)
|
||||
*/
|
||||
public function createMetadata()
|
||||
{
|
||||
// Limit users to non-teacher/non-parent
|
||||
$users = $this->user
|
||||
->select('users.id, users.firstname, users.lastname')
|
||||
->join('user_roles', 'user_roles.user_id = users.id')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->whereNotIn('roles.name', ['teacher', 'parent'])
|
||||
->distinct()
|
||||
->findAll();
|
||||
|
||||
return $this->success([
|
||||
'users' => $users,
|
||||
'retailors' => $this->retailors,
|
||||
], 'Metadata retrieved successfully');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'category' => 'required|in:Expense,Purchase,Reimbursement',
|
||||
'amount' => 'required|numeric|gt:0',
|
||||
'purchased_by' => 'required',
|
||||
'retailor' => 'nullable|max:255',
|
||||
'date_of_purchase'=> 'nullable|date',
|
||||
];
|
||||
|
||||
// Handle file upload validation
|
||||
if ($this->laravelRequest->hasFile('receipt')) {
|
||||
$rules['receipt'] = 'file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf';
|
||||
} else {
|
||||
$rules['receipt'] = 'required|file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf';
|
||||
}
|
||||
|
||||
// Use Laravel Validator directly for file validation
|
||||
$validator = Validator::make($this->laravelRequest->all(), $rules);
|
||||
if ($validator->fails()) {
|
||||
return $this->respondValidationError($validator->errors()->toArray());
|
||||
}
|
||||
|
||||
// Parse "purchased_by" as "7|John Doe" if provided in that format
|
||||
$purchasedById = $user->id;
|
||||
if (isset($data['purchased_by'])) {
|
||||
$purchasedInfo = (string) $data['purchased_by'];
|
||||
if (strpos($purchasedInfo, '|') !== false) {
|
||||
[$purchasedById, $purchasedByName] = array_pad(explode('|', $purchasedInfo, 2), 2, null);
|
||||
$purchasedById = (int) $purchasedById;
|
||||
} else {
|
||||
$purchasedById = (int) $purchasedInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file upload
|
||||
$receiptName = null;
|
||||
if ($this->laravelRequest->hasFile('receipt')) {
|
||||
$file = $this->laravelRequest->file('receipt');
|
||||
if ($file->isValid()) {
|
||||
$targetDir = storage_path('app/uploads/receipts');
|
||||
if (!is_dir($targetDir)) {
|
||||
@mkdir($targetDir, 0755, true);
|
||||
}
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$newName = uniqid('', true) . '.' . $extension;
|
||||
$file->move($targetDir, $newName);
|
||||
$receiptName = $newName;
|
||||
}
|
||||
}
|
||||
|
||||
$schoolYear = $this->schoolYear ?: date('Y');
|
||||
$semester = $this->semester ?: 'Fall';
|
||||
|
||||
$expenseData = [
|
||||
'category' => $data['category'],
|
||||
'amount' => $data['amount'],
|
||||
'receipt_path' => $receiptName,
|
||||
'description' => $data['description'] ?? null,
|
||||
'retailor' => !empty($data['retailor']) ? trim($data['retailor']) : null,
|
||||
'date_of_purchase' => $data['date_of_purchase'] ?? null,
|
||||
'purchased_by' => $purchasedById,
|
||||
'added_by' => $user->id,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'status' => 'pending',
|
||||
];
|
||||
|
||||
try {
|
||||
$expense = $this->expense->create($expenseData);
|
||||
$expense['receipt_url'] = $this->receiptUrl($receiptName);
|
||||
return $this->respondCreated($expense, 'Expense created successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Expense creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create expense', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for editing an expense
|
||||
*/
|
||||
public function editMetadata($id = null)
|
||||
{
|
||||
$expense = $this->expense->find($id);
|
||||
if (!$expense) {
|
||||
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Get users list
|
||||
$users = $this->user
|
||||
->select('users.id, users.firstname, users.lastname')
|
||||
->join('user_roles', 'user_roles.user_id = users.id')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->whereNotIn('roles.name', ['teacher', 'parent'])
|
||||
->distinct()
|
||||
->findAll();
|
||||
|
||||
$expense['receipt_url'] = $expense['receipt_path'] ? $this->receiptUrl(basename($expense['receipt_path'])) : null;
|
||||
|
||||
return $this->success([
|
||||
'expense' => $expense,
|
||||
'users' => $users,
|
||||
'retailors' => $this->retailors,
|
||||
], 'Metadata retrieved successfully');
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$expense = $this->expense->find($id);
|
||||
if (!$expense) {
|
||||
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'category' => 'required|in:Expense,Purchase,Reimbursement',
|
||||
'amount' => 'required|numeric|gt:0',
|
||||
'purchased_by' => 'required',
|
||||
'retailor' => 'nullable|max:255',
|
||||
'date_of_purchase'=> 'nullable|date',
|
||||
];
|
||||
|
||||
// Optional new receipt validation (only if provided)
|
||||
if ($this->laravelRequest->hasFile('receipt')) {
|
||||
$rules['receipt'] = 'file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf';
|
||||
}
|
||||
|
||||
// Use Laravel Validator directly for file validation
|
||||
$validator = Validator::make($this->laravelRequest->all(), $rules);
|
||||
if ($validator->fails()) {
|
||||
return $this->respondValidationError($validator->errors()->toArray());
|
||||
}
|
||||
|
||||
// Parse "purchased_by" as "id|Name" if provided in that format
|
||||
$purchasedById = $expense['purchased_by'];
|
||||
if (isset($data['purchased_by'])) {
|
||||
$purchasedInfo = (string) $data['purchased_by'];
|
||||
if (strpos($purchasedInfo, '|') !== false) {
|
||||
[$purchasedById] = array_pad(explode('|', $purchasedInfo, 2), 2, null);
|
||||
$purchasedById = (int) $purchasedById;
|
||||
} else {
|
||||
$purchasedById = (int) $purchasedInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep old receipt unless replaced or removed
|
||||
$receiptName = $expense['receipt_path'];
|
||||
if ($this->laravelRequest->hasFile('receipt')) {
|
||||
$file = $this->laravelRequest->file('receipt');
|
||||
if ($file->isValid()) {
|
||||
$targetDir = storage_path('app/uploads/receipts');
|
||||
if (!is_dir($targetDir)) {
|
||||
@mkdir($targetDir, 0755, true);
|
||||
}
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$newName = uniqid('', true) . '.' . $extension;
|
||||
$file->move($targetDir, $newName);
|
||||
$receiptName = $newName;
|
||||
}
|
||||
}
|
||||
if (isset($data['remove_receipt']) && $data['remove_receipt'] === '1') {
|
||||
$receiptName = null;
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'category' => $data['category'],
|
||||
'amount' => $data['amount'],
|
||||
'description' => $data['description'] ?? $expense['description'],
|
||||
'retailor' => isset($data['retailor']) ? (trim($data['retailor']) ?: null) : $expense['retailor'],
|
||||
'date_of_purchase'=> $data['date_of_purchase'] ?? $expense['date_of_purchase'],
|
||||
'purchased_by' => $purchasedById,
|
||||
'receipt_path' => $receiptName,
|
||||
'updated_by' => $user->id,
|
||||
];
|
||||
|
||||
try {
|
||||
$this->expense->update($id, $updateData);
|
||||
$updatedExpense = $this->expense->find($id);
|
||||
$updatedExpense['receipt_url'] = $this->receiptUrl($receiptName);
|
||||
return $this->success($updatedExpense, 'Expense updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Expense update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update expense', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateStatus()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
$id = isset($data['id']) ? (int)$data['id'] : null;
|
||||
$status = $data['status'] ?? null;
|
||||
$reason = $data['reason'] ?? '';
|
||||
|
||||
if (!$id || !in_array($status, ['approved', 'denied'], true)) {
|
||||
log_message('error', 'Invalid status or ID');
|
||||
return $this->respondError('Invalid data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$expense = $this->expense->find($id);
|
||||
if (!$expense) {
|
||||
log_message('error', 'Expense not found for ID ' . $id);
|
||||
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->expense->update($id, [
|
||||
'status' => $status,
|
||||
'status_reason' => $reason,
|
||||
'approved_by' => $user->id,
|
||||
'updated_by' => $user->id
|
||||
]);
|
||||
|
||||
$updatedExpense = $this->expense->find($id);
|
||||
return $this->success($updatedExpense, 'Expense status updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Expense update failed for ID ' . $id . ': ' . $e->getMessage());
|
||||
return $this->respondError('Update failed', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete($id = null)
|
||||
{
|
||||
$expense = $this->expense->find($id);
|
||||
if (!$expense) {
|
||||
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$expense->delete();
|
||||
return $this->success(null, 'Expense deleted successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Expense delete error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete expense', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a public URL for a receipt filename through Files::receipt($name).
|
||||
* Expects just the filename (e.g., "1759113425_1c443e607e1900f92f62.png").
|
||||
*/
|
||||
private function receiptUrl(?string $filename): ?string
|
||||
{
|
||||
if (!$filename) {
|
||||
return null;
|
||||
}
|
||||
// Using API route pattern - ensure route exists in routes/api.php
|
||||
// Example: Route::get('files/receipt/{name}', [FilesController::class, 'receipt']);
|
||||
return url('/api/v1/files/receipt/' . $filename);
|
||||
}
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\AdditionalCharge;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\User;
|
||||
use App\Models\Invoice;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ExtraChargesController extends BaseApiController
|
||||
{
|
||||
protected AdditionalCharge $additionalCharge;
|
||||
protected Configuration $config;
|
||||
protected User $user;
|
||||
protected Invoice $invoice;
|
||||
protected ?string $schoolYear;
|
||||
protected ?string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->additionalCharge = model(AdditionalCharge::class);
|
||||
$this->user = model(User::class);
|
||||
$this->invoice = model(Invoice::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->schoolYear = $this->config->getConfig('school_year');
|
||||
$this->semester = $this->config->getConfig('semester');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$year = (string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
|
||||
$sem = (string) ($this->request->getGet('semester') ?? $this->semester ?? '');
|
||||
$status = $this->request->getGet('status') ?: null;
|
||||
$q = trim((string) ($this->request->getGet('q') ?? '')) ?: null;
|
||||
$per = (int) ($this->request->getGet('per_page') ?? 50);
|
||||
|
||||
$paginator = $this->additionalCharge->listAllForTerm($year, $sem, $status, $q, $per);
|
||||
|
||||
$meta = [
|
||||
'perPage' => $paginator->perPage(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'total' => $paginator->total(),
|
||||
'pageCount' => $paginator->lastPage(),
|
||||
];
|
||||
|
||||
return $this->success([
|
||||
'school_year' => $year,
|
||||
'semester' => $sem,
|
||||
'rows' => $paginator->items(),
|
||||
'pager' => $meta,
|
||||
], 'Extra charges retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$charge = $this->additionalCharge->find($id);
|
||||
if (!$charge) {
|
||||
return $this->respondError('Extra charge not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($charge->toArray(), 'Extra charge retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parent options for Select2 (with search)
|
||||
*/
|
||||
public function parentOptions()
|
||||
{
|
||||
$q = trim((string) ($this->request->getGet('q') ?? ''));
|
||||
$limit = 20;
|
||||
|
||||
$query = $this->user
|
||||
->select('users.id, users.firstname, users.lastname, users.email')
|
||||
->join('user_roles', 'user_roles.user_id = users.id')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->whereRaw('LOWER(roles.name) = ?', ['parent'])
|
||||
->whereNull('user_roles.deleted_at');
|
||||
|
||||
if ($q !== '') {
|
||||
$query->where(function ($builder) use ($q) {
|
||||
$builder->where('users.firstname', 'like', "%{$q}%")
|
||||
->orWhere('users.lastname', 'like', "%{$q}%")
|
||||
->orWhere('users.email', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$rows = $query
|
||||
->orderBy('users.lastname', 'ASC')
|
||||
->orderBy('users.firstname', 'ASC')
|
||||
->limit($limit)
|
||||
->findAll();
|
||||
|
||||
$results = array_map(function ($r) {
|
||||
$name = trim(($r['lastname'] ?? '') . ', ' . ($r['firstname'] ?? ''));
|
||||
$email = $r['email'] ?? '';
|
||||
$label = $name !== ',' ? $name : ('User #' . $r['id']);
|
||||
if ($email) $label .= ' — ' . $email;
|
||||
$label .= ' (ID: ' . $r['id'] . ')';
|
||||
return ['id' => (int) $r['id'], 'text' => $label];
|
||||
}, $rows);
|
||||
|
||||
return $this->success(['results' => $results], 'Parent options retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoices for a parent (for Select2)
|
||||
*/
|
||||
public function invoicesForParent()
|
||||
{
|
||||
$parentId = (int) ($this->request->getGet('parent_id') ?? 0);
|
||||
$schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
|
||||
|
||||
$rows = ($parentId > 0)
|
||||
? ($this->invoice->getInvoicesByUserId($parentId, $schoolYear) ?? [])
|
||||
: [];
|
||||
|
||||
$results = array_map(function ($inv) {
|
||||
$id = (int) ($inv['id'] ?? 0);
|
||||
$num = (string) ($inv['invoice_number'] ?? ('INV-' . $id));
|
||||
$status = strtolower((string) ($inv['status'] ?? ''));
|
||||
$balance = (float) ($inv['balance'] ?? 0);
|
||||
$issue = $inv['issue_date'] ?? null;
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'invoice_number' => $num,
|
||||
'status' => $status,
|
||||
'balance' => $balance,
|
||||
'issue_date' => $issue,
|
||||
'text' => $num
|
||||
. ($status ? ' — ' . ucfirst($status) : '')
|
||||
. ' (Bal: $' . number_format($balance, 2) . ')',
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->success(['results' => $results], 'Invoices retrieved successfully');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'parent_id' => 'required|integer',
|
||||
'title' => 'required|string|min:2',
|
||||
'amount' => 'required|numeric',
|
||||
'charge_type' => 'required|in:add,deduct',
|
||||
'due_date' => 'nullable|date',
|
||||
'invoice_id' => 'nullable|integer',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$invoiceId = !empty($data['invoice_id']) ? (int) $data['invoice_id'] : null;
|
||||
$chargeType = (string) $data['charge_type'];
|
||||
|
||||
$amountAbs = round(abs((float) $data['amount']), 2);
|
||||
$signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs;
|
||||
|
||||
$payload = [
|
||||
'parent_id' => (int) $data['parent_id'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $data['school_year'] ?? $this->schoolYear,
|
||||
'semester' => $data['semester'] ?? $this->semester,
|
||||
'charge_type' => $chargeType,
|
||||
'title' => trim($data['title']),
|
||||
'description' => trim($data['description'] ?? ''),
|
||||
'amount' => $signedAmount,
|
||||
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
|
||||
'status' => $invoiceId ? 'applied' : 'pending',
|
||||
'created_by' => $user->id,
|
||||
'created_at' => Carbon::now('UTC')->toDateTimeString(),
|
||||
];
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Get invoice before
|
||||
$invoiceBefore = $this->normalizeRow(
|
||||
$this->invoice->getInvoicesByParentId($data['parent_id'], $this->schoolYear)
|
||||
);
|
||||
|
||||
// Insert charge
|
||||
$chargeId = $this->additionalCharge->insert($payload);
|
||||
if (!$chargeId) {
|
||||
throw new \Exception('Failed to insert charge');
|
||||
}
|
||||
|
||||
// Apply to invoice if present
|
||||
if ($invoiceId) {
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoice->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
$this->invoice->deductAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'apply/deductAdditionalCharge failed: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// Get invoice after
|
||||
$invoiceAfter = $this->normalizeRow(
|
||||
$this->invoice->getInvoicesByParentId($data['parent_id'], $this->schoolYear)
|
||||
);
|
||||
|
||||
// Get parent user info
|
||||
$parentUser = $this->user->getUserInfoById($data['parent_id']);
|
||||
|
||||
DB::commit();
|
||||
|
||||
// Build event payload
|
||||
$first = $parentUser['first_name'] ?? $parentUser['firstname'] ?? '';
|
||||
$last = $parentUser['last_name'] ?? $parentUser['lastname'] ?? '';
|
||||
$parentName = trim($first . ' ' . $last);
|
||||
|
||||
$invoiceTotal = $invoiceAfter ? (float) ($invoiceAfter['total_amount'] ?? 0) : 0.0;
|
||||
$preBalance = $invoiceBefore ? (float) ($invoiceBefore['balance'] ?? 0) : 0.0;
|
||||
$postBalance = $invoiceAfter ? (float) ($invoiceAfter['balance'] ?? 0) : 0.0;
|
||||
|
||||
$eventData = [
|
||||
'user_id' => (int) ($parentUser['id'] ?? 0),
|
||||
'email' => $parentUser['email'] ?? null,
|
||||
'firstname' => $first,
|
||||
'lastname' => $last,
|
||||
'parentName' => $parentName,
|
||||
'school_year' => $payload['school_year'],
|
||||
'semester' => $payload['semester'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_number' => $invoiceAfter['invoice_number'] ?? ($invoiceId ? 'INV-' . $invoiceId : null),
|
||||
'charge_id' => $chargeId,
|
||||
'charge_title' => $payload['title'],
|
||||
'charge_desc' => $payload['description'],
|
||||
'charge_type' => $payload['charge_type'],
|
||||
'amount_signed' => $signedAmount,
|
||||
'amount_abs' => $amountAbs,
|
||||
'due_date' => $payload['due_date'],
|
||||
'created_at' => $payload['created_at'],
|
||||
'pre_balance' => $preBalance,
|
||||
'post_balance' => $postBalance,
|
||||
'invoice_total' => $invoiceTotal,
|
||||
'portal_link' => url('/login'),
|
||||
'invoice_link' => $invoiceId ? url('parent/invoices/view/' . $invoiceId) : url('parent/invoices'),
|
||||
];
|
||||
|
||||
// Dispatch event
|
||||
Event::dispatch('extraCharge', [$eventData]);
|
||||
|
||||
return $this->respondCreated([
|
||||
'id' => $chargeId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => (int) $data['parent_id'],
|
||||
], 'Charge recorded successfully');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Transaction failed in ExtraChargesController::store: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to save charge: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$row = $this->additionalCharge->find($id);
|
||||
if (!$row) {
|
||||
return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$newAmount = isset($data['amount']) ? (float) $data['amount'] : (float) $row['amount'];
|
||||
$delta = $newAmount - (float) $row['amount'];
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Update the charge
|
||||
$this->additionalCharge->update($id, [
|
||||
'title' => trim($data['title'] ?? $row['title']),
|
||||
'description' => trim($data['description'] ?? $row['description']),
|
||||
'amount' => $newAmount,
|
||||
'due_date' => $data['due_date'] ?? $row['due_date'],
|
||||
'charge_type' => $data['charge_type'] ?? $row['charge_type'],
|
||||
]);
|
||||
|
||||
// If it's already applied on an invoice and the amount changed, reflect the delta
|
||||
if ($row['status'] === 'applied' && !empty($row['invoice_id']) && abs($delta) > 0.00001) {
|
||||
if ($delta > 0) {
|
||||
$this->invoice->applyAdditionalCharge((int) $row['invoice_id'], $delta);
|
||||
} else {
|
||||
$this->invoice->reverseAdditionalCharge((int) $row['invoice_id'], -$delta);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
$updated = $this->additionalCharge->find($id);
|
||||
return $this->success($updated->toArray(), 'Charge updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Failed to update charge: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update charge', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a charge as void and roll back its impact on the invoice if applied.
|
||||
*/
|
||||
public function void($id = null)
|
||||
{
|
||||
$row = $this->additionalCharge->find((int) $id);
|
||||
if (!$row) {
|
||||
return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$invoiceId = (int) ($row['invoice_id'] ?? 0);
|
||||
$amountAbs = round(abs((float) ($row['amount'] ?? 0)), 2);
|
||||
$chargeType = (string) ($row['charge_type'] ?? 'add');
|
||||
$status = (string) ($row['status'] ?? 'pending');
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) {
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoice->reverseAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
// voiding a deduction -> add back
|
||||
$this->invoice->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'void(): invoice adjust failed: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$this->additionalCharge->update((int) $id, [
|
||||
'status' => 'void',
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $this->success(null, 'Charge voided successfully');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Failed to void charge: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to void charge', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse a previously applied charge: undo invoice impact and return to pending state.
|
||||
*/
|
||||
public function reverse($id = null)
|
||||
{
|
||||
$row = $this->additionalCharge->find((int) $id);
|
||||
if (!$row) {
|
||||
return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$invoiceId = (int) ($row['invoice_id'] ?? 0);
|
||||
$amountAbs = round(abs((float) ($row['amount'] ?? 0)), 2);
|
||||
$chargeType = (string) ($row['charge_type'] ?? 'add');
|
||||
$status = (string) ($row['status'] ?? 'pending');
|
||||
|
||||
if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) {
|
||||
return $this->respondError('Nothing to reverse', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoice->reverseAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
$this->invoice->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'reverse(): invoice adjust failed: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->additionalCharge->update((int) $id, [
|
||||
'status' => 'pending',
|
||||
'invoice_id' => null,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $this->success(null, 'Charge reversed to pending successfully');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Failed to reverse charge: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to reverse charge', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete($id = null)
|
||||
{
|
||||
$charge = $this->additionalCharge->find($id);
|
||||
if (!$charge) {
|
||||
return $this->respondError('Extra charge not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$charge->delete();
|
||||
return $this->success(null, 'Extra charge deleted successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to delete charge: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete charge', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize query result into a single associative row
|
||||
*/
|
||||
private function normalizeRow($row): ?array
|
||||
{
|
||||
if (!$row) return null;
|
||||
if (is_array($row) && isset($row[0]) && is_array($row[0])) {
|
||||
return $row[0]; // unwrap first row from a result set
|
||||
}
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Models\StudentClass;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FamilyAdminController extends BaseApiController
|
||||
{
|
||||
protected Configuration $config;
|
||||
protected Invoice $invoice;
|
||||
protected Payment $payment;
|
||||
protected StudentClass $studentClass;
|
||||
protected ?string $schoolYear;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->config = model(Configuration::class);
|
||||
$this->invoice = model(Invoice::class);
|
||||
$this->payment = model(Payment::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->schoolYear = $this->config->getConfig('school_year');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
|
||||
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
|
||||
|
||||
try {
|
||||
$data = [
|
||||
'student' => null,
|
||||
'families' => [],
|
||||
'guardians' => [],
|
||||
'students' => [],
|
||||
'search_students' => [],
|
||||
'search_guardians' => [],
|
||||
];
|
||||
|
||||
// Simple student list for select
|
||||
$data['students'] = DB::table('students')
|
||||
->select('id', DB::raw("CONCAT(lastname, ', ', firstname) AS name"))
|
||||
->orderBy('lastname')
|
||||
->orderBy('firstname')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
// Preload search datasets
|
||||
$data['search_students'] = DB::table('students')
|
||||
->select('id', 'firstname', 'lastname')
|
||||
->orderBy('lastname')
|
||||
->orderBy('firstname')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$data['search_guardians'] = DB::table('family_guardians as fg')
|
||||
->distinct()
|
||||
->select('u.id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone')
|
||||
->join('users as u', 'u.id', '=', 'fg.user_id')
|
||||
->orderBy('u.lastname')
|
||||
->orderBy('u.firstname')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
// If a guardian is provided, resolve one of their students
|
||||
if (!$studentId && $guardianId) {
|
||||
$row = DB::table('family_guardians as fg')
|
||||
->select('s.id as student_id')
|
||||
->join('family_students as fs', 'fs.family_id', '=', 'fg.family_id')
|
||||
->join('students as s', 's.id', '=', 'fs.student_id')
|
||||
->where('fg.user_id', $guardianId)
|
||||
->orderBy('s.lastname')
|
||||
->orderBy('s.firstname')
|
||||
->first();
|
||||
|
||||
if (!$row) {
|
||||
$row = DB::table('students as s')
|
||||
->select('s.id as student_id')
|
||||
->where('s.parent_id', $guardianId)
|
||||
->orderBy('s.lastname')
|
||||
->orderBy('s.firstname')
|
||||
->first();
|
||||
}
|
||||
|
||||
if ($row && !empty($row->student_id)) {
|
||||
$studentId = (int) $row->student_id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($studentId > 0) {
|
||||
$student = DB::table('students')
|
||||
->select('id', 'firstname', 'lastname')
|
||||
->where('id', $studentId)
|
||||
->first();
|
||||
|
||||
if ($student) {
|
||||
$data['student'] = (array) $student;
|
||||
|
||||
// Fetch all families for this student
|
||||
$families = DB::table('family_students as fs')
|
||||
->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'
|
||||
)
|
||||
->join('families as f', 'f.id', '=', 'fs.family_id')
|
||||
->where('fs.student_id', $studentId)
|
||||
->orderByDesc('fs.is_primary_home')
|
||||
->orderBy('f.household_name')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
// Enrich each family with guardians, students, and financials
|
||||
foreach ($families as &$fam) {
|
||||
$fid = (int) $fam['id'];
|
||||
|
||||
// Guardians
|
||||
$guardians = DB::table('family_guardians as fg')
|
||||
->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'
|
||||
)
|
||||
->join('users as u', 'u.id', '=', 'fg.user_id')
|
||||
->where('fg.family_id', $fid)
|
||||
->orderByDesc('fg.is_primary')
|
||||
->orderBy('u.lastname')
|
||||
->orderBy('u.firstname')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$fam['guardians'] = $guardians;
|
||||
|
||||
// Students in this family
|
||||
$studentsRows = DB::table('family_students as fs')
|
||||
->select('s.id', 's.firstname', 's.lastname')
|
||||
->join('students as s', 's.id', '=', 'fs.student_id')
|
||||
->where('fs.family_id', $fid)
|
||||
->orderBy('s.lastname')
|
||||
->orderBy('s.firstname')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
// Enrich with grade label
|
||||
if (!empty($studentsRows)) {
|
||||
foreach ($studentsRows as &$sr) {
|
||||
$sid = (int) ($sr['id'] ?? 0);
|
||||
$sr['grade'] = $sid && $this->schoolYear
|
||||
? (string) ($this->studentClass->getClassSectionsByStudentId($sid, $this->schoolYear) ?? '')
|
||||
: '';
|
||||
}
|
||||
unset($sr);
|
||||
}
|
||||
|
||||
$fam['students'] = $studentsRows;
|
||||
|
||||
// Financials: invoices and payments for all guardians
|
||||
$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)
|
||||
->orderByDesc('issue_date')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$fam['invoices'] = $invRows;
|
||||
|
||||
// Map invoice id -> number
|
||||
$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)
|
||||
->orderByDesc('payment_date')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$fam['payments'] = $payRows;
|
||||
}
|
||||
}
|
||||
unset($fam);
|
||||
|
||||
$data['families'] = $families;
|
||||
|
||||
// Back-compat: also expose guardians of first family
|
||||
if (!empty($families)) {
|
||||
$familyId = (int) $families[0]['id'];
|
||||
$data['guardians'] = DB::table('family_guardians as fg')
|
||||
->select(
|
||||
'u.id', 'u.firstname', 'u.lastname', 'u.email',
|
||||
'fg.relation', 'fg.is_primary', 'fg.receive_emails'
|
||||
)
|
||||
->join('users as u', 'u.id', '=', 'fg.user_id')
|
||||
->where('fg.family_id', $familyId)
|
||||
->orderByDesc('fg.is_primary')
|
||||
->orderBy('u.lastname')
|
||||
->orderBy('u.firstname')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success($data, 'Family admin data retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Family admin error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve family admin data', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function search()
|
||||
{
|
||||
$q = trim((string) ($this->request->getGet('q') ?? ''));
|
||||
if ($q === '') {
|
||||
return $this->success(['items' => []], 'Search completed');
|
||||
}
|
||||
|
||||
try {
|
||||
$qs = '%' . $q . '%';
|
||||
|
||||
// Students suggestions
|
||||
$students = DB::table('students')
|
||||
->select('id', 'firstname', 'lastname')
|
||||
->whereRaw("CONCAT_WS(' ', firstname, lastname) LIKE ?", [$qs])
|
||||
->orderBy('lastname')
|
||||
->orderBy('firstname')
|
||||
->limit(8)
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
// Parents/Guardians suggestions
|
||||
$guardians = DB::table('users')
|
||||
->select('id', 'firstname', 'lastname', 'email', 'cellphone')
|
||||
->where(function ($query) use ($qs) {
|
||||
$query->whereRaw("CONCAT_WS(' ', firstname, lastname) LIKE ?", [$qs])
|
||||
->orWhere('email', 'like', $qs)
|
||||
->orWhere('cellphone', 'like', $qs);
|
||||
})
|
||||
->orderBy('lastname')
|
||||
->orderBy('firstname')
|
||||
->limit(8)
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$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->success(['items' => $items], 'Search completed successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Family admin search error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to perform search', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function card()
|
||||
{
|
||||
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
|
||||
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
|
||||
$familyId = (int) ($this->request->getGet('family_id') ?? 0);
|
||||
|
||||
try {
|
||||
if (!$familyId) {
|
||||
if ($studentId) {
|
||||
$row = DB::table('family_students as fs')
|
||||
->select('f.id')
|
||||
->join('families as f', 'f.id', '=', 'fs.family_id')
|
||||
->where('fs.student_id', $studentId)
|
||||
->orderByDesc('fs.is_primary_home')
|
||||
->orderBy('f.household_name')
|
||||
->first();
|
||||
|
||||
if ($row && !empty($row->id)) {
|
||||
$familyId = (int) $row->id;
|
||||
}
|
||||
} elseif ($guardianId) {
|
||||
// Try via guardians link
|
||||
$row = DB::table('family_guardians as fg')
|
||||
->select('f.id')
|
||||
->join('families as f', 'f.id', '=', 'fg.family_id')
|
||||
->where('fg.user_id', $guardianId)
|
||||
->orderBy('f.household_name')
|
||||
->first();
|
||||
|
||||
if ($row && !empty($row->id)) {
|
||||
$familyId = (int) $row->id;
|
||||
} else {
|
||||
// Fallback via students.parent_id → family_students
|
||||
$row = DB::table('students as s')
|
||||
->select('f.id')
|
||||
->join('family_students as fs', 'fs.student_id', '=', 's.id')
|
||||
->join('families as f', 'f.id', '=', 'fs.family_id')
|
||||
->where('s.parent_id', $guardianId)
|
||||
->orderByDesc('fs.is_primary_home')
|
||||
->orderBy('f.household_name')
|
||||
->first();
|
||||
|
||||
if ($row && !empty($row->id)) {
|
||||
$familyId = (int) $row->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$familyId) {
|
||||
return $this->respondError('Family not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$family = DB::table('families as f')
|
||||
->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'
|
||||
)
|
||||
->where('f.id', $familyId)
|
||||
->first();
|
||||
|
||||
if (!$family) {
|
||||
return $this->respondError('Family not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$family = (array) $family;
|
||||
|
||||
// Guardians
|
||||
$guardians = DB::table('family_guardians as fg')
|
||||
->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'
|
||||
)
|
||||
->join('users as u', 'u.id', '=', 'fg.user_id')
|
||||
->where('fg.family_id', $familyId)
|
||||
->orderByDesc('fg.is_primary')
|
||||
->orderBy('u.lastname')
|
||||
->orderBy('u.firstname')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$family['guardians'] = $guardians;
|
||||
|
||||
// Students
|
||||
$studentsRows = DB::table('family_students as fs')
|
||||
->select('s.id', 's.firstname', 's.lastname')
|
||||
->join('students as s', 's.id', '=', 'fs.student_id')
|
||||
->where('fs.family_id', $familyId)
|
||||
->orderBy('s.lastname')
|
||||
->orderBy('s.firstname')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
if (!empty($studentsRows)) {
|
||||
foreach ($studentsRows as &$sr) {
|
||||
$sid = (int) ($sr['id'] ?? 0);
|
||||
$sr['grade'] = $sid && $this->schoolYear
|
||||
? (string) ($this->studentClass->getClassSectionsByStudentId($sid, $this->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
|
||||
$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)
|
||||
->orderByDesc('updated_at')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
->orderByDesc('issue_date')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$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)
|
||||
->orderByDesc('payment_date')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$family['payments'] = $payRows;
|
||||
}
|
||||
|
||||
return $this->success(['family' => $family], 'Family card retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Family card error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve family card', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,651 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\FamilyGuardian;
|
||||
use App\Models\Family;
|
||||
use App\Models\FamilyStudent;
|
||||
use App\Models\Student;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FamilyController extends BaseApiController
|
||||
{
|
||||
protected Family $family;
|
||||
protected FamilyStudent $familyStudent;
|
||||
protected FamilyGuardian $familyGuardian;
|
||||
protected Student $student;
|
||||
protected User $user;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->family = model(Family::class);
|
||||
$this->familyStudent = model(FamilyStudent::class);
|
||||
$this->familyGuardian = model(FamilyGuardian::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->user = model(User::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
|
||||
$result = $this->paginate($this->family->newQuery()->orderBy('id'), $page, $perPage);
|
||||
|
||||
foreach ($result['data'] as &$family) {
|
||||
$family['students'] = $this->familyStudent
|
||||
->where('family_id', $family['id'])
|
||||
->findAll();
|
||||
$family['guardians'] = $this->familyGuardian
|
||||
->where('family_id', $family['id'])
|
||||
->findAll();
|
||||
}
|
||||
|
||||
return $this->success($result, 'Families retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$family = $this->family->find($id);
|
||||
if (!$family) {
|
||||
return $this->respondError('Family not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$family['students'] = $this->familyStudent->where('family_id', $id)->findAll();
|
||||
$family['guardians'] = $this->familyGuardian->where('family_id', $id)->findAll();
|
||||
|
||||
return $this->success($family, 'Family retrieved successfully');
|
||||
}
|
||||
|
||||
public function getByStudent($id = null)
|
||||
{
|
||||
$familyIds = $this->familyStudent->where('student_id', $id)->findColumn('family_id');
|
||||
if (empty($familyIds)) {
|
||||
return $this->success([], 'No families found for this student');
|
||||
}
|
||||
|
||||
$families = $this->family->whereIn('id', $familyIds)->get();
|
||||
$payload = [];
|
||||
foreach ($families as $family) {
|
||||
$family->students = $this->familyStudent->where('family_id', $family->id)->findAll();
|
||||
$family->guardians = $this->familyGuardian->where('family_id', $family->id)->findAll();
|
||||
$payload[] = $family;
|
||||
}
|
||||
|
||||
return $this->success($payload, 'Families retrieved successfully');
|
||||
}
|
||||
|
||||
public function getGuardians($id = null)
|
||||
{
|
||||
$family = $this->family->find($id);
|
||||
if (!$family) {
|
||||
return $this->respondError('Family not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$guardians = $this->familyGuardian->where('family_id', $id)->findAll();
|
||||
return $this->success($guardians, 'Guardians retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get families for a student
|
||||
* GET /api/v1/families/by-student/{student_id}
|
||||
*/
|
||||
public function familiesByStudent(int $studentId)
|
||||
{
|
||||
$rows = DB::table('family_students as fs')
|
||||
->select('f.*', 'fs.is_primary_home')
|
||||
->join('families as f', 'f.id', '=', 'fs.family_id')
|
||||
->where('fs.student_id', $studentId)
|
||||
->where('f.is_active', 1)
|
||||
->orderByDesc('fs.is_primary_home')
|
||||
->orderBy('f.household_name')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
return $this->success(['data' => $rows], 'Families retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guardians for a family
|
||||
* GET /api/v1/families/{family_id}/guardians
|
||||
*/
|
||||
public function guardiansByFamily(int $familyId)
|
||||
{
|
||||
$rows = DB::table('family_guardians as fg')
|
||||
->select(
|
||||
'u.id AS user_id', 'u.firstname', 'u.lastname', 'u.email',
|
||||
'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms'
|
||||
)
|
||||
->join('users as u', 'u.id', '=', 'fg.user_id')
|
||||
->where('fg.family_id', $familyId)
|
||||
->orderByDesc('fg.is_primary')
|
||||
->orderBy('u.lastname')
|
||||
->orderBy('u.firstname')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
return $this->success(['data' => $rows], 'Guardians retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap families from existing students.parent_id
|
||||
* POST /api/v1/families/bootstrap
|
||||
*/
|
||||
public function bootstrap()
|
||||
{
|
||||
// Guard: ensure required tables exist
|
||||
foreach (['families', 'family_students', 'family_guardians'] as $t) {
|
||||
if (!DB::getSchemaBuilder()->hasTable($t)) {
|
||||
return $this->respondError(
|
||||
"Missing required table '{$t}'. Run migrations.",
|
||||
Response::HTTP_INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Get all distinct primary parents from students.parent_id
|
||||
$primaryParents = DB::table('students')
|
||||
->select('parent_id')
|
||||
->whereNotNull('parent_id')
|
||||
->distinct()
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$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 = DB::table('students')
|
||||
->select('id')
|
||||
->where('parent_id', $primaryUserId)
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $this->success([
|
||||
'families_created' => $created,
|
||||
'students_linked' => $linkedStudents,
|
||||
'guardians_linked' => $linkedGuardians,
|
||||
], 'Bootstrap completed successfully');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Family bootstrap error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to bootstrap families: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach second parent by user ID
|
||||
* POST /api/v1/families/attach-second-by-user
|
||||
*/
|
||||
public function attachSecondByUser()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$studentId = (int) ($data['student_id'] ?? 0);
|
||||
$userId = (int) ($data['user_id'] ?? 0);
|
||||
$relation = (string) ($data['relation'] ?? 'secondary');
|
||||
|
||||
if (!$studentId || !$userId) {
|
||||
return $this->respondError('student_id and user_id required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$familyId = $this->familyIdFromStudentPrimary($studentId);
|
||||
if (!$familyId) {
|
||||
return $this->respondError('No primary family found for this student', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
|
||||
|
||||
return $this->success([
|
||||
'attached' => $rows,
|
||||
'family_id' => $familyId,
|
||||
], 'Guardian attached successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach second parent by email (creates user if needed)
|
||||
* POST /api/v1/families/attach-second-by-email
|
||||
*/
|
||||
public function attachSecondByEmail()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$studentId = (int) ($data['student_id'] ?? 0);
|
||||
$email = trim((string) ($data['email'] ?? ''));
|
||||
$first = trim((string) ($data['firstname'] ?? ''));
|
||||
$last = trim((string) ($data['lastname'] ?? ''));
|
||||
$relation = (string) ($data['relation'] ?? 'secondary');
|
||||
|
||||
if (!$studentId || !$email) {
|
||||
return $this->respondError('student_id and email required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$familyId = $this->familyIdFromStudentPrimary($studentId);
|
||||
if (!$familyId) {
|
||||
return $this->respondError('No primary family found for this student', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user = $this->user->where('email', $email)->first();
|
||||
if (!$user) {
|
||||
// Create a minimal/stub user
|
||||
$newUser = $this->user->create([
|
||||
'firstname' => $first ?: '',
|
||||
'lastname' => $last ?: '',
|
||||
'email' => $email,
|
||||
'status' => 'Active',
|
||||
'user_type' => 'secondary',
|
||||
]);
|
||||
if (!$newUser || !isset($newUser['id'])) {
|
||||
return $this->respondError('Failed to create user', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
$userId = (int) $newUser['id'];
|
||||
} else {
|
||||
$userId = (int) $user['id'];
|
||||
}
|
||||
|
||||
$rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
|
||||
|
||||
return $this->success([
|
||||
'attached' => $rows,
|
||||
'family_id' => $familyId,
|
||||
'user_id' => $userId,
|
||||
], 'Guardian attached successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set primary home flag for a student-family relationship
|
||||
* POST /api/v1/families/set-primary-home
|
||||
*/
|
||||
public function setPrimaryHome()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$familyId = (int) ($data['family_id'] ?? 0);
|
||||
$studentId = (int) ($data['student_id'] ?? 0);
|
||||
$flag = (int) ($data['is_primary_home'] ?? 0);
|
||||
|
||||
if (!$familyId || !$studentId) {
|
||||
return $this->respondError('family_id and student_id required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->familyStudent
|
||||
->where(['family_id' => $familyId, 'student_id' => $studentId])
|
||||
->update(['is_primary_home' => $flag ? 1 : 0]);
|
||||
|
||||
return $this->success(null, 'Primary home flag updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Set primary home error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update primary home flag', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set guardian flags (receive_emails, is_primary, receive_sms, relation)
|
||||
* POST /api/v1/families/set-guardian-flags
|
||||
*/
|
||||
public function setGuardianFlags()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$familyId = (int) ($data['family_id'] ?? 0);
|
||||
$userId = (int) ($data['user_id'] ?? 0);
|
||||
|
||||
if (!$familyId || !$userId) {
|
||||
return $this->respondError('family_id and user_id required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$updateData = [];
|
||||
foreach (['receive_emails', 'is_primary', 'receive_sms', 'relation'] as $k) {
|
||||
if (isset($data[$k])) {
|
||||
$val = $data[$k];
|
||||
$updateData[$k] = in_array($k, ['receive_emails', 'is_primary', 'receive_sms'])
|
||||
? (int) $val
|
||||
: (string) $val;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->respondError('No flags provided', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->familyGuardian
|
||||
->where(['family_id' => $familyId, 'user_id' => $userId])
|
||||
->update($updateData);
|
||||
|
||||
return $this->success(null, 'Guardian flags updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Set guardian flags error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update guardian flags', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a guardian from a family
|
||||
* POST /api/v1/families/unlink-guardian
|
||||
*/
|
||||
public function unlinkGuardian()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$familyId = (int) ($data['family_id'] ?? 0);
|
||||
$userId = (int) ($data['user_id'] ?? 0);
|
||||
|
||||
if (!$familyId || !$userId) {
|
||||
return $this->respondError('family_id and user_id required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->familyGuardian
|
||||
->where(['family_id' => $familyId, 'user_id' => $userId])
|
||||
->delete();
|
||||
|
||||
return $this->success(null, 'Guardian unlinked successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Unlink guardian error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to unlink guardian', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a student from a family
|
||||
* POST /api/v1/families/unlink-student
|
||||
*/
|
||||
public function unlinkStudent()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$familyId = (int) ($data['family_id'] ?? 0);
|
||||
$studentId = (int) ($data['student_id'] ?? 0);
|
||||
|
||||
if (!$familyId || !$studentId) {
|
||||
return $this->respondError('family_id and student_id required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->familyStudent
|
||||
->where(['family_id' => $familyId, 'student_id' => $studentId])
|
||||
->delete();
|
||||
|
||||
return $this->success(null, 'Student unlinked successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Unlink student error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to unlink student', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import second parents from legacy 'parents' table
|
||||
* POST /api/v1/families/import-second-parents
|
||||
*/
|
||||
public function importSecondParentsFromLegacy()
|
||||
{
|
||||
$L = [
|
||||
'table' => 'parents',
|
||||
'firstparent_id' => 'firstparent_id',
|
||||
'second_user_id' => 'secondparent_id',
|
||||
'second_email' => 'secondparent_email',
|
||||
'second_firstname' => 'secondparent_firstname',
|
||||
'second_lastname' => 'secondparent_lastname',
|
||||
];
|
||||
|
||||
if (!DB::getSchemaBuilder()->hasTable($L['table'])) {
|
||||
return $this->respondError("Legacy table '{$L['table']}' not found", Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
foreach (['families', 'family_students', 'family_guardians'] as $t) {
|
||||
if (!DB::getSchemaBuilder()->hasTable($t)) {
|
||||
return $this->respondError(
|
||||
"Missing required table '{$t}'. Run migrations.",
|
||||
Response::HTTP_INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = DB::table($L['table'])
|
||||
->select(
|
||||
DB::raw("{$L['firstparent_id']} AS first_id"),
|
||||
DB::raw("{$L['second_user_id']} AS second_id"),
|
||||
DB::raw("{$L['second_email']} AS email"),
|
||||
DB::raw("{$L['second_firstname']} AS firstname"),
|
||||
DB::raw("{$L['second_lastname']} AS lastname")
|
||||
)
|
||||
->where(function ($query) use ($L) {
|
||||
$query->where($L['second_user_id'], '!=', 0)
|
||||
->orWhere($L['second_email'], '!=', '');
|
||||
})
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$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->family->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->user->where('email', $email)->first();
|
||||
if (!$user) {
|
||||
$newUser = $this->user->create([
|
||||
'firstname' => $r['firstname'] ?? '',
|
||||
'lastname' => $r['lastname'] ?? '',
|
||||
'email' => $email,
|
||||
'status' => 'Active',
|
||||
'user_type' => 'secondary',
|
||||
]);
|
||||
if ($newUser && isset($newUser['id'])) {
|
||||
$userId = (int) $newUser['id'];
|
||||
$createdUsers++;
|
||||
} else {
|
||||
$userId = 0;
|
||||
}
|
||||
} else {
|
||||
$userId = (int) $user['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$userId) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure all students for this primary parent are linked to this family
|
||||
$stuRows = DB::table('students')
|
||||
->select('id')
|
||||
->where('parent_id', $firstId)
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'created_users' => $createdUsers,
|
||||
'guardians_linked' => $linked,
|
||||
'skipped' => $skipped,
|
||||
'total_source' => count($rows),
|
||||
], 'Legacy import completed successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Import second parents error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to import second parents: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* Private helpers
|
||||
* =======================================================*/
|
||||
|
||||
/**
|
||||
* Ensure there is exactly one family per primary parent (users.id)
|
||||
* Returns $familyId and increments $createdCounter by reference if newly created.
|
||||
*/
|
||||
protected function ensureFamilyForPrimaryParent(int $primaryUserId, int &$createdCounter): int
|
||||
{
|
||||
$code = 'FAM-' . $primaryUserId;
|
||||
$row = $this->family->where('family_code', $code)->first();
|
||||
if ($row) {
|
||||
return (int) $row['id'];
|
||||
}
|
||||
|
||||
$family = $this->family->create([
|
||||
'family_code' => $code,
|
||||
'household_name' => 'Family of User ' . $primaryUserId,
|
||||
'is_active' => 1,
|
||||
]);
|
||||
|
||||
if ($family && isset($family['id'])) {
|
||||
$createdCounter++;
|
||||
return (int) $family['id'];
|
||||
}
|
||||
|
||||
// If create failed, try to find it again (race condition)
|
||||
$row = $this->family->where('family_code', $code)->first();
|
||||
if ($row) {
|
||||
return (int) $row['id'];
|
||||
}
|
||||
|
||||
throw new \Exception('Failed to create family for primary parent ' . $primaryUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a student to a family (idempotent; returns rows affected >0 if inserted)
|
||||
*/
|
||||
protected function attachStudentToFamily(int $studentId, int $familyId): int
|
||||
{
|
||||
// Check if already exists
|
||||
$exists = $this->familyStudent
|
||||
->where('family_id', $familyId)
|
||||
->where('student_id', $studentId)
|
||||
->first();
|
||||
|
||||
if ($exists) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Insert
|
||||
$result = $this->familyStudent->create([
|
||||
'family_id' => $familyId,
|
||||
'student_id' => $studentId,
|
||||
'is_primary_home' => 1,
|
||||
]);
|
||||
|
||||
return ($result && isset($result['id'])) ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// Check if already exists
|
||||
$exists = $this->familyGuardian
|
||||
->where('family_id', $familyId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if ($exists) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Insert
|
||||
$result = $this->familyGuardian->create([
|
||||
'family_id' => $familyId,
|
||||
'user_id' => $userId,
|
||||
'relation' => $relation,
|
||||
'is_primary' => $isPrimary ? 1 : 0,
|
||||
'receive_emails' => $receiveEmails,
|
||||
'receive_sms' => $receiveSms,
|
||||
]);
|
||||
|
||||
return ($result && isset($result['id'])) ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the "primary" family for a student = family created from parent_id
|
||||
*/
|
||||
protected function familyIdFromStudentPrimary(int $studentId): ?int
|
||||
{
|
||||
// Get primary parent id for the student
|
||||
$st = $this->student->select('parent_id')->find($studentId);
|
||||
if (!$st || empty($st['parent_id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The canonical family uses code FAM-{parent_id}
|
||||
$code = 'FAM-' . (int) $st['parent_id'];
|
||||
$row = $this->family->select('id')->where('family_code', $code)->first();
|
||||
if ($row) {
|
||||
return (int) $row['id'];
|
||||
}
|
||||
|
||||
// If not found, fall back to any family linked already
|
||||
$row = DB::table('family_students')
|
||||
->select('family_id')
|
||||
->where('student_id', $studentId)
|
||||
->orderByDesc('is_primary_home')
|
||||
->first();
|
||||
|
||||
return $row ? (int) $row->family_id : null;
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use Illuminate\Http\Response as HttpResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class FilesController extends BaseApiController
|
||||
{
|
||||
protected array $allowedExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
||||
|
||||
public function receipt(string $name)
|
||||
{
|
||||
// 1) Path traversal guard
|
||||
if ($name !== basename($name)) {
|
||||
return $this->respondError('Invalid filename', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// 2) Allow-list extensions (optional but safer)
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $this->allowedExtensions, true)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
// 3) Build path under storage/app/uploads/receipts
|
||||
$path = $this->storagePath("uploads/receipts/{$name}");
|
||||
|
||||
if (!is_file($path)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return $this->streamFile($path, $name);
|
||||
}
|
||||
|
||||
public function reimb(string $name)
|
||||
{
|
||||
// 1) Path traversal guard
|
||||
if ($name !== basename($name)) {
|
||||
return $this->respondError('Invalid filename', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// 2) Allow-list extensions
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $this->allowedExtensions, true)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
// 3) Build path under storage/app/uploads/reimbursements
|
||||
$path = $this->storagePath("uploads/reimbursements/{$name}");
|
||||
|
||||
if (!is_file($path)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return $this->streamFile($path, $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for reimb() for backward compatibility
|
||||
*/
|
||||
public function reimbursement(string $name)
|
||||
{
|
||||
return $this->reimb($name);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$type = $this->request->getGet('type') ?? 'receipts';
|
||||
$allowedTypes = ['receipts', 'reimbursements', 'checks'];
|
||||
|
||||
if (!in_array($type, $allowedTypes, true)) {
|
||||
return $this->respondError('Invalid file type', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$path = $this->storagePath("uploads/{$type}/");
|
||||
$files = [];
|
||||
|
||||
if (is_dir($path)) {
|
||||
foreach (scandir($path) as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
$filePath = $path . $file;
|
||||
if (!is_file($filePath)) {
|
||||
continue;
|
||||
}
|
||||
$files[] = [
|
||||
'name' => $file,
|
||||
'size' => filesize($filePath),
|
||||
'modified' => date('Y-m-d H:i:s', filemtime($filePath)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'type' => $type,
|
||||
'files' => $files,
|
||||
'count' => count($files),
|
||||
], 'Files retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Files list error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve files', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
protected function streamFile(string $path, string $name): HttpResponse
|
||||
{
|
||||
// 4) MIME detection (finfo is more reliable than mime_content_type)
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($fi) {
|
||||
$detected = finfo_file($fi, $path);
|
||||
if ($detected) {
|
||||
$mime = $detected;
|
||||
}
|
||||
finfo_close($fi);
|
||||
}
|
||||
} elseif (function_exists('mime_content_type')) {
|
||||
$mime = mime_content_type($path) ?: $mime;
|
||||
}
|
||||
|
||||
// 5) Caching (ETag + Last-Modified) and 304 support
|
||||
$mtime = filemtime($path) ?: time();
|
||||
$size = filesize($path) ?: 0;
|
||||
$etag = md5($name . '|' . $mtime . '|' . $size);
|
||||
|
||||
$ifNoneMatch = trim($this->laravelRequest->header('If-None-Match', ''), '"');
|
||||
$ifModifiedSince = $this->laravelRequest->header('If-Modified-Since');
|
||||
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
|
||||
|
||||
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
|
||||
($imsTime !== false && $imsTime >= $mtime)
|
||||
) {
|
||||
return response('', Response::HTTP_NOT_MODIFIED)
|
||||
->header('ETag', $etag)
|
||||
->header('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||
}
|
||||
|
||||
// 6) Stream the file inline
|
||||
return response()->make(file_get_contents($path), Response::HTTP_OK, [
|
||||
'Content-Type' => $mime,
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Content-Disposition' => 'inline; filename="' . $name . '"',
|
||||
'Content-Length' => (string) $size,
|
||||
'ETag' => $etag,
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function storagePath(string $relative): string
|
||||
{
|
||||
return storage_path('app/' . $relative);
|
||||
}
|
||||
|
||||
protected function isAllowedExtension(string $name): bool
|
||||
{
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
return in_array($ext, $this->allowedExtensions, true);
|
||||
}
|
||||
|
||||
protected function detectMime(string $path): string
|
||||
{
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo) {
|
||||
$mime = finfo_file($finfo, $path);
|
||||
finfo_close($finfo);
|
||||
if ($mime) {
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
} elseif (function_exists('mime_content_type')) {
|
||||
$mime = mime_content_type($path);
|
||||
if ($mime) {
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\FinalExam;
|
||||
use App\Models\Student;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FinalController extends BaseApiController
|
||||
{
|
||||
protected FinalExam $final;
|
||||
protected Configuration $config;
|
||||
protected Student $student;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->final = model(FinalExam::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->student = model(Student::class);
|
||||
}
|
||||
|
||||
public function getByStudent($id = null)
|
||||
{
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$final = $this->final->newQuery()
|
||||
->when($id, fn($q) => $q->where('student_id', $id))
|
||||
->when($schoolYear, fn($q) => $q->where('school_year', $schoolYear))
|
||||
->when($semester, fn($q) => $q->where('semester', $semester))
|
||||
->first();
|
||||
|
||||
return $this->success($final ?: null, 'Final exam retrieved successfully');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'student_id' => 'required|integer',
|
||||
'class_section_id' => 'required|integer',
|
||||
];
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$query = $this->final->newQuery()
|
||||
->where('student_id', $data['student_id'])
|
||||
->where('class_section_id', $data['class_section_id']);
|
||||
if ($schoolYear) {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($semester) {
|
||||
$query->where('semester', $semester);
|
||||
}
|
||||
$existing = $query->first();
|
||||
|
||||
$finalData = [
|
||||
'student_id' => $data['student_id'],
|
||||
'class_section_id' => $data['class_section_id'],
|
||||
'score' => $data['score'] ?? null,
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'updated_by' => $user->id ?? null,
|
||||
];
|
||||
|
||||
try {
|
||||
if ($existing) {
|
||||
$existing->fill($finalData);
|
||||
$existing->save();
|
||||
$final = $existing->fresh();
|
||||
} else {
|
||||
$final = $this->final->create($finalData);
|
||||
}
|
||||
return $this->success($final, 'Final exam saved successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Final exam save error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to save final exam', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$final = $this->final->find($id);
|
||||
if (!$final) {
|
||||
return $this->respondError('Final exam not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowed = ['score', 'comment'];
|
||||
$updateData = [];
|
||||
foreach ($allowed as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$updateData['updated_by'] = $this->getCurrentUser()->id ?? null;
|
||||
|
||||
try {
|
||||
$final->fill($updateData);
|
||||
$final->save();
|
||||
return $this->success($final->fresh(), 'Final exam updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Final exam update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update final exam', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get students with their final exam scores for a class section
|
||||
*
|
||||
* @param int|null $classSectionId
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function getByClassSection($classSectionId = null)
|
||||
{
|
||||
// Get class_section_id from route parameter, query param, or request body
|
||||
$incoming = $classSectionId
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? $this->request->getPost('class_section_id')
|
||||
?? null;
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Missing or invalid class section ID', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->getSavedScores($classSectionId);
|
||||
return $this->success($result, 'Students with final exam scores retrieved successfully');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->respondError($e->getMessage(), Response::HTTP_BAD_REQUEST);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Final exam getByClassSection error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve students with final exam scores', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get students with their final exam scores for a class section
|
||||
*
|
||||
* @param int $classSectionId
|
||||
* @return array
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
protected function getSavedScores(int $classSectionId): array
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid class section id.');
|
||||
}
|
||||
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// Subquery: pick ONE final exam row per student (latest by id)
|
||||
$latestFinalSub = $db->table('final_exam')
|
||||
->select('student_id, MAX(id) AS max_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('student_id')
|
||||
->getCompiledSelect();
|
||||
|
||||
// Main query: roster + LEFT JOIN the single final exam row
|
||||
$builder = $this->student->builder();
|
||||
|
||||
$builder
|
||||
->select('
|
||||
s.id AS student_id,
|
||||
s.school_id,
|
||||
s.firstname,
|
||||
s.lastname,
|
||||
fe.score
|
||||
')
|
||||
->from('students s')
|
||||
->join(
|
||||
'student_class sc',
|
||||
'sc.student_id = s.id
|
||||
AND sc.class_section_id = ' . (int) $classSectionId . '
|
||||
AND sc.school_year = ' . $db->escape($schoolYear),
|
||||
'inner',
|
||||
false
|
||||
)
|
||||
->join('(' . $latestFinalSub . ') fl', 'fl.student_id = s.id', 'left', false)
|
||||
->join('final_exam fe', 'fe.id = fl.max_id', 'left')
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC');
|
||||
|
||||
$builder->distinct();
|
||||
$rows = $builder->get()->getResultArray();
|
||||
|
||||
// Ensure one row per student
|
||||
$unique = [];
|
||||
foreach ($rows as $r) {
|
||||
$unique[(int)$r['student_id']] = $r;
|
||||
}
|
||||
$students = array_values($unique);
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
'class_section_id' => $classSectionId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,724 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\DiscountUsage;
|
||||
use App\Models\Expense;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Reimbursement;
|
||||
use App\Models\Refund;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\AdditionalCharge;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class FinancialController extends BaseApiController
|
||||
{
|
||||
protected Invoice $invoice;
|
||||
protected Payment $payment;
|
||||
protected Refund $refund;
|
||||
protected Expense $expense;
|
||||
protected Reimbursement $reimbursement;
|
||||
protected DiscountUsage $discount;
|
||||
protected Configuration $config;
|
||||
protected AdditionalCharge $additionalCharge;
|
||||
|
||||
// Special recipients for donations (Masjid/Donation reimbursements)
|
||||
protected const SPECIAL_RECIPIENTS = [
|
||||
// Add recipient IDs here if needed
|
||||
// Example: 1 => 'Masjid',
|
||||
// 2 => 'Donation',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->invoice = model(Invoice::class);
|
||||
$this->payment = model(Payment::class);
|
||||
$this->refund = model(Refund::class);
|
||||
$this->expense = model(Expense::class);
|
||||
$this->reimbursement = model(Reimbursement::class);
|
||||
$this->discount = model(DiscountUsage::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->additionalCharge = model(AdditionalCharge::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed financial report with invoices, payments, payment breakdown, refunds, expenses, reimbursements, discounts
|
||||
*/
|
||||
public function report()
|
||||
{
|
||||
$dateFrom = $this->request->getGet('date_from');
|
||||
$dateTo = $this->request->getGet('date_to');
|
||||
$schoolYear = $this->request->getGet('school_year');
|
||||
|
||||
try {
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// Build filtered models
|
||||
$invoice = $this->invoice;
|
||||
$refund = $this->refund;
|
||||
$discount = $this->discount;
|
||||
$expense = $this->expense;
|
||||
$reimbursement = $this->reimbursement;
|
||||
|
||||
// Apply filters
|
||||
if ($schoolYear) {
|
||||
$invoice->where('invoices.school_year', $schoolYear);
|
||||
$refund->where('school_year', $schoolYear);
|
||||
$discount->where('school_year', $schoolYear);
|
||||
$expense->where('school_year', $schoolYear);
|
||||
$reimbursement->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$hasFrom = !empty($dateFrom);
|
||||
$hasTo = !empty($dateTo);
|
||||
|
||||
if ($hasFrom) {
|
||||
$invoice->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom);
|
||||
$refund->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
$discount->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
||||
$expense->where('DATE(created_at) >=', $dateFrom);
|
||||
$reimbursement->where('DATE(created_at) >=', $dateFrom);
|
||||
}
|
||||
|
||||
if ($hasTo) {
|
||||
$invoice->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo);
|
||||
$refund->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
$discount->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
||||
$expense->where('DATE(created_at) <=', $dateTo);
|
||||
$reimbursement->where('DATE(created_at) <=', $dateTo);
|
||||
}
|
||||
|
||||
// Get invoices
|
||||
$invoices = $invoice
|
||||
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
|
||||
->join('users', 'users.id = invoices.parent_id')
|
||||
->findAll();
|
||||
|
||||
// Helper to build filtered Payment
|
||||
$buildPayment = function () use ($schoolYear, $dateFrom, $dateTo) {
|
||||
$pm = $this->payment;
|
||||
if ($schoolYear) {
|
||||
$pm->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($dateFrom)) {
|
||||
$pm->where('DATE(payment_date) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$pm->where('DATE(payment_date) <=', $dateTo);
|
||||
}
|
||||
return $pm;
|
||||
};
|
||||
|
||||
// Payments aggregated by invoice_id
|
||||
$payments = $buildPayment()
|
||||
->select('invoice_id, SUM(paid_amount) AS paid_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy('invoice_id')
|
||||
->findAll();
|
||||
|
||||
// Payment breakdown by method (cash/credit/check)
|
||||
$paymentBreakdownRows = $buildPayment()
|
||||
->select("
|
||||
invoice_id,
|
||||
CASE
|
||||
WHEN LOWER(TRIM(payment_method)) = 'cash' THEN 'cash'
|
||||
WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN 'check'
|
||||
WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN 'credit'
|
||||
ELSE 'other'
|
||||
END AS method,
|
||||
SUM(paid_amount) AS amount
|
||||
")
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy('invoice_id, method')
|
||||
->findAll();
|
||||
|
||||
$paymentBreakdown = [];
|
||||
foreach ($paymentBreakdownRows as $row) {
|
||||
$iid = (int)($row['invoice_id'] ?? 0);
|
||||
if ($iid <= 0) continue;
|
||||
$method = (string)$row['method'];
|
||||
$amount = (float)$row['amount'];
|
||||
if (!isset($paymentBreakdown[$iid])) $paymentBreakdown[$iid] = [];
|
||||
$paymentBreakdown[$iid][$method] = $amount;
|
||||
}
|
||||
|
||||
// Overall payment totals by method
|
||||
$paymentTotalsRow = $buildPayment()
|
||||
->select("
|
||||
SUM(paid_amount) AS total_all,
|
||||
SUM(CASE WHEN LOWER(TRIM(payment_method)) = 'cash' THEN paid_amount ELSE 0 END) AS total_cash,
|
||||
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN paid_amount ELSE 0 END) AS total_check,
|
||||
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit
|
||||
")
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->first() ?? [];
|
||||
|
||||
$paymentTotals = [
|
||||
'total_all' => (float)($paymentTotalsRow['total_all'] ?? 0),
|
||||
'total_cash' => (float)($paymentTotalsRow['total_cash'] ?? 0),
|
||||
'total_check' => (float)($paymentTotalsRow['total_check'] ?? 0),
|
||||
'total_credit' => (float)($paymentTotalsRow['total_credit'] ?? 0),
|
||||
];
|
||||
|
||||
// Refunds (grouped)
|
||||
$refunds = $refund
|
||||
->select('invoice_id, school_year, SUM(refund_paid_amount) AS total_refunded')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy(['invoice_id', 'school_year'])
|
||||
->findAll();
|
||||
|
||||
// Discounts (grouped)
|
||||
$discounts = $discount
|
||||
->select('invoice_id, school_year, SUM(discount_amount) AS discount_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy(['invoice_id', 'school_year'])
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// Expenses
|
||||
$expenses = $expense
|
||||
->select('category, SUM(amount) AS total_amount')
|
||||
->groupBy('category')
|
||||
->findAll();
|
||||
|
||||
// Reimbursements
|
||||
$reimbursements = $reimbursement
|
||||
->select('status, SUM(amount) AS total_amount')
|
||||
->groupBy('status')
|
||||
->findAll();
|
||||
|
||||
// School year options
|
||||
$schoolYears = [];
|
||||
try {
|
||||
$rows = $db->table('invoices')
|
||||
->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$val = (string)($r['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
if (empty($schoolYears) && !empty($schoolYear)) {
|
||||
$schoolYears[] = (string)$schoolYear;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'selectedYear' => $schoolYear,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'schoolYears' => $schoolYears,
|
||||
'invoices' => $invoices,
|
||||
'payments' => $payments,
|
||||
'paymentBreakdown' => $paymentBreakdown,
|
||||
'paymentTotals' => $paymentTotals,
|
||||
'refunds' => $refunds,
|
||||
'expenses' => $expenses,
|
||||
'reimbursements' => $reimbursements,
|
||||
'discounts' => $discounts,
|
||||
], 'Financial report retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Financial report error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve financial report', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Financial report summary with aggregated totals
|
||||
*/
|
||||
public function reportSummary()
|
||||
{
|
||||
$dateFrom = $this->request->getGet('date_from');
|
||||
$dateTo = $this->request->getGet('date_to');
|
||||
$schoolYear = $this->request->getGet('school_year');
|
||||
|
||||
try {
|
||||
$data = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear);
|
||||
|
||||
// Build school year options
|
||||
$schoolYears = [];
|
||||
try {
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('invoices')
|
||||
->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$val = (string)($r['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
if (empty($schoolYears) && !empty($data['schoolYear'])) {
|
||||
$schoolYears[] = (string)$data['schoolYear'];
|
||||
}
|
||||
|
||||
$data['schoolYears'] = $schoolYears;
|
||||
|
||||
return $this->success($data, 'Financial report summary retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Financial report summary error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve financial report summary', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download financial report as CSV
|
||||
*/
|
||||
public function downloadCsv()
|
||||
{
|
||||
$dateFrom = $this->request->getGet('date_from');
|
||||
$dateTo = $this->request->getGet('date_to');
|
||||
$schoolYear = $this->request->getGet('school_year');
|
||||
|
||||
try {
|
||||
$invoice = $this->invoice;
|
||||
$expense = $this->expense;
|
||||
$reimbursement = $this->reimbursement;
|
||||
|
||||
// Invoices with filters
|
||||
$invBuilder = $invoice
|
||||
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
|
||||
->join('users', 'users.id = invoices.parent_id');
|
||||
|
||||
if (!empty($schoolYear)) {
|
||||
$invBuilder->where('invoices.school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($dateFrom)) {
|
||||
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo);
|
||||
}
|
||||
$invoices = $invBuilder->findAll();
|
||||
|
||||
// Expenses summary
|
||||
$expBuilder = $expense->select('category, SUM(amount) AS total_amount');
|
||||
if (!empty($schoolYear)) {
|
||||
$expBuilder->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($dateFrom)) {
|
||||
$expBuilder->where('DATE(created_at) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$expBuilder->where('DATE(created_at) <=', $dateTo);
|
||||
}
|
||||
$expenses = $expBuilder->groupBy('category')->findAll();
|
||||
|
||||
// Reimbursements summary
|
||||
$reimbBuilder = $reimbursement->select('status, SUM(amount) AS total_amount');
|
||||
if (!empty($schoolYear)) {
|
||||
$reimbBuilder->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($dateFrom)) {
|
||||
$reimbBuilder->where('DATE(created_at) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$reimbBuilder->where('DATE(created_at) <=', $dateTo);
|
||||
}
|
||||
$reimbursements = $reimbBuilder->groupBy('status')->findAll();
|
||||
|
||||
$filename = 'financial_report_' . date('Ymd_His') . '.csv';
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
];
|
||||
|
||||
return new StreamedResponse(function () use ($invoices, $expenses, $reimbursements) {
|
||||
$out = fopen('php://output', 'w');
|
||||
|
||||
// Invoices section
|
||||
fputcsv($out, ['Invoice #', 'Parent', 'School Year', 'Total', 'Paid', 'Balance', 'Refund']);
|
||||
foreach ($invoices as $inv) {
|
||||
fputcsv($out, [
|
||||
$inv['invoice_number'] ?? '',
|
||||
$inv['parent_name'] ?? '',
|
||||
$inv['school_year'] ?? '',
|
||||
$inv['total_amount'] ?? 0,
|
||||
$inv['paid_amount'] ?? 0,
|
||||
$inv['balance'] ?? 0,
|
||||
$inv['refund_amount'] ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// Expenses section
|
||||
fputcsv($out, []);
|
||||
fputcsv($out, ['Expenses Summary']);
|
||||
fputcsv($out, ['Category', 'Total Amount']);
|
||||
foreach ($expenses as $exp) {
|
||||
fputcsv($out, [
|
||||
$exp['category'] ?? '',
|
||||
$exp['total_amount'] ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// Reimbursements section
|
||||
fputcsv($out, []);
|
||||
fputcsv($out, ['Reimbursements Summary']);
|
||||
fputcsv($out, ['Status', 'Total Amount']);
|
||||
foreach ($reimbursements as $r) {
|
||||
fputcsv($out, [
|
||||
$r['status'] ?? '',
|
||||
$r['total_amount'] ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
}, Response::HTTP_OK, $headers);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'CSV download error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to generate CSV', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get financial summary data
|
||||
*/
|
||||
protected function getFinancialSummary(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: $this->config->getConfig('school_year');
|
||||
|
||||
$invoice = $this->invoice;
|
||||
$payment = $this->payment;
|
||||
$refund = $this->refund;
|
||||
$expense = $this->expense;
|
||||
$reimbursement = $this->reimbursement;
|
||||
$discount = $this->discount;
|
||||
|
||||
// Invoices: Total Charges
|
||||
$invoiceBuilder = $invoice->where('school_year', $schoolYear);
|
||||
if (!empty($dateFrom)) {
|
||||
$invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo);
|
||||
}
|
||||
$invoices = $invoiceBuilder->findAll();
|
||||
$totalCharges = array_sum(array_column($invoices, 'total_amount'));
|
||||
|
||||
// Additional Charges
|
||||
$acBuilder = $this->additionalCharge->where('school_year', $schoolYear)
|
||||
->where('status !=', 'void');
|
||||
if ($dateFrom && $dateTo) {
|
||||
$acBuilder->where('due_date >=', $dateFrom)
|
||||
->where('due_date <=', $dateTo);
|
||||
}
|
||||
$acResult = $acBuilder->selectSum('amount')->first();
|
||||
$totalExtraCharges = isset($acResult['amount']) ? (float) $acResult['amount'] : 0.00;
|
||||
$totalCharges = $totalCharges + $totalExtraCharges;
|
||||
|
||||
// Payments: Total Paid
|
||||
$paymentBuilder = $payment->where('school_year', $schoolYear);
|
||||
if (!empty($dateFrom)) {
|
||||
$paymentBuilder->where('DATE(payment_date) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$paymentBuilder->where('DATE(payment_date) <=', $dateTo);
|
||||
}
|
||||
$paymentResult = $paymentBuilder->selectSum('paid_amount')->first();
|
||||
$totalPaid = isset($paymentResult['paid_amount']) ? (float) $paymentResult['paid_amount'] : 0.00;
|
||||
|
||||
// Expenses
|
||||
$expenseBuilder = $expense->where('school_year', $schoolYear);
|
||||
if (!empty($dateFrom)) {
|
||||
$expenseBuilder->where('DATE(created_at) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$expenseBuilder->where('DATE(created_at) <=', $dateTo);
|
||||
}
|
||||
$expenseResult = $expenseBuilder->selectSum('amount')->first();
|
||||
$totalExpenses = isset($expenseResult['amount']) ? (float) $expenseResult['amount'] : 0.00;
|
||||
|
||||
// Reimbursements
|
||||
$reimbursementBuilder = $reimbursement->where('school_year', $schoolYear);
|
||||
if (!empty($dateFrom)) {
|
||||
$reimbursementBuilder->where('DATE(created_at) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$reimbursementBuilder->where('DATE(created_at) <=', $dateTo);
|
||||
}
|
||||
$reimbursementResult = $reimbursementBuilder->selectSum('amount')->first();
|
||||
$totalReimbursements = isset($reimbursementResult['amount']) ? (float) $reimbursementResult['amount'] : 0.00;
|
||||
|
||||
// Donations line: reimbursements recorded against Masjid/Donation recipients
|
||||
$donationToSchool = 0.0;
|
||||
$specialRecipientIds = array_map('intval', array_keys(self::SPECIAL_RECIPIENTS));
|
||||
if (!empty($specialRecipientIds)) {
|
||||
$donationBuilder = $reimbursement
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('reimbursed_to', $specialRecipientIds);
|
||||
if (!empty($dateFrom)) {
|
||||
$donationBuilder->where('DATE(created_at) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$donationBuilder->where('DATE(created_at) <=', $dateTo);
|
||||
}
|
||||
$donationRow = $donationBuilder->selectSum('amount')->first();
|
||||
$donationToSchool = isset($donationRow['amount']) ? (float) $donationRow['amount'] : 0.00;
|
||||
}
|
||||
$totalReimbursements = max(0.0, $totalReimbursements - $donationToSchool);
|
||||
|
||||
// Refunds
|
||||
$refundBuilder = $refund
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'Paid')
|
||||
->where('refund_paid_amount IS NOT NULL');
|
||||
if (!empty($dateFrom)) {
|
||||
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$refundResult = $refundBuilder->selectSum('refund_paid_amount')->first();
|
||||
$totalRefunds = isset($refundResult['refund_paid_amount']) ? (float) $refundResult['refund_paid_amount'] : 0.00;
|
||||
|
||||
// Discounts
|
||||
$discountBuilder = $discount
|
||||
->join('invoices', 'invoices.id = discount_usages.invoice_id')
|
||||
->where('invoices.school_year', $schoolYear);
|
||||
if (!empty($dateFrom)) {
|
||||
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) <=', $dateTo);
|
||||
}
|
||||
$discountResult = $discountBuilder->selectSum('discount_amount')->first();
|
||||
$totalDiscounts = isset($discountResult['discount_amount']) ? (float) $discountResult['discount_amount'] : 0.00;
|
||||
|
||||
// Net & Unpaid
|
||||
$totalUnpaid = ($totalCharges - $totalDiscounts) - $totalPaid;
|
||||
$amountCollected = $totalPaid;
|
||||
$netAmount = ($totalCharges - $totalDiscounts - $totalRefunds);
|
||||
|
||||
return [
|
||||
'schoolYear' => $schoolYear,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'totalCharges' => $totalCharges,
|
||||
'totalExtraCharges' => $totalExtraCharges,
|
||||
'totalDiscounts' => $totalDiscounts,
|
||||
'totalRefunds' => $totalRefunds,
|
||||
'totalExpenses' => $totalExpenses,
|
||||
'totalReimbursements' => $totalReimbursements,
|
||||
'donationToSchool' => $donationToSchool,
|
||||
'totalPaid' => $totalPaid,
|
||||
'amountCollected' => $amountCollected,
|
||||
'totalUnpaid' => $totalUnpaid,
|
||||
'netAmount' => $netAmount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* List parents with outstanding balances (> 0) for a school year
|
||||
*/
|
||||
public function unpaidParents()
|
||||
{
|
||||
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
$schoolYear = (string) ($this->config->getConfig('school_year') ?? date('Y'));
|
||||
}
|
||||
|
||||
try {
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// Build school year options
|
||||
$schoolYears = [];
|
||||
try {
|
||||
$rows = $db->table('invoices')
|
||||
->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$val = (string)($r['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
if (empty($schoolYears) && !empty($schoolYear)) {
|
||||
$schoolYears[] = (string)$schoolYear;
|
||||
}
|
||||
|
||||
// Aggregate balances by parent
|
||||
$invRows = $db->table('invoices i')
|
||||
->select('i.id, i.parent_id, i.total_amount, u.firstname, u.lastname, u.email')
|
||||
->join('users u', 'u.id = i.parent_id', 'inner')
|
||||
->where('i.school_year', $schoolYear)
|
||||
->orderBy('i.parent_id', 'ASC')
|
||||
->orderBy('i.id', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
$byParent = [];
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
||||
|
||||
foreach ($invRows as $r) {
|
||||
$iid = (int)($r['id'] ?? 0);
|
||||
$pid = (int)($r['parent_id'] ?? 0);
|
||||
if ($iid <= 0 || $pid <= 0) continue;
|
||||
|
||||
// Sum payments for this invoice
|
||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paidRow = $qb->get()->getRowArray();
|
||||
$paidSum = (float)($paidRow['tot'] ?? 0);
|
||||
|
||||
// Sum discounts
|
||||
$discRow = $db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->get()->getRowArray();
|
||||
$discSum = (float)($discRow['tot'] ?? 0);
|
||||
|
||||
// Sum refunds PAID
|
||||
$refRow = $db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$refSum = (float)($refRow['tot'] ?? 0);
|
||||
|
||||
$total = (float)($r['total_amount'] ?? 0);
|
||||
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
|
||||
|
||||
if (!isset($byParent[$pid])) {
|
||||
$byParent[$pid] = [
|
||||
'parent_id' => $pid,
|
||||
'firstname' => (string)($r['firstname'] ?? ''),
|
||||
'lastname' => (string)($r['lastname'] ?? ''),
|
||||
'email' => (string)($r['email'] ?? ''),
|
||||
'total_invoice' => 0.0,
|
||||
'total_balance' => 0.0,
|
||||
'total_paid' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
$byParent[$pid]['total_invoice'] += $total;
|
||||
$byParent[$pid]['total_balance'] += $balance;
|
||||
$byParent[$pid]['total_paid'] += $paidSum;
|
||||
}
|
||||
|
||||
// Build rows list (only parents with positive balance)
|
||||
$rows = [];
|
||||
$installmentEndRaw = (string) ($this->config->getConfig('installment_date') ?? '');
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? 'America/New_York');
|
||||
$tzNY = new \DateTimeZone($tzName);
|
||||
$todayNY = new \DateTimeImmutable('today', $tzNY);
|
||||
$endDate = null;
|
||||
if ($installmentEndRaw) {
|
||||
try { $endDate = new \DateTimeImmutable($installmentEndRaw, $tzNY); } catch (\Throwable $e) { $endDate = null; }
|
||||
}
|
||||
|
||||
$monthsUntil = function (?\DateTimeImmutable $end) use ($todayNY): int {
|
||||
if (!$end) return 0;
|
||||
$y = (int)$end->format('Y') - (int)$todayNY->format('Y');
|
||||
$m = (int)$end->format('n') - (int)$todayNY->format('n');
|
||||
$months = $y * 12 + $m;
|
||||
if ((int)$end->format('j') > (int)$todayNY->format('j')) $months += 1;
|
||||
return max(0, $months);
|
||||
};
|
||||
|
||||
foreach ($byParent as $pid => $agg) {
|
||||
$balance = (float)($agg['total_balance'] ?? 0);
|
||||
if ($balance > 0.00001) {
|
||||
$remMonths = $monthsUntil($endDate);
|
||||
$remainingInstallments = max(1, $remMonths);
|
||||
$instAmount = round($balance / $remainingInstallments, 2);
|
||||
|
||||
$rows[] = [
|
||||
'parent_id' => $pid,
|
||||
'firstname' => $agg['firstname'],
|
||||
'lastname' => $agg['lastname'],
|
||||
'email' => $agg['email'],
|
||||
'total_invoice' => (float)$agg['total_invoice'],
|
||||
'total_balance' => $balance,
|
||||
'remaining_installments' => $remainingInstallments,
|
||||
'installment_amount' => $instAmount,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Order by total_balance desc
|
||||
usort($rows, static function ($a, $b) {
|
||||
return ($b['total_balance'] <=> $a['total_balance']);
|
||||
});
|
||||
|
||||
// Compute next installment date
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$now = new \DateTime('now', $tz);
|
||||
$nextInstallmentDt = (clone $now)->modify('first day of next month');
|
||||
$nextInstallmentYmd = $nextInstallmentDt->format('Y-m-d');
|
||||
|
||||
// Determine payment types
|
||||
$parentIds = array_values(array_unique(array_map(static fn($r) => (int)($r['parent_id'] ?? 0), $rows)));
|
||||
$hasPayments = [];
|
||||
$paidTotals = [];
|
||||
|
||||
if (!empty($parentIds)) {
|
||||
try {
|
||||
$pRows = $db->table('payments')
|
||||
->select('parent_id, SUM(paid_amount) AS total_paid')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('parent_id')
|
||||
->get()->getResultArray();
|
||||
foreach ($pRows as $pr) {
|
||||
$pid = (int)($pr['parent_id'] ?? 0);
|
||||
if ($pid > 0) {
|
||||
$hasPayments[$pid] = true;
|
||||
$paidTotals[$pid] = (float)($pr['total_paid'] ?? 0);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
$dataRows = array_map(function(array $r) use ($hasPayments, $paidTotals, $nextInstallmentYmd) {
|
||||
$pid = (int)($r['parent_id'] ?? 0);
|
||||
$name = trim((string)($r['firstname'] ?? '') . ' ' . (string)($r['lastname'] ?? ''));
|
||||
|
||||
return [
|
||||
'parent_id' => $pid,
|
||||
'parent_name' => $name,
|
||||
'email' => (string)($r['email'] ?? ''),
|
||||
'total_invoice' => (float)($r['total_invoice'] ?? 0),
|
||||
'total_balance' => (float)($r['total_balance'] ?? 0),
|
||||
'remaining_installments' => (int)($r['remaining_installments'] ?? 0),
|
||||
'installment_amount' => (float)($r['installment_amount'] ?? 0),
|
||||
'type' => isset($hasPayments[$pid]) ? 'installment' : 'no_payment',
|
||||
'total_paid' => isset($paidTotals[$pid]) ? (float)$paidTotals[$pid] : 0.0,
|
||||
'has_installment'=> isset($hasPayments[$pid]) ? 1 : 0,
|
||||
'next_installment' => $nextInstallmentYmd,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->success([
|
||||
'school_year' => $schoolYear,
|
||||
'schoolYears' => $schoolYears,
|
||||
'results' => $dataRows,
|
||||
], 'Unpaid parents retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Unpaid parents error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve unpaid parents', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\CurrentFlag;
|
||||
use App\Models\Flag;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\Student;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FlagController extends BaseApiController
|
||||
{
|
||||
protected CurrentFlag $currentFlag;
|
||||
protected Flag $flag;
|
||||
protected Student $student;
|
||||
protected ClassSection $classSection;
|
||||
protected StudentClass $studentClass;
|
||||
protected Configuration $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->currentFlag = model(CurrentFlag::class);
|
||||
$this->flag = model(Flag::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->classSection = model(ClassSection::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->config = model(Configuration::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$studentId = $this->request->getGet('student_id');
|
||||
$flagType = $this->request->getGet('flag_type') ?? $this->request->getGet('flag');
|
||||
$state = $this->request->getGet('state') ?? $this->request->getGet('flag_state');
|
||||
|
||||
$query = $this->currentFlag->newQuery()->orderBy('created_at', 'DESC');
|
||||
if ($studentId) {
|
||||
$query->where('student_id', $studentId);
|
||||
}
|
||||
if ($flagType) {
|
||||
$query->where('flag', $flagType);
|
||||
}
|
||||
if ($state) {
|
||||
$query->where('flag_state', $state);
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
// Also return class sections (grades) for UI
|
||||
$classSections = $this->classSection->findAll();
|
||||
$grades = [];
|
||||
foreach ($classSections as $section) {
|
||||
$grades[] = [
|
||||
'id' => $section['class_section_id'] ?? $section['id'] ?? null,
|
||||
'name' => $section['class_section_name'] ?? $section['name'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$result['grades'] = $grades;
|
||||
|
||||
return $this->success($result, 'Flags retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$flag = $this->currentFlag->find($id);
|
||||
if (!$flag) {
|
||||
return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
return $this->success($flag, 'Flag retrieved successfully');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return $this->addFlag();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a flag for a student
|
||||
* If a flag of the same type exists, it updates it; otherwise creates a new one
|
||||
*/
|
||||
public function addFlag()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$studentId = $data['student_id'] ?? $data['student'] ?? null;
|
||||
$flagType = $data['flag'] ?? $data['flag_type'] ?? null;
|
||||
$description = $data['description'] ?? $data['open_description'] ?? null;
|
||||
$flagState = $data['flag_state'] ?? 'Open';
|
||||
$stateDescription = $data['state_description'] ?? null;
|
||||
$grade = $data['grade'] ?? null;
|
||||
|
||||
if (!$studentId || !$flagType || !$description) {
|
||||
return $this->respondError('Missing required fields: student_id, flag, description', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Retrieve student information
|
||||
$student = $this->student->find($studentId);
|
||||
if (!$student) {
|
||||
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$userId = $user->id ?? null;
|
||||
$semester = $this->config->getConfig('semester');
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$currentDateTime = date('Y-m-d H:i:s');
|
||||
|
||||
// Check if a flag of the same type already exists for this student
|
||||
$existingFlag = $this->currentFlag
|
||||
->where('student_id', $studentId)
|
||||
->where('flag', $flagType)
|
||||
->first();
|
||||
|
||||
if ($existingFlag) {
|
||||
// Update existing flag
|
||||
$updateData = [
|
||||
'flag_datetime' => $currentDateTime,
|
||||
'updated_by_open' => $userId,
|
||||
];
|
||||
|
||||
// Append description based on flag state
|
||||
if ($flagState === 'Closed') {
|
||||
$updateData['flag_state'] = 'Closed';
|
||||
$existingCloseDesc = $existingFlag['close_description'] ?? '';
|
||||
$updateData['close_description'] = $existingCloseDesc
|
||||
? $existingCloseDesc . PHP_EOL . $stateDescription
|
||||
: $stateDescription;
|
||||
} elseif ($flagState === 'Canceled') {
|
||||
$updateData['flag_state'] = 'Canceled';
|
||||
$existingCancelDesc = $existingFlag['cancel_description'] ?? '';
|
||||
$updateData['cancel_description'] = $existingCancelDesc
|
||||
? $existingCancelDesc . PHP_EOL . $stateDescription
|
||||
: $stateDescription;
|
||||
} else {
|
||||
$existingOpenDesc = $existingFlag['open_description'] ?? '';
|
||||
$updateData['open_description'] = $existingOpenDesc
|
||||
? $existingOpenDesc . PHP_EOL . $description
|
||||
: $description;
|
||||
}
|
||||
|
||||
$this->currentFlag->update($existingFlag['id'], $updateData);
|
||||
$flag = $this->currentFlag->find($existingFlag['id']);
|
||||
|
||||
return $this->success($flag, 'Flag updated successfully');
|
||||
} else {
|
||||
// Create new flag
|
||||
$flagData = [
|
||||
'student_id' => $student['id'],
|
||||
'student_name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
||||
'grade' => $grade,
|
||||
'flag' => $flagType,
|
||||
'flag_datetime' => $currentDateTime,
|
||||
'flag_state' => 'Open',
|
||||
'updated_by_open' => $userId,
|
||||
'open_description' => $description,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
];
|
||||
|
||||
$flag = $this->currentFlag->create($flagData);
|
||||
return $this->success($flag, 'New flag added successfully', Response::HTTP_CREATED);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Add flag error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to add flag', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateState($id = null)
|
||||
{
|
||||
$flag = $this->currentFlag->find($id);
|
||||
if (!$flag) {
|
||||
return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$newState = $data['flag_state'] ?? null;
|
||||
if (!$newState) {
|
||||
return $this->respondError('Flag state is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$updateData = ['flag_state' => $newState];
|
||||
|
||||
// Update appropriate description field based on state
|
||||
if (isset($data['state_description'])) {
|
||||
if ($newState === 'Closed') {
|
||||
$updateData['close_description'] = $data['state_description'];
|
||||
} elseif ($newState === 'Canceled') {
|
||||
$updateData['cancel_description'] = $data['state_description'];
|
||||
} else {
|
||||
$updateData['open_description'] = $data['state_description'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->currentFlag->update($id, $updateData);
|
||||
$flag = $this->currentFlag->find($id);
|
||||
|
||||
return $this->success($flag, 'Flag state updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Update flag state error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update flag state', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function close($id = null)
|
||||
{
|
||||
return $this->closeFlag($id);
|
||||
}
|
||||
|
||||
public function cancel($id = null)
|
||||
{
|
||||
return $this->cancelFlag($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a flag and move it to history
|
||||
*/
|
||||
public function closeFlag($flagId = null)
|
||||
{
|
||||
$flagId = $flagId ?? $this->request->getPost('id') ?? $this->request->getGet('id');
|
||||
if (!$flagId) {
|
||||
return $this->respondError('Flag ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->getCurrentUser();
|
||||
$userId = $user->id ?? null;
|
||||
|
||||
// Get the current flag data
|
||||
$flagData = $this->currentFlag->find($flagId);
|
||||
if (!$flagData) {
|
||||
return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check if flag is already closed
|
||||
if ($flagData['flag_state'] === 'Closed') {
|
||||
return $this->respondError('Flag is already closed', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get close description from request
|
||||
$data = $this->payloadData();
|
||||
$closeDescription = $data['state_description'] ?? $data['close_description'] ?? null;
|
||||
|
||||
if (empty($closeDescription)) {
|
||||
return $this->respondError('Close description is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Update the flag state to Closed
|
||||
$this->currentFlag->update($flagId, [
|
||||
'flag_state' => 'Closed',
|
||||
'updated_by_closed' => $userId,
|
||||
'close_description' => $closeDescription,
|
||||
]);
|
||||
|
||||
// Retrieve updated flag data
|
||||
$flagData = $this->currentFlag->find($flagId);
|
||||
|
||||
// Move to history
|
||||
return $this->moveToHistory($flagData);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Close flag error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to close flag', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a flag and move it to history
|
||||
*/
|
||||
public function cancelFlag($flagId = null)
|
||||
{
|
||||
$flagId = $flagId ?? $this->request->getPost('id') ?? $this->request->getGet('id');
|
||||
if (!$flagId) {
|
||||
return $this->respondError('Flag ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->getCurrentUser();
|
||||
$userId = $user->id ?? null;
|
||||
|
||||
// Get the current flag data
|
||||
$flagData = $this->currentFlag->find($flagId);
|
||||
if (!$flagData) {
|
||||
return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check if flag is already canceled
|
||||
if ($flagData['flag_state'] === 'Canceled') {
|
||||
return $this->respondError('Flag is already canceled', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get cancel description from request
|
||||
$data = $this->payloadData();
|
||||
$cancelDescription = $data['state_description'] ?? $data['cancel_description'] ?? null;
|
||||
|
||||
if (empty($cancelDescription)) {
|
||||
return $this->respondError('Cancel description is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Update the flag state to Canceled
|
||||
$this->currentFlag->update($flagId, [
|
||||
'flag_state' => 'Canceled',
|
||||
'updated_by_canceled' => $userId,
|
||||
'cancel_description' => $cancelDescription,
|
||||
]);
|
||||
|
||||
// Retrieve updated flag data
|
||||
$flagData = $this->currentFlag->find($flagId);
|
||||
|
||||
// Move to history
|
||||
return $this->moveToHistory($flagData);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Cancel flag error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to cancel flag', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move flag to history table and delete from current_flag table
|
||||
*/
|
||||
protected function moveToHistory($flagData)
|
||||
{
|
||||
try {
|
||||
// Prepare data for the flag table
|
||||
$dataToInsert = [
|
||||
'student_id' => $flagData['student_id'] ?? null,
|
||||
'student_name' => $flagData['student_name'] ?? null,
|
||||
'grade' => $flagData['grade'] ?? null,
|
||||
'flag' => $flagData['flag'] ?? null,
|
||||
'flag_datetime' => $flagData['flag_datetime'] ?? null,
|
||||
'flag_state' => $flagData['flag_state'] ?? null,
|
||||
'updated_by_open' => $flagData['updated_by_open'] ?? null,
|
||||
'open_description' => $flagData['open_description'] ?? null,
|
||||
'updated_by_closed' => $flagData['updated_by_closed'] ?? null,
|
||||
'close_description' => $flagData['close_description'] ?? null,
|
||||
'updated_by_canceled' => $flagData['updated_by_canceled'] ?? null,
|
||||
'cancel_description' => $flagData['cancel_description'] ?? null,
|
||||
'semester' => $flagData['semester'] ?? null,
|
||||
'school_year' => $flagData['school_year'] ?? null,
|
||||
'created_at' => $flagData['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
'updated_at' => $flagData['updated_at'] ?? date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
// Insert data into the flag table
|
||||
$this->flag->insert($dataToInsert);
|
||||
|
||||
// Delete the entry from the current_flag table
|
||||
$this->currentFlag->delete($flagData['id']);
|
||||
|
||||
return $this->success(['moved_to_history' => true], 'Flag has been moved to history');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Move to history error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to move flag to history', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function getStudentsByGrade($gradeId = null)
|
||||
{
|
||||
try {
|
||||
$studentIds = $this->studentClass->newQuery()
|
||||
->where('class_section_id', $gradeId)
|
||||
->pluck('student_id')
|
||||
->toArray();
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return $this->success([], 'No students found for this grade');
|
||||
}
|
||||
|
||||
$students = $this->student->newQuery()
|
||||
->whereIn('id', $studentIds)
|
||||
->get()
|
||||
->map(fn($student) => [
|
||||
'id' => $student->id,
|
||||
'name' => trim(($student->firstname ?? '') . ' ' . ($student->lastname ?? '')),
|
||||
])
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return $this->success($students, 'Students retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Get students by grade error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve students', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FrontendController extends BaseApiController
|
||||
{
|
||||
public function getUser()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$user = model(User::class);
|
||||
$userInfo = $user->getUserInfoById($user->id);
|
||||
|
||||
if (!$userInfo) {
|
||||
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$firstname = (string) ($userInfo['firstname'] ?? '');
|
||||
$lastname = (string) ($userInfo['lastname'] ?? '');
|
||||
|
||||
return $this->success([
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'fullname' => trim($firstname . ' ' . $lastname),
|
||||
'initials' => strtoupper(($firstname[0] ?? '') . ($lastname[0] ?? '')),
|
||||
], 'User info retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Frontend user error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve user info', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function getPages()
|
||||
{
|
||||
try {
|
||||
return $this->success([
|
||||
'pages' => [
|
||||
'facility' => '/facility',
|
||||
'team' => '/team',
|
||||
'call_to_action' => '/call_to_action',
|
||||
'testimonial' => '/testimonial',
|
||||
],
|
||||
], 'Frontend pages retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Frontend pages error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve frontend pages', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\AttendanceRecord;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\FinalExam;
|
||||
use App\Models\Homework;
|
||||
use App\Models\MidtermExam;
|
||||
use App\Models\Project;
|
||||
use App\Models\Quiz;
|
||||
use App\Models\ScoreComment;
|
||||
use App\Models\SemesterScore;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class GradingController extends BaseApiController
|
||||
{
|
||||
protected Configuration $config;
|
||||
protected Homework $homework;
|
||||
protected User $user;
|
||||
protected StudentClass $studentClass;
|
||||
protected Student $student;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected ClassSection $classSection;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
protected $semesterScoreService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->config = model(Configuration::class);
|
||||
$this->homework = model(Homework::class);
|
||||
$this->user = model(User::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->classSection = model(ClassSection::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
$this->semesterScoreService = \Config\Services::semesterScoreService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show scores for a specific type, class section, and student
|
||||
*/
|
||||
public function show($type, $classSectionId = null, $studentId = null)
|
||||
{
|
||||
$model = $this->getModelByType($type);
|
||||
if (!$model) {
|
||||
return $this->respondError('Invalid type', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$student = model(Student::class);
|
||||
$student = null;
|
||||
if ($studentId) {
|
||||
$student = $student->find($studentId);
|
||||
if (!$student) {
|
||||
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$scores = $model->where([
|
||||
'student_id' => $studentId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear
|
||||
])->findAll();
|
||||
|
||||
return $this->success([
|
||||
'student' => $student,
|
||||
'scores' => $scores,
|
||||
'type' => $type,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
], 'Scores retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Grading show error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve scores', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update scores for various types
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$type = $data['type'] ?? null;
|
||||
$studentId = (int) ($data['student_id'] ?? 0);
|
||||
$classSectionId = (int) ($data['class_section_id'] ?? 0);
|
||||
|
||||
if (!$type) {
|
||||
return $this->respondError('Type is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$model = $this->getModelByType($type);
|
||||
if (!$model) {
|
||||
return $this->respondError('Invalid type', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
if (in_array($type, ['homework', 'quiz', 'project'])) {
|
||||
$scoreIds = $data['score_ids'] ?? [];
|
||||
$scores = $data['scores'] ?? [];
|
||||
$comments = $data['comments'] ?? [];
|
||||
|
||||
foreach ($scoreIds as $i => $id) {
|
||||
$updateData = [
|
||||
'score' => $scores[$i] ?? null,
|
||||
'updated_at' => utc_now()
|
||||
];
|
||||
if (isset($comments[$i])) {
|
||||
$updateData['comment'] = $comments[$i];
|
||||
}
|
||||
$model->update($id, $updateData);
|
||||
}
|
||||
} elseif (in_array($type, ['midterm', 'final', 'test'])) {
|
||||
$score = $data['score'] ?? null;
|
||||
$updateData = [
|
||||
'score' => $score,
|
||||
'updated_at' => utc_now()
|
||||
];
|
||||
|
||||
$existing = $model->where([
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear
|
||||
])->first();
|
||||
|
||||
if ($existing) {
|
||||
$model->update($existing['id'], $updateData);
|
||||
} else {
|
||||
$insertData = array_merge($updateData, [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'created_at' => utc_now()
|
||||
]);
|
||||
$model->insert($insertData);
|
||||
}
|
||||
} elseif ($type === 'comments') {
|
||||
$comment = $data['comment'] ?? null;
|
||||
|
||||
// Remove existing comments for this student/semester/year
|
||||
$model->where([
|
||||
'student_id' => $studentId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear
|
||||
])->delete();
|
||||
|
||||
// Insert new comment
|
||||
$model->insert([
|
||||
'student_id' => $studentId,
|
||||
'score_type' => 'general',
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'comment' => $comment,
|
||||
'commented_by' => $this->getCurrentUserId(),
|
||||
'created_at' => utc_now()
|
||||
]);
|
||||
}
|
||||
|
||||
// Update semester scores for students in the class section
|
||||
if ($classSectionId > 0) {
|
||||
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId(
|
||||
$classSectionId,
|
||||
$this->semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
|
||||
try {
|
||||
foreach ($studentTeacherInfo as $studentInfo) {
|
||||
$this->semesterScoreService->updateStudentScores([
|
||||
'student_id' => $studentInfo['student_id'],
|
||||
'school_id' => $studentInfo['school_id'] ?? null,
|
||||
'class_section_id' => $studentInfo['class_section_id'],
|
||||
'updated_by' => $studentInfo['updated_by'] ?? $this->getCurrentUserId(),
|
||||
], $this->semester, $this->schoolYear);
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success(null, 'Scores updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Grading update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get grading main view data - all students grouped by class sections
|
||||
*/
|
||||
public function grading()
|
||||
{
|
||||
$schoolYear = (string) $this->schoolYear;
|
||||
$semester = (string) $this->semester;
|
||||
|
||||
// Roster + dual-join to semester_scores:
|
||||
// ss_b: match when semester_scores.class_section_id = BUSINESS ID (student_class.class_section_id)
|
||||
// ss_p: match when semester_scores.class_section_id = PK (classSection.id)
|
||||
$rows = DB::table('student_class as sc')
|
||||
->select([
|
||||
'cs.id as section_pk',
|
||||
'cs.class_section_id as section_id',
|
||||
'cs.class_id',
|
||||
'cs.class_section_name',
|
||||
's.id as student_id',
|
||||
's.school_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
DB::raw('COALESCE(ss_b.ptap_score, ss_p.ptap_score) as ss_ptap_score'),
|
||||
DB::raw('COALESCE(ss_b.semester_score, ss_p.semester_score) as ss_semester_score'),
|
||||
'ss_b.class_section_id as matched_biz_csid',
|
||||
'ss_p.class_section_id as matched_pk_csid'
|
||||
])
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->leftJoin('semester_scores as ss_b', function($join) use ($semester, $schoolYear) {
|
||||
$join->on('ss_b.student_id', '=', 's.id')
|
||||
->on('ss_b.class_section_id', '=', 'sc.class_section_id')
|
||||
->whereRaw('LOWER(ss_b.semester) = LOWER(?)', [$semester])
|
||||
->where('ss_b.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoin('semester_scores as ss_p', function($join) use ($semester, $schoolYear) {
|
||||
$join->on('ss_p.student_id', '=', 's.id')
|
||||
->on('ss_p.class_section_id', '=', 'cs.id')
|
||||
->whereRaw('LOWER(ss_p.semester) = LOWER(?)', [$semester])
|
||||
->where('ss_p.school_year', '=', $schoolYear);
|
||||
})
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->orderBy('cs.class_id', 'ASC')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
// Build structures keyed by BUSINESS section id
|
||||
$grades = []; // class_id => [ ['class_section_id','class_section_name'], ... ]
|
||||
$studentsBySection = []; // section_id => [ students... ]
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$sectionId = (int) ($r->section_id ?? 0);
|
||||
$classId = (int) ($r->class_id ?? 0);
|
||||
$sectionName = (string) ($r->class_section_name ?? '');
|
||||
|
||||
if ($sectionId <= 0 || $classId <= 0) continue;
|
||||
|
||||
if (!isset($grades[$classId])) {
|
||||
$grades[$classId] = [];
|
||||
}
|
||||
|
||||
$exists = false;
|
||||
foreach ($grades[$classId] as $s) {
|
||||
if ((int)$s['class_section_id'] === $sectionId) {
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$exists) {
|
||||
$grades[$classId][] = [
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => $sectionName,
|
||||
];
|
||||
}
|
||||
|
||||
$sid = (int) ($r->student_id ?? 0);
|
||||
if ($sid <= 0) continue;
|
||||
|
||||
$ptapScore = $r->ss_ptap_score ?? null;
|
||||
$semesterScore = $r->ss_semester_score ?? null;
|
||||
|
||||
if (!isset($studentsBySection[$sectionId])) {
|
||||
$studentsBySection[$sectionId] = [];
|
||||
}
|
||||
|
||||
$studentsBySection[$sectionId][] = [
|
||||
'id' => $sid,
|
||||
'school_id' => $r->school_id ?? null,
|
||||
'firstname' => $r->firstname ?? null,
|
||||
'lastname' => $r->lastname ?? null,
|
||||
'ptap' => is_null($ptapScore) ? null : (float) $ptapScore,
|
||||
'semester_score' => is_null($semesterScore) ? null : (float) $semesterScore,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'grades' => $grades,
|
||||
'students_by_section' => $studentsBySection,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
], 'Grading data retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scores and comments for students
|
||||
*/
|
||||
public function getScoreComment()
|
||||
{
|
||||
// Get all students for the current semester and school year
|
||||
$studentClassEntries = $this->studentClass
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->findAll();
|
||||
|
||||
// Group student IDs
|
||||
$studentIds = array_map(fn($entry) => $entry['student_id'], $studentClassEntries);
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return $this->success([], 'No students found');
|
||||
}
|
||||
|
||||
// Fetch all scores and comments for the students
|
||||
$scoresAndComments = $this->getAllScoresAndComments($studentIds, $this->semester, $this->schoolYear);
|
||||
|
||||
return $this->success($scoresAndComments, 'Scores and comments retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all scores and comments for a list of students based on semester and school year.
|
||||
*
|
||||
* @param array $studentIds List of student IDs.
|
||||
* @param string $semester Current semester (e.g., 'fall', 'spring').
|
||||
* @param string $schoolYear Current school year (e.g., '2025-2026').
|
||||
* @return array
|
||||
*/
|
||||
private function getAllScoresAndComments($studentIds, $semester, $schoolYear)
|
||||
{
|
||||
// Validate input parameters
|
||||
if (empty($studentIds) || !is_array($studentIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (empty($semester) || empty($schoolYear)) {
|
||||
throw new \InvalidArgumentException('Semester and school year must be provided');
|
||||
}
|
||||
|
||||
// Initialize models
|
||||
$models = [
|
||||
'final_exam' => model(FinalExam::class),
|
||||
'homework' => model(Homework::class),
|
||||
'midterm' => model(MidtermExam::class),
|
||||
'project' => model(Project::class),
|
||||
'quiz' => model(Quiz::class),
|
||||
'comments' => model(ScoreComment::class),
|
||||
'semester_scores' => model(SemesterScore::class),
|
||||
'student' => model(Student::class),
|
||||
'student_class' => model(StudentClass::class),
|
||||
'teacher_class' => model(TeacherClass::class),
|
||||
'config' => model(Configuration::class),
|
||||
'attendance' => model(AttendanceRecord::class)
|
||||
];
|
||||
|
||||
// Get semester days configuration
|
||||
$semesterKey = strtolower($semester) === 'fall' ? 'total_semester1_days' : 'total_semester2_days';
|
||||
$totalSemesterDays = $models['config']->getConfig($semesterKey) ?? 0;
|
||||
|
||||
// Common query conditions
|
||||
$conditions = [
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear
|
||||
];
|
||||
|
||||
// Fetch all student data first
|
||||
$students = $models['student']->whereIn('id', $studentIds)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
if (empty($students)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Initialize result array with student data
|
||||
$allScores = [];
|
||||
|
||||
foreach ($students as $student) {
|
||||
$className = $models['student_class']->getClassSectionsByStudentId($student['id'], $schoolYear);
|
||||
$updatedBy = $models['teacher_class']->getTeacherIdByClassSection($className, $semester, $schoolYear);
|
||||
|
||||
$allScores[$student['id']] = [
|
||||
'school_id' => $student['school_id'],
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'class_name' => $className,
|
||||
'comments' => []
|
||||
];
|
||||
}
|
||||
|
||||
// Fetch and process attendance data
|
||||
foreach ($studentIds as $studentId) {
|
||||
if (!isset($allScores[$studentId])) continue;
|
||||
|
||||
$absences = $models['attendance']->getTotalAbsences($studentId, $semester, $schoolYear);
|
||||
$attendance = min((($totalSemesterDays - $absences + 1) / $totalSemesterDays) * 100, 100);
|
||||
|
||||
$allScores[$studentId]['attendance'] = [
|
||||
'score' => round($attendance, 2),
|
||||
'absences' => $absences,
|
||||
'total_days' => $totalSemesterDays
|
||||
];
|
||||
}
|
||||
|
||||
// Fetch and process all score types
|
||||
$scoreTypes = [
|
||||
'final_exam' => $models['final_exam'],
|
||||
'homework' => $models['homework'],
|
||||
'midterm' => $models['midterm'],
|
||||
'project' => $models['project'],
|
||||
'quiz' => $models['quiz'],
|
||||
'semester_score' => $models['semester_scores']
|
||||
];
|
||||
|
||||
foreach ($scoreTypes as $type => $model) {
|
||||
$scores = $model->whereIn('student_id', $studentIds)
|
||||
->where($conditions)
|
||||
->findAll();
|
||||
|
||||
foreach ($scores as $score) {
|
||||
if ($type === 'semester_score') {
|
||||
$allScores[$score['student_id']][$type] = [
|
||||
'homework_avg' => $score['homework_avg'],
|
||||
'quiz_avg' => $score['quiz_avg'],
|
||||
'project_avg' => $score['project_avg'],
|
||||
'midterm_exam_score' => $score['midterm_exam_score'],
|
||||
'final_exam_score' => $score['final_exam_score'],
|
||||
'attendance_score' => $score['attendance_score'],
|
||||
'participation_score' => $score['participation_score'],
|
||||
'ptap_score' => $score['ptap_score'],
|
||||
'test_avg' => $score['test_avg'],
|
||||
'semester_score' => $score['semester_score'],
|
||||
'semester' => $score['semester'],
|
||||
'school_year' => $score['school_year']
|
||||
];
|
||||
} else {
|
||||
$allScores[$score['student_id']][$type] = $score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and process comments
|
||||
$comments = $models['comments']->whereIn('student_id', $studentIds)
|
||||
->where($conditions)
|
||||
->findAll();
|
||||
|
||||
foreach ($comments as $comment) {
|
||||
$allScores[$comment['student_id']]['comments'][] = $comment;
|
||||
}
|
||||
|
||||
return $allScores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model instance by type
|
||||
*/
|
||||
private function getModelByType(?string $type)
|
||||
{
|
||||
return match ($type) {
|
||||
'homework' => model(Homework::class),
|
||||
'quiz' => model(Quiz::class),
|
||||
'project' => model(Project::class),
|
||||
'midterm' => model(MidtermExam::class),
|
||||
'final' => model(FinalExam::class),
|
||||
'test' => model(SemesterScore::class),
|
||||
'comments' => model(ScoreComment::class),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class HealthController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* Check if a path exists and is writable
|
||||
*/
|
||||
private function checkPath(string $label, string $path): array
|
||||
{
|
||||
return [
|
||||
'label' => $label,
|
||||
'path' => $path,
|
||||
'exists' => is_dir($path),
|
||||
'writable' => is_writable($path),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check endpoint - checks filesystem paths and database schema
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
try {
|
||||
// Use storage_path('app') as the base (equivalent to WRITEPATH in CodeIgniter)
|
||||
$uploadsBase = storage_path('app') . DIRECTORY_SEPARATOR . 'uploads';
|
||||
|
||||
$paths = [
|
||||
'uploads' => $uploadsBase,
|
||||
'uploads/reimbursements' => $uploadsBase . DIRECTORY_SEPARATOR . 'reimbursements',
|
||||
'uploads/receipts' => $uploadsBase . DIRECTORY_SEPARATOR . 'receipts',
|
||||
'uploads/checks' => $uploadsBase . DIRECTORY_SEPARATOR . 'checks',
|
||||
];
|
||||
|
||||
$pathsStatus = [];
|
||||
foreach ($paths as $label => $p) {
|
||||
$pathsStatus[] = $this->checkPath($label, $p);
|
||||
}
|
||||
|
||||
// Use Laravel's schema builder to check tables and columns
|
||||
$schema = DB::getSchemaBuilder();
|
||||
|
||||
$dbChecks = [
|
||||
'user_preferences_exists' => $schema->hasTable('user_preferences'),
|
||||
'settings_exists' => $schema->hasTable('settings'),
|
||||
'migrations_exists' => $schema->hasTable('migrations'),
|
||||
];
|
||||
|
||||
$dbChecks['user_preferences_has_timezone'] = $dbChecks['user_preferences_exists']
|
||||
? $schema->hasColumn('user_preferences', 'timezone')
|
||||
: false;
|
||||
|
||||
$dbChecks['settings_has_timezone'] = $dbChecks['settings_exists']
|
||||
? $schema->hasColumn('settings', 'timezone')
|
||||
: false;
|
||||
|
||||
// Check if all paths are OK
|
||||
$okPaths = array_reduce($pathsStatus, function ($carry, $row) {
|
||||
return $carry && $row['exists'] && $row['writable'];
|
||||
}, true);
|
||||
|
||||
// Check if database schema is OK
|
||||
$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' => storage_path('app'),
|
||||
'timestamp' => date('c'),
|
||||
];
|
||||
|
||||
$status = $ok ? Response::HTTP_OK : Response::HTTP_SERVICE_UNAVAILABLE;
|
||||
|
||||
// Return JSON response with appropriate status code
|
||||
return response()->json($payload, $status);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Health check error: ' . $e->getMessage());
|
||||
return $this->error('Health check failed', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Homework;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class HomeworkController extends BaseApiController
|
||||
{
|
||||
protected Homework $homework;
|
||||
protected Configuration $config;
|
||||
protected StudentClass $studentClass;
|
||||
protected Student $student;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected User $user;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
protected $semesterScoreService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->homework = model(Homework::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->user = model(User::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
$this->semesterScoreService = \Config\Services::semesterScoreService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated homework list
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$classSectionId = $this->request->getGet('class_section_id');
|
||||
|
||||
$query = $this->homework->newQuery()
|
||||
->when($this->schoolYear !== '', fn($q) => $q->where('school_year', $this->schoolYear))
|
||||
->when($this->semester !== '', fn($q) => $q->where('semester', $this->semester))
|
||||
->when($classSectionId, fn($q) => $q->where('class_section_id', $classSectionId))
|
||||
->orderBy('created_at', 'DESC');
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
return $this->success($result, 'Homework retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get homework by student ID
|
||||
*/
|
||||
public function getByStudent($id = null)
|
||||
{
|
||||
$query = $this->homework->newQuery()
|
||||
->when($id, fn($q) => $q->where('student_id', $id))
|
||||
->when($this->schoolYear !== '', fn($q) => $q->where('school_year', $this->schoolYear))
|
||||
->when($this->semester !== '', fn($q) => $q->where('semester', $this->semester));
|
||||
|
||||
$homework = $query->get()->toArray();
|
||||
return $this->success($homework, 'Homework retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single homework entry
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$errors = $this->validateRequest($data, [
|
||||
'student_id' => 'required|integer',
|
||||
'class_section_id' => 'required|integer',
|
||||
'homework_index' => 'required|integer',
|
||||
]);
|
||||
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'student_id' => $data['student_id'],
|
||||
'class_section_id' => $data['class_section_id'],
|
||||
'homework_index' => $data['homework_index'],
|
||||
'score' => $data['score'] ?? null,
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'updated_by' => $user->id ?? null,
|
||||
];
|
||||
|
||||
$homework = $this->homework->create($payload);
|
||||
return $this->success($homework, 'Homework created successfully', Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single homework entry
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$homework = $this->homework->find($id);
|
||||
if (!$homework) {
|
||||
return $this->respondError('Homework not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user = $this->getCurrentUser();
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowed = ['score', 'comment'];
|
||||
$update = [];
|
||||
foreach ($allowed as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$update[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($update)) {
|
||||
return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$update['updated_by'] = $user->id ?? null;
|
||||
$update['updated_at'] = utc_now();
|
||||
$this->homework->update($id, $update);
|
||||
|
||||
return $this->success($this->homework->find($id), 'Homework updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update homework scores for multiple students
|
||||
*/
|
||||
public function updateScores()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$scores = $data['scores'] ?? null;
|
||||
$classSectionId = (int) ($data['class_section_id'] ?? 0);
|
||||
$updatedBy = $this->getCurrentUserId();
|
||||
|
||||
if (!$scores || !is_array($scores)) {
|
||||
return $this->respondError('Scores data is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($scores as $studentId => $hwData) {
|
||||
if (!is_numeric($studentId)) continue;
|
||||
|
||||
$student = $this->student->find($studentId);
|
||||
if (!$student) continue;
|
||||
|
||||
foreach ($hwData as $index => $score) {
|
||||
// STEP 1: Always fetch the existing record
|
||||
$existing = $this->homework->where([
|
||||
'student_id' => $studentId,
|
||||
'homework_index' => $index,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
])->first();
|
||||
|
||||
// STEP 2: If blank, delete if exists
|
||||
if ($score === '' || $score === null) {
|
||||
if ($existing) {
|
||||
$this->homework->delete($existing['id']);
|
||||
log_message('debug', "Deleted blank score for student $studentId index $index");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// STEP 3: Save numeric score
|
||||
if (is_numeric($score)) {
|
||||
$updateData = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $student['school_id'] ?? '',
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'homework_index' => $index,
|
||||
'score' => (float)$score,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$this->homework->update($existing['id'], $updateData);
|
||||
log_message('debug', "Updated homework ID {$existing['id']} with score $score");
|
||||
} else {
|
||||
$updateData['created_at'] = utc_now();
|
||||
$this->homework->insert($updateData);
|
||||
log_message('debug', "Inserted score for student $studentId index $index");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update semester scores for students in the class section
|
||||
$studentUserInfo = $this->student->getStudentInfoByClassSectionId(
|
||||
$classSectionId,
|
||||
$this->semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
|
||||
try {
|
||||
foreach ($studentUserInfo as $studentInfo) {
|
||||
$this->semesterScoreService->updateStudentScores([
|
||||
'student_id' => $studentInfo['student_id'],
|
||||
'school_id' => $studentInfo['school_id'] ?? null,
|
||||
'class_section_id' => $studentInfo['class_section_id'],
|
||||
'updated_by' => $studentInfo['updated_by'] ?? $updatedBy,
|
||||
], $this->semester, $this->schoolYear);
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success(null, 'Homework scores updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Update homework scores error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update homework scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new homework column/index for all students in a class section
|
||||
*/
|
||||
public function addNextColumn()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$classSectionId = (int) ($data['class_section_id'] ?? 0);
|
||||
$updatedBy = $this->getCurrentUserId();
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Get the highest existing homework_index
|
||||
$existingIndexes = $this->homework
|
||||
->select('homework_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->groupBy('homework_index')
|
||||
->orderBy('homework_index', 'DESC')
|
||||
->findAll();
|
||||
|
||||
$maxIndex = 0;
|
||||
foreach ($existingIndexes as $row) {
|
||||
if (isset($row['homework_index']) && is_numeric($row['homework_index'])) {
|
||||
$maxIndex = max($maxIndex, (int)$row['homework_index']);
|
||||
}
|
||||
}
|
||||
|
||||
$nextIndex = $maxIndex + 1;
|
||||
|
||||
// Step 2: Get all students in the class
|
||||
$students = $this->studentClass
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->findAll();
|
||||
|
||||
// Step 3: Insert a new homework row for each student if not already exists
|
||||
$firstInsertedId = null;
|
||||
$insertedCount = 0;
|
||||
|
||||
foreach ($students as $i => $student) {
|
||||
$studentId = $student['student_id'];
|
||||
|
||||
// Check if record already exists
|
||||
$existing = $this->homework
|
||||
->where('student_id', $studentId)
|
||||
->where('homework_index', $nextIndex)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->first();
|
||||
|
||||
if ($existing) continue;
|
||||
|
||||
$studentRecord = $this->student->find($studentId);
|
||||
$insertData = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $studentRecord['school_id'] ?? '',
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'homework_index' => $nextIndex,
|
||||
'score' => null,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
];
|
||||
|
||||
$id = $this->homework->insert($insertData);
|
||||
if ($i === 0) {
|
||||
$firstInsertedId = $id;
|
||||
}
|
||||
$insertedCount++;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'homework_index' => $nextIndex,
|
||||
'new_homework_id' => $firstInsertedId,
|
||||
'students_processed' => $insertedCount
|
||||
], 'Homework column added successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Add homework column error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to add homework column: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get homework data for a class section (for management view)
|
||||
*/
|
||||
public function getByClassSection()
|
||||
{
|
||||
$classSectionId = (int) ($this->request->getGet('class_section_id') ?? 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$homeworkScores = $this->getHomeworkScoresByStudent($classSectionId, $this->semester, $this->schoolYear);
|
||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $this->schoolYear);
|
||||
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
return $this->success([
|
||||
'homework_scores' => $homeworkScores,
|
||||
'students' => $students,
|
||||
'homework_headers' => $homeworkHeaders,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_section_id' => $classSectionId,
|
||||
'has_homework_cols' => !empty($homeworkHeaders),
|
||||
], 'Homework data retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Get homework by class section error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve homework data: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get students with their homework scores
|
||||
*/
|
||||
public function getStudentsWithScores()
|
||||
{
|
||||
$classSectionId = (int) ($this->request->getGet('class_section_id') ?? 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $this->semester, $this->schoolYear);
|
||||
$students = $this->getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders);
|
||||
|
||||
return $this->success([
|
||||
'students' => $students,
|
||||
'homework_headers' => $homeworkHeaders,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_section_id' => $classSectionId,
|
||||
], 'Students with homework scores retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Get students with scores error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve students with scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────── Helper Methods ────────────────
|
||||
|
||||
/**
|
||||
* Get students by class section and year
|
||||
*/
|
||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
||||
{
|
||||
// Step 1: Get student IDs from student_class table
|
||||
$studentClassRows = $this->studentClass
|
||||
->select('student_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentIds = array_column($studentClassRows, 'student_id');
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 2: Get student data from students table
|
||||
$students = $this->student
|
||||
->whereIn('id', $studentIds)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->orderBy('firstname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get homework scores grouped by student
|
||||
*/
|
||||
private function getHomeworkScoresByStudent($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$rows = $this->homework
|
||||
->select('student_id, homework_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentScores = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$studentId = $row['student_id'];
|
||||
$index = $row['homework_index'];
|
||||
$score = $row['score'];
|
||||
|
||||
if (!isset($studentScores[$studentId])) {
|
||||
$studentScores[$studentId] = ['scores' => []];
|
||||
}
|
||||
|
||||
$studentScores[$studentId]['scores'][$index] = $score;
|
||||
}
|
||||
|
||||
return $studentScores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get students with their homework scores
|
||||
*/
|
||||
private function getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders)
|
||||
{
|
||||
$studentClasses = $this->studentClass
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->findAll();
|
||||
|
||||
$students = [];
|
||||
|
||||
foreach ($studentClasses as $sc) {
|
||||
$student = $this->student->find($sc['student_id']);
|
||||
if (!$student) continue;
|
||||
|
||||
$scores = [];
|
||||
|
||||
foreach ($homeworkHeaders as $index) {
|
||||
$entry = $this->homework
|
||||
->where('homework_index', $index)
|
||||
->where('student_id', $sc['student_id'])
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->first();
|
||||
|
||||
$scores[$index] = $entry['score'] ?? '';
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $sc['student_id'],
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'scores' => $scores
|
||||
];
|
||||
}
|
||||
|
||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get homework headers (unique homework_index values)
|
||||
*/
|
||||
private function getHomeworkHeaders($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$rows = $this->homework
|
||||
->select('homework_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('homework_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$headers = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$index = $row['homework_index'];
|
||||
if (!is_array($index)) {
|
||||
$headers[$index] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($headers);
|
||||
return array_keys($headers);
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Calendar;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Homework;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class HomeworkTrackingController extends BaseApiController
|
||||
{
|
||||
protected Calendar $calendar;
|
||||
protected Homework $homework;
|
||||
protected Configuration $config;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->calendar = model(Calendar::class);
|
||||
$this->homework = model(Homework::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get homework tracking data with Sunday-based schedule
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
try {
|
||||
// Get date range from request or use defaults
|
||||
$startDate = $this->request->getGet('start_date') ?? '2025-09-21';
|
||||
$endDate = $this->request->getGet('end_date') ?? '2026-01-18';
|
||||
|
||||
// Parse dates
|
||||
try {
|
||||
$start = new \DateTime($startDate);
|
||||
} catch (\Throwable $e) {
|
||||
$start = new \DateTime('today');
|
||||
}
|
||||
|
||||
try {
|
||||
$end = new \DateTime($endDate);
|
||||
} catch (\Throwable $e) {
|
||||
$end = new \DateTime('today');
|
||||
}
|
||||
|
||||
// Move start to the first Sunday on/after start
|
||||
if ((int)$start->format('w') !== 0) {
|
||||
$start = (clone $start)->modify('next sunday');
|
||||
}
|
||||
|
||||
// Collect all Sundays within range
|
||||
$sundays = [];
|
||||
$d = clone $start;
|
||||
while ($d <= $end) {
|
||||
$sundays[] = $d->format('Y-m-d');
|
||||
$d->modify('+7 days');
|
||||
}
|
||||
|
||||
// Map "no school" events by date (Y-m-d) using explicit DB flag
|
||||
$events = $this->calendar->getEventsBySchoolYear($this->schoolYear) ?? [];
|
||||
$eventDays = [];
|
||||
|
||||
foreach ($events as $ev) {
|
||||
$ymd = substr((string)($ev['date'] ?? $ev['start_date'] ?? ''), 0, 10);
|
||||
$no = (int)($ev['no_school'] ?? 0);
|
||||
if ($ymd && $no === 1) {
|
||||
$eventDays[$ymd] = true; // yellow highlight for no-school day
|
||||
}
|
||||
}
|
||||
|
||||
// Map Sundays to ordinal homework_index, skipping event Sundays
|
||||
$dateToIndex = [];
|
||||
$idx = 0;
|
||||
foreach ($sundays as $ymd) {
|
||||
if (!isset($eventDays[$ymd])) {
|
||||
$idx++;
|
||||
$dateToIndex[$ymd] = $idx;
|
||||
} else {
|
||||
$dateToIndex[$ymd] = null; // event day (no homework expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate homework presence + first entered date per class_section_id + homework_index
|
||||
$rows = DB::table('homework')
|
||||
->select('class_section_id', 'homework_index', DB::raw('MIN(created_at) AS first_created'), DB::raw('COUNT(*) AS cnt'))
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester)
|
||||
->groupBy('class_section_id', 'homework_index')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$hasHomework = [];
|
||||
$hwEnteredAt = [];
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$csid = (int)($r->class_section_id ?? 0);
|
||||
$hi = (int)($r->homework_index ?? 0);
|
||||
$cnt = (int)($r->cnt ?? 0);
|
||||
|
||||
if ($csid > 0 && $hi > 0 && $cnt > 0) {
|
||||
$hasHomework[$csid][$hi] = true;
|
||||
$dateStr = substr((string)($r->first_created ?? ''), 0, 10);
|
||||
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
// Build date-based presence mapped to the nearest prior non-NoSchool Sunday
|
||||
$rowsByDate = DB::table('homework')
|
||||
->select(DB::raw('class_section_id, DATE(created_at) AS hw_date, MIN(created_at) AS first_created, COUNT(*) AS cnt'))
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester)
|
||||
->groupBy(DB::raw('class_section_id, DATE(created_at)'))
|
||||
->orderBy('hw_date', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$hasHomeworkByDate = [];
|
||||
$hwEnteredAtByDate = [];
|
||||
|
||||
foreach ($rowsByDate as $r) {
|
||||
$csid = (int)($r->class_section_id ?? 0);
|
||||
$d = substr((string)($r->hw_date ?? ''), 0, 10);
|
||||
$cnt = (int)($r->cnt ?? 0);
|
||||
$firstCreated = substr((string)($r->first_created ?? ''), 0, 10);
|
||||
|
||||
if ($csid <= 0 || !$d || $cnt <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the index of the last Sunday on or before $d
|
||||
$baseIndex = -1;
|
||||
for ($i = count($sundays) - 1; $i >= 0; $i--) {
|
||||
if ($sundays[$i] <= $d) {
|
||||
$baseIndex = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($baseIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map this homework day to only the nearest prior non–No School Sunday
|
||||
$j = $baseIndex;
|
||||
while ($j >= 0 && !empty($eventDays[$sundays[$j]])) {
|
||||
$j--;
|
||||
}
|
||||
|
||||
if ($j < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sd = $sundays[$j];
|
||||
|
||||
// Mark homework on that Sunday for this class_section (single mapping)
|
||||
$hasHomeworkByDate[$csid][$sd] = true;
|
||||
|
||||
if (empty($hwEnteredAtByDate[$csid][$sd])) {
|
||||
$hwEnteredAtByDate[$csid][$sd] = $firstCreated ?: $d;
|
||||
} else {
|
||||
// Keep the earliest date for display
|
||||
$existing = (string)$hwEnteredAtByDate[$csid][$sd];
|
||||
$candidate = $firstCreated ?: $d;
|
||||
if ($candidate && (!$existing || $candidate < $existing)) {
|
||||
$hwEnteredAtByDate[$csid][$sd] = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch teachers and TAs per section for this term
|
||||
$teacherRows = DB::table('teacher_class as tc')
|
||||
->select('tc.class_section_id', 'tc.position', 'cs.class_section_name', 'cs.class_id', 'u.firstname', 'u.lastname')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
|
||||
->leftJoin('users as u', 'u.id', '=', 'tc.teacher_id')
|
||||
->where('tc.school_year', $this->schoolYear)
|
||||
->where('tc.semester', $this->semester)
|
||||
->whereNotNull('tc.class_section_id')
|
||||
->whereIn('tc.position', ['main', 'ta'])
|
||||
->orderBy('cs.class_id', 'ASC')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderByRaw("FIELD(tc.position, 'main','ta')")
|
||||
->orderBy('u.firstname', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$bySection = [];
|
||||
|
||||
foreach ($teacherRows as $r) {
|
||||
$sid = (int)($r->class_section_id ?? 0);
|
||||
if ($sid <= 0) continue;
|
||||
|
||||
if (!isset($bySection[$sid])) {
|
||||
$bySection[$sid] = [
|
||||
'class_section_id' => $sid,
|
||||
'class_id' => (int)($r->class_id ?? 0),
|
||||
'class_section_name' => (string)($r->class_section_name ?? ''),
|
||||
'teachers' => [],
|
||||
'tas' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$name = trim(($r->firstname ?? '') . ' ' . ($r->lastname ?? ''));
|
||||
$pos = strtolower((string)($r->position ?? ''));
|
||||
|
||||
if ($name !== '') {
|
||||
if ($pos === 'main') {
|
||||
$bySection[$sid]['teachers'][] = $name;
|
||||
} elseif ($pos === 'ta') {
|
||||
$bySection[$sid]['tas'][] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$teachers = array_values($bySection);
|
||||
|
||||
// Sort by grade order: KG(13) → Grade 1..11 → Youth(12) → others
|
||||
usort($teachers, function ($a, $b) {
|
||||
$ai = (int)($a['class_id'] ?? 0);
|
||||
$bi = (int)($b['class_id'] ?? 0);
|
||||
|
||||
$ord = function ($cid) {
|
||||
if ($cid === 13) return 0; // KG first
|
||||
if ($cid >= 1 && $cid <= 11) return $cid; // Grades 1..11
|
||||
if ($cid === 12) return 99; // Youth last of known
|
||||
return 200 + $cid; // any others after
|
||||
};
|
||||
|
||||
$cmp = $ord($ai) <=> $ord($bi);
|
||||
if ($cmp !== 0) return $cmp;
|
||||
|
||||
return strnatcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? '');
|
||||
});
|
||||
|
||||
// Simple server-side pagination for rows (8 lines per page)
|
||||
$perPage = 8;
|
||||
$totalRows = count($teachers);
|
||||
$totalPages = max(1, (int)ceil($totalRows / $perPage));
|
||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($page > $totalPages) $page = $totalPages;
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$teachersPage = array_slice($teachers, $offset, $perPage);
|
||||
|
||||
return $this->success([
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'sundays' => $sundays,
|
||||
'event_days' => $eventDays,
|
||||
'date_to_index' => $dateToIndex,
|
||||
'teachers' => $teachersPage,
|
||||
'has_homework' => $hasHomework,
|
||||
'hw_entered_at' => $hwEnteredAt,
|
||||
'has_homework_by_date' => $hasHomeworkByDate,
|
||||
'hw_entered_at_by_date' => $hwEnteredAtByDate,
|
||||
'pagination' => [
|
||||
'page' => $page,
|
||||
'total_pages' => $totalPages,
|
||||
'per_page' => $perPage,
|
||||
'total_rows' => $totalRows,
|
||||
],
|
||||
], 'Homework tracking retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Homework tracking error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve homework tracking: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InfoIconController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* Get user profile icon information (name and initials)
|
||||
*/
|
||||
public function profileIcon()
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
|
||||
if (!$userId) {
|
||||
return $this->respondError('User not logged in', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$user = model(User::class);
|
||||
$user = $user->getUserInfoById($userId);
|
||||
|
||||
if (!$user) {
|
||||
return $this->success([
|
||||
'user_name' => 'User not found',
|
||||
'user_initials' => '??'
|
||||
], 'User info retrieved');
|
||||
}
|
||||
|
||||
$firstname = (string) ($user['firstname'] ?? '');
|
||||
$lastname = (string) ($user['lastname'] ?? '');
|
||||
|
||||
$userName = trim($firstname . ' ' . $lastname);
|
||||
if ($userName === '') {
|
||||
$userName = $user['email'] ?? 'User #' . $userId;
|
||||
}
|
||||
|
||||
$userInitials = '??';
|
||||
if (!empty($firstname) && !empty($lastname)) {
|
||||
$userInitials = strtoupper(($firstname[0] ?? '') . ($lastname[0] ?? ''));
|
||||
} elseif (!empty($firstname)) {
|
||||
$userInitials = strtoupper(substr($firstname, 0, 2));
|
||||
} elseif (!empty($lastname)) {
|
||||
$userInitials = strtoupper(substr($lastname, 0, 2));
|
||||
} elseif (!empty($user['email'])) {
|
||||
$email = $user['email'];
|
||||
$userInitials = strtoupper(substr($email, 0, 2));
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'user_name' => $userName,
|
||||
'user_initials' => $userInitials
|
||||
], 'Profile icon info retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Profile icon error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve profile icon info', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,205 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\IpAttempt;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class IpBanController extends BaseApiController
|
||||
{
|
||||
protected IpAttempt $ipAttempt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->ipAttempt = model(IpAttempt::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$status = strtolower(trim((string) ($this->request->getGet('status') ?? 'all')));
|
||||
$now = Carbon::now('UTC')->toDateTimeString();
|
||||
|
||||
$query = $this->ipAttempt->newQuery()->orderBy('updated_at', 'DESC');
|
||||
|
||||
if ($status === 'active') {
|
||||
$query->where('blocked_until', '>', $now);
|
||||
} elseif ($status === 'expired') {
|
||||
$query->where('blocked_until', '<=', $now)
|
||||
->whereNotNull('blocked_until');
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
return $this->success($result, 'IP bans retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$ban = $this->ipAttempt->find($id);
|
||||
if (!$ban) {
|
||||
return $this->error('IP ban not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($ban, 'IP ban retrieved successfully');
|
||||
}
|
||||
|
||||
public function unban()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$id = (int) ($payload['id'] ?? 0);
|
||||
$ip = trim((string) ($payload['ip'] ?? ''));
|
||||
$all = (int) ($payload['all'] ?? 0) === 1;
|
||||
|
||||
try {
|
||||
if ($all) {
|
||||
$now = Carbon::now('UTC')->toDateTimeString();
|
||||
$builder = $this->ipAttempt->newQuery();
|
||||
$count = $builder->where('blocked_until', '>', $now)
|
||||
->update([
|
||||
'blocked_until' => null,
|
||||
'attempts' => 0,
|
||||
]);
|
||||
|
||||
return $this->success(['count' => $count], $count . ' IP(s) unbanned.');
|
||||
}
|
||||
|
||||
$row = null;
|
||||
if ($id > 0) {
|
||||
$row = $this->ipAttempt->find($id);
|
||||
} elseif ($ip !== '') {
|
||||
$row = $this->ipAttempt->newQuery()
|
||||
->where('ip_address', $ip)
|
||||
->first();
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
return $this->error('IP record not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$ipAddress = $row['ip_address'] ?? $row->ip_address ?? '';
|
||||
$this->ipAttempt->update($row->id ?? $row['id'], [
|
||||
'blocked_until' => null,
|
||||
'attempts' => 0,
|
||||
]);
|
||||
|
||||
return $this->success(['ip' => $ipAddress], 'IP ' . $ipAddress . ' unbanned.');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'IP unban error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to unban IP', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function ban()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'ip' => 'required|ip',
|
||||
'blocked_until' => 'required|date',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$ip = trim($payload['ip']);
|
||||
$blockedUntil = Carbon::parse($payload['blocked_until'])
|
||||
->timezone('UTC')
|
||||
->format('Y-m-d H:i:s');
|
||||
$reason = $payload['reason'] ?? null;
|
||||
$attempts = (int) ($payload['attempts'] ?? 5);
|
||||
|
||||
$existing = $this->ipAttempt->newQuery()
|
||||
->where('ip_address', $ip)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->ipAttempt->update($existing->id ?? $existing['id'], [
|
||||
'blocked_until' => $blockedUntil,
|
||||
'attempts' => $attempts,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
$ban = $this->ipAttempt->find($existing->id ?? $existing['id']);
|
||||
} else {
|
||||
$ban = $this->ipAttempt->create([
|
||||
'ip_address' => $ip,
|
||||
'blocked_until' => $blockedUntil,
|
||||
'attempts' => $attempts,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
$responseData = $ban instanceof Model ? $ban->toArray() : $ban;
|
||||
return $this->success($responseData, 'IP banned successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'IP ban error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to ban IP', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/ip-bans/ban-now
|
||||
* Ban an IP immediately for a specified number of hours
|
||||
*/
|
||||
public function banNow()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
|
||||
// Accept both POST and GET params
|
||||
$id = (int) ($payload['id'] ?? $this->request->getGet('id') ?? 0);
|
||||
$ip = trim((string) ($payload['ip'] ?? $this->request->getGet('ip') ?? ''));
|
||||
$hours = (int) ($payload['hours'] ?? $this->request->getGet('hours') ?? 24);
|
||||
|
||||
if ($hours <= 0) {
|
||||
$hours = 24;
|
||||
}
|
||||
|
||||
try {
|
||||
$row = null;
|
||||
if ($id > 0) {
|
||||
$row = $this->ipAttempt->find($id);
|
||||
} elseif ($ip !== '') {
|
||||
$row = $this->ipAttempt->newQuery()
|
||||
->where('ip_address', $ip)
|
||||
->first();
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
return $this->error('IP record not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$blockedUntil = Carbon::now('UTC')->addHours($hours)->format('Y-m-d H:i:s');
|
||||
$currentAttempts = (int) ($row['attempts'] ?? $row->attempts ?? 0);
|
||||
|
||||
$this->ipAttempt->update($row['id'] ?? $row->id, [
|
||||
'blocked_until' => $blockedUntil,
|
||||
// Optionally set attempts to threshold for clarity
|
||||
'attempts' => max($currentAttempts, 10),
|
||||
]);
|
||||
|
||||
$ipAddress = $row['ip_address'] ?? $row->ip_address;
|
||||
return $this->success([
|
||||
'ip' => $ipAddress,
|
||||
'blocked_until' => $blockedUntil,
|
||||
'hours' => $hours,
|
||||
], "IP {$ipAddress} banned for {$hours}h.");
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'IP ban now error: ' . $e->getMessage());
|
||||
return $this->respondError('Unable to ban: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\UserRole;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LandingPageController extends BaseApiController
|
||||
{
|
||||
protected Configuration $config;
|
||||
protected StudentClass $studentClass;
|
||||
protected Invoice $invoice;
|
||||
protected UserRole $userRole;
|
||||
protected TeacherClass $teacherClass;
|
||||
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
protected ?string $lastDayOfRegistration;
|
||||
protected ?string $refundDeadline;
|
||||
protected ?int $classSectionId;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->config = model(Configuration::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->invoice = model(Invoice::class);
|
||||
$this->userRole = model(UserRole::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
|
||||
// Fetch Enrollment and Refund Deadlines from Configuration
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
$this->lastDayOfRegistration = $this->config->getConfig('enrollment_deadline') ?? 'Not set';
|
||||
$this->refundDeadline = $this->config->getConfig('refund_deadline') ?? 'Not set';
|
||||
|
||||
// Get class_id and store it in session
|
||||
$this->classSectionId = $this->classSectionId();
|
||||
if ($this->classSectionId) {
|
||||
session()->put('class_section_id', $this->classSectionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/landing
|
||||
* Get landing page data based on user role
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userRole = $this->getUserRole();
|
||||
$role = strtolower($userRole);
|
||||
|
||||
switch ($role) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/landing/data
|
||||
* Get basic landing page data (backward compatibility)
|
||||
*/
|
||||
public function getData()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = [
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'enrollment_deadline' => $this->lastDayOfRegistration,
|
||||
'refund_deadline' => $this->refundDeadline,
|
||||
'class_section_id' => $this->classSectionId,
|
||||
'user_role' => session()->get('active_role') ?? $this->getUserRole(),
|
||||
];
|
||||
|
||||
return $this->success($payload, 'Landing page data retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Landing page data error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve landing page data', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
protected function classSectionId(): ?int
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$teacherClass = $this->teacherClass->getClassSectionIdByUserId($userId);
|
||||
if (!$teacherClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) ($teacherClass['class_section_id'] ?? null);
|
||||
}
|
||||
|
||||
protected function administrator()
|
||||
{
|
||||
return $this->success([
|
||||
'role' => 'administrator',
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'enrollment_deadline' => $this->lastDayOfRegistration,
|
||||
'refund_deadline' => $this->refundDeadline,
|
||||
], 'Administrator dashboard data');
|
||||
}
|
||||
|
||||
protected function admin()
|
||||
{
|
||||
return $this->success([
|
||||
'role' => 'admin',
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'enrollment_deadline' => $this->lastDayOfRegistration,
|
||||
'refund_deadline' => $this->refundDeadline,
|
||||
], 'Admin dashboard data');
|
||||
}
|
||||
|
||||
protected function teacher()
|
||||
{
|
||||
return $this->success([
|
||||
'role' => 'teacher',
|
||||
'class_section_id' => $this->classSectionId,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'enrollment_deadline' => $this->lastDayOfRegistration,
|
||||
'refund_deadline' => $this->refundDeadline,
|
||||
], 'Teacher dashboard data');
|
||||
}
|
||||
|
||||
protected function student()
|
||||
{
|
||||
return $this->success([
|
||||
'role' => 'student',
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'enrollment_deadline' => $this->lastDayOfRegistration,
|
||||
'refund_deadline' => $this->refundDeadline,
|
||||
], 'Student dashboard data');
|
||||
}
|
||||
|
||||
protected function parentDashboard()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$parentId = $user->id;
|
||||
$userType = session()->get('user_type');
|
||||
|
||||
// Map user type to roles
|
||||
if ($userType === 'primary') {
|
||||
$parentId = $parentId;
|
||||
} elseif ($userType === 'secondary') {
|
||||
// If user type is 'Secondary', find the parent_id from the parents table
|
||||
$parentData = DB::table('parents')
|
||||
->select('parent_id')
|
||||
->where('secondparent_user_id', $parentId)
|
||||
->first();
|
||||
|
||||
if ($parentData) {
|
||||
$parentId = $parentData->parent_id;
|
||||
}
|
||||
} elseif ($userType === 'tertiary') {
|
||||
// If user type is 'Tertiary', find the parent_id from the authorized_users table
|
||||
$authUserData = DB::table('authorized_users')
|
||||
->select('user_id as parent_id')
|
||||
->where('authorized_user_id', $parentId)
|
||||
->first();
|
||||
|
||||
if ($authUserData) {
|
||||
$parentId = $authUserData->parent_id;
|
||||
}
|
||||
}
|
||||
|
||||
// If no firstparent ID is found, show an error
|
||||
if (!$parentId) {
|
||||
return $this->respondError('Unable to retrieve student data. Please contact support.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch Notifications (only active, non-expired, non-deleted)
|
||||
$notifications = DB::table('notifications')
|
||||
->select([
|
||||
'notifications.id',
|
||||
'notifications.title',
|
||||
'notifications.message',
|
||||
'notifications.target_group',
|
||||
'notifications.created_at',
|
||||
'notifications.expires_at',
|
||||
'user_notifications.user_id',
|
||||
DB::raw("CASE
|
||||
WHEN user_notifications.user_id IS NOT NULL THEN 'personal'
|
||||
ELSE 'broadcast'
|
||||
END as notification_type")
|
||||
])
|
||||
->leftJoin('user_notifications', function ($join) use ($parentId) {
|
||||
$join->on('user_notifications.notification_id', '=', 'notifications.id')
|
||||
->where('user_notifications.user_id', '=', $parentId);
|
||||
})
|
||||
->where(function ($query) use ($parentId) {
|
||||
$query->where('notifications.target_group', 'parent')
|
||||
->orWhere('user_notifications.user_id', $parentId);
|
||||
})
|
||||
->whereNull('notifications.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('notifications.expires_at')
|
||||
->orWhere('notifications.expires_at', '>', DB::raw('NOW()'));
|
||||
})
|
||||
->groupBy('notifications.id', 'notifications.title', 'notifications.message',
|
||||
'notifications.target_group', 'notifications.created_at',
|
||||
'notifications.expires_at', 'user_notifications.user_id')
|
||||
->orderBy('notifications.created_at', 'DESC')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return (array) $item;
|
||||
})
|
||||
->toArray();
|
||||
|
||||
// Fetch Student Information (no filtering needed by school year or semester)
|
||||
$students = DB::table('students')
|
||||
->where('parent_id', $parentId)
|
||||
->get()
|
||||
->map(function ($student) {
|
||||
$studentArray = (array) $student;
|
||||
// Get class_section_name and treat it as grade
|
||||
$classSection = $this->studentClass->getClassSectionsByStudentId($student->id, $this->schoolYear);
|
||||
$studentArray['class_section'] = $classSection;
|
||||
$studentArray['grade'] = $classSection; // grade same as section
|
||||
return $studentArray;
|
||||
})
|
||||
->toArray();
|
||||
|
||||
// Fetch Attendance Records (filtered by most recent school year and semester)
|
||||
$attendanceData = DB::table('attendance_data')
|
||||
->select('attendance_data.*', 'students.firstname', 'students.lastname')
|
||||
->join('students', 'students.id', '=', 'attendance_data.student_id')
|
||||
->where('students.parent_id', $parentId)
|
||||
->where('attendance_data.school_year', $this->schoolYear)
|
||||
->where('attendance_data.semester', $this->semester)
|
||||
->orderBy('attendance_data.date', 'DESC')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return (array) $item;
|
||||
})
|
||||
->toArray();
|
||||
|
||||
// Fetch Grades (filtered by most recent school year and semester)
|
||||
$grades = DB::table('final_score')
|
||||
->select('final_score.*', 'students.firstname', 'students.lastname')
|
||||
->join('students', 'students.id', '=', 'final_score.student_id')
|
||||
->where('students.parent_id', $parentId)
|
||||
->where('final_score.school_year', $this->schoolYear)
|
||||
->where('final_score.semester', $this->semester)
|
||||
->orderBy('final_score.created_at', 'DESC')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return (array) $item;
|
||||
})
|
||||
->toArray();
|
||||
|
||||
// Fetch Enrollments (filtered by most recent school year and semester)
|
||||
$enrollments = DB::table('enrollments')
|
||||
->select('enrollments.*', 'classes.class_name')
|
||||
->join('students', 'students.id', '=', 'enrollments.student_id')
|
||||
->join('classes', 'classes.id', '=', 'enrollments.class_section_id')
|
||||
->where('students.parent_id', $parentId)
|
||||
->where('enrollments.school_year', $this->schoolYear)
|
||||
->where('enrollments.semester', $this->semester)
|
||||
->orderBy('enrollments.enrollment_date', 'DESC')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return (array) $item;
|
||||
})
|
||||
->toArray();
|
||||
|
||||
// Fetch latest invoice balance
|
||||
$paymentBalance = $this->invoice->getLatestInvoiceTotalAmount($parentId);
|
||||
|
||||
return $this->success([
|
||||
'role' => 'parent',
|
||||
'notifications' => $notifications,
|
||||
'students' => $students,
|
||||
'attendance' => $attendanceData,
|
||||
'grades' => $grades,
|
||||
'enrollments' => $enrollments,
|
||||
'lastDayOfRegistration' => $this->lastDayOfRegistration,
|
||||
'withdrawalDeadline' => $this->refundDeadline,
|
||||
'paymentBalance' => $paymentBalance,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
], 'Parent dashboard data retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Parent dashboard error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve parent dashboard data: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
protected function guest()
|
||||
{
|
||||
return $this->success([
|
||||
'role' => 'guest',
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
], 'Guest dashboard data');
|
||||
}
|
||||
|
||||
protected function getUserRole(): string
|
||||
{
|
||||
// Try to get from session first
|
||||
$role = session()->get('user_role');
|
||||
if ($role) {
|
||||
return $role;
|
||||
}
|
||||
|
||||
// Try active_role
|
||||
$role = session()->get('active_role');
|
||||
if ($role) {
|
||||
return $role;
|
||||
}
|
||||
|
||||
// Get from current user's roles
|
||||
$user = $this->getCurrentUser();
|
||||
if ($user && !empty($user->roles)) {
|
||||
return $user->roles[0] ?? 'guest';
|
||||
}
|
||||
|
||||
// Fallback to database lookup
|
||||
$userId = $this->getCurrentUserId();
|
||||
if ($userId) {
|
||||
return $this->getUserRoleFromDatabase($userId);
|
||||
}
|
||||
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
protected function getUserRoleFromDatabase($userId): string
|
||||
{
|
||||
try {
|
||||
$roles = $this->userRole->getRolesByUserId($userId);
|
||||
if (!empty($roles) && isset($roles[0]['role_name'])) {
|
||||
return $roles[0]['role_name'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to get user role from database: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return 'guest';
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\LateSlipLog;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LateSlipLogsController extends BaseApiController
|
||||
{
|
||||
protected LateSlipLog $log;
|
||||
protected Configuration $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->log = model(LateSlipLog::class);
|
||||
$this->config = model(Configuration::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/late-slip-logs
|
||||
* Get paginated late slip logs with optional filters
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$defaultYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$defaultSem = (string) ($this->config->getConfig('semester') ?? '');
|
||||
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $defaultYear));
|
||||
$semester = trim((string) ($this->request->getGet('semester') ?? $defaultSem));
|
||||
$q = trim((string) ($this->request->getGet('q') ?? ''));
|
||||
$dateFrom = trim((string) ($this->request->getGet('date_from') ?? ''));
|
||||
$dateTo = trim((string) ($this->request->getGet('date_to') ?? ''));
|
||||
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(200, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
|
||||
// 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);
|
||||
|
||||
try {
|
||||
$query = $this->log->newQuery();
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
if ($semester !== '') {
|
||||
$query->where('semester', $semester);
|
||||
}
|
||||
|
||||
if ($q !== '') {
|
||||
$query->like('student_name', $q);
|
||||
}
|
||||
|
||||
if ($df) {
|
||||
$query->where('slip_date >=', $df);
|
||||
}
|
||||
|
||||
if ($dt) {
|
||||
$query->where('slip_date <=', $dt);
|
||||
}
|
||||
|
||||
$result = $this->paginate($query->orderBy('id', 'DESC'), $page, $perPage);
|
||||
|
||||
return $this->success([
|
||||
'logs' => $result['data'],
|
||||
'pagination' => $result['pagination'],
|
||||
'filters' => [
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'q' => $q,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
],
|
||||
], 'Late slip logs retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Late slip logs index error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve late slip logs', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/late-slip-logs/{id}
|
||||
* Get a single late slip log by ID
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$log = $this->log->find($id);
|
||||
if (!$log) {
|
||||
return $this->error('Late slip log not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($log, 'Late slip log retrieved successfully');
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Message;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class MessageController extends BaseApiController
|
||||
{
|
||||
protected Message $message;
|
||||
protected Configuration $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->message = model(Message::class);
|
||||
$this->config = model(Configuration::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$type = $this->request->getGet('type');
|
||||
$read = $this->request->getGet('read');
|
||||
|
||||
$query = $this->message->newQuery();
|
||||
|
||||
if ($type === 'sent') {
|
||||
$query->where('sender_id', $user->id);
|
||||
} else {
|
||||
$query->where('recipient_id', $user->id);
|
||||
}
|
||||
|
||||
if ($read !== null) {
|
||||
$isRead = filter_var($read, FILTER_VALIDATE_BOOLEAN);
|
||||
$query->where('read_status', $isRead ? 1 : 0);
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
return $this->success($result, 'Messages retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$message = $this->message->newQuery()
|
||||
->where('id', $id)
|
||||
->where(function ($query) use ($user) {
|
||||
$query->where('sender_id', $user->id)
|
||||
->orWhere('recipient_id', $user->id);
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$message) {
|
||||
return $this->error('Message not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (($message->recipient_id ?? $message['recipient_id'] ?? null) === $user->id) {
|
||||
$readStatus = $message->read_status ?? $message['read_status'] ?? null;
|
||||
if ($readStatus === 0 || $readStatus === '0') {
|
||||
$this->message->update($message->id ?? $message['id'], [
|
||||
'read_status' => 1,
|
||||
'read_datetime'=> Carbon::now('UTC')->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
$message->read_status = 1;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = $message instanceof Model ? $message->toArray() : (array) $message;
|
||||
|
||||
return $this->success($payload, 'Message retrieved successfully');
|
||||
}
|
||||
|
||||
public function send()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'to' => 'required|integer',
|
||||
'subject' => 'required|max_length[255]',
|
||||
'body' => 'required',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$maxMessageNumber = $this->message->newQuery()
|
||||
->selectRaw('COALESCE(MAX(message_number), 0) AS message_number')
|
||||
->first();
|
||||
$messageNumber = (int) ($maxMessageNumber->message_number ?? $maxMessageNumber['message_number'] ?? 0) + 1;
|
||||
|
||||
$messageData = [
|
||||
'sender_id' => $user->id,
|
||||
'recipient_id' => (int) $payload['to'],
|
||||
'subject' => $payload['subject'],
|
||||
'message' => $payload['body'],
|
||||
'sent_datetime' => Carbon::now('UTC')->format('Y-m-d H:i:s'),
|
||||
'read_status' => 0,
|
||||
'message_number'=> $messageNumber,
|
||||
'priority' => $payload['priority'] ?? 'normal',
|
||||
'status' => 'sent',
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
];
|
||||
|
||||
try {
|
||||
$message = $this->message->create($messageData);
|
||||
|
||||
return $this->success($message->toArray(), 'Message sent successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Message send error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to send message', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Message;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRole;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class MessagesController extends BaseApiController
|
||||
{
|
||||
protected Message $message;
|
||||
protected UserRole $userRole;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected StudentClass $studentClass;
|
||||
protected Student $student;
|
||||
protected User $user;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->message = model(Message::class);
|
||||
$this->userRole = model(UserRole::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->user = model(User::class);
|
||||
}
|
||||
|
||||
public function inbox()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$messages = $this->message->getInboxMessages((int) $user->id);
|
||||
return $this->success($messages, 'Inbox messages retrieved');
|
||||
}
|
||||
|
||||
public function sent()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$messages = $this->message->getSentMessages((int) $user->id);
|
||||
return $this->success($messages, 'Sent messages retrieved');
|
||||
}
|
||||
|
||||
public function drafts()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$messages = $this->message->getDraftMessages((int) $user->id);
|
||||
return $this->success($messages, 'Draft messages retrieved');
|
||||
}
|
||||
|
||||
public function trash()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$messages = $this->message->getTrashedMessages((int) $user->id);
|
||||
return $this->success($messages, 'Trashed messages retrieved');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$role = $this->resolveRoleName((int) $user->id);
|
||||
return $this->success([
|
||||
'role' => $role,
|
||||
'receivedMessages' => $this->getReceivedMessages((int) $user->id),
|
||||
], 'Message overview retrieved');
|
||||
}
|
||||
|
||||
public function send()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$recipientId = $payload['recipient_id'] ?? null;
|
||||
|
||||
if (!$recipientId && !empty($payload['recipient_role'])) {
|
||||
$recipientId = $this->getRecipientId(['name' => $payload['recipient_role']]);
|
||||
}
|
||||
|
||||
if (!$recipientId) {
|
||||
return $this->respondError('Recipient not provided', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$attachmentPath = $this->storeAttachment();
|
||||
|
||||
$data = [
|
||||
'sender_id' => (int) $user->id,
|
||||
'recipient_id' => (int) $recipientId,
|
||||
'subject' => $payload['subject'] ?? null,
|
||||
'message' => $payload['message'] ?? null,
|
||||
'sent_datetime' => Carbon::now('UTC')->format('Y-m-d H:i:s'),
|
||||
'message_number'=> $this->generateMessageNumber(),
|
||||
'priority' => $payload['priority'] ?? 'normal',
|
||||
'attachment' => $attachmentPath,
|
||||
'status' => $payload['status'] ?? 'sent',
|
||||
'semester' => $payload['semester'] ?? null,
|
||||
'school_year' => $payload['school_year'] ?? null,
|
||||
];
|
||||
|
||||
$message = $this->message->create($data);
|
||||
|
||||
return $this->success($message->toArray(), 'Message sent successfully');
|
||||
}
|
||||
|
||||
public function receive()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$messages = $this->getReceivedMessages((int) $user->id);
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$this->markAsRead((int) ($message['id'] ?? 0));
|
||||
}
|
||||
|
||||
return $this->success($messages, 'Received messages retrieved');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/messages/recipients/{type}
|
||||
* Get recipients list (teachers or parents)
|
||||
*/
|
||||
public function getRecipients($type)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$type = strtolower(trim((string) ($type ?? '')));
|
||||
if (!in_array($type, ['teacher', 'parent'], true)) {
|
||||
return $this->respondError('Unsupported recipient type. Must be "teacher" or "parent"', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$recipients = [];
|
||||
$teacherClasses = $this->teacherClass->findAll();
|
||||
|
||||
if ($type === 'teacher') {
|
||||
foreach ($teacherClasses as $teacher) {
|
||||
$userRow = $this->user->find($teacher['teacher_id'] ?? null);
|
||||
if (!$userRow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recipients[(int) $teacher['teacher_id']] = [
|
||||
'id' => (int) $teacher['teacher_id'],
|
||||
'name' => trim(($userRow['firstname'] ?? '') . ' ' . ($userRow['lastname'] ?? '')),
|
||||
];
|
||||
}
|
||||
} elseif ($type === 'parent') {
|
||||
foreach ($teacherClasses as $class) {
|
||||
$students = $this->studentClass->where('class_section_id', $class['class_section_id'] ?? null)->findAll();
|
||||
foreach ($students as $student) {
|
||||
$studentData = $this->student->find($student['student_id'] ?? null);
|
||||
if (!$studentData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get first parent (primary parent)
|
||||
if (!empty($studentData['parent_id'])) {
|
||||
$firstParent = $this->user->find($studentData['parent_id']);
|
||||
if ($firstParent) {
|
||||
$parentName = trim(($firstParent['firstname'] ?? '') . ' ' . ($firstParent['lastname'] ?? ''));
|
||||
if ($parentName) {
|
||||
$recipients[(int) $studentData['parent_id']] = [
|
||||
'id' => (int) $studentData['parent_id'],
|
||||
'name' => $parentName,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get second parent if exists
|
||||
// Check if secondparent_user_id exists in students table
|
||||
if (!empty($studentData['secondparent_user_id'])) {
|
||||
$secondParent = $this->user->find($studentData['secondparent_user_id']);
|
||||
if ($secondParent) {
|
||||
$parentName = trim(($secondParent['firstname'] ?? '') . ' ' . ($secondParent['lastname'] ?? ''));
|
||||
if ($parentName) {
|
||||
$recipients[(int) $studentData['secondparent_user_id']] = [
|
||||
'id' => (int) $studentData['secondparent_user_id'],
|
||||
'name' => $parentName,
|
||||
];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: check parents table for second parent
|
||||
$parentRow = DB::table('parents')
|
||||
->where('firstparent_id', $studentData['parent_id'] ?? 0)
|
||||
->first();
|
||||
|
||||
if ($parentRow && !empty($parentRow->secondparent_user_id)) {
|
||||
$secondParent = $this->user->find($parentRow->secondparent_user_id);
|
||||
if ($secondParent) {
|
||||
$parentName = trim(($secondParent['firstname'] ?? '') . ' ' . ($secondParent['lastname'] ?? ''));
|
||||
if ($parentName) {
|
||||
$recipients[(int) $parentRow->secondparent_user_id] = [
|
||||
'id' => (int) $parentRow->secondparent_user_id,
|
||||
'name' => $parentName,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success(array_values($recipients), 'Recipients retrieved successfully');
|
||||
}
|
||||
|
||||
protected function resolveRoleName(int $userId): ?string
|
||||
{
|
||||
$role = $this->userRole->newQuery()
|
||||
->select('roles.name')
|
||||
->join('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->where('user_roles.user_id', $userId)
|
||||
->first();
|
||||
|
||||
return $role['name'] ?? null;
|
||||
}
|
||||
|
||||
protected function getReceivedMessages(int $userId): array
|
||||
{
|
||||
$rows = DB::table('messages')
|
||||
->select('messages.*', 'users.firstname AS sender_name')
|
||||
->join('users', 'messages.sender_id', '=', 'users.id')
|
||||
->where('messages.recipient_id', $userId)
|
||||
->orderBy('sent_datetime', 'DESC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return array_map(fn($row) => (array) $row, $rows);
|
||||
}
|
||||
|
||||
protected function markAsRead(int $messageId): void
|
||||
{
|
||||
if ($messageId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->message->markAsRead($messageId);
|
||||
}
|
||||
|
||||
protected function generateMessageNumber(): string
|
||||
{
|
||||
return 'MSG-' . Carbon::now('UTC')->format('YmdHis');
|
||||
}
|
||||
|
||||
protected function getRecipientId(array $role): ?int
|
||||
{
|
||||
$roleName = strtolower($role['name'] ?? '');
|
||||
|
||||
return match ($roleName) {
|
||||
'teacher' => 1,
|
||||
'parent' => 2,
|
||||
'admin' => 3,
|
||||
'student' => 4,
|
||||
'guest' => 5,
|
||||
'administrator' => 6,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
protected function storeAttachment(): ?string
|
||||
{
|
||||
/** @var UploadedFile|null $file */
|
||||
$file = $this->laravelRequest->file('attachment');
|
||||
if (!$file || !$file->isValid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::putFile('messages', $file);
|
||||
}
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class MessagingController extends BaseApiController
|
||||
{
|
||||
protected Message $message;
|
||||
protected User $user;
|
||||
protected Configuration $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->message = model(Message::class);
|
||||
$this->user = model(User::class);
|
||||
$this->config = model(Configuration::class);
|
||||
}
|
||||
|
||||
public function conversations()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = (int) $user->id;
|
||||
|
||||
$sql = <<<'SQL'
|
||||
SELECT
|
||||
CASE WHEN sender_id = ? THEN recipient_id ELSE sender_id END AS other_user_id,
|
||||
MAX(sent_datetime) AS last_message_time,
|
||||
SUM(CASE WHEN recipient_id = ? AND read_status = 0 THEN 1 ELSE 0 END) AS unread_count,
|
||||
(
|
||||
SELECT m2.message
|
||||
FROM messages m2
|
||||
WHERE (m2.sender_id = ? AND m2.recipient_id = CASE WHEN m1.sender_id = ? THEN m1.recipient_id ELSE m1.sender_id END)
|
||||
OR (m2.recipient_id = ? AND m2.sender_id = CASE WHEN m1.sender_id = ? THEN m1.recipient_id ELSE m1.sender_id END)
|
||||
ORDER BY m2.sent_datetime DESC
|
||||
LIMIT 1
|
||||
) AS last_message
|
||||
FROM messages m1
|
||||
WHERE (sender_id = ? OR recipient_id = ?)
|
||||
AND (subject = '' OR subject IS NULL OR subject = 'chat')
|
||||
GROUP BY other_user_id
|
||||
ORDER BY last_message_time DESC
|
||||
SQL;
|
||||
|
||||
$rows = DB::select($sql, array_fill(0, 8, $userId));
|
||||
|
||||
$conversations = [];
|
||||
foreach ($rows as $row) {
|
||||
$otherUser = $this->user->find($row->other_user_id);
|
||||
if (!$otherUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$conversations[] = [
|
||||
'user_id' => $row->other_user_id,
|
||||
'user_name' => trim(($otherUser['firstname'] ?? '') . ' ' . ($otherUser['lastname'] ?? '')),
|
||||
'user_initials' => strtoupper(($otherUser['firstname'][0] ?? '') . ($otherUser['lastname'][0] ?? '')),
|
||||
'last_message' => $row->last_message ?? '',
|
||||
'last_message_time'=> $row->last_message_time ?? null,
|
||||
'unread_count' => (int) ($row->unread_count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success($conversations, 'Conversations retrieved successfully');
|
||||
}
|
||||
|
||||
public function conversation($userId = null)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if (!$userId) {
|
||||
return $this->error('User ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$currentUserId = (int) $user->id;
|
||||
$otherUserId = (int) $userId;
|
||||
|
||||
$otherUser = $this->user->find($otherUserId);
|
||||
if (!$otherUser) {
|
||||
return $this->error('User not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 50)));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$baseQuery = DB::table('messages')
|
||||
->where(function ($query) use ($currentUserId, $otherUserId) {
|
||||
$query->where('sender_id', $currentUserId)
|
||||
->where('recipient_id', $otherUserId);
|
||||
})
|
||||
->orWhere(function ($query) use ($currentUserId, $otherUserId) {
|
||||
$query->where('sender_id', $otherUserId)
|
||||
->where('recipient_id', $currentUserId);
|
||||
})
|
||||
->whereRaw("(subject = '' OR subject IS NULL OR subject = 'chat')")
|
||||
->orderBy('sent_datetime', 'ASC');
|
||||
|
||||
$total = (clone $baseQuery)->count();
|
||||
$messages = (clone $baseQuery)->offset($offset)->limit($perPage)->get();
|
||||
|
||||
foreach ($messages as $message) {
|
||||
if ($message->recipient_id === $currentUserId && ((int) $message->read_status) === 0) {
|
||||
$this->message->update($message->id, [
|
||||
'read_status' => 1,
|
||||
'read_datetime'=> Carbon::now('UTC')->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$enriched = [];
|
||||
foreach ($messages as $msg) {
|
||||
$enriched[] = [
|
||||
'id' => $msg->id,
|
||||
'sender_id' => $msg->sender_id,
|
||||
'recipient_id' => $msg->recipient_id,
|
||||
'message' => $msg->message,
|
||||
'sent_datetime' => $msg->sent_datetime,
|
||||
'read_status' => $msg->read_status,
|
||||
'read_datetime' => $msg->read_datetime,
|
||||
'attachment' => $msg->attachment ?? null,
|
||||
'is_sender' => $msg->sender_id === $currentUserId,
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'messages' => $enriched,
|
||||
'other_user' => [
|
||||
'id' => $otherUser['id'],
|
||||
'name' => trim(($otherUser['firstname'] ?? '') . ' ' . ($otherUser['lastname'] ?? '')),
|
||||
'initials' => strtoupper(($otherUser['firstname'][0] ?? '') . ($otherUser['lastname'][0] ?? '')),
|
||||
],
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'total_pages' => (int) ceil($total / $perPage),
|
||||
],
|
||||
];
|
||||
|
||||
return $this->success($payload, 'Messages retrieved successfully');
|
||||
}
|
||||
|
||||
public function send()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'recipient_id' => 'required|integer',
|
||||
'message' => 'required|max_length[5000]',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$recipientId = (int) $payload['recipient_id'];
|
||||
if ($recipientId === (int) $user->id) {
|
||||
return $this->error('Cannot send message to yourself', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$recipient = $this->user->find($recipientId);
|
||||
if (!$recipient) {
|
||||
return $this->error('Recipient not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$schoolYear = $this->config->getConfig('school_year') ?? date('Y');
|
||||
$semester = $this->config->getConfig('semester') ?? 'Fall';
|
||||
|
||||
$maxMessageNumber = $this->message->newQuery()
|
||||
->selectRaw('COALESCE(MAX(message_number), 0) AS message_number')
|
||||
->first();
|
||||
|
||||
$messageNumber = (int) ($maxMessageNumber->message_number ?? $maxMessageNumber['message_number'] ?? 0) + 1;
|
||||
|
||||
$messageData = [
|
||||
'sender_id' => (int) $user->id,
|
||||
'recipient_id' => $recipientId,
|
||||
'subject' => 'chat',
|
||||
'message' => $payload['message'],
|
||||
'sent_datetime' => Carbon::now('UTC')->format('Y-m-d H:i:s'),
|
||||
'read_status' => 0,
|
||||
'message_number'=> $messageNumber,
|
||||
'priority' => 'normal',
|
||||
'status' => 'sent',
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'attachment' => $payload['attachment'] ?? null,
|
||||
];
|
||||
|
||||
try {
|
||||
$message = $this->message->create($messageData);
|
||||
return $this->success([
|
||||
'id' => $message->id,
|
||||
'sender_id' => $message->sender_id,
|
||||
'recipient_id' => $message->recipient_id,
|
||||
'message' => $message->message,
|
||||
'sent_datetime' => $message->sent_datetime,
|
||||
'read_status' => $message->read_status,
|
||||
'attachment' => $message->attachment,
|
||||
'is_sender' => true,
|
||||
], 'Message sent successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Message send error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to send message', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function markRead()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$currentUserId = (int) $user->id;
|
||||
|
||||
try {
|
||||
if (!empty($payload['message_ids']) && is_array($payload['message_ids'])) {
|
||||
$messageIds = array_map('intval', $payload['message_ids']);
|
||||
$updated = $this->message->newQuery()
|
||||
->whereIn('id', $messageIds)
|
||||
->where('recipient_id', $currentUserId)
|
||||
->where('read_status', 0)
|
||||
->update([
|
||||
'read_status' => 1,
|
||||
'read_datetime'=> Carbon::now('UTC')->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->success([
|
||||
'updated_count' => $updated,
|
||||
], 'Messages marked as read');
|
||||
}
|
||||
|
||||
if (isset($payload['user_id'])) {
|
||||
$senderId = (int) $payload['user_id'];
|
||||
$updated = $this->message->newQuery()
|
||||
->where('sender_id', $senderId)
|
||||
->where('recipient_id', $currentUserId)
|
||||
->where('read_status', 0)
|
||||
->update([
|
||||
'read_status' => 1,
|
||||
'read_datetime'=> Carbon::now('UTC')->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->success([
|
||||
'updated_count' => $updated,
|
||||
], 'Messages marked as read');
|
||||
}
|
||||
|
||||
return $this->error('Either message_ids or user_id is required', Response::HTTP_BAD_REQUEST);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Mark read error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to mark messages as read', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function unreadCount()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$count = $this->message->newQuery()
|
||||
->where('recipient_id', (int) $user->id)
|
||||
->where('read_status', 0)
|
||||
->whereRaw("(subject = '' OR subject IS NULL OR subject = 'chat')")
|
||||
->count();
|
||||
|
||||
return $this->success([
|
||||
'unread_count' => $count,
|
||||
], 'Unread count retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Unread count error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to get unread count', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function searchUsers()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$query = trim((string) ($this->request->getGet('q') ?? ''));
|
||||
if (strlen($query) < 2) {
|
||||
return $this->error('Search query must be at least 2 characters', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$limit = min(20, max(1, (int) ($this->request->getGet('limit') ?? 10)));
|
||||
|
||||
try {
|
||||
$users = $this->user->newQuery()
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('firstname', 'LIKE', "%{$query}%")
|
||||
->orWhere('lastname', 'LIKE', "%{$query}%")
|
||||
->orWhere('email', 'LIKE', "%{$query}%");
|
||||
})
|
||||
->where('id', '!=', (int) $user->id)
|
||||
->where('status', 'active')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$results = [];
|
||||
foreach ($users as $u) {
|
||||
$results[] = [
|
||||
'id' => $u->id,
|
||||
'name' => trim(($u->firstname ?? '') . ' ' . ($u->lastname ?? '')),
|
||||
'initials' => strtoupper(($u->firstname[0] ?? '') . ($u->lastname[0] ?? '')),
|
||||
'email' => $u->email ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success($results, 'Users retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Search users error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to search users', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteMessage($id = null)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if (!$id) {
|
||||
return $this->error('Message ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$message = $this->message->find($id);
|
||||
if (!$message) {
|
||||
return $this->error('Message not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($message->sender_id !== (int) $user->id && $message->recipient_id !== (int) $user->id) {
|
||||
return $this->error('Unauthorized to delete this message', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$this->message->update($id, [
|
||||
'status' => 'deleted',
|
||||
]);
|
||||
|
||||
return $this->success(null, 'Message deleted successfully');
|
||||
}
|
||||
|
||||
public function deleteConversation($userId = null)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if (!$userId) {
|
||||
return $this->error('User ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$currentUserId = (int) $user->id;
|
||||
$otherUserId = (int) $userId;
|
||||
|
||||
try {
|
||||
$updated = $this->message->newQuery()
|
||||
->where(function ($query) use ($currentUserId, $otherUserId) {
|
||||
$query->where('sender_id', $currentUserId)
|
||||
->where('recipient_id', $otherUserId);
|
||||
})
|
||||
->orWhere(function ($query) use ($currentUserId, $otherUserId) {
|
||||
$query->where('sender_id', $otherUserId)
|
||||
->where('recipient_id', $currentUserId);
|
||||
})
|
||||
->whereRaw("(subject = '' OR subject IS NULL OR subject = 'chat')")
|
||||
->update(['status' => 'deleted']);
|
||||
|
||||
return $this->success([
|
||||
'deleted_count' => $updated,
|
||||
], 'Conversation deleted successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Delete conversation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete conversation', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\MidtermExam;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class MidtermController extends BaseApiController
|
||||
{
|
||||
protected Configuration $config;
|
||||
protected Student $student;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected MidtermExam $midtermExam;
|
||||
protected SemesterScoreService $semesterScoreService;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->config = model(Configuration::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->midtermExam = model(MidtermExam::class);
|
||||
$this->semesterScoreService = app(SemesterScoreService::class);
|
||||
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/midterm
|
||||
* Get midterm exam data for a class section
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// Get class_section_id from POST -> GET -> session
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Persist for downstream use
|
||||
session()->put('class_section_id', $classSectionId);
|
||||
|
||||
try {
|
||||
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
return $this->success([
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'school_year' => $result['schoolYear'],
|
||||
'class_section_id' => $classSectionId,
|
||||
], 'Midterm exam data retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Midterm index error: ' . $e->getMessage());
|
||||
return $this->respondError($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/midterm
|
||||
* Update midterm exam scores (teacher endpoint)
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
$updatedBy = $this->getCurrentUserId();
|
||||
if (!$updatedBy) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$scores = $payload['final_score'] ?? null;
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return $this->respondError('No scores submitted.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->saveExamScores('midterm_exam', $scores, $updatedBy, $classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
// Add semester and school_year to each student info for the service
|
||||
foreach ($studentTeacherInfo as &$student) {
|
||||
$student['semester'] = $this->semester;
|
||||
$student['school_year'] = $this->schoolYear;
|
||||
}
|
||||
unset($student);
|
||||
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
|
||||
} catch (RuntimeException $e) {
|
||||
log_message('error', 'SemesterScoreService error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_count' => count($scores),
|
||||
], 'Midterm exam scores updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Midterm update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update midterm exam scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/midterm/management
|
||||
* Update midterm exam scores (management endpoint)
|
||||
*/
|
||||
public function updateManagement()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
$scores = $payload['final_score'] ?? null;
|
||||
$updatedBy = (int) ($payload['teacher_id'] ?? $this->getCurrentUserId() ?? 0);
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return $this->respondError('No scores submitted.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($updatedBy <= 0) {
|
||||
return $this->respondError('Missing teacher ID.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->saveExamScores('midterm_exam', $scores, $updatedBy, $classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
// Add semester and school_year to each student info for the service
|
||||
foreach ($studentTeacherInfo as &$student) {
|
||||
$student['semester'] = $this->semester;
|
||||
$student['school_year'] = $this->schoolYear;
|
||||
}
|
||||
unset($student);
|
||||
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
|
||||
} catch (RuntimeException $e) {
|
||||
log_message('error', 'SemesterScoreService error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Return the same data structure as index for consistency
|
||||
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
return $this->success([
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'school_year' => $result['schoolYear'],
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_count' => count($scores),
|
||||
], 'Midterm exam scores updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Midterm updateManagement error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update midterm exam scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/midterm/management
|
||||
* Get midterm exam data for management view
|
||||
*/
|
||||
public function showManagement()
|
||||
{
|
||||
// Get class_section_id from POST -> GET -> session
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Load roster + saved scores for this class/term
|
||||
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
return $this->success([
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'school_year' => $result['schoolYear'],
|
||||
'class_section_id' => $classSectionId,
|
||||
], 'Midterm exam management data retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Midterm showManagement error: ' . $e->getMessage());
|
||||
return $this->respondError($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save exam scores to the database
|
||||
*/
|
||||
private function saveExamScores(string $table, array $scores, int $updatedBy, int $classSectionId, string $semester, string $schoolYear): void
|
||||
{
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($scores as $studentId => $data) {
|
||||
if (!is_numeric($studentId) || !isset($data['score'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = $data['score'];
|
||||
|
||||
$existing = DB::table($table)
|
||||
->where('student_id', $studentId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
$dataToSave = [
|
||||
'student_id' => (int) $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'score' => is_numeric($score) ? (float) $score : null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
DB::table($table)
|
||||
->where('id', $existing->id)
|
||||
->update($dataToSave);
|
||||
} else {
|
||||
$dataToSave['created_at'] = $now;
|
||||
DB::table($table)->insert($dataToSave);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get roster + saved midterm scores for a specific classSection/term.
|
||||
*/
|
||||
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid class section id.');
|
||||
}
|
||||
|
||||
session()->put('class_section_id', $classSectionId);
|
||||
|
||||
// Subquery: pick ONE midterm row per student (latest by id).
|
||||
$latestMidtermSub = DB::table('midterm_exam')
|
||||
->select('student_id', DB::raw('MAX(id) AS max_id'))
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('student_id');
|
||||
|
||||
// Main query: roster for this class/term + LEFT JOIN the single midterm row
|
||||
$rows = DB::table('students as s')
|
||||
->select([
|
||||
's.id AS student_id',
|
||||
's.school_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
'me.score',
|
||||
])
|
||||
->join('student_class as sc', function ($join) use ($classSectionId, $semester, $schoolYear) {
|
||||
$join->on('sc.student_id', '=', 's.id')
|
||||
->where('sc.class_section_id', '=', $classSectionId)
|
||||
->where('sc.semester', '=', $semester)
|
||||
->where('sc.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoinSub($latestMidtermSub, 'ml', function ($join) {
|
||||
$join->on('ml.student_id', '=', 's.id');
|
||||
})
|
||||
->leftJoin('midterm_exam as me', 'me.id', '=', 'ml.max_id')
|
||||
->distinct()
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
// Final safety net: ensure one row per student_id
|
||||
$unique = [];
|
||||
foreach ($rows as $r) {
|
||||
$rArray = (array) $r;
|
||||
$unique[(int) $rArray['student_id']] = $rArray;
|
||||
}
|
||||
|
||||
$students = array_values($unique);
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\NavItem;
|
||||
use App\Models\RoleNavItem;
|
||||
use App\Models\Role;
|
||||
use App\Services\NavbarService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NavBuilderController extends BaseApiController
|
||||
{
|
||||
protected NavItem $items;
|
||||
protected RoleNavItem $maps;
|
||||
protected NavbarService $service;
|
||||
protected Role $role;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->items = model(NavItem::class);
|
||||
$this->maps = model(RoleNavItem::class);
|
||||
$this->service = app(NavbarService::class);
|
||||
$this->role = model(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has admin access to nav-builder
|
||||
*/
|
||||
protected function ensureAdmin(): void
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
throw new \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException('', 'Authentication required');
|
||||
}
|
||||
|
||||
$roleNames = $user->roles ?? [];
|
||||
if (empty($roleNames)) {
|
||||
throw new \Symfony\Component\HttpKernel\Exception\ForbiddenHttpException('Access denied');
|
||||
}
|
||||
|
||||
// Map role names -> ids
|
||||
$roleIdRows = DB::table('roles')
|
||||
->select('id')
|
||||
->whereIn('name', $roleNames)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$roleIds = array_map('intval', array_column($roleIdRows, 'id'));
|
||||
|
||||
if (empty($roleIds)) {
|
||||
throw new \Symfony\Component\HttpKernel\Exception\ForbiddenHttpException('Access denied');
|
||||
}
|
||||
|
||||
// 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')
|
||||
->whereIn('rni.role_id', $roleIds)
|
||||
->first();
|
||||
|
||||
if (!$allowed) {
|
||||
throw new \Symfony\Component\HttpKernel\Exception\ForbiddenHttpException('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/nav-builder/data
|
||||
* Get navigation builder data (items, roles, parent options)
|
||||
*/
|
||||
public function data()
|
||||
{
|
||||
try {
|
||||
$this->ensureAdmin();
|
||||
$payload = $this->buildNavPayload();
|
||||
return $this->success($payload, 'Navigation data retrieved successfully');
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
return $this->respondError($e->getMessage(), $e->getStatusCode());
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Nav builder data error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve navigation data', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/nav-builder
|
||||
* Save or update a navigation item
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
try {
|
||||
$this->ensureAdmin();
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$id = (int) ($payload['id'] ?? 0);
|
||||
|
||||
// Accept either "menu_parent_id" or "parent_id" from the form
|
||||
$menuParentRaw = $payload['menu_parent_id'] ?? $payload['parent_id'] ?? null;
|
||||
$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->find($menuParentId);
|
||||
if (!$parent) {
|
||||
return $this->respondError('Selected parent does not exist.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'menu_parent_id' => $menuParentId,
|
||||
'label' => trim((string) ($payload['label'] ?? '')),
|
||||
'url' => trim((string) ($payload['url'] ?? '')) ?: null,
|
||||
'icon_class' => trim((string) ($payload['icon_class'] ?? '')) ?: null,
|
||||
'target' => trim((string) ($payload['target'] ?? '')) ?: null,
|
||||
'sort_order' => (int) ($payload['sort_order'] ?? 0),
|
||||
'is_enabled' => (int) (($payload['is_enabled'] ?? false) ? 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) ($payload['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 $this->success([
|
||||
'id' => $id,
|
||||
'message' => 'Menu saved successfully',
|
||||
], 'Menu saved successfully');
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
return $this->respondError($e->getMessage(), $e->getStatusCode());
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Nav builder save error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to save menu item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/nav-builder/{id}
|
||||
* Delete a navigation item
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
try {
|
||||
$this->ensureAdmin();
|
||||
|
||||
$id = (int) $id;
|
||||
$this->items->delete($id); // children handled by FK (SET NULL on parent)
|
||||
|
||||
$this->service->clearCache();
|
||||
|
||||
return $this->success([
|
||||
'id' => $id,
|
||||
'message' => 'Menu item deleted successfully',
|
||||
], 'Menu item deleted successfully');
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
return $this->respondError($e->getMessage(), $e->getStatusCode());
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Nav builder delete error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete menu item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/nav-builder/reorder
|
||||
* Reorder navigation items
|
||||
*/
|
||||
public function reorder()
|
||||
{
|
||||
try {
|
||||
$this->ensureAdmin();
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$orders = $payload['orders'] ?? [];
|
||||
|
||||
foreach ($orders as $id => $order) {
|
||||
$this->items->update((int) $id, ['sort_order' => (int) $order]);
|
||||
}
|
||||
|
||||
$this->service->clearCache();
|
||||
|
||||
return $this->success(['ok' => true], 'Navigation items reordered successfully');
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
return $this->respondError($e->getMessage(), $e->getStatusCode());
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Nav builder reorder error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to reorder navigation items: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distinct roles
|
||||
*/
|
||||
protected function distinctRoles(): array
|
||||
{
|
||||
return DB::table('roles')
|
||||
->select('id', 'name')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build navigation payload
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort tree alphabetically
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten tree for response
|
||||
*/
|
||||
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,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Notification;
|
||||
use App\Models\UserNotification;
|
||||
use Carbon\Carbon;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NotificationController extends BaseApiController
|
||||
{
|
||||
protected Notification $notification;
|
||||
protected UserNotification $userNotification;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->notification = model(Notification::class);
|
||||
$this->userNotification = model(UserNotification::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$read = $this->request->getGet('read');
|
||||
|
||||
$query = $this->userNotification->newQuery()
|
||||
->select([
|
||||
'notifications.*',
|
||||
'user_notifications.is_read AS is_read',
|
||||
'user_notifications.read_at',
|
||||
])
|
||||
->join('notifications', 'notifications.id', '=', 'user_notifications.notification_id')
|
||||
->where('user_notifications.user_id', (int) $user->id);
|
||||
|
||||
if ($read !== null) {
|
||||
$isRead = filter_var($read, FILTER_VALIDATE_BOOLEAN);
|
||||
$query->where('user_notifications.is_read', $isRead ? 1 : 0);
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
return $this->success($result, 'Notifications retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$notification = $this->userNotification->newQuery()
|
||||
->select([
|
||||
'notifications.*',
|
||||
'user_notifications.is_read AS is_read',
|
||||
'user_notifications.read_at',
|
||||
])
|
||||
->join('notifications', 'notifications.id', '=', 'user_notifications.notification_id')
|
||||
->where('user_notifications.user_id', (int) $user->id)
|
||||
->where('user_notifications.notification_id', $id)
|
||||
->first();
|
||||
|
||||
if (!$notification) {
|
||||
return $this->error('Notification not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($notification, 'Notification retrieved successfully');
|
||||
}
|
||||
|
||||
public function markRead($id = null)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userNotification = $this->userNotification->newQuery()
|
||||
->where('user_id', (int) $user->id)
|
||||
->where('notification_id', $id)
|
||||
->first();
|
||||
|
||||
if (!$userNotification) {
|
||||
return $this->error('Notification not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->userNotification->update($userNotification->id, [
|
||||
'is_read' => 1,
|
||||
'read_at' => Carbon::now('UTC')->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->success(null, 'Notification marked as read');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Notification mark read error: ' . (string) $e->getMessage());
|
||||
return $this->respondError('Failed to mark notification as read', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Notification;
|
||||
use App\Models\UserNotification;
|
||||
use App\Models\User;
|
||||
use App\Services\EmailService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NotificationManagementController extends BaseApiController
|
||||
{
|
||||
protected Notification $notification;
|
||||
protected UserNotification $userNotification;
|
||||
protected User $user;
|
||||
protected EmailService $emailService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->notification = model(Notification::class);
|
||||
$this->userNotification = model(UserNotification::class);
|
||||
$this->user = model(User::class);
|
||||
$this->emailService = app(EmailService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/notifications/active
|
||||
* Get active (non-deleted, non-expired) notifications
|
||||
*/
|
||||
public function activeNotificationsData()
|
||||
{
|
||||
try {
|
||||
$targetGroup = $this->request->getGet('target_group');
|
||||
$payload = $this->buildActiveNotificationsPayload($targetGroup);
|
||||
return $this->success($payload, 'Active notifications retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Active notifications data error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve active notifications', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/notifications/deleted
|
||||
* Get soft-deleted notifications
|
||||
*/
|
||||
public function deletedNotificationsData()
|
||||
{
|
||||
try {
|
||||
$payload = $this->buildDeletedNotificationsPayload();
|
||||
return $this->success($payload, 'Deleted notifications retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Deleted notifications data error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve deleted notifications', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/notifications/send
|
||||
* Create and send notifications to a target group
|
||||
*/
|
||||
public function send()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
|
||||
$title = trim((string) ($payload['title'] ?? ''));
|
||||
$message = trim((string) ($payload['message'] ?? ''));
|
||||
$group = trim((string) ($payload['target_group'] ?? ''));
|
||||
$channels = (array) ($payload['channels'] ?? []);
|
||||
|
||||
if (empty($title) || empty($message) || empty($group) || empty($channels)) {
|
||||
return $this->respondError('Title, message, target_group, and channels are required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Create notification
|
||||
$notifId = $this->notification->insert([
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'target_group' => $group,
|
||||
'delivery_channels' => implode(',', $channels),
|
||||
'created_at' => utc_now(),
|
||||
], true);
|
||||
|
||||
if (!$notifId) {
|
||||
return $this->respondError('Failed to create notification', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// Get users by role (target_group)
|
||||
$users = $this->user->getUsersByRole($group);
|
||||
|
||||
$sentCount = 0;
|
||||
$emailCount = 0;
|
||||
$smsCount = 0;
|
||||
|
||||
foreach ($users as $user) {
|
||||
// Create user notification record
|
||||
$this->userNotification->insert([
|
||||
'notification_id' => $notifId,
|
||||
'user_id' => (int) $user['id'],
|
||||
'is_read' => 0,
|
||||
]);
|
||||
|
||||
// Send via email if requested
|
||||
if (in_array('email', $channels) && !empty($user['email'])) {
|
||||
try {
|
||||
$this->sendEmail($user['email'], $title, $message);
|
||||
$emailCount++;
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to send email to ' . $user['email'] . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Send via SMS if requested
|
||||
if (in_array('sms', $channels) && !empty($user['cellphone'])) {
|
||||
try {
|
||||
$this->sendSMS($user['cellphone'], $message);
|
||||
$smsCount++;
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to send SMS to ' . $user['cellphone'] . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$sentCount++;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'notification_id' => $notifId,
|
||||
'users_notified' => $sentCount,
|
||||
'emails_sent' => $emailCount,
|
||||
'sms_sent' => $smsCount,
|
||||
], 'Notification sent successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Notification send error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to send notification: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/notifications/{id}/restore
|
||||
* Restore a soft-deleted notification
|
||||
*/
|
||||
public function restore($id)
|
||||
{
|
||||
try {
|
||||
$id = (int) $id;
|
||||
|
||||
// Check if notification exists and is deleted
|
||||
$notification = $this->notification->withDeleted()->find($id);
|
||||
if (!$notification) {
|
||||
return $this->respondError('Notification not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$restored = $this->notification->restoreNotification($id);
|
||||
|
||||
// Check if update was successful (update returns affected rows or false)
|
||||
if ($restored !== false) {
|
||||
return $this->success([
|
||||
'id' => $id,
|
||||
], 'Notification restored successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to restore notification', Response::HTTP_BAD_REQUEST);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Notification restore error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to restore notification: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build active notifications payload
|
||||
*/
|
||||
private function buildActiveNotificationsPayload(?string $targetGroup): array
|
||||
{
|
||||
$targetGroup = $targetGroup !== null ? trim($targetGroup) : null;
|
||||
if ($targetGroup === '') {
|
||||
$targetGroup = null;
|
||||
}
|
||||
|
||||
$rows = $this->notification->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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build deleted notifications payload
|
||||
*/
|
||||
private function buildDeletedNotificationsPayload(): array
|
||||
{
|
||||
$rows = $this->notification->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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email notification
|
||||
*/
|
||||
protected function sendEmail(string $to, string $subject, string $message): void
|
||||
{
|
||||
try {
|
||||
$this->emailService->setTo($to);
|
||||
$this->emailService->setSubject($subject);
|
||||
$this->emailService->setMessage($message);
|
||||
$this->emailService->send();
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Email send error: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMS notification (placeholder - logs for now)
|
||||
*/
|
||||
protected function sendSMS(string $phone, string $message): void
|
||||
{
|
||||
log_message('info', "SMS to {$phone}: {$message}");
|
||||
// TODO: Implement actual SMS sending via SendSMSController or SMS service
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
class NotificationsController extends NotificationController
|
||||
{
|
||||
// Alias controller for route naming consistency
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\ContactUs;
|
||||
use App\Services\EmailService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PageController extends BaseApiController
|
||||
{
|
||||
protected ContactUs $contactUs;
|
||||
protected EmailService $emailService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->contactUs = model(ContactUs::class);
|
||||
$this->emailService = app(EmailService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/pages/contact
|
||||
* Submit contact form
|
||||
*/
|
||||
public function submitContact()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'email' => 'required|email',
|
||||
'message' => 'required|min_length[10]',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$email = strtolower(trim((string) ($payload['email'] ?? '')));
|
||||
$message = trim((string) ($payload['message'] ?? ''));
|
||||
|
||||
try {
|
||||
// Save the message to the database using ContactUs
|
||||
// Note: ContactUs expects different fields, so we'll use DB directly
|
||||
// or adapt to the model structure if needed
|
||||
$contactData = [
|
||||
'email' => $email,
|
||||
'message' => $message,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
// Try using model first, fallback to DB if model structure differs
|
||||
$saved = false;
|
||||
try {
|
||||
// Check if ContactUs supports email/message fields directly
|
||||
// ContactUs has different fields (sender_id, receiver_id, etc.), so use DB directly
|
||||
$saved = DB::table('contactus')->insert($contactData);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to save contact data: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to save message. Please try again.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
if (!$saved) {
|
||||
log_message('error', 'Failed to save contact data');
|
||||
return $this->respondError('Failed to save message. Please try again.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// Prepare the email content
|
||||
$recipient = 'alrahma.isgl@gmail.com';
|
||||
$subject = 'Contact Us Form Submission';
|
||||
$emailMessage = "You have received a new message from {$email}:\n\n{$message}";
|
||||
|
||||
// Send email using EmailService
|
||||
$emailSent = false;
|
||||
try {
|
||||
$this->emailService->setTo($recipient);
|
||||
$this->emailService->setFrom(
|
||||
config('mail.from.address', 'no-reply@alrahmaisgl.org'),
|
||||
config('mail.from.name', 'Alrahma Team')
|
||||
);
|
||||
$this->emailService->setSubject($subject);
|
||||
$this->emailService->setMessage($emailMessage);
|
||||
$emailSent = $this->emailService->send();
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to send email: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
if (!$emailSent) {
|
||||
log_message('error', 'Failed to send email.');
|
||||
// Don't fail the request if email fails, just log it
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'email' => $email,
|
||||
'email_sent' => $emailSent,
|
||||
], 'Message sent successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Contact form error: ' . $e->getMessage());
|
||||
return $this->respondError('An unexpected error occurred. Please try again.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/pages/privacy
|
||||
* Get privacy policy HTML content
|
||||
*/
|
||||
public function privacyPolicy()
|
||||
{
|
||||
return $this->renderStaticFile('privacy_policy.html', 'privacy_policy');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/pages/terms
|
||||
* Get terms of service HTML content
|
||||
*/
|
||||
public function termsOfService()
|
||||
{
|
||||
return $this->renderStaticFile('terms_of_service.html', 'terms_of_service');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/pages/help
|
||||
* Get help center HTML content
|
||||
*/
|
||||
public function helpCenter()
|
||||
{
|
||||
return $this->renderStaticFile('help_center.html', 'help_center');
|
||||
}
|
||||
|
||||
protected function renderStaticFile(string $filename, string $type)
|
||||
{
|
||||
$path = base_path('public/html/' . $filename);
|
||||
|
||||
if (!is_readable($path)) {
|
||||
return $this->respondError('Failed to retrieve ' . str_replace('_', ' ', $type), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
try {
|
||||
$content = file_get_contents($path);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', ucfirst($type) . ' load error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve ' . str_replace('_', ' ', $type), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'content' => $content,
|
||||
'type' => $type,
|
||||
], ucfirst(str_replace('_', ' ', $type)) . ' retrieved successfully');
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\ParentAttendanceReport;
|
||||
use App\Models\Student;
|
||||
use Carbon\Carbon;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ParentAttendanceReportController extends BaseApiController
|
||||
{
|
||||
protected ParentAttendanceReport $report;
|
||||
protected Student $student;
|
||||
protected Configuration $config;
|
||||
protected string $schoolYear;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->report = model(ParentAttendanceReport::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'student_ids' => 'required|is_array',
|
||||
'date' => 'required|date_format:Y-m-d',
|
||||
'type' => 'required|in:absent,late,early_dismissal',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$studentIds = (array) $payload['student_ids'];
|
||||
$date = $payload['date'];
|
||||
$type = $payload['type'];
|
||||
$arrivalTime = $payload['arrival_time'] ?? null;
|
||||
$dismissTime = $payload['dismiss_time'] ?? null;
|
||||
$reason = $payload['reason'] ?? null;
|
||||
|
||||
$reports = [];
|
||||
|
||||
try {
|
||||
foreach ($studentIds as $studentId) {
|
||||
$reportData = [
|
||||
'parent_id' => (int) $user->id,
|
||||
'student_id' => (int) $studentId,
|
||||
'report_date' => $date,
|
||||
'type' => $type,
|
||||
'arrival_time'=> $arrivalTime,
|
||||
'dismiss_time'=> $dismissTime,
|
||||
'reason' => $reason,
|
||||
'school_year' => $this->schoolYear,
|
||||
];
|
||||
|
||||
$report = $this->report->create($reportData);
|
||||
$reports[] = $report->toArray();
|
||||
}
|
||||
|
||||
return $this->success($reports, 'Attendance reports submitted successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Attendance report submission error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to submit attendance report', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
|
||||
$query = $this->report->newQuery()
|
||||
->where('parent_id', (int) $user->id)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('report_date', 'DESC');
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
return $this->success($result, 'Attendance reports retrieved successfully');
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$report = $this->report->find($id);
|
||||
if (!$report) {
|
||||
return $this->error('Attendance report not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ((int) ($report['parent_id'] ?? 0) !== (int) $user->id) {
|
||||
return $this->error('Access denied', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowedFields = ['type', 'arrival_time', 'dismiss_time', 'reason'];
|
||||
$updateData = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $payload)) {
|
||||
$updateData[$field] = $payload[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->report->update($id, $updateData);
|
||||
$updatedReport = $this->report->find($id);
|
||||
return $this->success($updatedReport, 'Attendance report updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Attendance report update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update attendance report', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,767 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\AuthorizedUser;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\EmergencyContact;
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\EventCharges;
|
||||
use App\Models\Event;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\ParentAttendanceReport;
|
||||
use App\Models\PromotionQueue;
|
||||
use App\Models\Refund;
|
||||
use App\Models\StudentAllergy;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\StudentMedicalCondition;
|
||||
use App\Models\Student;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
class ParentController extends BaseApiController
|
||||
{
|
||||
protected User $user;
|
||||
protected Student $student;
|
||||
protected StudentClass $studentClass;
|
||||
protected Enrollment $enrollment;
|
||||
protected Configuration $config;
|
||||
protected EventCharges $charges;
|
||||
protected Event $event;
|
||||
protected StudentMedicalCondition $medicalCondition;
|
||||
protected StudentAllergy $allergy;
|
||||
protected Refund $refund;
|
||||
protected Invoice $invoice;
|
||||
protected ClassSection $classSection;
|
||||
protected PromotionQueue $promotion;
|
||||
protected EmergencyContact $emergencyContact;
|
||||
protected AuthorizedUser $authorizedUser;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->user = model(User::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->enrollment = model(Enrollment::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->charges = model(EventCharges::class);
|
||||
$this->event = model(Event::class);
|
||||
$this->medicalCondition = model(StudentMedicalCondition::class);
|
||||
$this->allergy = model(StudentAllergy::class);
|
||||
$this->refund = model(Refund::class);
|
||||
$this->invoice = model(Invoice::class);
|
||||
$this->classSection = model(ClassSection::class);
|
||||
$this->promotion = model(PromotionQueue::class);
|
||||
$this->emergencyContact = model(EmergencyContact::class);
|
||||
$this->authorizedUser = model(AuthorizedUser::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
|
||||
$query = $this->user->newQuery()
|
||||
->select('users.*')
|
||||
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
|
||||
->join('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->where('roles.name', 'parent')
|
||||
->groupBy('users.id');
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
foreach ($result['data'] as &$parent) {
|
||||
unset($parent['password'], $parent['token']);
|
||||
}
|
||||
|
||||
return $this->success($result, 'Parents retrieved successfully');
|
||||
}
|
||||
|
||||
public function enrollClassesData($id)
|
||||
{
|
||||
try {
|
||||
$parentId = (int) $id;
|
||||
$parent = $this->user->find($parentId);
|
||||
if (!$parent) {
|
||||
return $this->error('Parent not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$schoolYear = (string) ($this->config->getConfig('school_year'));
|
||||
$semester = (string) ($this->config->getConfig('semester'));
|
||||
$lastDayOfRegistration = (string) ($this->config->getConfig('enrollment_deadline'));
|
||||
$schoolStartDate = (string) ($this->config->getConfig('school_start_date'));
|
||||
$withdrawalDeadline = (string) ($this->config->getConfig('refund_deadline'));
|
||||
|
||||
$selectedYear = (string) ($this->request->getGet('school_year') ?? $schoolYear);
|
||||
$isEditable = ($selectedYear === $schoolYear);
|
||||
|
||||
$students = DB::table('students')
|
||||
->where('parent_id', $parentId)
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
$statusMap = [
|
||||
'admission under review' => 'admission under review',
|
||||
'payment pending' => 'payment pending',
|
||||
'enrolled' => 'enrolled',
|
||||
'withdraw under review' => 'withdraw under review',
|
||||
'refund pending' => 'refund pending',
|
||||
'withdrawn' => 'withdrawn',
|
||||
'denied' => 'denied',
|
||||
'waitlist' => 'waitlist',
|
||||
];
|
||||
|
||||
foreach ($students as &$student) {
|
||||
$studentId = (int) ($student['id'] ?? 0);
|
||||
|
||||
$classRow = DB::table('student_class')
|
||||
->select('class_section_id')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $selectedYear)
|
||||
->first();
|
||||
$classSectionId = $classRow->class_section_id ?? null;
|
||||
$student['class_section'] = $classSectionId
|
||||
? ($this->classSection->getClassSectionNameBySectionId($classSectionId) ?? 'Class not Assigned')
|
||||
: 'Class not Assigned';
|
||||
|
||||
$enr = DB::table('enrollments')
|
||||
->select('enrollment_status', 'admission_status', 'is_withdrawn')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $selectedYear)
|
||||
->orderByDesc('updated_at')
|
||||
->first();
|
||||
|
||||
if ($enr && isset($enr->admission_status)) {
|
||||
$student['admission_status'] = $enr->admission_status;
|
||||
if ($enr->admission_status === 'denied') {
|
||||
$student['enrollment_status'] = 'denied';
|
||||
} else {
|
||||
$est = $enr->enrollment_status ?? '';
|
||||
$student['enrollment_status'] = $statusMap[$est] ?? 'not enrolled';
|
||||
}
|
||||
} else {
|
||||
$student['admission_status'] = null;
|
||||
$student['enrollment_status'] = 'not enrolled';
|
||||
}
|
||||
|
||||
$student['disable_enroll'] = in_array(
|
||||
$student['enrollment_status'],
|
||||
['admission under review', 'payment pending', 'enrolled', 'withdraw under review', 'denied'],
|
||||
true
|
||||
);
|
||||
}
|
||||
unset($student);
|
||||
|
||||
$schoolYears = DB::table('enrollments')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
->orderByDesc('school_year')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
if (empty($schoolYears)) {
|
||||
$schoolYears[] = ['school_year' => $schoolYear];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'students' => $students,
|
||||
'schoolYears' => $schoolYears,
|
||||
'selectedYear' => $selectedYear,
|
||||
'isEditable' => $isEditable,
|
||||
'withdrawalDeadline' => $withdrawalDeadline,
|
||||
'lastDayOfRegistration'=> $lastDayOfRegistration,
|
||||
'schoolStartDate' => $schoolStartDate,
|
||||
], 'Enroll classes data');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'enrollClassesData error: ' . $e->getMessage());
|
||||
return $this->error('Server error', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateEnrollments($id)
|
||||
{
|
||||
$parentId = (int) $id;
|
||||
$parent = $this->user->find($parentId);
|
||||
if (!$parent) {
|
||||
return $this->error('Parent not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$enroll = array_map('intval', (array) ($payload['enroll'] ?? []));
|
||||
$withdraw = array_map('intval', (array) ($payload['withdraw'] ?? []));
|
||||
|
||||
if (empty($enroll) && empty($withdraw)) {
|
||||
return $this->error('No students selected for enrollment or withdrawal.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$result = $this->processEnrollmentUpdate($parentId, $enroll, $withdraw);
|
||||
return $this->success($result, 'Enrollment updates processed');
|
||||
}
|
||||
|
||||
public function emergencyContacts($id)
|
||||
{
|
||||
$parentId = (int) $id;
|
||||
$rows = $this->emergencyContact->getEmergencyContactsByParentId($parentId);
|
||||
return $this->success($rows, 'Emergency contacts retrieved');
|
||||
}
|
||||
|
||||
public function saveEmergencyContact($id)
|
||||
{
|
||||
$parentId = (int) $id;
|
||||
$data = $this->payloadData();
|
||||
|
||||
$payload = [
|
||||
'parent_id' => $parentId,
|
||||
'emergency_contact_name' => trim((string) ($data['emergency_contact_name'] ?? '')),
|
||||
'cellphone' => trim((string) ($data['cellphone'] ?? '')),
|
||||
'email' => trim((string) ($data['email'] ?? '')) ?: null,
|
||||
'relation' => trim((string) ($data['relation'] ?? '')) ?: null,
|
||||
'semester' => (string) $this->config->getConfig('semester'),
|
||||
'school_year' => (string) $this->config->getConfig('school_year'),
|
||||
];
|
||||
|
||||
if ($payload['emergency_contact_name'] === '' || $payload['cellphone'] === '') {
|
||||
return $this->error('Name and cellphone are required', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$idToUpdate = isset($data['id']) ? (int) $data['id'] : 0;
|
||||
if ($idToUpdate > 0) {
|
||||
$row = $this->emergencyContact->find($idToUpdate);
|
||||
if (!$row || (int) $row['parent_id'] !== $parentId) {
|
||||
return $this->error('Contact not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
$this->emergencyContact->update($idToUpdate, $payload);
|
||||
return $this->success(['id' => $idToUpdate], 'Emergency contact updated');
|
||||
}
|
||||
|
||||
$newId = $this->emergencyContact->insert($payload);
|
||||
if (!$newId) {
|
||||
return $this->error('Failed to save emergency contact', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
return $this->success(['id' => $newId], 'Emergency contact created');
|
||||
}
|
||||
|
||||
public function authorizedUsers($id)
|
||||
{
|
||||
$parentId = (int) $id;
|
||||
$rows = $this->authorizedUser->newQuery()
|
||||
->where('user_id', $parentId)
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success($rows, 'Authorized users retrieved');
|
||||
}
|
||||
|
||||
public function addAuthorizedUser($id)
|
||||
{
|
||||
$parentId = (int) $id;
|
||||
$data = $this->payloadData();
|
||||
|
||||
$payload = [
|
||||
'user_id' => $parentId,
|
||||
'firstname' => trim((string) ($data['firstname'] ?? '')),
|
||||
'lastname' => trim((string) ($data['lastname'] ?? '')),
|
||||
'email' => trim((string) ($data['email'] ?? '')),
|
||||
'phone_number' => trim((string) ($data['phone_number'] ?? '')),
|
||||
'relation_to_user'=> trim((string) ($data['relation_to_user'] ?? '')),
|
||||
'status' => 'Pending',
|
||||
];
|
||||
|
||||
if ($payload['firstname'] === '' || $payload['lastname'] === '' || $payload['email'] === '' || $payload['phone_number'] === '') {
|
||||
return $this->error('Missing required fields', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
if (!$this->authorizedUser->insert($payload)) {
|
||||
return $this->error('Failed to create authorized user', Response::HTTP_INTERNAL_SERVER_ERROR, $this->authorizedUser->errors());
|
||||
}
|
||||
return $this->success([], 'Authorized user request created');
|
||||
}
|
||||
|
||||
public function createStudent($id)
|
||||
{
|
||||
$parentId = (int) $id;
|
||||
$data = $this->payloadData();
|
||||
|
||||
$allowed = [
|
||||
'firstname', 'lastname', 'dob', 'age', 'gender', 'registration_grade', 'photo_consent', 'rfid_tag',
|
||||
];
|
||||
$payload = array_intersect_key($data, array_flip($allowed));
|
||||
$payload['parent_id'] = $parentId;
|
||||
$payload['is_new'] = isset($data['is_new']) ? (int) $data['is_new'] : 1;
|
||||
$payload['school_year'] = (string) $this->config->getConfig('school_year');
|
||||
$payload['semester'] = (string) $this->config->getConfig('semester');
|
||||
$payload['registration_date'] = Carbon::now('UTC')->format('Y-m-d');
|
||||
|
||||
if (empty($payload['firstname']) || empty($payload['lastname']) || empty($payload['gender'])) {
|
||||
return $this->error('firstname, lastname, and gender are required', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$student = $this->student->create($payload);
|
||||
if (!$student) {
|
||||
return $this->error('Failed to create student', Response::HTTP_INTERNAL_SERVER_ERROR, $this->student->errors());
|
||||
}
|
||||
return $this->success(['id' => $student->id], 'Student created');
|
||||
}
|
||||
|
||||
public function updateStudent($id, $studentId)
|
||||
{
|
||||
$parentId = (int) $id;
|
||||
$studentId = (int) $studentId;
|
||||
|
||||
$row = $this->student->find($studentId);
|
||||
if (!$row || (int) $row['parent_id'] !== $parentId) {
|
||||
return $this->error('Student not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
$allowed = ['firstname', 'lastname', 'dob', 'age', 'gender', 'registration_grade', 'photo_consent', 'rfid_tag'];
|
||||
$payload = array_intersect_key($data, array_flip($allowed));
|
||||
if (empty($payload)) {
|
||||
return $this->error('No valid fields to update', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$ok = $this->student->update($studentId, $payload);
|
||||
if (!$ok) {
|
||||
return $this->error('Failed to update student', Response::HTTP_INTERNAL_SERVER_ERROR, $this->student->errors());
|
||||
}
|
||||
|
||||
return $this->success([], 'Student updated');
|
||||
}
|
||||
|
||||
public function deleteStudent($id, $studentId)
|
||||
{
|
||||
$parentId = (int) $id;
|
||||
$studentId = (int) $studentId;
|
||||
|
||||
$row = $this->student->find($studentId);
|
||||
if (!$row || (int) $row['parent_id'] !== $parentId) {
|
||||
return $this->error('Student not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$ok = $this->student->delete($studentId);
|
||||
if (!$ok) {
|
||||
return $this->error('Failed to delete student', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->success([], 'Student deleted');
|
||||
}
|
||||
|
||||
private function processEnrollmentUpdate(int $parentId, array $enroll, array $withdraw): array
|
||||
{
|
||||
$schoolYear = (string) $this->config->getConfig('school_year');
|
||||
$semester = (string) $this->config->getConfig('semester');
|
||||
$lastDayOfRegistration = (string) $this->config->getConfig('enrollment_deadline');
|
||||
$today = Carbon::now('UTC')->format('Y-m-d');
|
||||
|
||||
if ($lastDayOfRegistration && $today > date('Y-m-d', strtotime($lastDayOfRegistration))) {
|
||||
if (!empty($enroll)) {
|
||||
return [
|
||||
'enrolled' => [],
|
||||
'withdrawn' => [],
|
||||
'messages' => ['Enrollment deadline has passed. New enrollments are not allowed.'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$enrolled = [];
|
||||
$withdrawn = [];
|
||||
$messages = [];
|
||||
|
||||
foreach ($enroll as $studentId) {
|
||||
$studentId = (int) $studentId;
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $this->enrollment->newQuery()
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ((int) ($existing['is_withdrawn'] ?? 0) === 1) {
|
||||
$ok = $this->enrollment->update((int) $existing['id'], [
|
||||
'is_withdrawn' => 0,
|
||||
'withdrawal_date' => null,
|
||||
'enrollment_status' => 'payment pending',
|
||||
'updated_at' => Carbon::now('UTC')->toDateTimeString(),
|
||||
]);
|
||||
if ($ok) {
|
||||
$enrolled[] = $studentId;
|
||||
$messages[] = "Re-enrolled student {$studentId}.";
|
||||
}
|
||||
} else {
|
||||
$messages[] = "Student {$studentId} already enrolled.";
|
||||
}
|
||||
} else {
|
||||
$ok = $this->enrollment->insert([
|
||||
'student_id' => $studentId,
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'enrollment_date' => $today,
|
||||
'is_withdrawn' => 0,
|
||||
'enrollment_status' => 'admission under review',
|
||||
'admission_status' => 'pending',
|
||||
'created_at' => Carbon::now('UTC')->toDateTimeString(),
|
||||
]);
|
||||
if ($ok) {
|
||||
$enrolled[] = $studentId;
|
||||
$messages[] = "Enrolled student {$studentId}.";
|
||||
}
|
||||
}
|
||||
|
||||
$this->applyPromotionAssignment($studentId, $schoolYear);
|
||||
}
|
||||
|
||||
foreach ($withdraw as $studentId) {
|
||||
$studentId = (int) $studentId;
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$enrollment = $this->enrollment->newQuery()
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->where('is_withdrawn', 0)
|
||||
->first();
|
||||
|
||||
if ($enrollment) {
|
||||
$ok = $this->enrollment->update((int) $enrollment['id'], [
|
||||
'withdrawal_date' => $today,
|
||||
'enrollment_status' => 'withdraw under review',
|
||||
'updated_at' => Carbon::now('UTC')->toDateTimeString(),
|
||||
]);
|
||||
if ($ok) {
|
||||
$withdrawn[] = $studentId;
|
||||
$messages[] = "Withdrawn student {$studentId}.";
|
||||
}
|
||||
|
||||
$invoice = $this->invoice->newQuery()
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
|
||||
if ($invoice) {
|
||||
$existingRefund = $this->refund->newQuery()
|
||||
->where('parent_id', $parentId)
|
||||
->where('invoice_id', $invoice['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
if ($existingRefund) {
|
||||
$this->refund->update((int) $existingRefund['id'], [
|
||||
'reason' => 'Withdrawal under review for student ID ' . $studentId,
|
||||
'note' => null,
|
||||
'updated_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
} else {
|
||||
$this->refund->insert([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoice['id'],
|
||||
'requested_at' => Carbon::now('UTC')->toDateTimeString(),
|
||||
'school_year' => $schoolYear,
|
||||
'status' => 'Pending review',
|
||||
'reason' => 'Withdrawal under review for student ID ' . $studentId,
|
||||
'request' => 'new',
|
||||
'semester' => $semester,
|
||||
'refund_paid_amount' => 0.0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'enrolled' => $enrolled,
|
||||
'withdrawn' => $withdrawn,
|
||||
'messages' => $messages,
|
||||
];
|
||||
}
|
||||
|
||||
private function applyPromotionAssignment(int $studentId, string $year): void
|
||||
{
|
||||
try {
|
||||
$row = $this->promotion->newQuery()
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year_to', $year)
|
||||
->first();
|
||||
if (!$row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetSectionId = (int) ($row['to_class_section_id'] ?? 0);
|
||||
if ($targetSectionId <= 0) {
|
||||
$base = $this->classSection->getBaseSectionByClassId((int) $row['to_class_id']);
|
||||
if (!$base) {
|
||||
return;
|
||||
}
|
||||
$targetSectionId = (int) ($base['class_section_id'] ?? 0);
|
||||
}
|
||||
if ($targetSectionId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$semester = (string) $this->config->getConfig('semester');
|
||||
$exists = $this->studentClass->newQuery()
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $year)
|
||||
->where('semester', $semester)
|
||||
->first();
|
||||
|
||||
$payload = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $targetSectionId,
|
||||
'school_year' => $year,
|
||||
'semester' => $semester,
|
||||
'updated_by' => $this->getCurrentUserId(),
|
||||
'updated_at' => Carbon::now('UTC')->toDateTimeString(),
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
$this->studentClass->update((int) $exists['id'], $payload);
|
||||
} else {
|
||||
$payload['created_at'] = Carbon::now('UTC')->toDateTimeString();
|
||||
$this->studentClass->insert($payload);
|
||||
}
|
||||
|
||||
$this->promotion->update((int) $row['id'], [
|
||||
'status' => 'applied',
|
||||
'updated_at' => Carbon::now('UTC')->toDateTimeString(),
|
||||
'updated_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'applyPromotionAssignment (API) failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$parent = $this->user->newQuery()
|
||||
->select('users.*')
|
||||
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
|
||||
->join('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->where('roles.name', 'parent')
|
||||
->where('users.id', $id)
|
||||
->first();
|
||||
|
||||
if (!$parent) {
|
||||
return $this->error('Parent not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
unset($parent['password'], $parent['token']);
|
||||
return $this->success($parent, 'Parent retrieved successfully');
|
||||
}
|
||||
|
||||
public function getStudents($id = null)
|
||||
{
|
||||
$parent = $this->user->find($id);
|
||||
if (!$parent) {
|
||||
return $this->error('Parent not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$students = $this->student->newQuery()
|
||||
->where('parent_id', $id)
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
foreach ($students as &$student) {
|
||||
$classInfo = DB::table('student_class')
|
||||
->select('class_section.*', 'classes.name as class_name')
|
||||
->join('class_section', 'class_section.id', '=', 'student_class.class_section_id', 'left')
|
||||
->join('classes', 'classes.id', '=', 'class_section.class_id', 'left')
|
||||
->where('student_class.student_id', $student['id'])
|
||||
->orderByDesc('student_class.school_year')
|
||||
->first();
|
||||
|
||||
$student['current_class'] = $classInfo ? (array) $classInfo : null;
|
||||
|
||||
$student['medical_conditions'] = $this->medicalCondition
|
||||
->where('student_id', $student['id'])
|
||||
->findColumn('condition_name') ?? [];
|
||||
|
||||
$student['allergies'] = $this->allergy
|
||||
->where('student_id', $student['id'])
|
||||
->findColumn('allergy') ?? [];
|
||||
}
|
||||
|
||||
return $this->success($students, 'Students retrieved successfully');
|
||||
}
|
||||
|
||||
public function attendance($id)
|
||||
{
|
||||
try {
|
||||
$year = $this->request->getGet('school_year') ?? $this->config->getConfig('school_year');
|
||||
|
||||
$records = DB::table('attendance_data')
|
||||
->select('students.firstname', 'students.lastname', 'attendance_data.date', 'attendance_data.status', 'attendance_data.reason')
|
||||
->join('students', 'students.id', '=', 'attendance_data.student_id')
|
||||
->where('students.parent_id', $id)
|
||||
->where('attendance_data.school_year', $year)
|
||||
->orderByDesc('attendance_data.date')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
return $this->success($records, 'Attendance data retrieved successfully');
|
||||
} catch (Throwable $e) {
|
||||
return $this->error('Error fetching attendance: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function payments($id)
|
||||
{
|
||||
$invoices = DB::table('invoices')
|
||||
->where('parent_id', $id)
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($row) => (array) $row)
|
||||
->toArray();
|
||||
|
||||
return $this->success($invoices, 'Payments retrieved successfully');
|
||||
}
|
||||
|
||||
public function enrollments($id)
|
||||
{
|
||||
$schoolYear = $this->request->getGet('school_year') ?? $this->config->getConfig('school_year');
|
||||
$students = $this->student->where('parent_id', $id)->findAll();
|
||||
|
||||
$results = [];
|
||||
foreach ($students as $student) {
|
||||
$enrollment = $this->enrollment->newQuery()
|
||||
->where('student_id', $student['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
|
||||
$student['enrollment'] = $enrollment ?? [
|
||||
'enrollment_status' => 'not enrolled',
|
||||
'admission_status' => null,
|
||||
];
|
||||
$results[] = $student;
|
||||
}
|
||||
|
||||
return $this->success($results, 'Enrollment data retrieved successfully');
|
||||
}
|
||||
|
||||
public function enroll($id)
|
||||
{
|
||||
try {
|
||||
$data = $this->payloadData();
|
||||
if (empty($data['student_ids']) || !is_array($data['student_ids'])) {
|
||||
return $this->error('Invalid student_ids payload', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$parentId = (int) $id;
|
||||
$result = $this->processEnrollmentUpdate($parentId, array_map('intval', $data['student_ids']), []);
|
||||
return $this->success($result, 'Enrollment submitted successfully');
|
||||
} catch (Throwable $e) {
|
||||
return $this->error('Failed to enroll: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function events($id)
|
||||
{
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$events = $this->event->getActiveEvents($schoolYear, $semester);
|
||||
$charges = $this->charges->getChargesWithEventInfo($id, $schoolYear);
|
||||
|
||||
$chargeMap = [];
|
||||
foreach ($charges as $charge) {
|
||||
$chargeMap[$charge['student_id'] . ':' . $charge['event_id']] = $charge;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'events' => $events,
|
||||
'charges' => $chargeMap,
|
||||
], 'Event participation data retrieved');
|
||||
}
|
||||
|
||||
public function updateParticipation($id)
|
||||
{
|
||||
try {
|
||||
$payload = $this->payloadData();
|
||||
$participations = $payload['participation'] ?? [];
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
foreach ($participations as $key => $value) {
|
||||
[$studentId, $eventId] = explode(':', $key);
|
||||
$existing = $this->charges->where([
|
||||
'parent_id' => $id,
|
||||
'student_id' => $studentId,
|
||||
'event_id' => $eventId,
|
||||
])->first();
|
||||
|
||||
if ($value === 'no') {
|
||||
if ($existing) {
|
||||
$this->charges->delete($existing['id']);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$event = $this->event->getEvent($eventId, $schoolYear);
|
||||
if ($existing) {
|
||||
$this->charges->update($existing['id'], ['participation' => $value]);
|
||||
} else {
|
||||
$this->charges->insert([
|
||||
'parent_id' => $id,
|
||||
'student_id' => $studentId,
|
||||
'event_id' => $eventId,
|
||||
'participation' => $value,
|
||||
'charged' => $event['amount'],
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([], 'Participation updated successfully');
|
||||
} catch (Throwable $e) {
|
||||
return $this->error('Failed to update participation: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function profile($id)
|
||||
{
|
||||
$user = $this->user->find($id);
|
||||
if (!$user) {
|
||||
return $this->error('Parent not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
unset($user['password'], $user['token']);
|
||||
return $this->success($user, 'Profile retrieved successfully');
|
||||
}
|
||||
|
||||
public function updateProfile($id)
|
||||
{
|
||||
try {
|
||||
$data = $this->payloadData();
|
||||
if (!$data) {
|
||||
return $this->error('Invalid JSON payload', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowed = ['firstname', 'lastname', 'cellphone', 'address_street', 'city', 'state', 'zip'];
|
||||
$update = array_intersect_key($data, array_flip($allowed));
|
||||
if (empty($update)) {
|
||||
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$this->user->update($id, $update);
|
||||
return $this->success([], 'Profile updated successfully');
|
||||
} catch (Throwable $e) {
|
||||
return $this->error('Failed to update profile: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Participation;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ParticipationController extends BaseApiController
|
||||
{
|
||||
protected Configuration $config;
|
||||
protected Student $student;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected Participation $participation;
|
||||
protected SemesterScoreService $semesterScoreService;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->config = model(Configuration::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->participation = model(Participation::class);
|
||||
$this->semesterScoreService = app(SemesterScoreService::class);
|
||||
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/participation
|
||||
* Get participation scores for a class section
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// Get class_section_id from POST -> GET -> session
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Persist for downstream use
|
||||
session()->put('class_section_id', $classSectionId);
|
||||
|
||||
try {
|
||||
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
return $this->success([
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'school_year' => $result['schoolYear'],
|
||||
'class_section_id' => $classSectionId,
|
||||
], 'Participation scores retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Participation index error: ' . $e->getMessage());
|
||||
return $this->respondError($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/participation
|
||||
* Update participation scores (teacher endpoint)
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
$updatedBy = $this->getCurrentUserId();
|
||||
if (!$updatedBy) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$scores = $payload['final_score'] ?? null;
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return $this->respondError('No scores submitted.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->saveParticipationScores('participation', $scores, $updatedBy, $classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
// Add semester and school_year to each student info for the service
|
||||
foreach ($studentTeacherInfo as &$student) {
|
||||
$student['semester'] = $this->semester;
|
||||
$student['school_year'] = $this->schoolYear;
|
||||
}
|
||||
unset($student);
|
||||
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
|
||||
} catch (RuntimeException $e) {
|
||||
log_message('error', 'SemesterScoreService error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_count' => count($scores),
|
||||
], 'Participation scores updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Participation update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update participation scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/participation/management
|
||||
* Update participation scores (management endpoint)
|
||||
*/
|
||||
public function updateManagement()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
$scores = $payload['final_score'] ?? null;
|
||||
$updatedBy = (int) ($payload['teacher_id'] ?? $this->getCurrentUserId() ?? 0);
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return $this->respondError('No scores submitted.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($updatedBy <= 0) {
|
||||
return $this->respondError('Missing teacher ID.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->saveParticipationScores('participation', $scores, $updatedBy, $classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
// Add semester and school_year to each student info for the service
|
||||
foreach ($studentTeacherInfo as &$student) {
|
||||
$student['semester'] = $this->semester;
|
||||
$student['school_year'] = $this->schoolYear;
|
||||
}
|
||||
unset($student);
|
||||
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
|
||||
} catch (RuntimeException $e) {
|
||||
log_message('error', 'SemesterScoreService error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Return the same data structure as index for consistency
|
||||
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
return $this->success([
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'school_year' => $result['schoolYear'],
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_count' => count($scores),
|
||||
], 'Participation scores updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Participation updateManagement error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update participation scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/participation/management
|
||||
* Get participation scores for management view
|
||||
*/
|
||||
public function showManagement()
|
||||
{
|
||||
// Get class_section_id from POST -> GET -> session
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Load roster + saved scores for this class/term
|
||||
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
return $this->success([
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'school_year' => $result['schoolYear'],
|
||||
'class_section_id' => $classSectionId,
|
||||
], 'Participation management data retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Participation showManagement error: ' . $e->getMessage());
|
||||
return $this->respondError($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/participation/student/{id}
|
||||
* Get participation score for a specific student
|
||||
*/
|
||||
public function getByStudent($id = null)
|
||||
{
|
||||
$participation = $this->participation->newQuery()
|
||||
->where('student_id', $id)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester)
|
||||
->first();
|
||||
|
||||
return $this->success($participation ?: null, 'Participation retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save participation scores to the database
|
||||
*/
|
||||
private function saveParticipationScores(string $table, array $scores, int $updatedBy, int $classSectionId, string $semester, string $schoolYear): void
|
||||
{
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($scores as $studentId => $data) {
|
||||
if (!is_numeric($studentId) || !isset($data['score'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = $data['score'];
|
||||
|
||||
$existing = DB::table($table)
|
||||
->where('student_id', $studentId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
$dataToSave = [
|
||||
'student_id' => (int) $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'score' => is_numeric($score) ? (float) $score : null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
DB::table($table)
|
||||
->where('id', $existing->id)
|
||||
->update($dataToSave);
|
||||
} else {
|
||||
$dataToSave['created_at'] = $now;
|
||||
DB::table($table)->insert($dataToSave);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get roster + saved participation scores for a specific classSection/term.
|
||||
*/
|
||||
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid class section id.');
|
||||
}
|
||||
|
||||
session()->put('class_section_id', $classSectionId);
|
||||
|
||||
// Main query: Students + LEFT JOIN participation for same section/term
|
||||
$students = DB::table('students as s')
|
||||
->select([
|
||||
's.id AS student_id',
|
||||
's.school_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
'p.score',
|
||||
])
|
||||
->join('student_class as sc', function ($join) use ($classSectionId, $schoolYear) {
|
||||
$join->on('sc.student_id', '=', 's.id')
|
||||
->where('sc.class_section_id', '=', $classSectionId)
|
||||
->where('sc.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoin('participation as p', function ($join) use ($classSectionId, $semester, $schoolYear) {
|
||||
$join->on('p.student_id', '=', 's.id')
|
||||
->where('p.class_section_id', '=', $classSectionId)
|
||||
->where('p.semester', '=', $semester)
|
||||
->where('p.school_year', '=', $schoolYear);
|
||||
})
|
||||
->distinct()
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
return (array) $row;
|
||||
})
|
||||
->toArray();
|
||||
|
||||
// Final safety net: ensure one row per student_id
|
||||
$unique = [];
|
||||
foreach ($students as $r) {
|
||||
$unique[(int) $r['student_id']] = $r;
|
||||
}
|
||||
|
||||
$students = array_values($unique);
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,493 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\FamilyGuardian;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Models\PaymentNotificationLog;
|
||||
use App\Models\User;
|
||||
use App\Services\EmailService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PaymentNotificationController extends BaseApiController
|
||||
{
|
||||
protected PaymentNotificationLog $log;
|
||||
protected User $user;
|
||||
protected Configuration $config;
|
||||
protected Invoice $invoice;
|
||||
protected Payment $payment;
|
||||
protected FamilyGuardian $familyGuardian;
|
||||
protected EmailService $emailService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->log = model(PaymentNotificationLog::class);
|
||||
$this->user = model(User::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->invoice = model(Invoice::class);
|
||||
$this->payment = model(Payment::class);
|
||||
$this->familyGuardian = model(FamilyGuardian::class);
|
||||
$this->emailService = app(EmailService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/payments/notifications
|
||||
* List payment notification logs with optional filters
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$from = $this->request->getGet('from');
|
||||
$to = $this->request->getGet('to');
|
||||
$type = $this->request->getGet('type');
|
||||
|
||||
try {
|
||||
$rows = $this->log->listLogs($from, $to, $type);
|
||||
|
||||
// Preload parent names
|
||||
$parents = [];
|
||||
foreach ($rows as $r) {
|
||||
$pid = (int) ($r['parent_id'] ?? 0);
|
||||
if ($pid && !isset($parents[$pid])) {
|
||||
$u = $this->user->select('firstname,lastname')->find($pid);
|
||||
$parents[$pid] = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
// Add parent names to rows
|
||||
foreach ($rows as &$row) {
|
||||
$pid = (int) ($row['parent_id'] ?? 0);
|
||||
$row['parent_name'] = $parents[$pid] ?? '';
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$total = count($rows);
|
||||
$paginatedRows = array_slice($rows, $offset, $perPage);
|
||||
|
||||
$result = [
|
||||
'data' => $paginatedRows,
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'total_pages' => (int) ceil($total / $perPage),
|
||||
],
|
||||
];
|
||||
|
||||
return $this->success($result, 'Payment notification logs retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Payment notification logs error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve payment notification logs', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/payments/notifications/send
|
||||
* Send payment reminder notifications
|
||||
*
|
||||
* Body: parent_id? (int), type? ('no_payment'|'installment'), school_year?, force? (0|1)
|
||||
* - When parent_id provided: send for that parent only (if balance > 0 or force=1)
|
||||
* - Otherwise: send to all parents with positive balance for selected school year
|
||||
* respecting idempotency (1 email per month/type) unless force=1.
|
||||
*/
|
||||
public function send(): JsonResponse
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
|
||||
$parentId = (int) ($payload['parent_id'] ?? 0);
|
||||
$typeInput = (string) ($payload['type'] ?? '');
|
||||
$force = (int) ($payload['force'] ?? 0) === 1;
|
||||
$schoolYear = (string) ($payload['school_year'] ?? ($this->config->getConfig('school_year') ?? date('Y')));
|
||||
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$now = new \DateTime('now', $tz);
|
||||
$year = (int) $now->format('Y');
|
||||
$month = (int) $now->format('n');
|
||||
|
||||
// Helper: compute current total balance across invoices for this parent/year
|
||||
$computeBalance = function (int $pid) use ($schoolYear): array {
|
||||
$rows = DB::table('invoices')
|
||||
->select('id', 'total_amount')
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = DB::getSchemaBuilder()->hasColumn($paymentsTbl, 'status');
|
||||
$hasVoid = DB::getSchemaBuilder()->hasColumn($paymentsTbl, 'is_void');
|
||||
|
||||
$sumBalance = 0.0;
|
||||
$latestId = null;
|
||||
|
||||
foreach ($rows as $ir) {
|
||||
$ir = (array) $ir; // Convert to array for consistent access
|
||||
$iid = (int) ($ir['id'] ?? 0);
|
||||
if ($latestId === null) {
|
||||
$latestId = $iid;
|
||||
}
|
||||
$total = (float) ($ir['total_amount'] ?? 0);
|
||||
|
||||
$qb = DB::table('payments')->select(DB::raw('COALESCE(SUM(paid_amount),0) AS tot'))->where('invoice_id', $iid);
|
||||
|
||||
if ($hasStatus) {
|
||||
$qb->where(function ($query) {
|
||||
$query->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
|
||||
->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
|
||||
if ($hasVoid) {
|
||||
$qb->where(function ($query) {
|
||||
$query->where('is_void', 0)
|
||||
->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
|
||||
$paidRow = $qb->first();
|
||||
$paid = (float) (($paidRow && isset($paidRow->tot)) ? $paidRow->tot : 0);
|
||||
|
||||
$discRow = DB::table('discount_usages')
|
||||
->select(DB::raw('COALESCE(SUM(discount_amount),0) AS tot'))
|
||||
->where('invoice_id', $iid)
|
||||
->first();
|
||||
$disc = (float) (($discRow && isset($discRow->tot)) ? $discRow->tot : 0);
|
||||
|
||||
$rfndRow = DB::table('refunds')
|
||||
->select(DB::raw('COALESCE(SUM(refund_paid_amount),0) AS tot'))
|
||||
->where('invoice_id', $iid)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->first();
|
||||
$rfnd = (float) (($rfndRow && isset($rfndRow->tot)) ? $rfndRow->tot : 0);
|
||||
|
||||
$sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2));
|
||||
}
|
||||
|
||||
return [$sumBalance, $latestId];
|
||||
};
|
||||
|
||||
// Helper: get secondary guardian email
|
||||
$getSecondary = function (int $primaryUserId): ?string {
|
||||
$row = $this->familyGuardian->where('user_id', $primaryUserId)->first();
|
||||
if (!$row || empty($row['family_id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$others = $this->familyGuardian
|
||||
->where('family_id', (int) $row['family_id'])
|
||||
->where('user_id', '!=', $primaryUserId)
|
||||
->where('receive_emails', 1)
|
||||
->findAll();
|
||||
|
||||
foreach ($others as $g) {
|
||||
$email = $this->user->select('email')->find((int) $g['user_id'])['email'] ?? null;
|
||||
if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return $email;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Compose subject/body using same content as CLI command
|
||||
$compose = function (int $pid, float $balance) use ($schoolYear, $now, $tzName): array {
|
||||
$u = $this->user->find($pid) ?: [];
|
||||
$parentName = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
|
||||
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
|
||||
|
||||
$monthYear = $now->format('F Y');
|
||||
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
|
||||
$balanceFmt = '$' . number_format($balance, 2);
|
||||
|
||||
$intro = 'This is your monthly installment reminder for ' . $schoolYear . '.';
|
||||
|
||||
// Compute remaining/max installments similar to Manual Payment UI
|
||||
$installmentEndRaw = (string) $this->config->getConfig('installment_date');
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$end = null;
|
||||
|
||||
try {
|
||||
if ($installmentEndRaw) {
|
||||
$end = new \DateTimeImmutable($installmentEndRaw, $tz);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$end = null;
|
||||
}
|
||||
|
||||
$remMonths = 0;
|
||||
if ($end) {
|
||||
$y = (int) $end->format('Y') - (int) $today->format('Y');
|
||||
$m = (int) $end->format('n') - (int) $today->format('n');
|
||||
$remMonths = $y * 12 + $m;
|
||||
if ((int) $end->format('j') > (int) $today->format('j')) {
|
||||
$remMonths += 1;
|
||||
}
|
||||
if ($remMonths < 0) {
|
||||
$remMonths = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$remainingInst = max(1, $remMonths);
|
||||
$maxInst = max(($remMonths ?: 0), ($balance > 0 ? 2 : 0));
|
||||
$instDueFmt = '$' . number_format($remainingInst > 0 ? ($balance / $remainingInst) : 0, 2);
|
||||
|
||||
$bodyHtml = <<<HTML
|
||||
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
|
||||
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder</h2>
|
||||
<p>{$greeting}</p>
|
||||
<p>{$intro}</p>
|
||||
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
|
||||
<ul>
|
||||
<li><strong>Remaining installments:</strong> {$remainingInst}</li>
|
||||
<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>
|
||||
<li><strong>Maximum installments available:</strong> {$maxInst}</li>
|
||||
</ul>
|
||||
<p>As a friendly reminder, our tuition installments are due at the beginning of each month.</p>
|
||||
<p>You can review your invoice and payment options by logging in to your parent portal.</p>
|
||||
<p>Thank you for your prompt attention.</p>
|
||||
<p><em>Reminder Period:</em> {$monthYear}</p>
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
// Try to wrap in email layout view, fallback to plain HTML
|
||||
try {
|
||||
$body = view('emails._wrap_layout', [
|
||||
'title' => 'Monthly Tuition Reminder',
|
||||
'body_html' => $bodyHtml,
|
||||
], ['saveData' => false]);
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback if view doesn't exist
|
||||
$body = $bodyHtml;
|
||||
}
|
||||
|
||||
return [$subject, $body];
|
||||
};
|
||||
|
||||
$results = [
|
||||
'sent' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'details' => [],
|
||||
];
|
||||
|
||||
if ($parentId > 0) {
|
||||
// Single parent mode
|
||||
[$balance, $latestInv] = $computeBalance($parentId);
|
||||
|
||||
// Detect type if not provided
|
||||
if ($typeInput === 'no_payment' || $typeInput === 'installment') {
|
||||
$type = $typeInput;
|
||||
} else {
|
||||
$hasPayments = $this->payment
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->countAllResults() > 0;
|
||||
$type = $hasPayments ? 'installment' : 'no_payment';
|
||||
}
|
||||
|
||||
// Idempotency: skip if already sent this month for this type
|
||||
if (!$force && $this->log->existsForPeriod($parentId, $year, $month, $type)) {
|
||||
$results['skipped']++;
|
||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'already_sent'];
|
||||
} elseif ($balance <= 0 && !$force) {
|
||||
$results['skipped']++;
|
||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'no_balance'];
|
||||
} else {
|
||||
// Compute current-month installment suggestion
|
||||
$installmentEndRaw = (string) $this->config->getConfig('installment_date');
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$nowD = new \DateTimeImmutable('today', $tz);
|
||||
$endD = null;
|
||||
|
||||
try {
|
||||
if ($installmentEndRaw) {
|
||||
$endD = new \DateTimeImmutable($installmentEndRaw, $tz);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$endD = null;
|
||||
}
|
||||
|
||||
$remMonths = 0;
|
||||
if ($endD) {
|
||||
$y = (int) $endD->format('Y') - (int) $nowD->format('Y');
|
||||
$m = (int) $endD->format('n') - (int) $nowD->format('n');
|
||||
$remMonths = $y * 12 + $m;
|
||||
if ((int) $endD->format('j') > (int) $nowD->format('j')) {
|
||||
$remMonths += 1;
|
||||
}
|
||||
if ($remMonths < 0) {
|
||||
$remMonths = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$remInst = max(1, $remMonths);
|
||||
$instDue = round($balance / $remInst, 2);
|
||||
|
||||
$toEmail = $this->user->select('email')->find($parentId)['email'] ?? null;
|
||||
$toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null;
|
||||
$ccEmail = $getSecondary($parentId);
|
||||
|
||||
[$subject, $body] = $compose($parentId, $balance);
|
||||
|
||||
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
|
||||
|
||||
if ($ok && $ccEmail && strcasecmp($ccEmail, (string) $toEmail) !== 0) {
|
||||
$this->emailService->send($ccEmail, $subject, $body, 'finance');
|
||||
}
|
||||
|
||||
$this->log->insert([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $latestInv,
|
||||
'school_year' => $schoolYear,
|
||||
'period_year' => $year,
|
||||
'period_month' => $month,
|
||||
'type' => $type,
|
||||
'to_email' => $toEmail,
|
||||
'cc_email' => $ccEmail,
|
||||
'head_fa_notified' => 0,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $ok ? 'sent' : 'failed',
|
||||
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
|
||||
'balance_snapshot' => $balance,
|
||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if ($ok) {
|
||||
$results['sent']++;
|
||||
$results['details'][] = [
|
||||
'parent_id' => $parentId,
|
||||
'result' => 'sent',
|
||||
'balance' => $balance,
|
||||
'installment_due' => $instDue,
|
||||
'remaining' => $remInst,
|
||||
];
|
||||
} else {
|
||||
$results['failed']++;
|
||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'failed'];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Bulk mode: parents with positive balance in selected year
|
||||
$rows = DB::table('invoices')
|
||||
->select(DB::raw('DISTINCT parent_id'))
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
$pids = array_values(array_unique($rows->pluck('parent_id')->map(fn($id) => (int) $id)->toArray()));
|
||||
|
||||
foreach ($pids as $pid) {
|
||||
[$balance, $latestInv] = $computeBalance($pid);
|
||||
|
||||
if ($balance <= 0 && !$force) {
|
||||
$results['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasPayments = $this->payment
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $schoolYear)
|
||||
->countAllResults() > 0;
|
||||
$type = $hasPayments ? 'installment' : 'no_payment';
|
||||
|
||||
if (!$force && $this->log->existsForPeriod($pid, $year, $month, $type)) {
|
||||
$results['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$toEmail = $this->user->select('email')->find($pid)['email'] ?? null;
|
||||
$toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null;
|
||||
$ccEmail = $getSecondary($pid);
|
||||
|
||||
[$subject, $body] = $compose($pid, $balance);
|
||||
|
||||
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
|
||||
|
||||
if ($ok && $ccEmail && strcasecmp($ccEmail, (string) $toEmail) !== 0) {
|
||||
$this->emailService->send($ccEmail, $subject, $body, 'finance');
|
||||
}
|
||||
|
||||
// Optional: notify head of finance in-app
|
||||
$this->notifyHeadOfFinance($pid, $type, $balance);
|
||||
|
||||
$this->log->insert([
|
||||
'parent_id' => $pid,
|
||||
'invoice_id' => $latestInv,
|
||||
'school_year' => $schoolYear,
|
||||
'period_year' => $year,
|
||||
'period_month' => $month,
|
||||
'type' => $type,
|
||||
'to_email' => $toEmail,
|
||||
'cc_email' => $ccEmail,
|
||||
'head_fa_notified' => 1,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $ok ? 'sent' : 'failed',
|
||||
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
|
||||
'balance_snapshot' => $balance,
|
||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if ($ok) {
|
||||
$results['sent']++;
|
||||
} else {
|
||||
$results['failed']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'ok' => true,
|
||||
] + $results + [
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
], 'Payment notifications processed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify head of finance users about payment reminder sent
|
||||
*/
|
||||
private function notifyHeadOfFinance(int $parentId, string $type, float $balance): void
|
||||
{
|
||||
try {
|
||||
$heads = $this->getHeadOfFinanceUsers();
|
||||
foreach ($heads as $u) {
|
||||
// Create in-app notification if notification system exists
|
||||
// This is optional functionality - can be implemented later
|
||||
Log::info(sprintf(
|
||||
'Payment reminder sent: %s reminder to parent #%d (balance: $%0.2f)',
|
||||
$type,
|
||||
$parentId,
|
||||
$balance
|
||||
));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Silently fail - this is optional
|
||||
Log::debug('Failed to notify head of finance: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get head of finance users
|
||||
*/
|
||||
private function getHeadOfFinanceUsers(): array
|
||||
{
|
||||
$heads = $this->user->getUsersByRole('head of department (finance)');
|
||||
if (!empty($heads)) {
|
||||
return $heads;
|
||||
}
|
||||
|
||||
$acct = $this->user->getUsersByRole('accountant');
|
||||
return $acct ?: [];
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\PaymentTransaction;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PaymentTransactionController extends BaseApiController
|
||||
{
|
||||
protected PaymentTransaction $transaction;
|
||||
protected Configuration $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->transaction = model(PaymentTransaction::class);
|
||||
$this->config = model(Configuration::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$paymentId = $this->request->getGet('payment_id');
|
||||
$status = $this->request->getGet('status');
|
||||
|
||||
$query = $this->transaction->newQuery();
|
||||
|
||||
if ($paymentId) {
|
||||
$query->where('payment_id', $paymentId);
|
||||
}
|
||||
|
||||
if ($status) {
|
||||
$query->where('payment_status', $status);
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
return $this->success($result, 'Payment transactions retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$transaction = $this->transaction->find($id);
|
||||
if (!$transaction) {
|
||||
return $this->error('Payment transaction not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($transaction, 'Payment transaction retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/payment-transactions/payment/{paymentId}
|
||||
* Get all transactions for a specific payment
|
||||
*/
|
||||
public function getByPayment($paymentId = null)
|
||||
{
|
||||
if (!$paymentId) {
|
||||
return $this->error('Payment ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$transactions = $this->transaction->getTransactionsByPaymentId($paymentId);
|
||||
|
||||
if (empty($transactions)) {
|
||||
return $this->error('Transactions not found for the payment', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($transactions, 'Payment transactions retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Get transactions by payment error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve payment transactions', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/payment-transactions
|
||||
* Create a new payment transaction (installment)
|
||||
*/
|
||||
public function store()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'payment_id' => 'required|integer',
|
||||
'transaction_id' => 'required|unique:payment_transactions,transaction_id',
|
||||
'amount' => 'required|numeric|min:0',
|
||||
'payment_method' => 'required|string|max:50',
|
||||
'transaction_date' => 'permit_empty|valid_date[Y-m-d H:i:s]',
|
||||
'transaction_fee' => 'permit_empty|numeric|min:0',
|
||||
'payment_reference' => 'permit_empty|string|max:255',
|
||||
'is_full_payment' => 'permit_empty|in:0,1',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$transactionData = [
|
||||
'transaction_id' => $data['transaction_id'],
|
||||
'payment_id' => (int) $data['payment_id'],
|
||||
'transaction_date' => $data['transaction_date'] ?? date('Y-m-d H:i:s'),
|
||||
'amount' => (float) $data['amount'],
|
||||
'payment_method' => $data['payment_method'],
|
||||
'payment_status' => 'Pending', // Default status as per CodeIgniter version
|
||||
'transaction_fee' => isset($data['transaction_fee']) ? (float) $data['transaction_fee'] : 0,
|
||||
'payment_reference' => $data['payment_reference'] ?? null,
|
||||
'is_full_payment' => isset($data['is_full_payment']) ? ((int) $data['is_full_payment'] ? 1 : 0) : 0,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
|
||||
try {
|
||||
$transaction = $this->transaction->create($transactionData);
|
||||
return $this->respondCreated($transaction->toArray(), 'Payment transaction created successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Payment transaction creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create payment transaction', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/payment-transactions/{transactionId}/status
|
||||
* Update payment status by transaction ID
|
||||
*/
|
||||
public function updateStatus($transactionId = null)
|
||||
{
|
||||
if (!$transactionId) {
|
||||
return $this->error('Transaction ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data) || !isset($data['status'])) {
|
||||
return $this->error('Status is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$status = (string) $data['status'];
|
||||
|
||||
$rules = [
|
||||
'status' => 'required|string|max:50',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
// First check if transaction exists by transaction_id (string) or id (integer)
|
||||
$transaction = null;
|
||||
if (is_numeric($transactionId)) {
|
||||
$transaction = $this->transaction->find((int) $transactionId);
|
||||
}
|
||||
|
||||
if (!$transaction) {
|
||||
$transaction = $this->transaction->getTransactionById($transactionId);
|
||||
}
|
||||
|
||||
if (!$transaction) {
|
||||
return $this->error('Transaction not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Update by transaction_id if it's a string, otherwise by id
|
||||
$updated = false;
|
||||
if (is_numeric($transactionId)) {
|
||||
$updated = $this->transaction->update((int) $transactionId, ['payment_status' => $status]);
|
||||
} else {
|
||||
$updated = $this->transaction->updateTransactionStatus($transactionId, $status);
|
||||
}
|
||||
|
||||
if ($updated) {
|
||||
// Fetch updated transaction
|
||||
$updatedTransaction = is_numeric($transactionId)
|
||||
? $this->transaction->find((int) $transactionId)
|
||||
: $this->transaction->getTransactionById($transactionId);
|
||||
|
||||
return $this->success([
|
||||
'status' => 'success',
|
||||
'transaction' => $updatedTransaction,
|
||||
], 'Transaction status updated successfully');
|
||||
} else {
|
||||
return $this->respondError('Failed to update transaction status', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Update transaction status error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update transaction status', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\PayPalPayment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Response as ResponseFacade;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PaypalTransactionsController extends BaseApiController
|
||||
{
|
||||
protected PayPalPayment $paypalPayment;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->paypalPayment = model(PayPalPayment::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$keyword = $this->request->getGet('q');
|
||||
|
||||
$query = $this->paypalPayment->newQuery()->orderBy('created_at', 'DESC');
|
||||
|
||||
if ($keyword) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('transaction_id', 'LIKE', "%{$keyword}%")
|
||||
->orWhere('payer_email', 'LIKE', "%{$keyword}%")
|
||||
->orWhere('event_type', 'LIKE', "%{$keyword}%")
|
||||
->orWhere('order_id', 'LIKE', "%{$keyword}%")
|
||||
->orWhere('parent_school_id', 'LIKE', "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
return $this->success($result, 'PayPal transactions retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$transaction = $this->paypalPayment->find($id);
|
||||
if (!$transaction) {
|
||||
return $this->error('PayPal transaction not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($transaction, 'PayPal transaction retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/paypal-transactions/transaction/{transactionId}
|
||||
* Get transaction by PayPal transaction ID
|
||||
*/
|
||||
public function getByTransactionId($transactionId = null)
|
||||
{
|
||||
$transaction = $this->paypalPayment->newQuery()
|
||||
->where('transaction_id', $transactionId)
|
||||
->first();
|
||||
|
||||
if (!$transaction) {
|
||||
return $this->error('PayPal transaction not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($transaction, 'PayPal transaction retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/paypal-transactions/export-csv
|
||||
* Export PayPal transactions to CSV file
|
||||
*/
|
||||
public function exportCsv(): StreamedResponse
|
||||
{
|
||||
$keyword = $this->request->getGet('q');
|
||||
|
||||
$query = $this->paypalPayment->newQuery()->orderBy('created_at', 'DESC');
|
||||
|
||||
if ($keyword) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('transaction_id', 'LIKE', "%{$keyword}%")
|
||||
->orWhere('payer_email', 'LIKE', "%{$keyword}%")
|
||||
->orWhere('event_type', 'LIKE', "%{$keyword}%")
|
||||
->orWhere('order_id', 'LIKE', "%{$keyword}%")
|
||||
->orWhere('parent_school_id', 'LIKE', "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
$transactions = $query->get()->toArray();
|
||||
|
||||
$filename = 'paypal_transactions_' . date('Ymd_His') . '.csv';
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
'Pragma' => 'no-cache',
|
||||
'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
|
||||
'Expires' => '0',
|
||||
];
|
||||
|
||||
return ResponseFacade::streamDownload(function () use ($transactions) {
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// CSV headers
|
||||
fputcsv($output, [
|
||||
'ID',
|
||||
'Transaction ID',
|
||||
'Order ID',
|
||||
'Parent School ID',
|
||||
'Email',
|
||||
'Amount',
|
||||
'Net Amount',
|
||||
'Currency',
|
||||
'Status',
|
||||
'Event Type',
|
||||
'Created At',
|
||||
]);
|
||||
|
||||
foreach ($transactions as $t) {
|
||||
$t = (array) $t; // Convert to array for consistent access
|
||||
fputcsv($output, [
|
||||
$t['id'] ?? '',
|
||||
$t['transaction_id'] ?? '',
|
||||
$t['order_id'] ?? '',
|
||||
$t['parent_school_id'] ?? '',
|
||||
$t['payer_email'] ?? '',
|
||||
$t['amount'] ?? '',
|
||||
$t['net_amount'] ?? '',
|
||||
$t['currency'] ?? '',
|
||||
$t['status'] ?? '',
|
||||
$t['event_type'] ?? '',
|
||||
$t['created_at'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
}, $filename, $headers);
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\PayPalPayment;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PaypalWebhook extends BaseApiController
|
||||
{
|
||||
protected Configuration $config;
|
||||
protected PayPalPayment $payment;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->config = model(Configuration::class);
|
||||
$this->payment = model(PayPalPayment::class);
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$config = $this->loadConfig();
|
||||
if (!$config) {
|
||||
return $this->respondError('PayPal configuration incomplete', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->respondError('Invalid JSON payload', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$requiredHeaders = [
|
||||
'paypal-transmission-id',
|
||||
'paypal-transmission-time',
|
||||
'paypal-cert-url',
|
||||
'paypal-auth-algo',
|
||||
'paypal-transmission-sig',
|
||||
];
|
||||
|
||||
foreach ($requiredHeaders as $header) {
|
||||
if (!$this->laravelRequest->headers->has($header)) {
|
||||
return $this->respondError("Missing required header: {$header}", Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
$accessToken = $this->getAccessToken($config['client_id'], $config['secret'], $config['token_url']);
|
||||
if (!$accessToken) {
|
||||
return $this->respondError('Failed to get access token', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$verificationData = [
|
||||
'auth_algo' => $this->laravelRequest->headers->get('paypal-auth-algo'),
|
||||
'cert_url' => $this->laravelRequest->headers->get('paypal-cert-url'),
|
||||
'transmission_id' => $this->laravelRequest->headers->get('paypal-transmission-id'),
|
||||
'transmission_sig' => $this->laravelRequest->headers->get('paypal-transmission-sig'),
|
||||
'transmission_time' => $this->laravelRequest->headers->get('paypal-transmission-time'),
|
||||
'webhook_id' => $config['webhook_id'],
|
||||
'webhook_event' => $payload,
|
||||
];
|
||||
|
||||
if (!$this->verifyWebhookSignature($config['verify_url'], $accessToken, $verificationData)) {
|
||||
return $this->respondError('Webhook verification failed', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->processPayment($payload);
|
||||
return $this->success(['status' => 'success'], 'Webhook processed successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Payment processing error: ' . $e->getMessage());
|
||||
return $this->respondError('Payment processing error', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
protected function loadConfig(): ?array
|
||||
{
|
||||
$mode = (string) ($this->config->getConfig('paypal_mode') ?? '');
|
||||
|
||||
$configs = [
|
||||
'sandbox' => [
|
||||
'webhook_id' => $this->config->getConfig('sandbox_webhookid_paypal'),
|
||||
'client_id' => $this->config->getConfig('sandbox_clientid_paypal'),
|
||||
'secret' => $this->config->getConfig('sandbox_secret_paypal'),
|
||||
'verify_url' => $this->config->getConfig('sandbox_verifylink_paypal'),
|
||||
'token_url' => $this->config->getConfig('sandbox_tokenurl_paypal'),
|
||||
],
|
||||
'production' => [
|
||||
'webhook_id' => $this->config->getConfig('production_webhookid_paypal'),
|
||||
'client_id' => $this->config->getConfig('production_clientid_paypal'),
|
||||
'secret' => $this->config->getConfig('production_secret_paypal'),
|
||||
'verify_url' => $this->config->getConfig('production_verifylink_paypal'),
|
||||
'token_url' => $this->config->getConfig('production_tokenurl_paypal'),
|
||||
],
|
||||
];
|
||||
|
||||
if (!isset($configs[$mode])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cfg = $configs[$mode];
|
||||
if (in_array(null, $cfg, true) || in_array('', $cfg, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $cfg + ['mode' => $mode];
|
||||
}
|
||||
|
||||
protected function getAccessToken(string $clientId, string $secret, string $urlToken): ?string
|
||||
{
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->withBasicAuth($clientId, $secret)
|
||||
->acceptJson()
|
||||
->post($urlToken, ['grant_type' => 'client_credentials']);
|
||||
|
||||
if (!$response->successful()) {
|
||||
log_message('error', 'Access token fetch failed: HTTP ' . $response->status());
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->json('access_token');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Access token fetch failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function verifyWebhookSignature(string $verifyUrl, string $accessToken, array $verificationData): bool
|
||||
{
|
||||
try {
|
||||
$response = Http::withToken($accessToken)
|
||||
->acceptJson()
|
||||
->post($verifyUrl, $verificationData);
|
||||
|
||||
if (!$response->successful()) {
|
||||
log_message('error', 'Webhook verification failed: HTTP ' . $response->status());
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($response->json('verification_status') ?? '') === 'SUCCESS';
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Webhook verification failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function processPayment(array $eventData): void
|
||||
{
|
||||
$resource = $eventData['resource'] ?? [];
|
||||
|
||||
$this->payment->create([
|
||||
'webhook_id' => $eventData['id'] ?? null,
|
||||
'transaction_id' => $resource['id'] ?? null,
|
||||
'status' => $resource['status'] ?? null,
|
||||
'amount' => $resource['amount']['value'] ?? null,
|
||||
'currency' => $resource['amount']['currency_code'] ?? null,
|
||||
'payer_email' => $resource['payer']['email_address'] ?? null,
|
||||
'raw_payload' => json_encode($eventData),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PolicyController extends BaseApiController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return $this->success([], 'Policy index');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/policy/picture
|
||||
* Get school picture policy content
|
||||
*/
|
||||
public function schoolPicturePolicy()
|
||||
{
|
||||
$partialPath = base_path('resources/views/policy/picture_policy_partial.php');
|
||||
$viewPath = base_path('resources/views/policy/picture_policy.blade.php');
|
||||
|
||||
if (!is_readable($partialPath)) {
|
||||
return $this->respondError('Picture policy partial not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
// Include the PHP partial file (may return data array or output HTML)
|
||||
$content = include $partialPath;
|
||||
|
||||
// If partial returns an array, try to render the view with it
|
||||
// Otherwise, use the content directly
|
||||
if (is_array($content)) {
|
||||
if (is_readable($viewPath)) {
|
||||
$html = view('policy.picture_policy', $content)->render();
|
||||
} else {
|
||||
// Fallback: if view doesn't exist, return the content as-is
|
||||
$html = is_string($content) ? $content : json_encode($content);
|
||||
}
|
||||
} else {
|
||||
// Content is already HTML string
|
||||
$html = (string) $content;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Picture policy load error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to load picture policy', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->success(['html' => $html], 'Picture policy content retrieved');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/policy/school
|
||||
* Get school policy content
|
||||
*/
|
||||
public function schoolPolicy()
|
||||
{
|
||||
$metaPath = base_path('resources/views/policy/school_policy_partial.php');
|
||||
$viewPath = base_path('resources/views/policy/school_policy.blade.php');
|
||||
|
||||
if (!is_readable($metaPath)) {
|
||||
return $this->respondError('School policy partial not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
// Include the PHP partial file (returns data array)
|
||||
$content = include $metaPath;
|
||||
$meta = is_array($content) ? $content : [];
|
||||
|
||||
// Render the Blade view with the meta data
|
||||
if (is_readable($viewPath)) {
|
||||
$html = view('policy.school_policy', $meta)->render();
|
||||
} else {
|
||||
// Fallback: if view doesn't exist, return meta as JSON
|
||||
$html = json_encode($meta, JSON_PRETTY_PRINT);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'School policy load error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to load school policy', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'meta' => $meta,
|
||||
'html' => $html,
|
||||
], 'School policy content retrieved');
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Preferences;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PreferencesController extends BaseApiController
|
||||
{
|
||||
protected Preferences $preferences;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->preferences = model(Preferences::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$preferences = $this->preferences->newQuery()
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
if (!$preferences) {
|
||||
$preferences = [
|
||||
'notification_email' => 1,
|
||||
'notification_sms' => 1,
|
||||
'theme' => 'light',
|
||||
'language' => 'en',
|
||||
];
|
||||
}
|
||||
|
||||
$styleConfig = config('Style');
|
||||
$preferences['style_color'] = session()->get('style_color') ?? ($styleConfig->defaultStyle ?? 'blue');
|
||||
$preferences['menu_color'] = session()->get('menu_color') ?? ($styleConfig->defaultMenu ?? 'white');
|
||||
|
||||
return $this->success($preferences, 'Preferences retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Preferences retrieval error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve preferences', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$styleConfig = config('Style');
|
||||
$styleOptions = implode(',', array_keys($styleConfig->stylePalettes ?? []));
|
||||
$menuOptions = implode(',', array_keys($styleConfig->menuPalettes ?? []));
|
||||
|
||||
$rules = [
|
||||
'notification_email' => 'permit_empty|in_list[0,1]',
|
||||
'notification_sms' => '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',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$existing = $this->preferences->newQuery()
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
$prefData = [
|
||||
'user_id' => $user->id,
|
||||
'notification_email' => $data['notification_email'] ?? 1,
|
||||
'notification_sms' => $data['notification_sms'] ?? 1,
|
||||
'theme' => $data['theme'] ?? 'light',
|
||||
'language' => $data['language'] ?? 'en',
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$this->preferences->update($existing['id'], $prefData);
|
||||
$preferences = $this->preferences->find($existing['id']);
|
||||
} else {
|
||||
$pref = $this->preferences->create($prefData);
|
||||
$preferences = $pref instanceof \Illuminate\Database\Eloquent\Model ? $pref->toArray() : $pref;
|
||||
}
|
||||
|
||||
if (isset($data['style_color'])) {
|
||||
session()->put('style_color', $data['style_color']);
|
||||
}
|
||||
if (isset($data['menu_color'])) {
|
||||
session()->put('menu_color', $data['menu_color']);
|
||||
}
|
||||
|
||||
return $this->success($preferences, 'Preferences updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Preferences update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update preferences', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,624 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\BadgePrintLog;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\SemesterScore;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PrintablesReportsController extends BaseApiController
|
||||
{
|
||||
protected Student $student;
|
||||
protected ClassSection $classSection;
|
||||
protected Configuration $config;
|
||||
protected BadgePrintLog $badgePrintLog;
|
||||
protected SemesterScore $semesterScore;
|
||||
protected StudentClass $studentClass;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected User $user;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->student = model(Student::class);
|
||||
$this->classSection = model(ClassSection::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->badgePrintLog = model(BadgePrintLog::class);
|
||||
$this->semesterScore = model(SemesterScore::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->user = model(User::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/printables/report-card-meta
|
||||
* Get report card metadata for UI hydration
|
||||
*/
|
||||
public function reportCardMeta(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$year = trim((string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? ''));
|
||||
$sem = trim((string) ($this->request->getGet('semester') ?? $this->semester ?? ''));
|
||||
|
||||
if ($year === '') {
|
||||
// Fallback to latest present in classSection or semester_score
|
||||
$yr = DB::table('classSection')
|
||||
->select('school_year')
|
||||
->orderBy('school_year', 'DESC')
|
||||
->first();
|
||||
$year = (string) ($yr->school_year ?? '');
|
||||
}
|
||||
|
||||
// Build schoolYears from classSection and semester_scores
|
||||
$schoolYears = [];
|
||||
try {
|
||||
$q1 = DB::table('classSection')
|
||||
->select(DB::raw('DISTINCT school_year'))
|
||||
->whereNotNull('school_year')
|
||||
->orderBy('school_year', 'DESC')
|
||||
->pluck('school_year')
|
||||
->map(fn($r) => (string) $r)
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
$schoolYears = $q1;
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
$q2 = DB::table('semester_scores')
|
||||
->select(DB::raw('DISTINCT school_year'))
|
||||
->whereNotNull('school_year')
|
||||
->orderBy('school_year', 'DESC')
|
||||
->pluck('school_year')
|
||||
->map(fn($r) => (string) $r)
|
||||
->filter()
|
||||
->toArray();
|
||||
|
||||
foreach ($q2 as $val) {
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) {
|
||||
$schoolYears[] = $val;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
rsort($schoolYears);
|
||||
|
||||
// Semesters available for the selected year
|
||||
$semesters = [];
|
||||
try {
|
||||
$rs = DB::table('semester_scores')
|
||||
->select(DB::raw('DISTINCT semester'))
|
||||
->where('school_year', $year)
|
||||
->whereNotNull('semester')
|
||||
->orderBy('semester', 'ASC')
|
||||
->pluck('semester')
|
||||
->map(fn($r) => (string) $r)
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
$semesters = $rs;
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (empty($semesters)) {
|
||||
// Provide a sensible default list
|
||||
$defaults = array_filter([(string) ($this->semester ?? ''), 'Fall', 'Spring'], fn($v) => $v !== '');
|
||||
$semesters = array_values(array_unique($defaults));
|
||||
}
|
||||
|
||||
if ($sem === '' && !empty($semesters)) {
|
||||
$sem = $semesters[0];
|
||||
}
|
||||
|
||||
// Students: prefer those with scores in selected year (+semester if provided); fallback to all
|
||||
$students = [];
|
||||
try {
|
||||
$stuRows = DB::table('semester_scores ss')
|
||||
->select(DB::raw('DISTINCT s.id, s.firstname, s.lastname'))
|
||||
->join('students s', 's.id', '=', 'ss.student_id')
|
||||
->where('ss.school_year', $year)
|
||||
->where(function ($query) use ($sem) {
|
||||
if ($sem !== '') {
|
||||
$query->where('ss.semester', $sem);
|
||||
}
|
||||
})
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
if (!empty($stuRows)) {
|
||||
$students = array_map(fn($r) => [
|
||||
'id' => (int) $r->id,
|
||||
'firstname' => (string) ($r->firstname ?? ''),
|
||||
'lastname' => (string) ($r->lastname ?? ''),
|
||||
], $stuRows);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (empty($students)) {
|
||||
$allStudents = $this->student
|
||||
->select('id', 'firstname', 'lastname')
|
||||
->orderBy('lastname', 'ASC')
|
||||
->findAll();
|
||||
$students = array_map(fn($s) => [
|
||||
'id' => (int) ($s['id'] ?? 0),
|
||||
'firstname' => (string) ($s['firstname'] ?? ''),
|
||||
'lastname' => (string) ($s['lastname'] ?? ''),
|
||||
], $allStudents);
|
||||
}
|
||||
|
||||
// Class sections for selected year (fallback to all)
|
||||
$classSections = $this->classSection
|
||||
->select('id', 'class_section_id', 'class_section_name', 'school_year')
|
||||
->where('school_year', $year)
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
if (empty($classSections)) {
|
||||
$classSections = $this->classSection
|
||||
->select('id', 'class_section_id', 'class_section_name', 'school_year')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
$classSectionsFormatted = array_map(static fn($r) => [
|
||||
'id' => (int) ($r['id'] ?? 0),
|
||||
'class_section_id' => (int) ($r['class_section_id'] ?? ($r['id'] ?? 0)),
|
||||
'class_section_name' => (string) ($r['class_section_name'] ?? 'Section'),
|
||||
'school_year' => (string) ($r['school_year'] ?? ''),
|
||||
], $classSections);
|
||||
|
||||
return $this->success([
|
||||
'ok' => true,
|
||||
'schoolYears' => $schoolYears,
|
||||
'selectedYear' => $year,
|
||||
'semesters' => $semesters,
|
||||
'selectedSemester' => $sem,
|
||||
'students' => $students,
|
||||
'classSections' => $classSectionsFormatted,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
], 'Report card metadata retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Report card meta error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve report card metadata', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/printables/badge-status
|
||||
* Get badge print status for given users
|
||||
*/
|
||||
public function badgePrintStatus(): JsonResponse
|
||||
{
|
||||
$ids = $this->request->getGet('user_ids');
|
||||
if (is_string($ids)) {
|
||||
$ids = array_filter(array_map('trim', explode(',', $ids)), 'strlen');
|
||||
}
|
||||
if (!is_array($ids)) {
|
||||
$ids = $ids ? [$ids] : [];
|
||||
}
|
||||
$ids = array_values(array_unique(array_map(static fn($v) => (int) $v, $ids)));
|
||||
|
||||
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
|
||||
try {
|
||||
$status = $this->badgePrintLog->getStatus($ids, is_string($schoolYear) ? $schoolYear : null);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Badge print status error: ' . $e->getMessage());
|
||||
return $this->error('Failed to query status', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'ok' => true,
|
||||
'data' => $status,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
], 'Badge print status retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/printables/badge-status
|
||||
* Log a set of badge prints explicitly
|
||||
*/
|
||||
public function logBadgePrint(): JsonResponse
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$ids = $data['user_ids'] ?? [];
|
||||
if (!is_array($ids)) {
|
||||
$ids = $ids ? [$ids] : [];
|
||||
}
|
||||
$ids = array_values(array_unique(array_map(static fn($v) => (int) $v, $ids)));
|
||||
|
||||
$roles = $data['roles'] ?? [];
|
||||
$class = $data['classes'] ?? [];
|
||||
if (!is_array($roles)) {
|
||||
$roles = [];
|
||||
}
|
||||
if (!is_array($class)) {
|
||||
$class = [];
|
||||
}
|
||||
|
||||
$schoolYear = $data['school_year'] ?? $this->schoolYear;
|
||||
$actorId = $this->getCurrentUserId();
|
||||
|
||||
try {
|
||||
$cnt = $this->badgePrintLog->logPrints(
|
||||
$ids,
|
||||
$actorId,
|
||||
is_string($schoolYear) ? $schoolYear : null,
|
||||
$roles,
|
||||
$class,
|
||||
1
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'ok' => true,
|
||||
'inserted' => $cnt,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
], 'Badge prints logged successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Log badge print error: ' . $e->getMessage());
|
||||
return $this->error('Failed to log badge prints: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/printables/students-by-class/{classSectionId}
|
||||
* Get students by class section
|
||||
*/
|
||||
public function getByClass($classSectionId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$students = 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', (int) $classSectionId)
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->orderBy('s.lastname', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success($students, 'Students retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Get students by class error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve students', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/printables/students-by-class/{classSectionId}
|
||||
* Alternative endpoint name for students by class
|
||||
*/
|
||||
public function studentsByClass($classSectionId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$rows = $this->student
|
||||
->select('students.id', 'students.firstname', 'students.lastname', 'students.gender', 'cs.class_section_name AS registration_grade')
|
||||
->join('student_class sc', 'sc.student_id', '=', 'students.id', 'inner')
|
||||
->join('classSection cs', 'cs.class_section_id', '=', 'sc.class_section_id', 'inner')
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->where('sc.class_section_id', (int) $classSectionId)
|
||||
->groupBy('students.id', 'students.firstname', 'students.lastname', 'students.gender', 'cs.class_section_name')
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->findAll(1000);
|
||||
|
||||
return $this->success($rows, 'Students retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Students by class error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve students', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/printables/preview-sticker-counts
|
||||
* Preview sticker counts per student
|
||||
*/
|
||||
public function previewStickerCounts(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$classId = (int) ($this->request->getGet('class_id') ?? 0);
|
||||
|
||||
if ($classId > 0) {
|
||||
$result = $this->getStickerCountsPerStudentForClass($this->schoolYear, $classId, $this->semester);
|
||||
} else {
|
||||
$result = $this->getStickerCountsPerStudent($this->schoolYear, $this->semester);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'status' => 'ok',
|
||||
'data' => $result,
|
||||
], 'Sticker counts retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Preview sticker counts error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve sticker counts', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute sticker counts per student for entire school year
|
||||
*/
|
||||
protected function getStickerCountsPerStudent(string $schoolYear, ?string $semester = null): array
|
||||
{
|
||||
$specialMap = [
|
||||
'KG' => 1,
|
||||
'1-A' => 3,
|
||||
'1-B' => 4,
|
||||
'2-A' => 5,
|
||||
'2-B' => 5,
|
||||
'9' => 2,
|
||||
];
|
||||
|
||||
$isUpperBlock = static function (string $g): bool {
|
||||
return (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
|
||||
};
|
||||
|
||||
$isGrade5 = static function (string $g): bool {
|
||||
return (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||
};
|
||||
|
||||
$builder = DB::table('student_class sc')
|
||||
->select([
|
||||
'sc.student_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.is_new',
|
||||
'cs.class_section_name AS grade_label',
|
||||
])
|
||||
->join('students s', 's.id', '=', 'sc.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->join('classes c', 'c.id', '=', 'cs.class_id')
|
||||
->where('sc.school_year', $schoolYear);
|
||||
|
||||
if ($semester !== null && $semester !== '') {
|
||||
$builder->where('sc.semester', $semester);
|
||||
}
|
||||
|
||||
$rows = $builder->get()->toArray();
|
||||
|
||||
// Filter out Youth and empty labels
|
||||
$rows = array_values(array_filter($rows, static function ($r) {
|
||||
$g = trim((string) ($r->grade_label ?? ''));
|
||||
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
|
||||
}));
|
||||
|
||||
$out = [];
|
||||
$totalPrimary = 0;
|
||||
$totalSecondary = 0;
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$first = trim((string) ($r->firstname ?? ''));
|
||||
$last = trim((string) ($r->lastname ?? ''));
|
||||
$grade = trim((string) ($r->grade_label ?? ''));
|
||||
$isNew = ((int) ($r->is_new ?? 0)) === 1;
|
||||
|
||||
$primary = 0;
|
||||
$secondary = 0;
|
||||
|
||||
if (array_key_exists($grade, $specialMap)) {
|
||||
$primary = (int) $specialMap[$grade];
|
||||
$secondary = 0;
|
||||
} elseif ($isUpperBlock($grade)) {
|
||||
if ($isNew) {
|
||||
$primary = 4;
|
||||
$secondary = 0;
|
||||
} else {
|
||||
$primary = $isGrade5($grade) ? 3 : 2;
|
||||
$secondary = max(0, 4 - $primary);
|
||||
}
|
||||
} else {
|
||||
$primary = 4;
|
||||
$secondary = 0;
|
||||
}
|
||||
|
||||
$totalPrimary += $primary;
|
||||
$totalSecondary += $secondary;
|
||||
|
||||
$out[] = [
|
||||
'student_id' => (int) $r->student_id,
|
||||
'firstname' => $first,
|
||||
'lastname' => $last,
|
||||
'grade_label' => $grade,
|
||||
'primary_count' => $primary,
|
||||
'secondary_count' => $secondary,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'students' => $out,
|
||||
'totals' => [
|
||||
'primary' => $totalPrimary,
|
||||
'secondary' => $totalSecondary,
|
||||
'students' => count($out),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute sticker counts per student for one class section
|
||||
*/
|
||||
protected function getStickerCountsPerStudentForClass(
|
||||
string $schoolYear,
|
||||
int $classSectionId,
|
||||
?string $semester = null
|
||||
): array {
|
||||
$specialMap = [
|
||||
'1-A' => 3,
|
||||
'1-B' => 4,
|
||||
'2-A' => 5,
|
||||
'2-B' => 5,
|
||||
'9' => 2,
|
||||
];
|
||||
|
||||
$isUpperBlock = static fn($g) => (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
|
||||
$isGrade5 = static fn($g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||
|
||||
$b = DB::table('student_class sc')
|
||||
->select('sc.student_id', 's.firstname', 's.lastname', 's.is_new', 'cs.class_section_name AS grade_label')
|
||||
->join('students s', 's.id', '=', 'sc.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->join('classes c', 'c.id', '=', 'cs.class_id')
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->where('sc.class_section_id', $classSectionId);
|
||||
|
||||
if (!empty($semester)) {
|
||||
$b->where('sc.semester', $semester);
|
||||
}
|
||||
|
||||
$rows = $b->get()->toArray();
|
||||
|
||||
$rows = array_values(array_filter($rows, static function ($r) {
|
||||
$g = trim((string) ($r->grade_label ?? ''));
|
||||
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
|
||||
}));
|
||||
|
||||
$out = [];
|
||||
$tp = 0;
|
||||
$ts = 0;
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$g = trim((string) ($r->grade_label ?? ''));
|
||||
$isNew = ((int) ($r->is_new ?? 0)) === 1;
|
||||
|
||||
$p = 0;
|
||||
$s = 0;
|
||||
|
||||
if (isset($specialMap[$g])) {
|
||||
$p = $specialMap[$g];
|
||||
} elseif ($isUpperBlock($g)) {
|
||||
if ($isNew) {
|
||||
$p = 4;
|
||||
} else {
|
||||
$p = $isGrade5($g) ? 3 : 2;
|
||||
$s = max(0, 4 - $p);
|
||||
}
|
||||
} else {
|
||||
$p = 4;
|
||||
}
|
||||
|
||||
$tp += $p;
|
||||
$ts += $s;
|
||||
|
||||
$out[] = [
|
||||
'student_id' => (int) $r->student_id,
|
||||
'firstname' => trim((string) ($r->firstname ?? '')),
|
||||
'lastname' => trim((string) ($r->lastname ?? '')),
|
||||
'grade_label' => $g,
|
||||
'primary_count' => $p,
|
||||
'secondary_count' => $s,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'students' => $out,
|
||||
'totals' => [
|
||||
'primary' => $tp,
|
||||
'secondary' => $ts,
|
||||
'students' => count($out),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/printables/badge
|
||||
* Generate staff badge PDF
|
||||
*
|
||||
* Note: This method requires FPDF library. The PDF generation logic from the
|
||||
* CodeIgniter version should be integrated here when FPDF is available.
|
||||
*/
|
||||
public function badge(): Response
|
||||
{
|
||||
// TODO: Implement PDF badge generation using FPDF
|
||||
// This requires the FPDF library to be installed and the drawBadgeInCell method
|
||||
// to be implemented. For now, return an error indicating it's not implemented.
|
||||
|
||||
return $this->respondError(
|
||||
'Badge PDF generation is not yet implemented. FPDF library integration required.',
|
||||
Response::HTTP_NOT_IMPLEMENTED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/printables/generate-stickers
|
||||
* Generate student name stickers PDF
|
||||
*
|
||||
* Note: This method requires FPDF library. The PDF generation logic from the
|
||||
* CodeIgniter version should be integrated here when FPDF is available.
|
||||
*/
|
||||
public function generateStickers(): Response
|
||||
{
|
||||
// TODO: Implement PDF sticker generation using FPDF
|
||||
// This requires the FPDF library to be installed.
|
||||
// For now, return an error indicating it's not implemented.
|
||||
|
||||
return $this->respondError(
|
||||
'Sticker PDF generation is not yet implemented. FPDF library integration required.',
|
||||
Response::HTTP_NOT_IMPLEMENTED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/printables/report-card/{studentId}
|
||||
* Generate single student report card PDF
|
||||
*
|
||||
* Note: This method requires FPDF library. The PDF generation logic from the
|
||||
* CodeIgniter version should be integrated here when FPDF is available.
|
||||
*/
|
||||
public function generateSingleReport($studentId): Response
|
||||
{
|
||||
// TODO: Implement PDF report card generation using FPDF
|
||||
// This requires the FPDF library to be installed and the formatReportPDF
|
||||
// and prepareStudentReportData methods to be implemented.
|
||||
|
||||
return $this->respondError(
|
||||
'Report card PDF generation is not yet implemented. FPDF library integration required.',
|
||||
Response::HTTP_NOT_IMPLEMENTED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/printables/report-card/class/{classSectionId}
|
||||
* Generate class report cards PDF
|
||||
*
|
||||
* Note: This method requires FPDF library. The PDF generation logic from the
|
||||
* CodeIgniter version should be integrated here when FPDF is available.
|
||||
*/
|
||||
public function generateClassReport($classSectionId): Response
|
||||
{
|
||||
// TODO: Implement PDF class report cards generation using FPDF
|
||||
// This requires the FPDF library to be installed.
|
||||
|
||||
return $this->respondError(
|
||||
'Class report cards PDF generation is not yet implemented. FPDF library integration required.',
|
||||
Response::HTTP_NOT_IMPLEMENTED
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,621 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Project;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Services\SemesterScoreService;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ProjectController extends BaseApiController
|
||||
{
|
||||
protected Project $project;
|
||||
protected Configuration $config;
|
||||
protected Student $student;
|
||||
protected StudentClass $studentClass;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected SemesterScoreService $semesterScoreService;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->project = model(Project::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->semesterScoreService = app(SemesterScoreService::class);
|
||||
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/projects
|
||||
* List projects with optional filters
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$studentId = $this->request->getGet('student_id');
|
||||
$classSectionId = $this->request->getGet('class_section_id');
|
||||
|
||||
$query = $this->project->newQuery()
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester);
|
||||
|
||||
if ($studentId) {
|
||||
$query->where('student_id', $studentId);
|
||||
}
|
||||
|
||||
if ($classSectionId) {
|
||||
$query->where('class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
return $this->success($result, 'Projects retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/projects/{id}
|
||||
* Get a single project
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$project = $this->project->find($id);
|
||||
if (!$project) {
|
||||
return $this->error('Project not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($project, 'Project retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/projects/student/{id}
|
||||
* Get projects for a specific student
|
||||
*/
|
||||
public function getByStudent($id = null)
|
||||
{
|
||||
$projects = $this->project->newQuery()
|
||||
->where('student_id', $id)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester)
|
||||
->orderBy('project_index', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success($projects, 'Projects retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/projects
|
||||
* Create a new project entry
|
||||
*/
|
||||
public function store()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'student_id' => 'required|integer',
|
||||
'class_section_id' => 'required|integer',
|
||||
'project_index' => 'required|integer',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$projectData = [
|
||||
'student_id' => $data['student_id'],
|
||||
'class_section_id' => $data['class_section_id'],
|
||||
'project_index' => $data['project_index'],
|
||||
'score' => $data['score'] ?? null,
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'updated_by' => $user->id,
|
||||
];
|
||||
|
||||
try {
|
||||
$project = $this->project->create($projectData);
|
||||
return $this->success($project->toArray(), 'Project created successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Project creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create project', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/projects/{id}
|
||||
* Update a project entry
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$project = $this->project->find($id);
|
||||
if (!$project) {
|
||||
return $this->error('Project not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowedFields = ['score', 'comment', 'project_index'];
|
||||
$updateData = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$updateData['updated_by'] = $user->id;
|
||||
$updateData['updated_at'] = utc_now();
|
||||
|
||||
try {
|
||||
$this->project->update($id, $updateData);
|
||||
$updatedProject = $this->project->find($id);
|
||||
return $this->success($updatedProject, 'Project updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Project update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update project', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/projects/add-project
|
||||
* Get project data for teacher's class section (equivalent to addProject view)
|
||||
*/
|
||||
public function addProject()
|
||||
{
|
||||
$updatedBy = $this->getCurrentUserId();
|
||||
if (!$updatedBy) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$teacherClass = $this->teacherClass->where('teacher_id', $updatedBy)->first();
|
||||
if (!$teacherClass) {
|
||||
return $this->respondError('No class section found for the current teacher.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$classSectionId = $teacherClass['class_section_id'];
|
||||
session()->put('class_section_id', $classSectionId);
|
||||
|
||||
$projectRows = $this->project
|
||||
->select('id, project_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('updated_by', $updatedBy)
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->orderBy('project_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$projectHeaders = [];
|
||||
foreach ($projectRows as $row) {
|
||||
$projectHeaders[$row['project_index']][] = $row['id'];
|
||||
}
|
||||
|
||||
$headerLabels = array_keys($projectHeaders);
|
||||
|
||||
$studentsClasses = $this->studentClass->where('class_section_id', $classSectionId)->findAll();
|
||||
$students = [];
|
||||
|
||||
foreach ($studentsClasses as $studentClass) {
|
||||
$studentId = $studentClass['student_id'];
|
||||
$student = $this->student->find($studentId);
|
||||
if ($student) {
|
||||
$scores = [];
|
||||
foreach ($projectHeaders as $index => $ids) {
|
||||
$entry = $this->project
|
||||
->where('student_id', $studentId)
|
||||
->where('project_index', $index)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $this->semester)
|
||||
->first();
|
||||
|
||||
$scores[$index] = $entry['score'] ?? '';
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $studentId,
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'scores' => $scores
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
||||
|
||||
return $this->success([
|
||||
'students' => $students,
|
||||
'project_headers' => $headerLabels,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_section_id' => $classSectionId
|
||||
], 'Project data retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/projects/update-scores
|
||||
* Update project scores for multiple students
|
||||
*/
|
||||
public function updateProjectScores()
|
||||
{
|
||||
$updatedBy = $this->getCurrentUserId();
|
||||
if (!$updatedBy) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$scores = $payload['scores'] ?? null;
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return $this->respondError('No project scores submitted.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($scores as $studentId => $projects) {
|
||||
if (!is_numeric($studentId)) continue;
|
||||
|
||||
$student = $this->student->find($studentId);
|
||||
if (!$student) continue;
|
||||
|
||||
foreach ($projects as $index => $score) {
|
||||
if (!is_numeric($score)) continue;
|
||||
|
||||
$existing = $this->project->where([
|
||||
'student_id' => $studentId,
|
||||
'project_index' => $index,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester
|
||||
])->first();
|
||||
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $student['school_id'] ?? '',
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'project_index' => $index,
|
||||
'score' => is_numeric($score) ? (float)$score : null,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$this->project->update($existing['id'], $data);
|
||||
} else {
|
||||
$data['created_at'] = utc_now();
|
||||
$this->project->insert($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId);
|
||||
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
|
||||
} catch (RuntimeException $e) {
|
||||
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success(null, 'Project scores updated successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Update project scores error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update project scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/projects/add-column
|
||||
* Add a new project column for all students in a class section
|
||||
*/
|
||||
public function addNextProjectColumn()
|
||||
{
|
||||
$updatedBy = $this->getCurrentUserId();
|
||||
if (!$updatedBy) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$existingIndexes = $this->project
|
||||
->select('project_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('updated_by', $updatedBy)
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->groupBy('project_index')
|
||||
->orderBy('project_index', 'DESC')
|
||||
->findAll();
|
||||
|
||||
$maxIndex = 0;
|
||||
foreach ($existingIndexes as $row) {
|
||||
if (isset($row['project_index']) && is_numeric($row['project_index'])) {
|
||||
$maxIndex = max($maxIndex, (int)$row['project_index']);
|
||||
}
|
||||
}
|
||||
|
||||
$nextIndex = $maxIndex + 1;
|
||||
|
||||
$students = $this->studentClass->where('class_section_id', $classSectionId)->findAll();
|
||||
|
||||
$insertedCount = 0;
|
||||
foreach ($students as $student) {
|
||||
$exists = $this->project->where([
|
||||
'student_id' => $student['student_id'],
|
||||
'project_index' => $nextIndex,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
])->first();
|
||||
|
||||
if (!$exists) {
|
||||
$studentRecord = $this->student->find($student['student_id']);
|
||||
$this->project->insert([
|
||||
'student_id' => $student['student_id'],
|
||||
'school_id' => $studentRecord['school_id'] ?? '',
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'project_index' => $nextIndex,
|
||||
'score' => null,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
$insertedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'status' => 'success',
|
||||
'project_index' => $nextIndex,
|
||||
'students_processed' => $insertedCount
|
||||
], 'Project column added successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Add project column error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to add project column: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/projects/management
|
||||
* Get project management data for a class section
|
||||
*/
|
||||
public function showProjectMngt()
|
||||
{
|
||||
// Accept POST first, then GET (query string), then session fallback
|
||||
$incomingId = $this->request->getPost('class_section_id');
|
||||
if (!$incomingId) {
|
||||
$incomingId = $this->request->getGet('class_section_id');
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('No class section found for the current teacher or selection.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
session()->put('class_section_id', $classSectionId);
|
||||
|
||||
$projectScores = $this->getProjectScoresByStudent($classSectionId, $this->semester, $this->schoolYear);
|
||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $this->schoolYear);
|
||||
$projectHeaders = $this->getProjectHeaders($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
return $this->success([
|
||||
'project_scores' => $projectScores,
|
||||
'students' => $students,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'project_headers' => $projectHeaders,
|
||||
'class_section_id' => $classSectionId,
|
||||
], 'Project management data retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/projects/update
|
||||
* Update project scores (management endpoint)
|
||||
*/
|
||||
public function updateProject()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
$scores = $payload['scores'] ?? null;
|
||||
$studentIds = $payload['student_ids'] ?? null;
|
||||
$semester = $payload['semester'] ?? $this->semester;
|
||||
$schoolYear = $payload['school_year'] ?? $this->schoolYear;
|
||||
$updatedBy = $payload['teacher_id'] ?? $this->getCurrentUserId();
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
|
||||
|
||||
if (!$updatedBy) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($scores && $studentIds && $semester && $schoolYear) {
|
||||
try {
|
||||
$this->saveProjectScores($scores, $studentIds, $semester, $schoolYear, $updatedBy, $classSectionId);
|
||||
|
||||
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId);
|
||||
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
|
||||
} catch (RuntimeException $e) {
|
||||
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success(null, 'Project scores updated successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Update project error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to update scores. Missing required data.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Save project scores for multiple students
|
||||
*/
|
||||
private function saveProjectScores(array $scores, array $studentIds, string $semester, string $schoolYear, int $updatedBy, int $classSectionId)
|
||||
{
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($studentIds as $studentId) {
|
||||
if (!isset($scores[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($scores[$studentId] as $index => $score) {
|
||||
// Check if the record exists
|
||||
$existing = $this->project
|
||||
->where('student_id', $studentId)
|
||||
->where('project_index', $index)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('updated_by', $updatedBy)
|
||||
->first();
|
||||
|
||||
$student = $this->student->find($studentId);
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'project_index' => $index,
|
||||
'score' => is_numeric($score) ? floatval($score) : null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing && isset($existing['id'])) {
|
||||
$this->project->update($existing['id'], $data);
|
||||
} else {
|
||||
$data['created_at'] = $now;
|
||||
$data['school_id'] = $student['school_id'] ?? '';
|
||||
$this->project->insert($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get project headers for a class section
|
||||
*/
|
||||
private function getProjectHeaders($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$rows = $this->project
|
||||
->select('project_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('project_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$headers = [];
|
||||
foreach ($rows as $row) {
|
||||
$index = $row['project_index'];
|
||||
if (!is_array($index)) {
|
||||
$headers[$index] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($headers);
|
||||
return array_keys($headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get students by class section and year
|
||||
*/
|
||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
||||
{
|
||||
// Step 1: Get student IDs from student_class table
|
||||
$studentClassRows = $this->studentClass
|
||||
->select('student_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentIds = array_column($studentClassRows, 'student_id');
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return []; // No students found
|
||||
}
|
||||
|
||||
// Step 2: Get student data from students table
|
||||
$students = $this->student
|
||||
->whereIn('id', $studentIds)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get project scores by student
|
||||
*/
|
||||
private function getProjectScoresByStudent($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$rows = $this->project
|
||||
->select('student_id, project_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentScores = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = $row['student_id'];
|
||||
$index = $row['project_index'];
|
||||
$score = $row['score'];
|
||||
|
||||
$studentScores[$studentId]['scores'][$index] = $score;
|
||||
}
|
||||
|
||||
return $studentScores;
|
||||
}
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventoryMovement;
|
||||
use App\Models\PurchaseOrderItem;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Supplier;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PurchaseOrderController extends BaseApiController
|
||||
{
|
||||
protected PurchaseOrder $po;
|
||||
protected PurchaseOrderItem $item;
|
||||
protected Supplier $supplier;
|
||||
protected InventoryItem $inventoryItem;
|
||||
protected InventoryMovement $movement;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->po = model(PurchaseOrder::class);
|
||||
$this->item = model(PurchaseOrderItem::class);
|
||||
$this->supplier = model(Supplier::class);
|
||||
$this->inventoryItem = model(InventoryItem::class);
|
||||
$this->movement = model(InventoryMovement::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage= min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$keyword= trim((string) ($this->request->getGet('q') ?? ''));
|
||||
|
||||
$query = $this->po->newQuery()
|
||||
->select('purchase_orders.*', 'suppliers.name AS supplier_name')
|
||||
->leftJoin('suppliers', 'suppliers.id', '=', 'purchase_orders.supplier_id')
|
||||
->orderByDesc('purchase_orders.created_at');
|
||||
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('po_number', 'LIKE', "%{$keyword}%")
|
||||
->orWhere('suppliers.name', 'LIKE', "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
return $this->success($result, 'Purchase orders retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$po = $this->po->newQuery()
|
||||
->select('purchase_orders.*', 'suppliers.name AS supplier_name')
|
||||
->leftJoin('suppliers', 'suppliers.id', '=', 'purchase_orders.supplier_id')
|
||||
->where('purchase_orders.id', $id)
|
||||
->first();
|
||||
|
||||
if (!$po) {
|
||||
return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$items = $this->item->newQuery()
|
||||
->select('purchase_order_items.*', 'inventory_items.name AS supply_name', 'inventory_items.unit AS supply_unit')
|
||||
->leftJoin('inventory_items', 'inventory_items.id', '=', 'purchase_order_items.supply_id')
|
||||
->where('purchase_order_id', $id)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$po['items'] = $items;
|
||||
|
||||
return $this->success($po, 'Purchase order retrieved successfully');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Support both CodeIgniter-style form arrays and modern JSON format
|
||||
$supplyIds = $payload['item_supply_id'] ?? $payload['items'] ?? [];
|
||||
$descs = $payload['item_description'] ?? [];
|
||||
$qtys = $payload['item_quantity'] ?? [];
|
||||
$unitCosts = $payload['item_unit_cost'] ?? [];
|
||||
|
||||
// If items array is provided (modern format), extract data
|
||||
if (isset($payload['items']) && is_array($payload['items'])) {
|
||||
$items = [];
|
||||
foreach ($payload['items'] as $item) {
|
||||
if (isset($item['supply_id']) && isset($item['quantity']) && $item['quantity'] > 0) {
|
||||
$items[] = [
|
||||
'supply_id' => $item['supply_id'],
|
||||
'description' => $item['description'] ?? '',
|
||||
'quantity' => (int)$item['quantity'],
|
||||
'unit_cost' => (float)($item['unit_cost'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// CodeIgniter-style form arrays
|
||||
if (empty($supplyIds) || !is_array($supplyIds)) {
|
||||
return $this->respondError('Add at least one line item.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($supplyIds as $i => $sid) {
|
||||
$q = max(0, (int)($qtys[$i] ?? 0));
|
||||
$uc = (float)($unitCosts[$i] ?? 0);
|
||||
if ($sid && $q > 0) {
|
||||
$items[] = [
|
||||
'supply_id' => (int)$sid,
|
||||
'description' => trim($descs[$i] ?? ''),
|
||||
'quantity' => $q,
|
||||
'unit_cost' => $uc,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($items)) {
|
||||
return $this->respondError('Valid line items required.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'po_number' => 'required|max_length:255',
|
||||
'supplier_id' => 'required|integer',
|
||||
'order_date' => 'required|date',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Compute totals
|
||||
$subtotal = 0.0;
|
||||
foreach ($items as $item) {
|
||||
$subtotal += $item['quantity'] * $item['unit_cost'];
|
||||
}
|
||||
|
||||
$tax = (float)($payload['tax'] ?? 0.00);
|
||||
$total = $subtotal + $tax;
|
||||
|
||||
$status = $payload['status'] ?? 'ordered';
|
||||
$status = in_array($status, ['draft', 'ordered'], true) ? $status : 'ordered';
|
||||
|
||||
$poData = [
|
||||
'po_number' => $payload['po_number'],
|
||||
'supplier_id' => $payload['supplier_id'],
|
||||
'order_date' => $payload['order_date'],
|
||||
'expected_date' => $payload['expected_date'] ?? null,
|
||||
'status' => $status,
|
||||
'subtotal' => $subtotal,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
'notes' => $payload['notes'] ?? null,
|
||||
];
|
||||
|
||||
$po = $this->po->create($poData);
|
||||
$poId = $po->id ?? null;
|
||||
|
||||
if (!$poId) {
|
||||
DB::rollBack();
|
||||
return $this->respondError('Failed to create purchase order', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
$this->item->create([
|
||||
'purchase_order_id' => $poId,
|
||||
'supply_id' => $item['supply_id'],
|
||||
'description' => $item['description'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_cost' => $item['unit_cost'],
|
||||
'received_qty' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
$poArray = $this->show($poId)->getData(true)['data'];
|
||||
return $this->success($poArray, 'Purchase order created successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Purchase order creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create purchase order: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$po = $this->po->find($id);
|
||||
if (!$po) {
|
||||
return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowedFields = ['status', 'expected_date', 'notes'];
|
||||
$updateData = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $payload)) {
|
||||
$updateData[$field] = $payload[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->po->update($id, $updateData);
|
||||
$updatedPo = $this->po->find($id);
|
||||
return $this->success($updatedPo, 'Purchase order updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Purchase order update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update purchase order', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/purchase-orders/{id}/receive
|
||||
* Receive items (partial or full) from a purchase order
|
||||
*/
|
||||
public function receive($id = null)
|
||||
{
|
||||
$po = $this->po->find($id);
|
||||
if (!$po) {
|
||||
return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (in_array($po['status'], ['canceled', 'received'], true)) {
|
||||
return $this->respondError('PO not receivable. Status: ' . $po['status'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$received = $payload['received'] ?? []; // [itemId => qty]
|
||||
|
||||
if (empty($received) || !is_array($received)) {
|
||||
return $this->respondError('No items to receive.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$user = $this->getCurrentUser();
|
||||
$issuedBy = $user ? ($user->email ?? $user->username ?? 'system') : 'system';
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$completed = true;
|
||||
|
||||
foreach ($received as $itemId => $qty) {
|
||||
$qty = (int)$qty;
|
||||
if ($qty <= 0) continue;
|
||||
|
||||
$item = $this->item->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->item->update($itemId, [
|
||||
'received_qty' => (int)$item['received_qty'] + $toReceive
|
||||
]);
|
||||
|
||||
// Update inventory item on hand (if supply_id maps to inventory_items)
|
||||
if (!empty($item['supply_id'])) {
|
||||
$inventoryItem = $this->inventoryItem->find($item['supply_id']);
|
||||
if ($inventoryItem) {
|
||||
$newQty = (int)($inventoryItem['quantity'] ?? 0) + $toReceive;
|
||||
$this->inventoryItem->update($inventoryItem['id'], [
|
||||
'quantity' => $newQty
|
||||
]);
|
||||
|
||||
// Log transaction IN using inventory movements
|
||||
$this->movement->insert([
|
||||
'item_id' => $inventoryItem['id'],
|
||||
'qty_change' => $toReceive,
|
||||
'movement_type' => 'in',
|
||||
'reason' => 'PO ' . $po['po_number'],
|
||||
'note' => 'Received against PO',
|
||||
'performed_by' => $this->getCurrentUserId() ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (($item['received_qty'] + $toReceive) < $item['quantity']) {
|
||||
$completed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set PO status
|
||||
$this->po->update($id, [
|
||||
'status' => $completed ? 'received' : 'ordered'
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
$message = $completed ? 'PO fully received.' : 'PO partially received.';
|
||||
return $this->success(null, $message);
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Receive PO error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to receive items: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/purchase-orders/{id}/cancel
|
||||
* Cancel a purchase order
|
||||
*/
|
||||
public function cancel($id = null)
|
||||
{
|
||||
$po = $this->po->find($id);
|
||||
if (!$po) {
|
||||
return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($po['status'] === 'received') {
|
||||
return $this->respondError('Cannot cancel a received PO.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->po->update($id, ['status' => 'canceled']);
|
||||
$updatedPo = $this->po->find($id);
|
||||
return $this->success($updatedPo, 'PO canceled.');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Cancel PO error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to cancel purchase order: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,656 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Quiz;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use App\Services\SemesterScoreService;
|
||||
use RuntimeException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class QuizController extends BaseApiController
|
||||
{
|
||||
protected Quiz $quiz;
|
||||
protected Configuration $config;
|
||||
protected Student $student;
|
||||
protected StudentClass $studentClass;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected User $user;
|
||||
protected SemesterScoreService $semesterScoreService;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->quiz = model(Quiz::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->user = model(User::class);
|
||||
$this->semesterScoreService = app(SemesterScoreService::class);
|
||||
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/quizzes
|
||||
* List quizzes with optional filters
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$studentId = $this->request->getGet('student_id');
|
||||
$classSectionId = $this->request->getGet('class_section_id');
|
||||
|
||||
$query = $this->quiz->newQuery()
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester);
|
||||
|
||||
if ($studentId) {
|
||||
$query->where('student_id', $studentId);
|
||||
}
|
||||
|
||||
if ($classSectionId) {
|
||||
$query->where('class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
return $this->success($result, 'Quizzes retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/quizzes/{id}
|
||||
* Get a single quiz
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$quiz = $this->quiz->find($id);
|
||||
if (!$quiz) {
|
||||
return $this->error('Quiz not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($quiz, 'Quiz retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/quizzes/student/{id}
|
||||
* Get quizzes for a specific student
|
||||
*/
|
||||
public function getByStudent($id = null)
|
||||
{
|
||||
$quizzes = $this->quiz->newQuery()
|
||||
->where('student_id', $id)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester)
|
||||
->orderBy('quiz_index', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success($quizzes, 'Quizzes retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/quizzes
|
||||
* Create a new quiz entry
|
||||
*/
|
||||
public function store()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'student_id' => 'required|integer',
|
||||
'class_section_id' => 'required|integer',
|
||||
'quiz_index' => 'required|integer',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$quizData = [
|
||||
'student_id' => $data['student_id'],
|
||||
'class_section_id' => $data['class_section_id'],
|
||||
'quiz_index' => $data['quiz_index'],
|
||||
'score' => $data['score'] ?? null,
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'updated_by' => $user->id,
|
||||
];
|
||||
|
||||
try {
|
||||
$quiz = $this->quiz->create($quizData);
|
||||
return $this->success($quiz->toArray(), 'Quiz created successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Quiz creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create quiz', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/quizzes/{id}
|
||||
* Update a quiz entry
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$quiz = $this->quiz->find($id);
|
||||
if (!$quiz) {
|
||||
return $this->error('Quiz not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowedFields = ['score', 'comment', 'quiz_index'];
|
||||
$updateData = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$updateData['updated_by'] = $user->id;
|
||||
$updateData['updated_at'] = utc_now();
|
||||
|
||||
try {
|
||||
$this->quiz->update($id, $updateData);
|
||||
$updatedQuiz = $this->quiz->find($id);
|
||||
return $this->success($updatedQuiz, 'Quiz updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Quiz update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update quiz', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/quizzes/add-quiz
|
||||
* Get quiz data for teacher's class section (equivalent to addQuiz view)
|
||||
*/
|
||||
public function addQuiz()
|
||||
{
|
||||
// Get class_section_id from POST -> GET -> session
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
session()->put('class_section_id', $classSectionId);
|
||||
|
||||
// Get distinct quiz_index values for this class/term
|
||||
$quizHeaderRows = $this->quiz
|
||||
->select('quiz_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->groupBy('quiz_index')
|
||||
->orderBy('quiz_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$quizHeaders = array_map(static fn($r) => (int)$r['quiz_index'], $quizHeaderRows);
|
||||
|
||||
// Get roster (distinct students for this section/term)
|
||||
$roster = DB::table('student_class as sc')
|
||||
->select('s.id AS student_id', 's.firstname', 's.lastname', 's.school_id')
|
||||
->distinct()
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
// Get all existing quiz scores
|
||||
$scoreRows = $this->quiz
|
||||
->select('id, student_id, quiz_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->findAll();
|
||||
|
||||
$scoresMap = []; // [student_id][quiz_index] => score
|
||||
$idMap = []; // [student_id][quiz_index] => id
|
||||
|
||||
foreach ($scoreRows as $r) {
|
||||
$sid = (int)$r['student_id'];
|
||||
$qi = (int)$r['quiz_index'];
|
||||
$scoresMap[$sid][$qi] = $r['score'];
|
||||
$idMap[$sid][$qi] = (int)$r['id'];
|
||||
}
|
||||
|
||||
// Build students array with per-quiz scores
|
||||
$students = [];
|
||||
foreach ($roster as $st) {
|
||||
$sid = (int)$st->student_id;
|
||||
$quizScores = [];
|
||||
|
||||
foreach ($quizHeaders as $qi) {
|
||||
$quizScores[$qi] = $scoresMap[$sid][$qi] ?? '';
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $sid,
|
||||
'firstname' => $st->firstname,
|
||||
'lastname' => $st->lastname,
|
||||
'school_id' => $st->school_id ?? null,
|
||||
'scores' => $quizScores,
|
||||
'existingIds' => $idMap[$sid] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'students' => $students,
|
||||
'quiz_headers' => $quizHeaders,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_section_id' => $classSectionId
|
||||
], 'Quiz data retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/quizzes/update-scores
|
||||
* Update quiz scores for multiple students
|
||||
*/
|
||||
public function updateQuizScores()
|
||||
{
|
||||
$updatedBy = $this->getCurrentUserId();
|
||||
if (!$updatedBy) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$scores = $payload['scores'] ?? null;
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return $this->respondError('No quiz scores submitted.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($scores as $studentId => $quizData) {
|
||||
if (!is_numeric($studentId) || $studentId <= 0) continue;
|
||||
|
||||
// Normalize: if single input submitted
|
||||
if (!is_array($quizData)) {
|
||||
$quizData = [1 => $quizData];
|
||||
}
|
||||
|
||||
foreach ($quizData as $quizNumber => $score) {
|
||||
if (!is_numeric($quizNumber) || !is_numeric($score)) continue;
|
||||
|
||||
$existing = $this->quiz->where([
|
||||
'student_id' => $studentId,
|
||||
'quiz_index' => $quizNumber,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester
|
||||
])->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->quiz->update($existing['id'], [
|
||||
'score' => is_numeric($score) ? (float)$score : null,
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
} else {
|
||||
$student = $this->student->find($studentId);
|
||||
$this->quiz->insert([
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $student['school_id'] ?? '',
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'quiz_index' => $quizNumber,
|
||||
'score' => is_numeric($score) ? (float)$score : null,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId);
|
||||
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
|
||||
} catch (RuntimeException $e) {
|
||||
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success(null, 'Quiz scores updated successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Update quiz scores error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update quiz scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/quizzes/add-column
|
||||
* Add a new quiz column for all students in a class section
|
||||
*/
|
||||
public function addNextQuizColumn()
|
||||
{
|
||||
$updatedBy = $this->getCurrentUserId();
|
||||
if (!$updatedBy) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$existingQuizNumbers = $this->quiz
|
||||
->select('quiz_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('updated_by', $updatedBy)
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->groupBy('quiz_index')
|
||||
->orderBy('quiz_index', 'DESC')
|
||||
->findAll();
|
||||
|
||||
$maxQuizNumber = 0;
|
||||
foreach ($existingQuizNumbers as $row) {
|
||||
if (is_numeric($row['quiz_index'])) {
|
||||
$maxQuizNumber = max($maxQuizNumber, (int)$row['quiz_index']);
|
||||
}
|
||||
}
|
||||
|
||||
$nextQuizNumber = $maxQuizNumber + 1;
|
||||
|
||||
if ($nextQuizNumber <= 0) {
|
||||
return $this->respondError('Invalid quiz number', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$students = $this->studentClass
|
||||
->where('class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
|
||||
if (empty($students)) {
|
||||
return $this->respondError('No students found in this class section.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$firstInsertedId = null;
|
||||
$insertedCount = 0;
|
||||
|
||||
foreach ($students as $i => $student) {
|
||||
$exists = $this->quiz->where([
|
||||
'student_id' => $student['student_id'],
|
||||
'quiz_index' => $nextQuizNumber,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
])->first();
|
||||
|
||||
if ($exists) continue;
|
||||
|
||||
$studentRecord = $this->student->find($student['student_id']);
|
||||
$insertData = [
|
||||
'student_id' => $student['student_id'],
|
||||
'school_id' => $studentRecord['school_id'] ?? '',
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'quiz_index' => $nextQuizNumber,
|
||||
'score' => null,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
$id = $this->quiz->insert($insertData);
|
||||
if ($i === 0) {
|
||||
$firstInsertedId = $id;
|
||||
}
|
||||
$insertedCount++;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'status' => 'success',
|
||||
'quiz_index' => $nextQuizNumber,
|
||||
'new_quiz_id' => $firstInsertedId,
|
||||
'students_processed' => $insertedCount
|
||||
], 'Quiz column added successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Add quiz column error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to add quiz column: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/quizzes/management
|
||||
* Get quiz management data for a class section
|
||||
*/
|
||||
public function showQuizMngt()
|
||||
{
|
||||
// Accept POST first, then GET (query string), then session fallback
|
||||
$incomingId = $this->request->getPost('class_section_id');
|
||||
if (!$incomingId) {
|
||||
$incomingId = $this->request->getGet('class_section_id');
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('No class section found for the current teacher or selection.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
session()->put('class_section_id', $classSectionId);
|
||||
|
||||
$quizScores = $this->getQuizScoresByStudent($classSectionId, $this->semester, $this->schoolYear);
|
||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $this->schoolYear);
|
||||
$quizHeaders = $this->getQuizHeaders($classSectionId, $this->semester, $this->schoolYear);
|
||||
|
||||
return $this->success([
|
||||
'quiz_scores' => $quizScores,
|
||||
'students' => $students,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'quiz_headers' => $quizHeaders,
|
||||
'class_section_id' => $classSectionId,
|
||||
], 'Quiz management data retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/quizzes/update
|
||||
* Update quiz scores (management endpoint)
|
||||
*/
|
||||
public function updateQuiz()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
$scores = $payload['scores'] ?? null;
|
||||
$studentIds = $payload['student_ids'] ?? null;
|
||||
$semester = $payload['semester'] ?? $this->semester;
|
||||
$schoolYear = $payload['school_year'] ?? $this->schoolYear;
|
||||
$updatedBy = $payload['teacher_id'] ?? $this->getCurrentUserId();
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
|
||||
|
||||
if (!$updatedBy) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($scores && $studentIds && $semester && $schoolYear) {
|
||||
try {
|
||||
// Update scores using the same logic as updateQuizScores
|
||||
foreach ($scores as $studentId => $quizData) {
|
||||
if (!is_numeric($studentId) || $studentId <= 0) continue;
|
||||
|
||||
// Normalize: if single input submitted
|
||||
if (!is_array($quizData)) {
|
||||
$quizData = [1 => $quizData];
|
||||
}
|
||||
|
||||
foreach ($quizData as $quizNumber => $score) {
|
||||
if (!is_numeric($quizNumber) || !is_numeric($score)) continue;
|
||||
|
||||
$existing = $this->quiz->where([
|
||||
'student_id' => $studentId,
|
||||
'quiz_index' => $quizNumber,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester
|
||||
])->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->quiz->update($existing['id'], [
|
||||
'score' => is_numeric($score) ? (float)$score : null,
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
} else {
|
||||
$student = $this->student->find($studentId);
|
||||
$this->quiz->insert([
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $student['school_id'] ?? '',
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'quiz_index' => $quizNumber,
|
||||
'score' => is_numeric($score) ? (float)$score : null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId);
|
||||
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
|
||||
} catch (RuntimeException $e) {
|
||||
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success(null, 'Quiz scores updated successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Update quiz error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to update scores. Missing required data.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get students by class section and year
|
||||
*/
|
||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
||||
{
|
||||
// Step 1: Get student IDs from student_class table
|
||||
$studentClassRows = $this->studentClass
|
||||
->select('student_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentIds = array_column($studentClassRows, 'student_id');
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return []; // No students found
|
||||
}
|
||||
|
||||
// Step 2: Get student data from students table
|
||||
$students = $this->student
|
||||
->whereIn('id', $studentIds)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get quiz headers for a class section
|
||||
*/
|
||||
private function getQuizHeaders($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$rows = $this->quiz
|
||||
->select('quiz_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('quiz_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$headers = [];
|
||||
foreach ($rows as $row) {
|
||||
$index = $row['quiz_index'];
|
||||
if (!is_array($index)) {
|
||||
$headers[$index] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($headers);
|
||||
return array_keys($headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get quiz scores by student
|
||||
*/
|
||||
private function getQuizScoresByStudent($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$rows = $this->quiz
|
||||
->select('student_id, quiz_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentScores = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = $row['student_id'];
|
||||
$index = $row['quiz_index'];
|
||||
$score = $row['score'];
|
||||
|
||||
$studentScores[$studentId]['scores'][$index] = $score;
|
||||
}
|
||||
|
||||
return $studentScores;
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Student;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RFIDController extends BaseApiController
|
||||
{
|
||||
protected User $user;
|
||||
protected Student $student;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->user = model(User::class);
|
||||
$this->student = model(Student::class);
|
||||
}
|
||||
|
||||
public function process()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'rfid' => 'required',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$rfidTag = $data['rfid'];
|
||||
|
||||
$user = $this->user->where('rfid_tag', $rfidTag)->first();
|
||||
|
||||
$student = null;
|
||||
if (!$user) {
|
||||
$student = $this->student->where('rfid_tag', $rfidTag)->first();
|
||||
}
|
||||
|
||||
if (!$user && !$student) {
|
||||
return $this->error('RFID tag not recognized', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$logData = [
|
||||
'user_id' => $user['id'] ?? null,
|
||||
'student_id' => $student['id'] ?? null,
|
||||
'card_id' => $rfidTag,
|
||||
'scan_time' => Carbon::now('UTC')->toDateTimeString(),
|
||||
];
|
||||
|
||||
DB::table('scan_log')->insert($logData);
|
||||
|
||||
$result = [
|
||||
'rfid_tag' => $rfidTag,
|
||||
'user' => $user ? [
|
||||
'id' => $user['id'],
|
||||
'name' => trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')),
|
||||
] : null,
|
||||
'student' => $student ? [
|
||||
'id' => $student['id'],
|
||||
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
||||
] : null,
|
||||
];
|
||||
|
||||
return $this->success($result, 'RFID processed successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'RFID process error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to process RFID', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function logs()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
|
||||
try {
|
||||
$builder = DB::table('scan_log as sl')
|
||||
->select([
|
||||
'u.firstname as user_firstname',
|
||||
'u.lastname as user_lastname',
|
||||
's.firstname as student_firstname',
|
||||
's.lastname as student_lastname',
|
||||
'sl.card_id',
|
||||
'sl.scan_time',
|
||||
])
|
||||
->leftJoin('users as u', 'u.id', '=', 'sl.user_id')
|
||||
->leftJoin('students as s', 's.rfid_tag', '=', 'sl.card_id');
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$total = (clone $builder)->count();
|
||||
|
||||
$logs = (clone $builder)
|
||||
->orderBy('sl.scan_time', 'DESC')
|
||||
->offset($offset)
|
||||
->limit($perPage)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$result = [
|
||||
'data' => $logs,
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'total_pages' => (int) ceil($total / $perPage),
|
||||
],
|
||||
];
|
||||
|
||||
return $this->success($result, 'RFID scan logs retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'RFID logs error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve RFID logs', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Refund;
|
||||
use Carbon\Carbon;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RefundController extends BaseApiController
|
||||
{
|
||||
protected Refund $refund;
|
||||
protected Configuration $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->refund = model(Refund::class);
|
||||
$this->config = model(Configuration::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$parentId = $this->request->getGet('parent_id');
|
||||
$status = $this->request->getGet('status');
|
||||
|
||||
$query = $this->refund->newQuery();
|
||||
|
||||
if ($parentId) {
|
||||
$query->where('parent_id', $parentId);
|
||||
}
|
||||
|
||||
if ($status) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
return $this->success($result, 'Refunds retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$refund = $this->refund->find($id);
|
||||
if (!$refund) {
|
||||
return $this->error('Refund not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($refund, 'Refund retrieved successfully');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'parent_id' => 'required|integer',
|
||||
'invoice_id' => 'required|integer',
|
||||
'refund_amount' => 'required|numeric',
|
||||
'reason' => 'required',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$refundData = [
|
||||
'parent_id' => $data['parent_id'],
|
||||
'invoice_id' => $data['invoice_id'],
|
||||
'refund_amount' => $data['refund_amount'],
|
||||
'reason' => $data['reason'],
|
||||
'request' => $data['request'] ?? null,
|
||||
'status' => 'pending',
|
||||
'requested_at' => Carbon::now('UTC')->toDateTimeString(),
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
|
||||
try {
|
||||
$refund = $this->refund->create($refundData);
|
||||
return $this->success($refund->toArray(), 'Refund request created successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Refund creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create refund request', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$refund = $this->refund->find($id);
|
||||
if (!$refund) {
|
||||
return $this->error('Refund not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowedFields = ['status', 'refund_paid_amount', 'refund_method', 'check_nbr', 'note'];
|
||||
$updateData = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($updateData['status']) && $updateData['status'] === 'approved') {
|
||||
$updateData['approved_at'] = Carbon::now('UTC')->toDateTimeString();
|
||||
$updateData['approved_by'] = $user->id;
|
||||
}
|
||||
|
||||
if (isset($updateData['status']) && $updateData['status'] === 'refunded') {
|
||||
$updateData['refunded_at'] = Carbon::now('UTC')->toDateTimeString();
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$updateData['updated_by'] = $user->id;
|
||||
|
||||
try {
|
||||
$this->refund->update($id, $updateData);
|
||||
$updatedRefund = $this->refund->find($id);
|
||||
return $this->success($updatedRefund, 'Refund updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Refund update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update refund', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\ParentModel;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRole;
|
||||
use App\Services\EmailService;
|
||||
use App\Services\PhoneFormatterService;
|
||||
use App\Services\SchoolIdService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RegisterController extends BaseApiController
|
||||
{
|
||||
protected Configuration $config;
|
||||
protected User $user;
|
||||
protected UserRole $userRole;
|
||||
protected Role $role;
|
||||
protected Parent $parent;
|
||||
protected SchoolIdService $schoolIdService;
|
||||
protected EmailService $emailService;
|
||||
protected PhoneFormatterService $phoneFormatter;
|
||||
protected string $semester;
|
||||
protected string $schoolYear;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->config = model(Configuration::class);
|
||||
$this->user = model(User::class);
|
||||
$this->userRole = model(UserRole::class);
|
||||
$this->role = model(Role::class);
|
||||
$this->parent = model(ParentModel::class);
|
||||
$this->schoolIdService = app(SchoolIdService::class);
|
||||
$this->emailService = app(EmailService::class);
|
||||
$this->phoneFormatter = app(PhoneFormatterService::class);
|
||||
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$session = session();
|
||||
log_message('debug', 'CAPTCHA session value: ' . ($session->get('captcha_answer') ?? ''));
|
||||
|
||||
if (!$session->has('captcha_answer')) {
|
||||
$length = random_int(4, 8);
|
||||
$session->put('captcha_answer', $this->generateCaptchaText($length));
|
||||
}
|
||||
|
||||
return $this->respondSuccess([
|
||||
'captcha_question' => $session->get('captcha_answer'),
|
||||
], 'Captcha generated');
|
||||
}
|
||||
|
||||
public function registrationSuccess(): JsonResponse
|
||||
{
|
||||
$session = session();
|
||||
if (!$session->has('user_email')) {
|
||||
return $this->respondError('No registration session found.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return $this->respondSuccess([
|
||||
'email' => $session->get('user_email'),
|
||||
], 'Registration success data');
|
||||
}
|
||||
|
||||
public function register(): JsonResponse
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
log_message('debug', 'CAPTCHA session value: ' . (session()->get('captcha_answer') ?? ''));
|
||||
|
||||
$formatted = $this->formatUserInput($payload);
|
||||
$validationPayload = array_merge($payload, $formatted);
|
||||
$captcha = (string) ($payload['captcha'] ?? '');
|
||||
$isParent = !empty($payload['is_parent']) && (string) $payload['is_parent'] === '1';
|
||||
$noSecondParentInfo = !empty($payload['no_second_parent_info']) && (string) $payload['no_second_parent_info'] === '1';
|
||||
|
||||
$rules = [
|
||||
'firstname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'],
|
||||
'lastname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'],
|
||||
'gender' => ['required', Rule::in(['Male', 'Female'])],
|
||||
'email' => ['required', 'email', 'max:50'],
|
||||
'confirm_email' => ['required', 'same:email'],
|
||||
'cellphone' => ['required', 'regex:/^[\d\s\-\(\)\.]+$/', 'min:10', 'max:20'],
|
||||
'address_street' => ['nullable', 'regex:/^[a-zA-Z0-9\s\-]+$/', 'max:150'],
|
||||
'apt' => ['nullable', 'regex:/^[a-zA-Z0-9\s-]+$/', 'max:10'],
|
||||
'city' => ['required', 'regex:/^[a-zA-Z\s]+$/', 'min:2', 'max:30'],
|
||||
'state' => ['required', Rule::in(['CT', 'ME', 'MA', 'NH', 'NY', 'RI', 'VT'])],
|
||||
'zip' => ['required', 'regex:/^\d{5}$/'],
|
||||
'accept_school_policy' => ['required', 'accepted'],
|
||||
'captcha' => ['required', 'alpha_num', 'min:4', 'max:10'],
|
||||
];
|
||||
|
||||
if ($isParent && !$noSecondParentInfo) {
|
||||
$rules = array_merge($rules, [
|
||||
'second_firstname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'],
|
||||
'second_lastname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'],
|
||||
'second_gender' => ['required', Rule::in(['Male', 'Female'])],
|
||||
'second_email' => ['required', 'email', 'max:50'],
|
||||
'second_cellphone' => ['required', 'regex:/^[\d\s\-\(\)\.]+$/', 'min:10', 'max:20'],
|
||||
]);
|
||||
}
|
||||
|
||||
$errors = $this->validateRequest($validationPayload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
if ($isParent && !$noSecondParentInfo && !$this->hasSecondParentData($formatted)) {
|
||||
return $this->respondValidationError([
|
||||
'second_parent_info' => ['As a parent, please provide second parent information or check "No second parent info".'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($captcha !== (string) session()->get('captcha_answer')) {
|
||||
return $this->respondValidationError([
|
||||
'captcha' => ['Incorrect CAPTCHA answer. Please try again.'],
|
||||
], 'Incorrect CAPTCHA answer.');
|
||||
}
|
||||
|
||||
$email = $formatted['email'] ?? '';
|
||||
$existingUser = $this->user->newQuery()->where('email', $email)->first();
|
||||
|
||||
if ($existingUser) {
|
||||
$isVerified = (int) ($existingUser->is_verified ?? 0);
|
||||
$token = $existingUser->token ?? null;
|
||||
|
||||
if (!empty($token) && $isVerified === 0) {
|
||||
return $this->respondError(
|
||||
'This email address is already registered and is pending activation. Please check your email to activate your account.',
|
||||
Response::HTTP_CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
return $this->respondError(
|
||||
'The email address you entered is already in use. Please try a different one.',
|
||||
Response::HTTP_CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
$roleName = $isParent ? 'parent' : 'guest';
|
||||
$role = $this->role->newQuery()->where('name', $roleName)->first();
|
||||
if (!$role) {
|
||||
return $this->respondError('Role not found', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(48));
|
||||
$userData = [
|
||||
'firstname' => $formatted['firstname'] ?? null,
|
||||
'lastname' => $formatted['lastname'] ?? null,
|
||||
'gender' => $formatted['gender'] ?? null,
|
||||
'email' => $email,
|
||||
'cellphone' => $formatted['cellphone'] ?? null,
|
||||
'address_street' => $formatted['address_street'] ?? null,
|
||||
'apt' => $formatted['apt'] ?? null,
|
||||
'city' => $formatted['city'] ?? null,
|
||||
'state' => $formatted['state'] ?? null,
|
||||
'zip' => $formatted['zip'] ?? null,
|
||||
'token' => $token,
|
||||
'is_verified' => 0,
|
||||
'accept_school_policy' => (int) ($formatted['accept_school_policy'] ?? 0),
|
||||
'status' => 'Inactive',
|
||||
'school_id' => $this->schoolIdService->generate(),
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
];
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$user = $this->user->newQuery()->create($userData);
|
||||
$firstParentId = $user->id ?? null;
|
||||
|
||||
if (!$firstParentId) {
|
||||
DB::rollBack();
|
||||
return $this->respondError('Registration failed, please try again.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$this->userRole->newQuery()->create([
|
||||
'user_id' => $firstParentId,
|
||||
'role_id' => $role->id,
|
||||
'semester' => $this->semester,
|
||||
'school_year'=> $this->schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
]);
|
||||
|
||||
if ($isParent && !$noSecondParentInfo && $this->hasSecondParentData($formatted)) {
|
||||
$this->parent->newQuery()->create([
|
||||
'secondparent_firstname' => $formatted['second_firstname'] ?? null,
|
||||
'secondparent_lastname' => $formatted['second_lastname'] ?? null,
|
||||
'secondparent_gender' => $formatted['second_gender'] ?? null,
|
||||
'secondparent_email' => $formatted['second_email'] ?? null,
|
||||
'secondparent_phone' => $formatted['second_cellphone'] ?? null,
|
||||
'firstparent_id' => $firstParentId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
]);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Registration failed: ' . $e->getMessage());
|
||||
return $this->respondError('Registration failed, please try again.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$recipientName = trim(($formatted['firstname'] ?? '') . ' ' . ($formatted['lastname'] ?? ''));
|
||||
$emailData = [
|
||||
'recipientName' => $recipientName,
|
||||
'activationLink'=> url('/user/confirm/' . $token),
|
||||
'orgName' => 'Al Rahma Sunday School',
|
||||
'contactInfo' => 'alrahma.isgl@gmail.com',
|
||||
'logoUrl' => 'https://alrahmaisgl.org/assets/images/alrahma_logo.png',
|
||||
];
|
||||
|
||||
$html = $this->buildEmailBody($emailData, $isParent);
|
||||
$this->emailService->send($email, 'Email Confirmation', $html, 'general');
|
||||
|
||||
session()->put('user_email', $email);
|
||||
session()->forget('captcha_answer');
|
||||
|
||||
return $this->respondSuccess([
|
||||
'email' => $email,
|
||||
], 'Registration successful');
|
||||
}
|
||||
|
||||
private function generateCaptchaText(int $length): string
|
||||
{
|
||||
$characters = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghjkmnopqrstuvwxyz0123456789';
|
||||
$captchaText = '';
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$captchaText .= $characters[random_int(0, strlen($characters) - 1)];
|
||||
}
|
||||
|
||||
return $captchaText;
|
||||
}
|
||||
|
||||
private function formatUserInput(array $post): array
|
||||
{
|
||||
$g = static function (string $key) use ($post): string {
|
||||
return trim((string) ($post[$key] ?? ''));
|
||||
};
|
||||
|
||||
$data = [
|
||||
'firstname' => $this->formatName($g('firstname')),
|
||||
'lastname' => $this->formatName($g('lastname')),
|
||||
'gender' => $g('gender'),
|
||||
'email' => $this->formatEmail($g('email')),
|
||||
'confirm_email' => $this->formatEmail($g('confirm_email')),
|
||||
'cellphone' => $this->phoneFormatter->formatPhoneNumber($g('cellphone')),
|
||||
'address_street' => $this->nullIfEmpty($this->formatAddress($g('address_street'))),
|
||||
'apt' => $this->nullIfEmpty(strtoupper($g('apt'))),
|
||||
'city' => $this->formatName($g('city')),
|
||||
'state' => strtoupper($g('state')),
|
||||
'zip' => $g('zip'),
|
||||
'accept_school_policy' => (int) ($post['accept_school_policy'] ?? 0),
|
||||
];
|
||||
|
||||
$noSecondInfo = !empty($post['no_second_parent_info']);
|
||||
|
||||
$second_firstname = $this->formatName($g('second_firstname'));
|
||||
$second_lastname = $this->formatName($g('second_lastname'));
|
||||
$second_gender = $g('second_gender');
|
||||
$second_email = $this->formatEmail($g('second_email'));
|
||||
$second_cellphone = $this->phoneFormatter->formatPhoneNumber($g('second_cellphone'));
|
||||
|
||||
$secondProvided = !$noSecondInfo && (
|
||||
$second_firstname !== '' ||
|
||||
$second_lastname !== '' ||
|
||||
$second_gender !== '' ||
|
||||
$second_email !== '' ||
|
||||
preg_replace('/\D/', '', $second_cellphone) !== ''
|
||||
);
|
||||
|
||||
if ($secondProvided) {
|
||||
$data['second_firstname'] = $second_firstname;
|
||||
$data['second_lastname'] = $second_lastname;
|
||||
$data['second_gender'] = $second_gender;
|
||||
$data['second_email'] = $second_email;
|
||||
$data['second_cellphone'] = $second_cellphone;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function formatName(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
return ucwords(strtolower($name), " -");
|
||||
}
|
||||
|
||||
private function formatEmail(?string $email): string
|
||||
{
|
||||
return strtolower(trim((string) $email));
|
||||
}
|
||||
|
||||
private function formatAddress(?string $address): string
|
||||
{
|
||||
$address = trim((string) $address);
|
||||
if ($address === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ucwords(strtolower($address));
|
||||
}
|
||||
|
||||
private function nullIfEmpty(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function hasSecondParentData(array $data): bool
|
||||
{
|
||||
return !empty($data['second_firstname'])
|
||||
|| !empty($data['second_lastname'])
|
||||
|| !empty($data['second_gender'])
|
||||
|| !empty($data['second_email'])
|
||||
|| (!empty($data['second_cellphone']) && preg_replace('/\D/', '', $data['second_cellphone']) !== '');
|
||||
}
|
||||
|
||||
private function buildEmailBody(array $data, bool $isParent): string
|
||||
{
|
||||
$audience = $isParent ? 'Parent' : 'Staff Member';
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Email Confirmation</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Dear {$data['recipientName']},</p>
|
||||
<p>Thank you for registering with {$data['orgName']} as a {$audience}. Please confirm your email address by clicking the link below:</p>
|
||||
<p><a href="{$data['activationLink']}" target="_blank">Activate My Account</a></p>
|
||||
<p>If the link above does not work, copy and paste this URL into your browser:</p>
|
||||
<p>{$data['activationLink']}</p>
|
||||
<p>For questions, reach us at {$data['contactInfo']}.</p>
|
||||
<p>Thank you,<br>{$data['orgName']}</p>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Reimbursement;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ReimbursementController extends BaseApiController
|
||||
{
|
||||
protected Reimbursement $reimbursement;
|
||||
protected Configuration $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->reimbursement = model(Reimbursement::class);
|
||||
$this->config = model(Configuration::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$status = $this->request->getGet('status');
|
||||
$reimbursedTo = $this->request->getGet('reimbursed_to');
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$query = $this->reimbursement->newQuery()
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester);
|
||||
|
||||
if (!empty($status)) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if (!empty($reimbursedTo)) {
|
||||
$query->where('reimbursed_to', $reimbursedTo);
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
return $this->success($result, 'Reimbursements retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$reimbursement = $this->reimbursement->find($id);
|
||||
if (!$reimbursement) {
|
||||
return $this->error('Reimbursement not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($reimbursement, 'Reimbursement retrieved successfully');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'amount' => 'required|numeric',
|
||||
'reimbursed_to' => 'required|integer',
|
||||
'description' => 'required',
|
||||
'expense_id' => 'required|integer',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$reimbursementData = [
|
||||
'amount' => $data['amount'],
|
||||
'reimbursed_to' => $data['reimbursed_to'],
|
||||
'description' => $data['description'],
|
||||
'expense_id' => $data['expense_id'],
|
||||
'added_by' => $user->id,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'status' => $data['status'] ?? 'pending',
|
||||
'reimbursement_method' => $data['reimbursement_method'] ?? null,
|
||||
'check_number' => $data['check_number'] ?? null,
|
||||
];
|
||||
|
||||
try {
|
||||
$reimbursement = $this->reimbursement->create($reimbursementData);
|
||||
return $this->success($reimbursement->toArray(), 'Reimbursement created successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Reimbursement creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create reimbursement', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$reimbursement = $this->reimbursement->find($id);
|
||||
if (!$reimbursement) {
|
||||
return $this->error('Reimbursement not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowedFields = ['status', 'reimbursement_method', 'check_number'];
|
||||
$updateData = [];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($updateData['status']) && $updateData['status'] === 'approved') {
|
||||
$updateData['approved_by'] = $user->id;
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->reimbursement->update($id, $updateData);
|
||||
$updatedReimbursement = $this->reimbursement->find($id);
|
||||
return $this->success($updatedReimbursement, 'Reimbursement updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Reimbursement update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update reimbursement', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,727 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\RolePermission;
|
||||
use App\Models\Staff;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRole;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RolePermissionController extends BaseApiController
|
||||
{
|
||||
protected Role $role;
|
||||
protected Permission $permission;
|
||||
protected RolePermission $rolePermission;
|
||||
protected User $user;
|
||||
protected UserRole $userRole;
|
||||
protected Staff $staff;
|
||||
protected Configuration $config;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->role = model(Role::class);
|
||||
$this->permission = model(Permission::class);
|
||||
$this->rolePermission = model(RolePermission::class);
|
||||
$this->user = model(User::class);
|
||||
$this->userRole = model(UserRole::class);
|
||||
$this->staff = model(Staff::class);
|
||||
$this->config = model(Configuration::class);
|
||||
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/rolepermission/roles
|
||||
* List all roles with user counts
|
||||
*/
|
||||
public function roles()
|
||||
{
|
||||
$rows = DB::table('roles')
|
||||
->select('roles.id', 'roles.name', 'roles.description', DB::raw('COUNT(DISTINCT user_roles.user_id) AS user_count'))
|
||||
->leftJoin('user_roles', 'user_roles.role_id', '=', 'roles.id')
|
||||
->groupBy('roles.id', 'roles.name', 'roles.description')
|
||||
->orderBy('roles.name', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$normalized = array_map(static function ($row) {
|
||||
$row = (array) $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->success(['roles' => $normalized], 'Roles retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/rolepermission/roles/{id}
|
||||
* Get a single role
|
||||
*/
|
||||
public function showRole($id = null)
|
||||
{
|
||||
$role = $this->role->find($id);
|
||||
if (!$role) {
|
||||
return $this->error('Role not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($role, 'Role retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/rolepermission/roles
|
||||
* Create a new role
|
||||
*/
|
||||
public function storeRole()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min:3|max:255|unique:roles,name',
|
||||
'slug' => 'nullable|max:64|unique:roles,slug',
|
||||
'description' => 'nullable|max:500',
|
||||
'dashboard_route' => ['required', 'min:1', 'max:255', 'regex:/^[a-z0-9_\/\-]+$/'],
|
||||
'priority' => 'required|integer|min:0|max:100000',
|
||||
'is_active' => 'required|in:0,1',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$rawName = trim((string) ($payload['name'] ?? ''));
|
||||
$rawSlug = trim((string) ($payload['slug'] ?? ''));
|
||||
$route = trim((string) ($payload['dashboard_route'] ?? ''));
|
||||
$desc = trim((string) ($payload['description'] ?? ''));
|
||||
$priority = (int) ($payload['priority'] ?? 0);
|
||||
$isActive = (string) ($payload['is_active'] ?? '0') === '1' ? 1 : 0;
|
||||
|
||||
$name = strtolower($rawName);
|
||||
$dashboard_route = strtolower($route);
|
||||
$description = strtolower($desc);
|
||||
|
||||
// Build slug: from input if provided, else from name
|
||||
$slug = $rawSlug !== '' ? $rawSlug : $name;
|
||||
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $slug));
|
||||
$slug = trim($slug, '_');
|
||||
$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(),
|
||||
];
|
||||
|
||||
try {
|
||||
$role = $this->role->create($data);
|
||||
return $this->success($role->toArray(), 'Role created successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Role creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create role', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/rolepermission/roles/{id}
|
||||
* Update a role
|
||||
*/
|
||||
public function updateRole($id = null)
|
||||
{
|
||||
$role = $this->role->find($id);
|
||||
if (!$role) {
|
||||
return $this->error('Role not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => ['required', 'min:3', 'max:255', Rule::unique('roles', 'name')->ignore($id)],
|
||||
'slug' => ['nullable', 'max:64', Rule::unique('roles', 'slug')->ignore($id)],
|
||||
'description' => 'nullable|max:500',
|
||||
'dashboard_route' => ['required', 'min:1', 'max:255', 'regex:/^[a-z0-9_\/\-]+$/'],
|
||||
'priority' => 'required|integer|min:0|max:100000',
|
||||
'is_active' => 'required|in:0,1',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$rawName = trim((string) ($payload['name'] ?? ''));
|
||||
$rawSlug = trim((string) ($payload['slug'] ?? ''));
|
||||
$route = trim((string) ($payload['dashboard_route'] ?? ''));
|
||||
$desc = trim((string) ($payload['description'] ?? ''));
|
||||
$priority = (int) ($payload['priority'] ?? 0);
|
||||
$isActive = (string) ($payload['is_active'] ?? '0') === '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
|
||||
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(),
|
||||
];
|
||||
|
||||
try {
|
||||
$this->role->update($id, $data);
|
||||
$updatedRole = $this->role->find($id);
|
||||
return $this->success($updatedRole, 'Role updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Role update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update role', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/rolepermission/roles/{id}
|
||||
* Delete a role
|
||||
*/
|
||||
public function deleteRole($id = null)
|
||||
{
|
||||
$role = $this->role->find($id);
|
||||
if (!$role) {
|
||||
return $this->error('Role not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->role->delete($id);
|
||||
return $this->success(null, 'Role deleted successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Role deletion error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete role', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/rolepermission/permissions
|
||||
* List all permissions
|
||||
*/
|
||||
public function permissions()
|
||||
{
|
||||
$rows = $this->permission
|
||||
->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->success(['permissions' => $normalized], 'Permissions retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/rolepermission/permissions
|
||||
* Create a new permission
|
||||
*/
|
||||
public function storePermission()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$name = trim((string) ($payload['name'] ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
return $this->respondError('Permission name is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Check existence
|
||||
$existing = $this->permission->where('name', $name)->first();
|
||||
if ($existing) {
|
||||
return $this->success($existing, 'Permission already exists. No changes made.');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'description' => trim((string) ($payload['description'] ?? '')),
|
||||
];
|
||||
|
||||
try {
|
||||
$permission = $this->permission->create($data);
|
||||
return $this->success($permission->toArray(), 'Permission created successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Permission creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create permission', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/rolepermission/permissions/{id}
|
||||
* Update a permission
|
||||
*/
|
||||
public function updatePermission($id = null)
|
||||
{
|
||||
$permission = $this->permission->find($id);
|
||||
if (!$permission) {
|
||||
return $this->error('Permission not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $id,
|
||||
'name' => trim((string) ($payload['name'] ?? '')),
|
||||
'description' => trim((string) ($payload['description'] ?? '')),
|
||||
];
|
||||
|
||||
try {
|
||||
$this->permission->update($id, $data);
|
||||
$updatedPermission = $this->permission->find($id);
|
||||
return $this->success($updatedPermission, 'Permission updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Permission update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update permission', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/rolepermission/permissions/{id}
|
||||
* Delete a permission
|
||||
*/
|
||||
public function deletePermission($id = null)
|
||||
{
|
||||
$permission = $this->permission->find($id);
|
||||
if (!$permission) {
|
||||
return $this->error('Permission not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->permission->delete($id);
|
||||
return $this->success(null, 'Permission deleted successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Permission deletion error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete permission', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/rolepermission/roles/{roleId}/permissions
|
||||
* Get permissions for a specific role
|
||||
*/
|
||||
public function rolePermissions($roleId = null)
|
||||
{
|
||||
$role = $this->role->find($roleId);
|
||||
if (!$role) {
|
||||
return $this->error('Role not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$permissions = $this->rolePermission
|
||||
->where('role_id', $roleId)
|
||||
->findAll();
|
||||
|
||||
// Get all permissions with role permission data
|
||||
$allPermissions = $this->permission->findAll();
|
||||
$existingPermissions = [];
|
||||
|
||||
foreach ($permissions 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'],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'role' => $role,
|
||||
'permissions' => $allPermissions,
|
||||
'role_permissions' => $existingPermissions,
|
||||
], 'Role permissions retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/rolepermission/roles/{roleId}/permissions
|
||||
* Save role permissions
|
||||
*/
|
||||
public function saveRolePermissions($roleId = null)
|
||||
{
|
||||
$role = $this->role->find($roleId);
|
||||
if (!$role) {
|
||||
return $this->error('Role not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$permissions = $payload['permissions'] ?? null;
|
||||
|
||||
if (!$permissions || !is_array($permissions)) {
|
||||
return $this->respondError('Permissions data is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Fetch existing permissions for the role
|
||||
$rolePermissions = $this->rolePermission->where('role_id', $roleId)->findAll();
|
||||
$existingPermissions = [];
|
||||
|
||||
foreach ($rolePermissions as $rolePermission) {
|
||||
$existingPermissions[$rolePermission['permission_id']] = $rolePermission;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Process the new permissions
|
||||
foreach ($permissions as $permissionId => $crudValues) {
|
||||
$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->rolePermission->where('role_id', $roleId)
|
||||
->where('permission_id', $permissionId)
|
||||
->update($permissionData);
|
||||
} else {
|
||||
// Insert new permission
|
||||
$permissionData['role_id'] = $roleId;
|
||||
$permissionData['permission_id'] = $permissionId;
|
||||
$permissionData['created_at'] = utc_now();
|
||||
$this->rolePermission->insert($permissionData);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove permissions that are no longer present
|
||||
foreach ($existingPermissions as $permissionId => $permissionData) {
|
||||
if (!isset($permissions[$permissionId])) {
|
||||
$this->rolePermission->where('role_id', $roleId)
|
||||
->where('permission_id', $permissionId)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $this->success(null, 'Permissions saved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Failed to save permissions: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to save permissions: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/rolepermission/users
|
||||
* Get users with their roles
|
||||
*/
|
||||
public function users()
|
||||
{
|
||||
$users = $this->buildUsersWithRolesPayload();
|
||||
return $this->success($users, 'Users with roles retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/rolepermission/users/{userId}/roles
|
||||
* Assign roles to a user
|
||||
*/
|
||||
public function assignRole($userId = null)
|
||||
{
|
||||
$user = $this->user->find($userId);
|
||||
if (!$user) {
|
||||
return $this->error('User not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$roleIdsRaw = $payload['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 = $this->getCurrentUserId();
|
||||
if (!$adminId) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Remove all existing roles for the user
|
||||
$this->userRole->where('user_id', $userId)->delete();
|
||||
|
||||
// Assign new roles
|
||||
if (!empty($roleIds)) {
|
||||
foreach ($roleIds as $roleId) {
|
||||
$this->userRole->insert([
|
||||
'user_id' => $userId,
|
||||
'role_id' => $roleId,
|
||||
'created_at' => utc_now(),
|
||||
'updated_by' => $adminId
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Load role names after saving
|
||||
$assignedRoles = !empty($roleIds)
|
||||
? $this->role->whereIn('id', $roleIds)->findAll()
|
||||
: [];
|
||||
|
||||
$roleNames = array_map(fn($r) => strtolower($r['name']), $assignedRoles ?? []);
|
||||
|
||||
// Update staff record if needed
|
||||
$this->updateStaffRecord($userId, $roleNames);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $this->success(null, 'Roles updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Error updating roles: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update roles: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/rolepermission/admins
|
||||
* Get admin users
|
||||
*/
|
||||
public function admins()
|
||||
{
|
||||
$admins = $this->user->newQuery()
|
||||
->select(['users.id', 'users.firstname', 'users.lastname', 'users.email'])
|
||||
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
|
||||
->join('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->where('roles.name', 'admin')
|
||||
->groupBy('users.id', 'users.firstname', 'users.lastname', 'users.email')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success($admins, 'Admin users retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Build users with roles payload
|
||||
*/
|
||||
private function buildUsersWithRolesPayload(): array
|
||||
{
|
||||
$rows = DB::table('users')
|
||||
->select('users.id', 'users.firstname', 'users.lastname', 'users.email', 'users.account_id', 'roles.name AS role_name')
|
||||
->leftJoin('user_roles', 'users.id', '=', 'user_roles.user_id')
|
||||
->leftJoin('roles', 'user_roles.role_id', '=', 'roles.id')
|
||||
->orderBy('users.id', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$users = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Update staff record based on roles
|
||||
*/
|
||||
private function updateStaffRecord(int $userId, array $newRoleNames): void
|
||||
{
|
||||
$excludedRoles = ['parent', 'student', 'guest'];
|
||||
|
||||
$loweredNewRoles = array_map('strtolower', $newRoleNames);
|
||||
|
||||
$user = $this->user->find($userId);
|
||||
if (!$user) {
|
||||
log_message('error', "updateStaffRecord: user_id {$userId} not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch existing staff record if it exists
|
||||
$existingStaff = $this->staff->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'] ?? null,
|
||||
'role_name' => implode(', ', $allRoles),
|
||||
'active_role' => $activeRole,
|
||||
'school_year' => $this->schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$this->staff->upsert($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Generate unique staff email
|
||||
*/
|
||||
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->staff->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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Ensure slug is unique
|
||||
*/
|
||||
private function ensureUniqueSlug(string $baseSlug, ?int $ignoreId = null): string
|
||||
{
|
||||
$slug = $baseSlug;
|
||||
$i = 2;
|
||||
|
||||
while (true) {
|
||||
$builder = $this->role->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;
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Role;
|
||||
use App\Models\UserRole;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RoleSwitcherController extends BaseApiController
|
||||
{
|
||||
protected UserRole $userRole;
|
||||
protected Role $role;
|
||||
protected Configuration $config;
|
||||
protected string $semester;
|
||||
protected string $schoolYear;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->userRole = model(UserRole::class);
|
||||
$this->role = model(Role::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/role-switcher
|
||||
* Get available roles for the current user
|
||||
* If user has only one role, returns auto_redirect flag with route
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->error('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$roles = $this->userRole
|
||||
->select('roles.name')
|
||||
->join('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->where('user_roles.user_id', $user->id)
|
||||
->whereNull('user_roles.deleted_at')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
if (empty($roles)) {
|
||||
return $this->error('No roles assigned to this user', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$roleNames = array_column($roles, 'name');
|
||||
|
||||
// If user has only one role, return auto-redirect information
|
||||
if (count($roleNames) === 1) {
|
||||
$roleName = $roleNames[0];
|
||||
$route = $this->getDashboardRoute($roleName);
|
||||
|
||||
return $this->success([
|
||||
'roles' => $roleNames,
|
||||
'auto_redirect' => true,
|
||||
'route' => $route,
|
||||
'role' => $roleName,
|
||||
], 'Single role found, auto-redirect available');
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'roles' => $roleNames,
|
||||
'auto_redirect' => false,
|
||||
'current_role' => Session::get('active_role'),
|
||||
], 'Roles retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Get roles error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve roles', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/role-switcher/switch
|
||||
* Switch to a specific role (role in request body)
|
||||
*/
|
||||
public function switchRole()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->error('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data) || !isset($data['role'])) {
|
||||
return $this->error('Role is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return $this->switchToRole($data['role'], $user->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/role-switcher/switch/{role}
|
||||
* Switch to a specific role (role in URL parameter)
|
||||
*/
|
||||
public function switchTo($role)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->error('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
return $this->switchToRole($role, $user->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to handle role switching logic
|
||||
*/
|
||||
private function switchToRole(string $role, int $userId)
|
||||
{
|
||||
try {
|
||||
$roleKey = is_string($role) ? $role : (string) $role;
|
||||
|
||||
// Verify user has this role
|
||||
$hasRole = $this->userRole
|
||||
->select('roles.name')
|
||||
->join('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->where('user_roles.user_id', $userId)
|
||||
->where(function ($query) use ($roleKey) {
|
||||
$query->where('LOWER(roles.name)', strtolower($roleKey))
|
||||
->orWhere('LOWER(roles.slug)', strtolower($roleKey));
|
||||
})
|
||||
->whereNull('user_roles.deleted_at')
|
||||
->first();
|
||||
|
||||
if (!$hasRole) {
|
||||
return $this->error('You do not have this role', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Set active role in session
|
||||
Session::put('active_role', $roleKey);
|
||||
|
||||
// Get dashboard route for this role
|
||||
$route = $this->getDashboardRoute($roleKey);
|
||||
|
||||
log_message('debug', 'Redirecting user to: ' . $route);
|
||||
|
||||
return $this->success([
|
||||
'role' => $roleKey,
|
||||
'route' => $route,
|
||||
'message' => 'Role switched successfully',
|
||||
], 'Role switched successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Role switch error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to switch role', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard route for a role
|
||||
*/
|
||||
private function getDashboardRoute(string $role): string
|
||||
{
|
||||
$key = is_string($role) ? $role : (string) $role;
|
||||
$route = $this->role->getRouteByNameOrSlug($key);
|
||||
|
||||
return $route ?? '/landing_page/guest_dashboard';
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Calendar;
|
||||
use App\Models\Configuration;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SchoolCalendarController extends BaseApiController
|
||||
{
|
||||
protected Calendar $calendar;
|
||||
protected Configuration $config;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->calendar = model(Calendar::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/school-calendar
|
||||
* Get calendar events filtered by school_year and optional semester
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
$semester = $this->request->getGet('semester');
|
||||
|
||||
try {
|
||||
$query = $this->calendar->newQuery();
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
if ($semester !== null && $semester !== '') {
|
||||
$events = $query->where('semester', $semester)->orderBy('date', 'ASC')->get()->toArray();
|
||||
} else {
|
||||
$events = $query->orderBy('date', 'ASC')->get()->toArray();
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'events' => $events,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
], 'Calendar events retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'School calendar error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve calendar events', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/school-calendar/{id}
|
||||
* Get a single calendar event
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$event = $this->calendar->find($id);
|
||||
if (!$event) {
|
||||
return $this->error('Event not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($event, 'Event retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/school-calendar
|
||||
* Create a new calendar event
|
||||
*/
|
||||
public function store()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'title' => 'required|min_length[3]',
|
||||
'start' => 'required|valid_date[Y-m-d]',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'title' => trim((string) ($payload['title'] ?? '')),
|
||||
'description' => trim((string) ($payload['description'] ?? '')),
|
||||
'date' => trim((string) ($payload['start'] ?? '')),
|
||||
'notify_parent' => isset($payload['notify_parent']) && $payload['notify_parent'] ? 1 : 0,
|
||||
'notify_teacher' => isset($payload['notify_teacher']) && $payload['notify_teacher'] ? 1 : 0,
|
||||
'notify_admin' => isset($payload['notify_admin']) && $payload['notify_admin'] ? 1 : 0,
|
||||
'no_school' => isset($payload['no_school']) && $payload['no_school'] ? 1 : 0,
|
||||
'school_year' => trim((string) ($payload['school_year'] ?? $this->schoolYear)),
|
||||
'semester' => trim((string) ($payload['semester'] ?? $this->semester)),
|
||||
];
|
||||
|
||||
try {
|
||||
$event = $this->calendar->create($data);
|
||||
log_message('info', 'Event saved successfully: ' . print_r($data, true));
|
||||
return $this->success($event, 'Event added successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to save event: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to save the event', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/school-calendar/{id}
|
||||
* Update a calendar event
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$event = $this->calendar->find($id);
|
||||
if (!$event) {
|
||||
return $this->error('Event not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'title' => 'permit_empty|min_length[3]',
|
||||
'start' => 'permit_empty|valid_date[Y-m-d]',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($payload, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$data = [];
|
||||
|
||||
if (isset($payload['title'])) {
|
||||
$data['title'] = trim((string) $payload['title']);
|
||||
}
|
||||
if (isset($payload['description'])) {
|
||||
$data['description'] = trim((string) $payload['description']);
|
||||
}
|
||||
if (isset($payload['start'])) {
|
||||
$data['date'] = trim((string) $payload['start']);
|
||||
}
|
||||
if (isset($payload['notify_parent'])) {
|
||||
$data['notify_parent'] = $payload['notify_parent'] ? 1 : 0;
|
||||
}
|
||||
if (isset($payload['notify_teacher'])) {
|
||||
$data['notify_teacher'] = $payload['notify_teacher'] ? 1 : 0;
|
||||
}
|
||||
if (isset($payload['notify_admin'])) {
|
||||
$data['notify_admin'] = $payload['notify_admin'] ? 1 : 0;
|
||||
}
|
||||
if (isset($payload['no_school'])) {
|
||||
$data['no_school'] = $payload['no_school'] ? 1 : 0;
|
||||
}
|
||||
if (isset($payload['school_year'])) {
|
||||
$data['school_year'] = trim((string) $payload['school_year']);
|
||||
}
|
||||
if (isset($payload['semester'])) {
|
||||
$data['semester'] = trim((string) $payload['semester']);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->calendar->update($id, $data);
|
||||
$updatedEvent = $this->calendar->find($id);
|
||||
return $this->success($updatedEvent, 'Event updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to update event: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update the event', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/school-calendar/{id}
|
||||
* Delete a calendar event
|
||||
*/
|
||||
public function delete($id = null)
|
||||
{
|
||||
$event = $this->calendar->find($id);
|
||||
if (!$event) {
|
||||
return $this->error('Event not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->calendar->delete($id);
|
||||
return $this->success(null, 'Event deleted successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to delete event: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete the event', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/school-calendar/view/parent
|
||||
* Get calendar events formatted for parent view
|
||||
*/
|
||||
public function calendarParentView()
|
||||
{
|
||||
$formattedEvents = $this->calendarView('parent');
|
||||
return $this->success(['events' => $formattedEvents], 'Parent calendar view retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/school-calendar/view/teacher
|
||||
* Get calendar events formatted for teacher view
|
||||
*/
|
||||
public function calendarTeacherView()
|
||||
{
|
||||
$formattedEvents = $this->calendarView('teacher');
|
||||
return $this->success(['events' => $formattedEvents], 'Teacher calendar view retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/school-calendar/view/admin
|
||||
* Get calendar events formatted for admin view
|
||||
*/
|
||||
public function calendarAdminView()
|
||||
{
|
||||
$formattedEvents = $this->calendarView('admin');
|
||||
return $this->success(['events' => $formattedEvents], 'Admin calendar view retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to format calendar events based on audience visibility rules
|
||||
*/
|
||||
private function calendarView(?string $audience = null): array
|
||||
{
|
||||
// Filter events by school year
|
||||
$query = $this->calendar->newQuery();
|
||||
if ($this->schoolYear !== '') {
|
||||
$query->where('school_year', $this->schoolYear);
|
||||
}
|
||||
$events = $query->orderBy('date', 'ASC')->get()->toArray();
|
||||
|
||||
// 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);
|
||||
|
||||
return $formattedEvents;
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\ScoreComment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ScoreCommentController extends BaseApiController
|
||||
{
|
||||
protected ScoreComment $scoreComment;
|
||||
protected Configuration $config;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->scoreComment = model(ScoreComment::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/score-comments
|
||||
* Save comments for students
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$comments = $payload['comments'] ?? null;
|
||||
$subjectId = $payload['subject_id'] ?? null;
|
||||
|
||||
if (!is_array($comments)) {
|
||||
return $this->error('No comments were submitted', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!$subjectId) {
|
||||
return $this->error('Subject ID is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$semester = Session::get('semester') ?? $this->semester;
|
||||
$teacherId = $user->id;
|
||||
|
||||
$saved = [];
|
||||
$errors = [];
|
||||
|
||||
try {
|
||||
foreach ($comments as $studentId => $commentTypes) {
|
||||
if (!is_array($commentTypes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($commentTypes as $scoreType => $comment) {
|
||||
if (trim((string) $comment) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedScoreType = strtolower((string) $scoreType);
|
||||
|
||||
$data = [
|
||||
'student_id' => (int) $studentId,
|
||||
'subject_id' => (int) $subjectId,
|
||||
'score_type' => $normalizedScoreType,
|
||||
'semester' => $semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'comment' => trim((string) $comment),
|
||||
'commented_by' => $teacherId,
|
||||
'created_at' => utc_now(),
|
||||
];
|
||||
|
||||
// Check if a comment already exists
|
||||
$existing = $this->scoreComment
|
||||
->where('student_id', $studentId)
|
||||
->where('subject_id', $subjectId)
|
||||
->where('score_type', $normalizedScoreType)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->first();
|
||||
|
||||
try {
|
||||
if ($existing) {
|
||||
$this->scoreComment->update($existing['id'], $data);
|
||||
$saved[] = $this->scoreComment->find($existing['id']);
|
||||
} else {
|
||||
$record = $this->scoreComment->create($data);
|
||||
$saved[] = is_array($record) ? $record : $record->toArray();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = "Failed to save comment for student {$studentId}, type {$scoreType}: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return $this->error('Some comments failed to save', Response::HTTP_PARTIAL_CONTENT, [
|
||||
'saved' => $saved,
|
||||
'errors' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->success(['saved' => $saved], 'Comments saved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Score comment save error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to save comments', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/score-comments/student/{studentId}
|
||||
* Get comments for a specific student
|
||||
*/
|
||||
public function getByStudent($studentId = null)
|
||||
{
|
||||
$subjectId = $this->request->getGet('subject_id');
|
||||
$scoreType = $this->request->getGet('score_type');
|
||||
|
||||
$query = $this->scoreComment->newQuery()
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester);
|
||||
|
||||
if (!empty($subjectId)) {
|
||||
$query->where('subject_id', $subjectId);
|
||||
}
|
||||
|
||||
if (!empty($scoreType)) {
|
||||
$query->where('score_type', $scoreType);
|
||||
}
|
||||
|
||||
$comments = $query->get()->toArray();
|
||||
|
||||
return $this->success($comments, 'Comments retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/score-comments/management
|
||||
* Get comments management view data for a class section
|
||||
*/
|
||||
public function viewCommentsMngt()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
$classSectionId = (int) (
|
||||
$payload['class_section_id'] ??
|
||||
$this->request->getGet('class_section_id') ??
|
||||
Session::get('class_section_id') ??
|
||||
0
|
||||
);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->error('Missing class section', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
Session::put('class_section_id', $classSectionId);
|
||||
|
||||
try {
|
||||
// Get roster for this class/term (distinct to avoid dupes)
|
||||
$students = DB::table('student_class as sc')
|
||||
->select('s.id AS student_id', 's.firstname', 's.lastname', 's.school_id')
|
||||
->distinct()
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
// Load only comments for these students in this term (ptap, midterm, final)
|
||||
$comments = [];
|
||||
|
||||
if (!empty($students)) {
|
||||
$studentIds = array_map(static fn($r) => (int) $r->student_id, $students);
|
||||
|
||||
$rawComments = $this->scoreComment
|
||||
->whereIn('student_id', $studentIds)
|
||||
->whereIn('score_type', ['midterm', 'final', 'ptap'])
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->findAll();
|
||||
|
||||
foreach ($rawComments as $c) {
|
||||
$sid = (int) $c['student_id'];
|
||||
$typ = (string) $c['score_type'];
|
||||
$comments[$sid][$typ] = [
|
||||
'comment' => $c['comment'] ?? null,
|
||||
'comment_review' => $c['comment_review'] ?? null,
|
||||
'commented_by' => $c['commented_by'] ?? null,
|
||||
'reviewed_by' => $c['reviewed_by'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'students' => array_map(function ($s) {
|
||||
return [
|
||||
'student_id' => $s->student_id,
|
||||
'firstname' => $s->firstname,
|
||||
'lastname' => $s->lastname,
|
||||
'school_id' => $s->school_id,
|
||||
];
|
||||
}, $students),
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_section_id' => $classSectionId,
|
||||
'comments' => $comments,
|
||||
], 'Comments management data retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Comments management error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve comments management data', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/score-comments/update
|
||||
* Update comments and reviews
|
||||
*/
|
||||
public function updateComments()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$semester = $payload['semester'] ?? $this->semester;
|
||||
$schoolYear = $payload['school_year'] ?? $this->schoolYear;
|
||||
$teacherId = $payload['teacher_id'] ?? $user->id;
|
||||
$comments = $payload['comments'] ?? [];
|
||||
$reviews = $payload['reviews'] ?? [];
|
||||
|
||||
if (!is_array($comments)) {
|
||||
return $this->error('Comments must be an array', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$isReviewer = $this->isReviewer();
|
||||
$updated = [];
|
||||
$errors = [];
|
||||
|
||||
try {
|
||||
foreach ($comments as $studentId => $types) {
|
||||
if (!is_array($types)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($types as $scoreType => $commentText) {
|
||||
// Check if comment already exists
|
||||
$existing = $this->scoreComment->where([
|
||||
'student_id' => (int) $studentId,
|
||||
'score_type' => strtolower((string) $scoreType),
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
])->first();
|
||||
|
||||
$reviewText = $reviews[$studentId][$scoreType] ?? null;
|
||||
|
||||
$data = [
|
||||
'comment' => trim((string) $commentText) === '' ? null : trim((string) $commentText),
|
||||
'commented_by' => (int) $teacherId,
|
||||
];
|
||||
|
||||
// Only update review if user is reviewer and review text exists
|
||||
if ($isReviewer && $reviewText !== null) {
|
||||
$data['comment_review'] = trim((string) $reviewText) === '' ? null : trim((string) $reviewText);
|
||||
$data['reviewed_by'] = (int) $teacherId;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($existing) {
|
||||
// Update
|
||||
$this->scoreComment->update($existing['id'], $data);
|
||||
$updated[] = $this->scoreComment->find($existing['id']);
|
||||
} else {
|
||||
// Insert
|
||||
$insertData = array_merge([
|
||||
'student_id' => (int) $studentId,
|
||||
'score_type' => strtolower((string) $scoreType),
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
], $data);
|
||||
$record = $this->scoreComment->create($insertData);
|
||||
$updated[] = is_array($record) ? $record : $record->toArray();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = "Failed to update comment for student {$studentId}, type {$scoreType}: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return $this->error('Some comments failed to update', Response::HTTP_PARTIAL_CONTENT, [
|
||||
'updated' => $updated,
|
||||
'errors' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->success(['updated' => $updated], 'Comments updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Comments update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update comments', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/score-comments/{id}
|
||||
* Update a single comment
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$comment = $this->scoreComment->find($id);
|
||||
if (!$comment) {
|
||||
return $this->error('Comment not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
if (empty($payload) || !array_key_exists('comment', $payload)) {
|
||||
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->scoreComment->update($id, ['comment' => $payload['comment']]);
|
||||
$updated = $this->scoreComment->find($id);
|
||||
return $this->success($updated, 'Comment updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Comment update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update comment', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if current user is a reviewer
|
||||
*/
|
||||
private function isReviewer(): bool
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user || empty($user->roles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get reviewer roles from configuration table
|
||||
$reviewerRoles = $this->config->getConfig('comment_reviewer');
|
||||
|
||||
if (!$reviewerRoles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert comma-separated string to array and trim whitespace
|
||||
$allowedRoles = array_map('trim', explode(',', (string) $reviewerRoles));
|
||||
$allowedRoles = array_map('strtolower', $allowedRoles);
|
||||
|
||||
// Check if any of the user's roles match the allowed reviewer roles
|
||||
$userRoles = array_map('strtolower', $user->roles);
|
||||
|
||||
foreach ($userRoles as $userRole) {
|
||||
if (in_array($userRole, $allowedRoles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,695 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\AttendanceRecord;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\FinalExam;
|
||||
use App\Models\Homework;
|
||||
use App\Models\MidtermExam;
|
||||
use App\Models\Project;
|
||||
use App\Models\Quiz;
|
||||
use App\Models\ScoreComment;
|
||||
use App\Models\SemesterScore;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ScoreController extends BaseApiController
|
||||
{
|
||||
protected SemesterScore $semesterScore;
|
||||
protected Configuration $config;
|
||||
protected ClassSection $classSection;
|
||||
protected StudentClass $studentClass;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected User $user;
|
||||
protected Student $student;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
protected string $targetHigh;
|
||||
protected string $targetLow;
|
||||
protected $semesterScoreService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->semesterScore = model(SemesterScore::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->classSection = model(ClassSection::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->user = model(User::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
$this->targetHigh = (string) ($this->config->getConfig('trophy_score') ?? '');
|
||||
$this->targetLow = (string) ($this->config->getConfig('pass_score') ?? '');
|
||||
$this->semesterScoreService = \Config\Services::semesterScoreService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/scores
|
||||
* Get student scores for teacher's class section
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$teacherId = $user->id;
|
||||
$teacher = $this->user->find($teacherId);
|
||||
$teacherName = $teacher ? trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')) : 'Unknown';
|
||||
|
||||
$activeSemester = $this->resolveActiveSemester();
|
||||
$effectiveSemester = $activeSemester;
|
||||
$effectiveSchoolYear = $this->schoolYear;
|
||||
|
||||
// Get class section ID for the active term
|
||||
$classSection = $this->teacherClass
|
||||
->where('teacher_id', $teacherId)
|
||||
->where('school_year', $effectiveSchoolYear)
|
||||
->where('semester', $effectiveSemester)
|
||||
->first();
|
||||
|
||||
if (!$classSection) {
|
||||
$fallback = $this->teacherClass
|
||||
->where('teacher_id', $teacherId)
|
||||
->first();
|
||||
|
||||
if ($fallback) {
|
||||
$classSection = $fallback;
|
||||
$effectiveSemester = $classSection['semester'] ?? $effectiveSemester;
|
||||
$effectiveSchoolYear = $classSection['school_year'] ?? $effectiveSchoolYear;
|
||||
log_message('info', sprintf(
|
||||
'Teacher ID %d class fallback to stored term %s / %s.',
|
||||
$teacherId,
|
||||
$effectiveSemester,
|
||||
$effectiveSchoolYear
|
||||
));
|
||||
} else {
|
||||
return $this->error('You do not have an assigned class yet. Please contact the administration.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
$classSectionId = $classSection['class_section_id'];
|
||||
|
||||
// Build assigned teacher/TA names for this section
|
||||
$rows = DB::table('teacher_class as tc')
|
||||
->select('tc.position', 'u.firstname', 'u.lastname')
|
||||
->join('users as u', 'u.id', '=', 'tc.teacher_id')
|
||||
->where('tc.class_section_id', $classSectionId)
|
||||
->where('tc.school_year', $effectiveSchoolYear)
|
||||
->where('tc.semester', $effectiveSemester)
|
||||
->orderByRaw("FIELD(tc.position, 'main', 'ta')")
|
||||
->orderBy('u.firstname', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$mains = [];
|
||||
$tas = [];
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$name = trim(($r->firstname ?? '') . ' ' . ($r->lastname ?? ''));
|
||||
if ($name === '') continue;
|
||||
|
||||
$pos = strtolower((string) ($r->position ?? ''));
|
||||
if (in_array($pos, ['main', 'teacher'], true)) {
|
||||
$mains[] = $name;
|
||||
} elseif (in_array($pos, ['ta', 'assistant', 'teacher_assistant'], true)) {
|
||||
$tas[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($mains) && !empty($teacherName)) {
|
||||
$mains = [$teacherName];
|
||||
}
|
||||
|
||||
// Resolve class section name
|
||||
$csRow = DB::table('classSection')
|
||||
->select('class_section_name')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->first();
|
||||
|
||||
$classSectionName = $csRow->class_section_name ?? '';
|
||||
|
||||
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId(
|
||||
$classSectionId,
|
||||
$effectiveSemester,
|
||||
$effectiveSchoolYear
|
||||
);
|
||||
|
||||
if (!empty($studentTeacherInfo) && $this->semesterScoreService !== null) {
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'ScoreController::index semester score refresh failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve the student scores data
|
||||
$students = $this->getStudentScores($classSectionId, $effectiveSemester, $effectiveSchoolYear, $teacherId);
|
||||
|
||||
// Sort students by last name, then first name
|
||||
usort($students, function ($a, $b) {
|
||||
$lastNameComparison = strcmp($a['lastname'], $b['lastname']);
|
||||
return $lastNameComparison === 0
|
||||
? strcmp($a['firstname'], $b['firstname'])
|
||||
: $lastNameComparison;
|
||||
});
|
||||
|
||||
return $this->success([
|
||||
'students' => array_values($students),
|
||||
'teacher_name' => $teacherName,
|
||||
'class_section_id' => $classSectionId,
|
||||
'class_section_name' => $classSectionName,
|
||||
'mains_text' => implode(', ', $mains),
|
||||
'tas_text' => implode(', ', $tas),
|
||||
'active_semester' => strtolower((string) $effectiveSemester),
|
||||
], 'Student scores retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/scores/student/{studentId}
|
||||
* Get scores for a specific student
|
||||
*/
|
||||
public function getByStudent($studentId = null)
|
||||
{
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$semester = $this->config->getConfig('semester');
|
||||
|
||||
$scores = $this->semesterScore->newQuery()
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success($scores, 'Scores retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/scores/update-final-exam
|
||||
* Update final exam scores
|
||||
*/
|
||||
public function updateFinalExamScores()
|
||||
{
|
||||
return $this->updateScores('final_exam');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/scores/update-midterm
|
||||
* Update midterm exam scores
|
||||
*/
|
||||
public function updateMidtermScores()
|
||||
{
|
||||
return $this->updateScores('midterm_exam');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/scores/update
|
||||
* Generic method to update scores for various types
|
||||
*/
|
||||
public function updateScores(string $table = null)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = $this->payloadData();
|
||||
$table = $table ?? $payload['table'] ?? null;
|
||||
|
||||
if (!$table) {
|
||||
return $this->error('Table type is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$scores = $payload['final_score'] ?? null;
|
||||
$teacherId = $user->id;
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? Session::get('class_section_id') ?? 0);
|
||||
$semester = $this->resolveActiveSemester();
|
||||
$affectedStudents = [];
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return $this->error('No scores submitted', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return $this->error('Missing class section', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($scores as $studentId => $data) {
|
||||
if (!is_numeric($studentId) || !is_numeric($data['score'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$builder = DB::table($table);
|
||||
|
||||
// Check if a record already exists
|
||||
$existing = $builder
|
||||
->where('student_id', $studentId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->first();
|
||||
|
||||
$insertData = [
|
||||
'student_id' => (int) $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $teacherId,
|
||||
'score' => is_numeric($data['score']) ? (float) $data['score'] : null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if (strcasecmp($table, 'quiz') === 0) {
|
||||
$insertData['quiz_index'] = $data['quiz_index'] ?? null;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$builder->where('id', $existing->id)->update($insertData);
|
||||
} else {
|
||||
$insertData['created_at'] = utc_now();
|
||||
$builder->insert($insertData);
|
||||
}
|
||||
|
||||
$affectedStudents[(int) $studentId] = true;
|
||||
}
|
||||
|
||||
// If final exam scores updated during Spring, trigger semester score recalculation
|
||||
if (strcasecmp($table, 'final_exam') === 0 && strcasecmp((string) $this->semester, 'Spring') === 0 && $this->semesterScoreService !== null) {
|
||||
try {
|
||||
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId(
|
||||
$classSectionId,
|
||||
$semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
if (!empty($studentTeacherInfo)) {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'semester score resync failed after final exam update: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'updated_count' => count($affectedStudents),
|
||||
'table' => $table,
|
||||
], ucfirst($table) . ' scores updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Score update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update scores', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/scores/parent-view
|
||||
* Get student scores for parent view
|
||||
*/
|
||||
public function viewStudentScore()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$parentId = $user->id;
|
||||
$userType = Session::get('user_type');
|
||||
$firstParentId = null;
|
||||
|
||||
// Identify the firstparent based on user type
|
||||
if ($userType === 'primary') {
|
||||
$firstParentId = $parentId;
|
||||
} elseif ($userType === 'secondary') {
|
||||
$parentData = DB::table('parents')
|
||||
->select('parent_id')
|
||||
->where('secondparent_user_id', $parentId)
|
||||
->first();
|
||||
|
||||
if ($parentData) {
|
||||
$firstParentId = $parentData->parent_id;
|
||||
}
|
||||
} elseif ($userType === 'tertiary') {
|
||||
$authUserData = DB::table('authorized_users')
|
||||
->select('user_id as parent_id')
|
||||
->where('authorized_user_id', $parentId)
|
||||
->first();
|
||||
|
||||
if ($authUserData) {
|
||||
$firstParentId = $authUserData->parent_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$firstParentId) {
|
||||
return $this->error('Unable to retrieve student data. Please contact support.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
$requestedSemester = $this->semester;
|
||||
|
||||
// Fetch distinct school years from semester_scores
|
||||
$schoolYears = DB::table('semester_scores')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
->pluck('school_year')
|
||||
->unique()
|
||||
->sortDesc()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$scores = [];
|
||||
|
||||
// Get all students with their class section info for this parent
|
||||
$students = DB::table('students')
|
||||
->select('students.id', 'students.school_id', 'students.firstname', 'students.lastname', 'classSection.class_section_name')
|
||||
->join('student_class', 'student_class.student_id', '=', 'students.id', 'left')
|
||||
->join('classSection', 'classSection.class_section_id', '=', 'student_class.class_section_id', 'left')
|
||||
->where('students.parent_id', $firstParentId)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
if (!empty($students)) {
|
||||
$studentIdList = array_column($students, 'id');
|
||||
|
||||
// Fetch data from semester_scores for these students
|
||||
$semesterScores = DB::table('semester_scores')
|
||||
->whereIn('student_id', $studentIdList)
|
||||
->where('school_year', $selectedYear)
|
||||
->where('semester', $requestedSemester)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
foreach ($students as $student) {
|
||||
$studentId = $student->id;
|
||||
|
||||
// Find this student's scores
|
||||
$studentScores = array_filter($semesterScores, function ($score) use ($studentId) {
|
||||
return $score->student_id == $studentId;
|
||||
});
|
||||
|
||||
if (!empty($studentScores)) {
|
||||
$score = reset($studentScores);
|
||||
$scores[$selectedYear][$requestedSemester][$studentId] = [
|
||||
'school_id' => $student->school_id,
|
||||
'student_firstname' => $student->firstname,
|
||||
'student_lastname' => $student->lastname,
|
||||
'class_section_name' => $student->class_section_name ?? 'Unknown Class',
|
||||
'comment' => $score->comment ?? null,
|
||||
'scores' => [
|
||||
'homework' => ['score' => $score->homework_avg ?? '-'],
|
||||
'quiz' => ['score' => $score->quiz_avg ?? '-'],
|
||||
'project' => ['score' => $score->project_avg ?? '-'],
|
||||
'attendance' => ['score' => $score->attendance_score ?? '-'],
|
||||
'participation_score' => ['score' => $score->participation_score ?? '-'],
|
||||
'midterm_exam' => ['score' => $score->midterm_exam_score ?? '-'],
|
||||
'final_exam' => ['score' => $score->final_exam_score ?? '-'],
|
||||
'semester' => ['score' => $score->semester_score ?? '-'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'organized_scores' => $scores,
|
||||
'school_years' => $schoolYears,
|
||||
'selected_year' => $selectedYear,
|
||||
'selected_semester' => $requestedSemester,
|
||||
], 'Student scores retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/scores/recalculate
|
||||
* Recalculate scores for students
|
||||
*/
|
||||
public function recalculate()
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
|
||||
$students = (array) ($payload['students'] ?? []);
|
||||
|
||||
$semester = (string) ($payload['semester'] ?? $this->config->getConfig('semester'));
|
||||
$schoolYear = (string) ($payload['school_year'] ?? $this->config->getConfig('school_year'));
|
||||
|
||||
try {
|
||||
if ($classSectionId > 0) {
|
||||
$students = DB::table('student_class as sc')
|
||||
->select('s.id AS student_id', 's.school_id', 'sc.class_section_id')
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
if (empty($students)) {
|
||||
return $this->error('No students provided to recalculate', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$service = \Config\Services::semesterScoreService();
|
||||
$count = 0;
|
||||
|
||||
foreach ($students as $student) {
|
||||
$studentId = (int) ($student['student_id'] ?? 0);
|
||||
$studentSchool = (string) ($student['school_id'] ?? '');
|
||||
$classSection = (int) ($student['class_section_id'] ?? 0);
|
||||
|
||||
if ($studentId <= 0 || $classSection <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$service->updateStudentScores([
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $studentSchool,
|
||||
'class_section_id' => $classSection,
|
||||
'updated_by' => $this->getCurrentUserId(),
|
||||
], $semester, $schoolYear);
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $this->success(['students_processed' => $count], 'Scores recalculated');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Score recalc error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to recalculate scores', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to get student scores
|
||||
*/
|
||||
private function getStudentScores(int $classSectionId, $semester, $schoolYear, int $teacherId): array
|
||||
{
|
||||
if (!$semester || !$schoolYear) {
|
||||
throw new RuntimeException('Semester or school year configuration is missing.');
|
||||
}
|
||||
|
||||
$totalSemesterDays = strtolower($semester) === 'fall'
|
||||
? $this->config->getConfig('total_semester1_days')
|
||||
: $this->config->getConfig('total_semester2_days');
|
||||
|
||||
if (!$totalSemesterDays) {
|
||||
throw new RuntimeException('Total semester days configuration is missing.');
|
||||
}
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
throw new RuntimeException('Invalid class section for the current teacher.');
|
||||
}
|
||||
|
||||
$students = $this->prepareStudents($classSectionId, $teacherId, $schoolYear);
|
||||
$studentIds = array_keys($students);
|
||||
|
||||
// Load regular scores
|
||||
$this->loadScoresByType(new Homework(), 'homework', $students, $classSectionId, $semester, $schoolYear);
|
||||
$this->loadScoresByType(new Quiz(), 'quiz', $students, $classSectionId, $semester, $schoolYear);
|
||||
$this->loadScoresByType(new Project(), 'project', $students, $classSectionId, $semester, $schoolYear);
|
||||
$this->loadScoresByType(new MidtermExam(), 'midterm', $students, $classSectionId, $semester, $schoolYear);
|
||||
$this->loadScoresByType(new FinalExam(), 'final_exam', $students, $classSectionId, $semester, $schoolYear);
|
||||
|
||||
$attendanceRecord = new AttendanceRecord();
|
||||
$this->calculateStudentScores($students, $semester, $totalSemesterDays, $attendanceRecord);
|
||||
|
||||
// Load all semester scores in one query
|
||||
$this->loadSemesterScores($students, $classSectionId, $semester, $schoolYear);
|
||||
|
||||
$this->loadComments(new ScoreComment(), $students, $studentIds, $semester, $schoolYear);
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function loadSemesterScores(&$students, $classSectionId, $semester, $schoolYear): void
|
||||
{
|
||||
$results = $this->semesterScore
|
||||
->select('student_id, attendance_score, ptap_score, semester_score, midterm_exam_score, final_exam_score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
foreach ($results as $row) {
|
||||
$studentId = $row['student_id'];
|
||||
|
||||
if (isset($students[$studentId])) {
|
||||
$attendanceScore = $this->roundScore($row['attendance_score'] ?? null);
|
||||
if ($attendanceScore !== null) {
|
||||
$students[$studentId]['attendance_score'] = $attendanceScore;
|
||||
}
|
||||
|
||||
$ptapScore = $this->roundScore($row['ptap_score'] ?? null);
|
||||
if ($ptapScore !== null) {
|
||||
$students[$studentId]['ptap_score'] = $ptapScore;
|
||||
}
|
||||
|
||||
$semesterScore = $this->roundScore($row['semester_score'] ?? null);
|
||||
if ($semesterScore !== null) {
|
||||
$students[$studentId]['semester_score'] = $semesterScore;
|
||||
}
|
||||
|
||||
$midtermScore = $this->roundScore($row['midterm_exam_score'] ?? null);
|
||||
if ($midtermScore !== null && empty($students[$studentId]['midterm'])) {
|
||||
$students[$studentId]['midterm'][] = $midtermScore;
|
||||
}
|
||||
|
||||
$finalExamScore = $this->roundScore($row['final_exam_score'] ?? null);
|
||||
if ($finalExamScore !== null && empty($students[$studentId]['final_exam'])) {
|
||||
$students[$studentId]['final_exam'][] = $finalExamScore;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function roundScore($value): ?float
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_numeric($value) ? round((float) $value, 1) : null;
|
||||
}
|
||||
|
||||
private function prepareStudents($classSectionId, $teacherId, $schoolYear): array
|
||||
{
|
||||
$students = [];
|
||||
$studentClasses = $this->studentClass->where('class_section_id', $classSectionId)->findAll();
|
||||
|
||||
foreach ($studentClasses as $studentClass) {
|
||||
$student = $this->student->select('id, firstname, lastname, age, school_id')->find($studentClass['student_id']);
|
||||
if ($student) {
|
||||
$students[$student['id']] = [
|
||||
'student_id' => $student['id'],
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'age' => $student['age'],
|
||||
'school_id' => $student['school_id'],
|
||||
'class_section_id' => $classSectionId,
|
||||
'school_year' => $schoolYear,
|
||||
'teacher_id' => $teacherId,
|
||||
'homework' => [],
|
||||
'quiz' => [],
|
||||
'midterm' => [],
|
||||
'final_exam' => [],
|
||||
'project' => [],
|
||||
'ptap' => [],
|
||||
'ptap_comment' => '',
|
||||
'midterm_comment' => '',
|
||||
'final_comment' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function loadScoresByType($model, $type, &$students, $classSectionId, $semester, $schoolYear): void
|
||||
{
|
||||
$records = $model->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
foreach ($records as $rec) {
|
||||
if (isset($students[$rec['student_id']])) {
|
||||
$students[$rec['student_id']][$type][] = $rec['score'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function loadComments($comment, &$students, $studentIds, $semester, $schoolYear): void
|
||||
{
|
||||
if (empty($studentIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$comments = $comment->whereIn('student_id', $studentIds)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
foreach ($comments as $comment) {
|
||||
$sid = $comment['student_id'];
|
||||
if (!isset($students[$sid])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = strtolower($comment['score_type']);
|
||||
|
||||
if ($type === 'ptap') {
|
||||
$students[$sid]['ptap_comment'] = $comment['comment'];
|
||||
$students[$sid]['ptap_comment_review'] = $comment['comment_review'] ?? null;
|
||||
} elseif ($type === 'midterm') {
|
||||
$students[$sid]['midterm_comment'] = $comment['comment'];
|
||||
$students[$sid]['midterm_comment_review'] = $comment['comment_review'] ?? null;
|
||||
} elseif ($type === 'final') {
|
||||
$students[$sid]['final_comment'] = $comment['comment'];
|
||||
$students[$sid]['final_comment_review'] = $comment['comment_review'] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function calculateStudentScores(&$students, $semester, $totalDays, $attendanceRecord): void
|
||||
{
|
||||
foreach ($students as $sid => &$s) {
|
||||
$absences = $attendanceRecord->getTotalAbsences($sid, $semester, $s['school_year'] ?? '');
|
||||
$attendance = min((($totalDays - $absences + 1) / $totalDays) * 100, 100);
|
||||
|
||||
$avgHomework = !empty($s['homework']) ? array_sum($s['homework']) / count($s['homework']) : 0;
|
||||
$avgQuiz = !empty($s['quiz']) ? array_sum($s['quiz']) / count($s['quiz']) : 0;
|
||||
$avgProject = !empty($s['project']) ? array_sum($s['project']) / count($s['project']) : 0;
|
||||
$ptap = round(($avgHomework + $avgQuiz + $avgProject) / 3, 1);
|
||||
|
||||
$midterm = !empty($s['midterm']) ? $s['midterm'][0] : 0;
|
||||
$final = !empty($s['final_exam']) ? $s['final_exam'][0] : 0;
|
||||
|
||||
$score = 0.2 * $ptap + 0.2 * $attendance;
|
||||
if (strtolower($semester) === 'fall') {
|
||||
$score += 0.6 * $midterm;
|
||||
} else {
|
||||
$score += 0.6 * $final;
|
||||
}
|
||||
|
||||
$s['average_homework'] = round($avgHomework, 1);
|
||||
$s['average_quiz'] = round($avgQuiz, 1);
|
||||
$s['average_project'] = round($avgProject, 1);
|
||||
$s['ptap_score'] = $ptap;
|
||||
$s['midterm_score'] = round($midterm, 1);
|
||||
$s['final_exam_score'] = round($final, 1);
|
||||
$s['attendance_score'] = round($attendance, 1);
|
||||
$s['semester_score'] = round(min($score, 100), 1);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveActiveSemester(): string
|
||||
{
|
||||
return Session::get('semester') ?? $this->semester;
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ScorePredictorController extends BaseApiController
|
||||
{
|
||||
protected Configuration $config;
|
||||
protected ClassSection $classSection;
|
||||
protected string $semester;
|
||||
protected string $schoolYear;
|
||||
protected $targetTrophy;
|
||||
protected $targetLow;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->config = model(Configuration::class);
|
||||
$this->classSection = model(ClassSection::class);
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->targetTrophy = $this->config->getConfig('trophy_score');
|
||||
$this->targetLow = $this->config->getConfig('pass_score');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/score-predictor/combined-report
|
||||
* Generate combined score prediction report with trophy winners
|
||||
*/
|
||||
public function combinedReport()
|
||||
{
|
||||
$selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
$classSectionId = $this->request->getGet('class_section_id');
|
||||
|
||||
try {
|
||||
$classSections = $this->classSection->findAll();
|
||||
|
||||
$students = DB::table('students as s')
|
||||
->select([
|
||||
's.id as student_id',
|
||||
's.school_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
'fall.semester_score as fall_score',
|
||||
'spring.semester_score as spring_score',
|
||||
'sc.class_section_id',
|
||||
])
|
||||
->leftJoin('student_class as sc', function ($join) use ($selectedYear) {
|
||||
$join->on('sc.student_id', '=', 's.id')
|
||||
->where('sc.school_year', $selectedYear);
|
||||
})
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->leftJoin('semester_scores as fall', function ($join) use ($selectedYear) {
|
||||
$join->on('fall.student_id', '=', 's.id')
|
||||
->where('fall.semester', 'fall')
|
||||
->where('fall.school_year', $selectedYear);
|
||||
})
|
||||
->leftJoin('semester_scores as spring', function ($join) use ($selectedYear) {
|
||||
$join->on('spring.student_id', '=', 's.id')
|
||||
->where('spring.semester', 'spring')
|
||||
->where('spring.school_year', $selectedYear);
|
||||
})
|
||||
->when(!empty($classSectionId), fn ($query) => $query->where('sc.class_section_id', $classSectionId))
|
||||
->groupBy('s.id')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
// Stats for spring
|
||||
$springStatsQuery = DB::table('semester_scores as ss')
|
||||
->selectRaw('AVG(ss.semester_score) as mean, STDDEV(ss.semester_score) as std')
|
||||
->join('student_class as sc', 'sc.student_id', '=', 'ss.student_id')
|
||||
->where('ss.semester', 'spring')
|
||||
->where('ss.school_year', $selectedYear)
|
||||
->where('sc.school_year', $selectedYear);
|
||||
|
||||
if (!empty($classSectionId)) {
|
||||
$springStatsQuery->where('sc.class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$springStats = $springStatsQuery->first();
|
||||
$mean = (float) ($springStats->mean ?? 70.0);
|
||||
$std = (float) ($springStats->std ?? 0.0);
|
||||
|
||||
$results = $this->buildPredictions($students, $mean, $std);
|
||||
|
||||
// Sort students by final average descending
|
||||
usort($results, function ($a, $b) {
|
||||
$aAvg = is_numeric($a['final_average']) ? $a['final_average'] : 0;
|
||||
$bAvg = is_numeric($b['final_average']) ? $b['final_average'] : 0;
|
||||
return $bAvg <=> $aAvg;
|
||||
});
|
||||
|
||||
$results = $this->assignTrophyStatuses($results);
|
||||
|
||||
return $this->success([
|
||||
'students' => $results,
|
||||
'school_year' => $selectedYear,
|
||||
'class_sections' => $classSections,
|
||||
'selected_class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
'stats' => [
|
||||
'mean' => $mean,
|
||||
'std' => $std,
|
||||
'target_trophy' => $this->targetTrophy,
|
||||
'target_pass' => $this->targetLow,
|
||||
],
|
||||
], 'Score prediction generated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Score predictor error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to generate score prediction', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
protected function buildPredictions(array $students, float $mean, float $std): array
|
||||
{
|
||||
$results = [];
|
||||
$trophyThreshold = max(94.0, (float) ($this->targetTrophy ?? 94.0));
|
||||
$passThreshold = (float) ($this->targetLow ?? 60.0);
|
||||
|
||||
foreach ($students as $student) {
|
||||
$student = (array) $student;
|
||||
$fall = isset($student['fall_score']) ? (float) $student['fall_score'] : null;
|
||||
$spring = isset($student['spring_score']) ? (float) $student['spring_score'] : null;
|
||||
$isNew = $fall === null;
|
||||
|
||||
$finalAverage = $isNew || $spring === null ? null : round(($fall + $spring) / 2, 1);
|
||||
$requiredHigh = $isNew ? null : round(2 * $trophyThreshold - $fall, 2);
|
||||
$requiredLow = $isNew ? null : round(2 * $passThreshold - $fall, 2);
|
||||
|
||||
$probHigh = $this->probability($requiredHigh, $mean, $std);
|
||||
$probLow = $this->probability($requiredLow, $mean, $std, false);
|
||||
|
||||
$results[] = [
|
||||
'student_id' => (int) ($student['student_id'] ?? 0),
|
||||
'school_id' => $student['school_id'] ?? null,
|
||||
'firstname' => $student['firstname'] ?? '',
|
||||
'lastname' => $student['lastname'] ?? '',
|
||||
'class_section_id' => $student['class_section_id'] ?? null,
|
||||
'fall_score' => $fall ?? 'N/A',
|
||||
'spring_score' => $spring ?? 'N/A',
|
||||
'final_average' => $finalAverage ?? 'N/A',
|
||||
'required_spring_score' => $requiredHigh ?? 'N/A',
|
||||
'winning_risk' => $this->riskLabelHigh($finalAverage, $requiredHigh, $probHigh, $trophyThreshold),
|
||||
'required_spring_to_pass' => $requiredLow ?? 'N/A',
|
||||
'failure_risk' => $this->riskLabelLow($requiredLow, $probLow),
|
||||
'status' => $this->statusLabel($finalAverage),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function probability(?float $required, float $mean, float $std, bool $isHigh = true): ?float
|
||||
{
|
||||
if ($required === null) {
|
||||
return null;
|
||||
}
|
||||
if ($std <= 0) {
|
||||
return $required <= $mean ? ($isHigh ? 1.0 : 0.0) : ($isHigh ? 0.0 : 1.0);
|
||||
}
|
||||
$z = ($required - $mean) / $std;
|
||||
$value = self::normalCDF($z);
|
||||
return $isHigh ? 1 - $value : $value;
|
||||
}
|
||||
|
||||
protected function riskLabelHigh($finalAverage, ?float $required, ?float $probability, float $threshold): string
|
||||
{
|
||||
if ($finalAverage !== null && $finalAverage >= $threshold) {
|
||||
return 'Achieved Trophy';
|
||||
}
|
||||
|
||||
if ($required !== null && $required > 100) {
|
||||
return 'Unreachable';
|
||||
}
|
||||
|
||||
if ($probability === null) {
|
||||
return 'New student';
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$probability >= 0.66 => 'Low',
|
||||
$probability >= 0.33 => 'Medium',
|
||||
default => 'High',
|
||||
};
|
||||
}
|
||||
|
||||
protected function riskLabelLow(?float $required, ?float $probability): string
|
||||
{
|
||||
if ($required !== null && $required > 100) {
|
||||
return 'Unlikely to pass';
|
||||
}
|
||||
|
||||
if ($probability === null) {
|
||||
return 'New student';
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$probability >= 0.66 => 'High',
|
||||
$probability >= 0.33 => 'Medium',
|
||||
default => 'Low',
|
||||
};
|
||||
}
|
||||
|
||||
protected function statusLabel($finalAverage): string
|
||||
{
|
||||
if ($finalAverage === null || !is_numeric($finalAverage)) {
|
||||
return 'Undetermined';
|
||||
}
|
||||
|
||||
return $finalAverage >= 60 ? 'Passed' : 'Failed';
|
||||
}
|
||||
|
||||
protected function assignTrophyStatuses(array $results): array
|
||||
{
|
||||
$tolerance = 0.2;
|
||||
$trophyThreshold = max(94.0, (float) ($this->targetTrophy ?? 94.0));
|
||||
|
||||
// Determine trophy winners per class
|
||||
$indicesByClass = [];
|
||||
|
||||
foreach ($results as $idx => $r) {
|
||||
$cid = $r['class_section_id'] ?? null;
|
||||
$indicesByClass[$cid][] = $idx;
|
||||
}
|
||||
|
||||
foreach ($indicesByClass as $cid => $idxList) {
|
||||
// Keep only indices with numeric final averages, in current sorted order
|
||||
$graded = array_values(array_filter($idxList, function ($i) use ($results) {
|
||||
return isset($results[$i]['final_average']) && is_numeric($results[$i]['final_average']);
|
||||
}));
|
||||
|
||||
$classSize = count($graded);
|
||||
if ($classSize === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$minTrophies = max((int) ceil($classSize / 4), 3);
|
||||
$minTrophies = min($minTrophies, $classSize);
|
||||
|
||||
$winners = [];
|
||||
|
||||
// 1) Include all students at/above threshold within this class
|
||||
foreach ($graded as $i) {
|
||||
$fa = $results[$i]['final_average'];
|
||||
if ($fa >= $trophyThreshold) {
|
||||
$winners[$i] = true;
|
||||
} else {
|
||||
break; // below threshold; following are lower
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Ensure at least the minimum trophies for this class
|
||||
if (count($winners) < $minTrophies) {
|
||||
foreach ($graded as $i) {
|
||||
if (!isset($winners[$i])) {
|
||||
$winners[$i] = true;
|
||||
if (count($winners) >= $minTrophies) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Tolerance tie-break within the class
|
||||
if (!empty($winners)) {
|
||||
$lastIncludedPos = null;
|
||||
$lastIncludedIdx = null;
|
||||
|
||||
for ($pos = count($graded) - 1; $pos >= 0; $pos--) {
|
||||
$gi = $graded[$pos];
|
||||
if (isset($winners[$gi])) {
|
||||
$lastIncludedPos = $pos;
|
||||
$lastIncludedIdx = $gi;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($lastIncludedPos !== null) {
|
||||
$lastGrade = $results[$lastIncludedIdx]['final_average'];
|
||||
for ($k = $lastIncludedPos + 1; $k < $classSize; $k++) {
|
||||
$gi = $graded[$k];
|
||||
$grade = $results[$gi]['final_average'];
|
||||
if (($lastGrade - $grade) <= $tolerance + 1e-9) {
|
||||
$winners[$gi] = true;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark winners (per class)
|
||||
foreach ($winners as $i => $_) {
|
||||
$results[$i]['winning_risk'] = 'Achieved Trophy';
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public static function normalCDF($z): float
|
||||
{
|
||||
$t = 1 / (1 + 0.3275911 * abs($z));
|
||||
$a1 = 0.254829592;
|
||||
$a2 = -0.284496736;
|
||||
$a3 = 1.421413741;
|
||||
$a4 = -1.453152027;
|
||||
$a5 = 1.061405429;
|
||||
$erf = 1 - ((((($a5 * $t + $a4) * $t + $a3) * $t + $a2) * $t + $a1) * $t) * exp(-$z * $z);
|
||||
$sign = $z < 0 ? -1 : 1;
|
||||
return 0.5 * (1 + $sign * $erf);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Services\EmailService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SendSMSController extends BaseApiController
|
||||
{
|
||||
protected array $carrierGateways = [
|
||||
'att' => ['sms' => '@txt.att.net', 'mms' => '@mms.att.net'],
|
||||
'verizon' => ['sms' => '@vtext.com', 'mms' => '@vzwpix.com'],
|
||||
'tmobile' => ['sms' => '@tmomail.net', 'mms' => '@tmomail.net'],
|
||||
'sprint' => ['sms' => '@messaging.sprintpcs.com', 'mms' => '@pm.sprint.com'],
|
||||
'boost' => ['sms' => '@sms.myboostmobile.com', 'mms' => '@myboostmobile.com'],
|
||||
'cricket' => ['sms' => '@sms.cricketwireless.net', 'mms' => '@mms.cricketwireless.net'],
|
||||
'uscellular' => ['sms' => '@email.uscc.net', 'mms' => '@mms.uscc.net'],
|
||||
'googlefi' => ['sms' => '@msg.fi.google.com', 'mms' => '@msg.fi.google.com'],
|
||||
'virgin' => ['sms' => '@vmobl.com', 'mms' => '@vmpix.com'],
|
||||
'metro' => ['sms' => '@mymetropcs.com', 'mms' => '@mymetropcs.com'],
|
||||
];
|
||||
|
||||
public function send()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'phone' => 'required|regex_match[/^\\d{10}$/]',
|
||||
'message' => 'required|max_length[160]',
|
||||
'carrier' => 'required|in_list[att,verizon,tmobile,sprint,boost,cricket,uscellular,googlefi,virgin,metro]',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$phone = preg_replace('/[^0-9]/', '', $data['phone']);
|
||||
$message = $data['message'];
|
||||
$carrier = strtolower($data['carrier']);
|
||||
$type = $data['type'] ?? 'sms';
|
||||
|
||||
if (!isset($this->carrierGateways[$carrier])) {
|
||||
return $this->respondError('Invalid carrier', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$gateway = $this->carrierGateways[$carrier][$type] ?? $this->carrierGateways[$carrier]['sms'];
|
||||
$recipient = $phone . $gateway;
|
||||
|
||||
try {
|
||||
/** @var EmailService $mailer */
|
||||
$mailer = app(EmailService::class);
|
||||
$mailer->setTo($recipient);
|
||||
$mailer->setFrom(env('MAIL_FROM_ADDRESS', 'no-reply@alrahmaisgl.org'), env('MAIL_FROM_NAME', 'Alrahma Team'));
|
||||
$mailer->setSubject('');
|
||||
$mailer->setMessage($message);
|
||||
|
||||
if (!$mailer->send()) {
|
||||
return $this->respondError('Failed to send SMS', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'phone' => $phone,
|
||||
'carrier' => $carrier,
|
||||
'type' => $type,
|
||||
'sent' => true,
|
||||
], 'SMS sent successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'SMS send error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to send SMS', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function carriers()
|
||||
{
|
||||
$carriers = [];
|
||||
foreach ($this->carrierGateways as $key => $gateways) {
|
||||
$carriers[] = [
|
||||
'id' => $key,
|
||||
'name' => ucfirst(str_replace(['us', 'google'], ['US ', 'Google '], $key)),
|
||||
'sms_gateway' => $gateways['sms'],
|
||||
'mms_gateway' => $gateways['mms'],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success($carriers, 'Carriers retrieved successfully');
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Config\SessionTimeout;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SessionController extends BaseApiController
|
||||
{
|
||||
public function getTimeoutConfig()
|
||||
{
|
||||
try {
|
||||
return $this->success([
|
||||
'timeout' => SessionTimeout::TIMEOUT_DURATION,
|
||||
'warning_time' => SessionTimeout::WARNING_THRESHOLD,
|
||||
'check_interval' => SessionTimeout::CHECK_INTERVAL,
|
||||
'logout_url' => '/api/v1/logout',
|
||||
'keep_alive_url' => '/api/v1/session/ping',
|
||||
'check_url' => '/api/v1/session/check',
|
||||
], 'Timeout configuration retrieved successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Timeout config error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to retrieve timeout configuration', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkTimeout()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Session expired', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$session = session();
|
||||
if (!$session->has('last_activity')) {
|
||||
return $this->respondError('Session expired', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$lastActivity = (int) $session->get('last_activity');
|
||||
$elapsed = time() - $lastActivity;
|
||||
|
||||
if ($elapsed > SessionTimeout::TIMEOUT_DURATION) {
|
||||
$session->invalidate();
|
||||
return $this->respondError('Session expired', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if ($elapsed > SessionTimeout::WARNING_THRESHOLD) {
|
||||
return $this->success([
|
||||
'status' => 'warning',
|
||||
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
|
||||
], 'Session warning');
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'status' => 'active',
|
||||
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
|
||||
], 'Session active');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Session check error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to check session', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function pingActivity()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Session expired', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$session = session();
|
||||
$lastActivity = (int) ($session->get('last_activity') ?? 0);
|
||||
|
||||
if (!$lastActivity || (time() - $lastActivity > SessionTimeout::TIMEOUT_DURATION)) {
|
||||
$session->invalidate();
|
||||
return $this->respondError('Session expired', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$session->put('last_activity', time());
|
||||
|
||||
return $this->success([
|
||||
'status' => 'active',
|
||||
'time_remaining' => SessionTimeout::TIMEOUT_DURATION,
|
||||
], 'Session activity updated');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Session ping error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update session activity', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Preferences;
|
||||
use App\Models\Settings;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SettingsController extends BaseApiController
|
||||
{
|
||||
protected Preferences $preferences;
|
||||
protected Settings $settings;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->preferences = model(Preferences::class);
|
||||
$this->settings = model(Settings::class);
|
||||
}
|
||||
|
||||
public function preferences()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$preference = $this->preferences->newQuery()
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
return $this->success($preference ? $preference->toArray() : [], 'Preferences retrieved successfully');
|
||||
}
|
||||
|
||||
public function updatePreferences()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowedFields = ['timezone', 'language', 'notifications_enabled', 'email_notifications'];
|
||||
$payload = ['user_id' => $user->id];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$payload[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$preference = $this->preferences->newQuery()
|
||||
->updateOrCreate(['user_id' => $user->id], $payload);
|
||||
|
||||
return $this->success($preference->fresh()->toArray(), 'Preferences updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Preferences update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update preferences', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function system()
|
||||
{
|
||||
$settings = $this->settings->newQuery()->get();
|
||||
$formatted = [];
|
||||
|
||||
foreach ($settings as $setting) {
|
||||
$row = $setting->toArray();
|
||||
$key = $row['key'] ?? $row['name'] ?? null;
|
||||
if ($key !== null) {
|
||||
$formatted[$key] = $row['value'] ?? $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success($formatted, 'System settings retrieved successfully');
|
||||
}
|
||||
}
|
||||
@@ -1,615 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\LateSlipLog;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Mike42\Escpos\Printer;
|
||||
use Mike42\Escpos\PrintConnectors\NetworkPrintConnector;
|
||||
use Mike42\Escpos\PrintConnectors\UsbPrintConnector;
|
||||
use Mike42\Escpos\PrintConnectors\WindowsPrintConnector;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class SlipPrinterController extends BaseApiController
|
||||
{
|
||||
protected Configuration $config;
|
||||
protected LateSlipLog $log;
|
||||
protected User $user;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->config = model(Configuration::class);
|
||||
$this->log = model(LateSlipLog::class);
|
||||
$this->user = model(User::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/slips/print
|
||||
* Generate and return a PDF late slip
|
||||
*/
|
||||
public function print(): SymfonyResponse|JsonResponse
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
|
||||
$slipData = [
|
||||
'school_year' => trim((string) ($data['school_year'] ?? $this->schoolYear ?: '')),
|
||||
'student_name' => trim((string) ($data['student_name'] ?? '')),
|
||||
'date' => trim((string) ($data['date'] ?? date('m/d/Y'))),
|
||||
'time_in' => trim((string) ($data['time_in'] ?? date('h:i A'))),
|
||||
'grade' => trim((string) ($data['grade'] ?? '')),
|
||||
'reason' => trim((string) ($data['reason'] ?? '')),
|
||||
'admin_name' => trim((string) ($data['admin_name'] ?? '')),
|
||||
];
|
||||
|
||||
// Always prefer current logged-in user's full name from DB
|
||||
$adminFromDb = $this->currentAdminName();
|
||||
if ($adminFromDb !== '') {
|
||||
$slipData['admin_name'] = $adminFromDb;
|
||||
}
|
||||
|
||||
// Validate minimal fields
|
||||
if ($slipData['student_name'] === '') {
|
||||
return $this->error('Student name is required.', SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
try {
|
||||
// Persist a log (best-effort) before generating PDF
|
||||
try {
|
||||
$printedBy = $this->getCurrentUserId();
|
||||
$logData = [
|
||||
'school_year' => $slipData['school_year'],
|
||||
'semester' => $this->semester,
|
||||
'student_name' => $slipData['student_name'],
|
||||
'slip_date' => $this->toDbDate($slipData['date']),
|
||||
'time_in' => $this->toDbTime($slipData['time_in']),
|
||||
'grade' => $slipData['grade'],
|
||||
'reason' => $slipData['reason'],
|
||||
'admin_name' => $slipData['admin_name'],
|
||||
];
|
||||
$this->log->logSlip($logData, $printedBy);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Late slip log insert failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Generate and return PDF
|
||||
return $this->renderSlipPdfResponse($slipData);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Late slip PDF generation failed: ' . $e->getMessage());
|
||||
return $this->error('Printing failed.', SymfonyResponse::HTTP_INTERNAL_SERVER_ERROR, [
|
||||
'details' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET/POST /api/v1/slips/preview
|
||||
* Show preview of late slips (GET returns list, POST returns JSON preview)
|
||||
*/
|
||||
public function preview(): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Resolve current school year
|
||||
$currentYear = trim((string) ($this->schoolYear ?? ''));
|
||||
if ($currentYear === '') {
|
||||
$latest = $this->log->newQuery()
|
||||
->select('school_year')
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
$currentYear = $latest['school_year'] ?? (date('Y') . '-' . (date('Y') + 1));
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
$selectedYear = trim((string) ($data['school_year'] ?? $currentYear));
|
||||
$selectedSemester = strtolower(trim((string) ($data['semester'] ?? '')));
|
||||
|
||||
$previewData = [
|
||||
'school_year' => $selectedYear,
|
||||
'student_name' => trim((string) ($data['student_name'] ?? '')),
|
||||
'date' => trim((string) ($data['date'] ?? date('m/d/Y'))),
|
||||
'time_in' => trim((string) ($data['time_in'] ?? date('h:i A'))),
|
||||
'grade' => trim((string) ($data['grade'] ?? '')),
|
||||
'reason' => trim((string) ($data['reason'] ?? '')),
|
||||
'admin_name' => trim((string) ($data['admin_name'] ?? '')),
|
||||
];
|
||||
|
||||
// Prefer logged-in admin name
|
||||
$adminFromDb = $this->currentAdminName();
|
||||
if ($adminFromDb !== '') {
|
||||
$previewData['admin_name'] = $adminFromDb;
|
||||
}
|
||||
|
||||
// Handle GET (return list of recent slips)
|
||||
if (strtolower($this->laravelRequest->method()) === 'get') {
|
||||
$builder = $this->log->newQuery();
|
||||
|
||||
// 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->where(function ($q) {
|
||||
$q->where('semester', 'fall')
|
||||
->orWhere('semester', '1')
|
||||
->orWhere('semester', 1);
|
||||
});
|
||||
} elseif ($selectedSemester === 'spring') {
|
||||
$builder->where(function ($q) {
|
||||
$q->where('semester', 'spring')
|
||||
->orWhere('semester', '2')
|
||||
->orWhere('semester', 2);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Execute query
|
||||
$logs = $builder->orderBy('id', 'DESC')->limit(50)->get()->toArray();
|
||||
|
||||
// Only use fallback when no filters are selected
|
||||
if (empty($logs) && !$hasFilter && $selectedYear === '') {
|
||||
$logs = $this->log->newQuery()->orderBy('id', 'DESC')->limit(50)->get()->toArray();
|
||||
}
|
||||
|
||||
// Format rows
|
||||
$rows = [];
|
||||
foreach ($logs as $r) {
|
||||
$dispDate = '';
|
||||
if (!empty($r['slip_date'])) {
|
||||
$ts = strtotime($r['slip_date']);
|
||||
$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[] = [
|
||||
'id' => (int) ($r['id'] ?? 0),
|
||||
'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'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'rows' => $rows,
|
||||
'school_year' => $selectedYear,
|
||||
'semester' => $selectedSemester,
|
||||
], 'Late slip logs retrieved');
|
||||
}
|
||||
|
||||
// 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: {$previewData['school_year']}\n\n";
|
||||
$body = implode("\n", $this->buildSlipLines($previewData, $lineWidth));
|
||||
$footer = str_repeat("\n", $feedLines);
|
||||
$full = $header . $body . $footer;
|
||||
|
||||
return $this->success([
|
||||
'ok' => true,
|
||||
'text' => $full,
|
||||
'width' => $lineWidth,
|
||||
], 'Preview generated');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Late slip preview failed: ' . $e->getMessage());
|
||||
return $this->error('Preview failed.', SymfonyResponse::HTTP_INTERNAL_SERVER_ERROR, [
|
||||
'details' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/slips/reprint/{id}
|
||||
* Reprint a late slip from a saved log row
|
||||
*/
|
||||
public function reprint($id = null): SymfonyResponse|JsonResponse
|
||||
{
|
||||
$id = (int) ($id ?? 0);
|
||||
if ($id <= 0) {
|
||||
return $this->error('Invalid slip id.', SymfonyResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$row = $this->log->find($id);
|
||||
} catch (\Throwable $e) {
|
||||
$row = null;
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
return $this->error('Slip not found.', SymfonyResponse::HTTP_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) {
|
||||
$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 {
|
||||
// Log again (best-effort) for audit trail
|
||||
try {
|
||||
$printedBy = $this->getCurrentUserId();
|
||||
$logData = [
|
||||
'school_year' => $data['school_year'],
|
||||
'semester' => $this->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'],
|
||||
];
|
||||
$this->log->logSlip($logData, $printedBy);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Late slip reprint log insert failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Generate and return PDF
|
||||
return $this->renderSlipPdfResponse($data);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Reprint failed: ' . $e->getMessage());
|
||||
return $this->error('Reprint failed: ' . $e->getMessage(), SymfonyResponse::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build monospaced slip lines for receipt printing
|
||||
*/
|
||||
private function buildSlipLines(array $data, ?int $lineWidth = null): array
|
||||
{
|
||||
$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 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 WindowsPrintConnector($name);
|
||||
return new Printer($connector);
|
||||
}
|
||||
Log::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 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 UsbPrintConnector($vid, $pid);
|
||||
return new Printer($connector);
|
||||
}
|
||||
|
||||
// Default: network
|
||||
$host = (string) ($cfg['host'] ?? '192.168.1.100');
|
||||
$port = (int) ($cfg['port'] ?? 9100);
|
||||
$connector = new NetworkPrintConnector($host, $port);
|
||||
return new Printer($connector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure any line is exactly $width characters
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to int
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get printer configuration from environment
|
||||
*/
|
||||
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;
|
||||
}
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current admin full name using session user_id
|
||||
*/
|
||||
private function currentAdminName(): string
|
||||
{
|
||||
try {
|
||||
$uid = $this->getCurrentUserId();
|
||||
if ($uid && $uid > 0) {
|
||||
$user = $this->user->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 return it as a response
|
||||
*/
|
||||
private function renderSlipPdfResponse(array $data): SymfonyResponse
|
||||
{
|
||||
// Determine paper size (mm)
|
||||
$paper = strtolower((string) ($this->laravelRequest->input('paper') ?? env('SLIP_PAPER', 'card')));
|
||||
|
||||
// Estimate dynamic height
|
||||
$fontPt = (float) ($this->laravelRequest->input('font_pt') ?? env('SLIP_FONT_PT', 13.0));
|
||||
$lineH = (float) ($this->laravelRequest->input('line_h') ?? env('SLIP_LINE_H', 2.0));
|
||||
$linesCount = (int) ($this->laravelRequest->input('rows') ?? 8);
|
||||
if ($linesCount < 1) {
|
||||
$linesCount = 8;
|
||||
}
|
||||
|
||||
$ptPerLine = $fontPt * $lineH;
|
||||
$mmPerPt = 25.4 / 72.0;
|
||||
$contentMm = $ptPerLine * $linesCount * $mmPerPt;
|
||||
$pageMarginsMm = 3.0 * 2;
|
||||
$fudgeMm = (float) ($this->laravelRequest->input('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);
|
||||
$wChars = 30;
|
||||
break;
|
||||
case 'receipt80':
|
||||
$wMm = 72.0;
|
||||
$hMm = 90.0;
|
||||
$wChars = 30;
|
||||
break;
|
||||
case 'receipt58':
|
||||
$wMm = 58.0;
|
||||
$hMm = 80.0;
|
||||
$wChars = 24;
|
||||
break;
|
||||
case 'card':
|
||||
default:
|
||||
$wMm = 85.60;
|
||||
$hMm = max($dynHeightMm, 40.0);
|
||||
$wChars = 40;
|
||||
}
|
||||
|
||||
// Optional manual override
|
||||
$hOverride = $this->laravelRequest->input('height_mm') ?? $this->laravelRequest->input('h');
|
||||
if (is_numeric($hOverride)) {
|
||||
$hMm = max(30.0, min(200.0, (float) $hOverride));
|
||||
}
|
||||
|
||||
// Build lines
|
||||
$w = $wChars;
|
||||
$lines = [];
|
||||
$lines[] = $this->fitLine('Al Rahma School at ISGL ' . ($data['school_year'] ?? ''), $w);
|
||||
$lines[] = $this->fitLine('Student Name: ' . (string) ($data['student_name'] ?? ''), $w);
|
||||
$lines[] = $this->fitLine('Date: ' . (string) ($data['date'] ?? ''), $w);
|
||||
$lines[] = $this->fitLine('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);
|
||||
|
||||
// Generate HTML for PDF
|
||||
$html = $this->generateSlipHtml($data, $lines, ['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');
|
||||
|
||||
if ($hMm < $dynHeightMm) {
|
||||
$hMm = $dynHeightMm;
|
||||
}
|
||||
|
||||
// Set paper size
|
||||
$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 response($dompdf->output(), 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="' . $filename . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML for the slip PDF
|
||||
*/
|
||||
private function generateSlipHtml(array $data, array $lines, array $page): string
|
||||
{
|
||||
$wMm = $page['w_mm'];
|
||||
$hMm = $page['h_mm'];
|
||||
|
||||
$html = '<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {
|
||||
margin: 0;
|
||||
size: ' . $wMm . 'mm ' . $hMm . 'mm;
|
||||
}
|
||||
body {
|
||||
margin: 3mm;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 13pt;
|
||||
line-height: 2.0;
|
||||
}
|
||||
.line {
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>';
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$html .= '<div class="line">' . htmlspecialchars($line, ENT_QUOTES, 'UTF-8') . '</div>';
|
||||
}
|
||||
|
||||
$html .= '</body>
|
||||
</html>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Staff;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StaffController extends BaseApiController
|
||||
{
|
||||
protected Staff $staff;
|
||||
protected User $user;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected Configuration $config;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->staff = model(Staff::class);
|
||||
$this->user = model(User::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$role = $this->request->getGet('role');
|
||||
|
||||
// Roles we never show
|
||||
$excludedRoles = ['student', 'parent', 'guest', 'inactive'];
|
||||
$normalizedExcluded = array_map('strtolower', $excludedRoles);
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($normalizedExcluded), '?'));
|
||||
|
||||
$query = $this->staff->newQuery()
|
||||
->whereRaw('LOWER(active_role) NOT IN (' . $placeholders . ')', $normalizedExcluded);
|
||||
|
||||
if (!empty($role)) {
|
||||
$query->whereRaw('LOWER(active_role) = ?', [strtolower((string) $role)]);
|
||||
}
|
||||
|
||||
$query->orderByDesc('created_at');
|
||||
|
||||
// Preload assignments for the selected school year (across all semesters)
|
||||
$assignRows = DB::table('teacher_class as tc')
|
||||
->select('tc.teacher_id', 'tc.position', 'cs.class_section_name')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
|
||||
->where('tc.school_year', (string) $this->schoolYear)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$assignByTeacher = [];
|
||||
foreach ($assignRows as $row) {
|
||||
$teacherId = (int) ($row->teacher_id ?? 0);
|
||||
if ($teacherId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$section = trim((string) ($row->class_section_name ?? ''));
|
||||
if ($section === '') {
|
||||
continue;
|
||||
}
|
||||
$position = strtolower((string) ($row->position ?? ''));
|
||||
if (!in_array($position, ['main', 'ta'], true)) {
|
||||
continue;
|
||||
}
|
||||
$assignByTeacher[$teacherId][] = $section . ' (' . $position . ')';
|
||||
}
|
||||
|
||||
// Get all staff (not paginated) for issues count calculation
|
||||
$allStaff = $query->get()->toArray();
|
||||
$issuesCount = 0;
|
||||
|
||||
foreach ($allStaff as $staff) {
|
||||
$roleName = strtolower((string) ($staff['active_role'] ?? ''));
|
||||
if (in_array($roleName, ['teacher', 'teacher_assistant'], true)) {
|
||||
$userId = (int) ($staff['user_id'] ?? 0);
|
||||
$labels = $assignByTeacher[$userId] ?? [];
|
||||
if (empty($labels)) {
|
||||
$issuesCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Paginate the results
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
// Apply transformations to paginated data
|
||||
foreach ($result['data'] as &$staff) {
|
||||
$userId = (int) ($staff['user_id'] ?? 0);
|
||||
$staff['school_id'] = $userId > 0 ? $this->user->getSchoolIdByUserId($userId) : null;
|
||||
|
||||
$roleName = strtolower((string) ($staff['active_role'] ?? ''));
|
||||
if (in_array($roleName, ['teacher', 'teacher_assistant'], true)) {
|
||||
$labels = $assignByTeacher[$userId] ?? [];
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
$staff['class_section'] = '—';
|
||||
$staff['verification_issue'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'data' => $result['data'],
|
||||
'pagination' => $result['pagination'],
|
||||
'issues_count' => $issuesCount,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
], 'Staff members retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$staff = $this->staff->find($id);
|
||||
if (!$staff) {
|
||||
return $this->respondError('Staff member not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$staff['school_id'] = $this->user->getSchoolIdByUserId((int) ($staff['user_id'] ?? 0));
|
||||
|
||||
return $this->success($staff, 'Staff member retrieved successfully');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$payload = [
|
||||
'firstname' => trim((string) ($data['firstname'] ?? '')),
|
||||
'lastname' => trim((string) ($data['lastname'] ?? '')),
|
||||
'email' => trim((string) ($data['email'] ?? '')),
|
||||
'phone' => trim((string) ($data['phone'] ?? '')),
|
||||
'role_name' => trim((string) ($data['role_name'] ?? '')),
|
||||
'school_year' => trim((string) ($data['school_year'] ?? $this->schoolYear)),
|
||||
'status' => trim((string) ($data['status'] ?? 'Active')),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
// Set active_role from role_name if provided
|
||||
if (!empty($payload['role_name'])) {
|
||||
$payload['active_role'] = strtolower($payload['role_name']);
|
||||
}
|
||||
|
||||
try {
|
||||
$inserted = $this->staff->insert($payload);
|
||||
if (!$inserted) {
|
||||
return $this->respondError('Failed to create staff member', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// Fetch the created record
|
||||
$staff = $this->staff->where('created_at', $now)->orderBy('id', 'DESC')->first();
|
||||
return $this->success($staff, 'Staff member added successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error('Staff creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create staff member', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$staff = $this->staff->find($id);
|
||||
if (!$staff) {
|
||||
return $this->respondError('Staff member not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Only update fields provided by the request to avoid wiping data
|
||||
$updateData = [];
|
||||
$allowedFields = ['firstname', 'lastname', 'email', 'phone', 'role_name', 'school_year', 'status'];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$value = $data[$field];
|
||||
if ($value !== null && $value !== '') {
|
||||
$updateData[$field] = trim((string) $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set active_role from role_name if role_name is being updated
|
||||
if (isset($updateData['role_name']) && !empty($updateData['role_name'])) {
|
||||
$updateData['active_role'] = strtolower($updateData['role_name']);
|
||||
}
|
||||
|
||||
// Always bump updated_at
|
||||
$updateData['updated_at'] = utc_now();
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->respondError('No changes detected', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$updated = $this->staff->update($id, $updateData);
|
||||
if (!$updated) {
|
||||
return $this->respondError('Failed to update staff member', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$updatedStaff = $this->staff->find($id);
|
||||
return $this->success($updatedStaff, 'Staff member updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error('Staff update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update staff member', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy($id = null)
|
||||
{
|
||||
$staff = $this->staff->find($id);
|
||||
if (!$staff) {
|
||||
return $this->respondError('Staff member not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->staff->delete($id);
|
||||
return $this->respondDeleted(null, 'Staff member deleted successfully');
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error('Staff deletion error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete staff member', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Api\BaseApiController;
|
||||
use App\Models\Stats;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
|
||||
@@ -1,904 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\EmergencyContact;
|
||||
use App\Models\StudentAllergy;
|
||||
use App\Models\StudentMedicalCondition;
|
||||
use App\Models\PromotionQueue;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StudentController extends BaseApiController
|
||||
{
|
||||
protected Student $student;
|
||||
protected StudentClass $studentClass;
|
||||
protected Configuration $config;
|
||||
protected ClassSection $classSection;
|
||||
protected Enrollment $enrollment;
|
||||
protected EmergencyContact $emergencyContact;
|
||||
protected StudentAllergy $allergy;
|
||||
protected StudentMedicalCondition $condition;
|
||||
protected PromotionQueue $promotionQueue;
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->student = model(Student::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->classSection = model(ClassSection::class);
|
||||
$this->enrollment = model(Enrollment::class);
|
||||
$this->emergencyContact = model(EmergencyContact::class);
|
||||
$this->allergy = model(StudentAllergy::class);
|
||||
$this->condition = model(StudentMedicalCondition::class);
|
||||
$this->promotionQueue = model(PromotionQueue::class);
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$parentId = $this->request->getGet('parent_id');
|
||||
$classId = $this->request->getGet('class_id');
|
||||
|
||||
$query = $this->student->newQuery();
|
||||
|
||||
if (!empty($parentId)) {
|
||||
$query->where('parent_id', $parentId);
|
||||
}
|
||||
|
||||
if (!empty($classId)) {
|
||||
$studentIds = $this->studentClass->newQuery()
|
||||
->where('class_section_id', $classId)
|
||||
->pluck('student_id')
|
||||
->toArray();
|
||||
|
||||
if (!empty($studentIds)) {
|
||||
$query->whereIn('id', $studentIds);
|
||||
} else {
|
||||
return $this->success([
|
||||
'data' => [],
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => 0,
|
||||
'total_pages' => 0,
|
||||
],
|
||||
], 'No students found');
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
foreach ($result['data'] as &$student) {
|
||||
$student['current_class'] = $this->studentClass->newQuery()
|
||||
->select([
|
||||
'cs.*',
|
||||
'c.class_name as class_name',
|
||||
])
|
||||
->from('student_class as sc')
|
||||
->join('class_sections as cs', 'cs.id', '=', 'sc.class_section_id')
|
||||
->join('classes as c', 'c.id', '=', 'cs.class_id')
|
||||
->where('sc.student_id', $student['id'])
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->where('sc.semester', $this->config->getConfig('semester'))
|
||||
->where('cs.school_year', $schoolYear)
|
||||
->where('cs.semester', $this->config->getConfig('semester'))
|
||||
->first();
|
||||
}
|
||||
|
||||
return $this->success($result, 'Students retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$student = $this->student->find($id);
|
||||
if (!$student) {
|
||||
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$schoolYear = $this->config->getConfig('school_year');
|
||||
$student['current_class'] = $this->studentClass->newQuery()
|
||||
->select([
|
||||
'cs.*',
|
||||
'c.class_name as class_name',
|
||||
])
|
||||
->from('student_class as sc')
|
||||
->join('class_sections as cs', 'cs.id', '=', 'sc.class_section_id')
|
||||
->join('classes as c', 'c.id', '=', 'cs.class_id')
|
||||
->where('sc.student_id', $id)
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->where('sc.semester', $this->config->getConfig('semester'))
|
||||
->where('cs.school_year', $schoolYear)
|
||||
->where('cs.semester', $this->config->getConfig('semester'))
|
||||
->first();
|
||||
|
||||
return $this->success($student, 'Student retrieved successfully');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'firstname' => 'required|min:2|max:100',
|
||||
'lastname' => 'required|min:2|max:100',
|
||||
'dob' => 'required|date',
|
||||
'gender' => 'required|in:male,female',
|
||||
'parent_id' => 'required|integer',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$data['school_year'] = $this->config->getConfig('school_year');
|
||||
$data['semester'] = $this->config->getConfig('semester');
|
||||
$data['registration_date'] = now()->toDateTimeString();
|
||||
|
||||
try {
|
||||
$student = $this->student->create($data);
|
||||
return $this->success($student->toArray(), 'Student created successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Student creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create student', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$student = $this->student->find($id);
|
||||
if (!$student) {
|
||||
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Build student data payload
|
||||
$studentData = [];
|
||||
$allowedFields = [
|
||||
'school_id', 'firstname', 'lastname', 'dob', 'age', 'gender',
|
||||
'registration_grade', 'photo_consent', 'parent_id', 'registration_date',
|
||||
'tuition_paid', 'year_of_registration', 'school_year', 'rfid_tag',
|
||||
'semester', 'is_new'
|
||||
];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $data) && $data[$field] !== null && $data[$field] !== '') {
|
||||
$value = $data[$field];
|
||||
// Apply title case to names
|
||||
if (in_array($field, ['firstname', 'lastname'])) {
|
||||
$value = $this->titleCase((string) $value);
|
||||
}
|
||||
$studentData[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle DOB and age calculation
|
||||
if (isset($data['dob'])) {
|
||||
try {
|
||||
$dob = Carbon::parse($data['dob']);
|
||||
$studentData['dob'] = $dob->format('Y-m-d');
|
||||
$studentData['age'] = $dob->age;
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
return $this->respondError('Invalid date of birth format', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle registration_date
|
||||
if (isset($data['registration_date']) && $data['registration_date'] !== '') {
|
||||
try {
|
||||
$regDate = Carbon::parse($data['registration_date']);
|
||||
$studentData['registration_date'] = $regDate->utc()->format('Y-m-d H:i:00');
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore invalid dates
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize boolean fields
|
||||
if (isset($data['photo_consent'])) {
|
||||
$studentData['photo_consent'] = (int) ($data['photo_consent'] === '1' || $data['photo_consent'] === true);
|
||||
}
|
||||
if (isset($data['tuition_paid'])) {
|
||||
$studentData['tuition_paid'] = (int) ($data['tuition_paid'] === '1' || $data['tuition_paid'] === true);
|
||||
}
|
||||
if (isset($data['is_new'])) {
|
||||
$studentData['is_new'] = (int) ($data['is_new'] === '1' || $data['is_new'] === true);
|
||||
}
|
||||
|
||||
// Update student if there are changes
|
||||
if (!empty($studentData)) {
|
||||
$this->student->where('id', $id)->update($studentData);
|
||||
}
|
||||
|
||||
// Handle health lists if provided
|
||||
$rawPost = $data;
|
||||
if (isset($rawPost['medical_conditions']) || isset($rawPost['medical_touched'])) {
|
||||
$medTouched = ($rawPost['medical_touched'] ?? '0') === '1';
|
||||
$medConditions = $rawPost['medical_conditions'] ?? '';
|
||||
if ($medTouched || (isset($rawPost['medical_conditions']) && trim($medConditions) !== '')) {
|
||||
$normConditions = $this->normalizeHealthList($medConditions, 100);
|
||||
$this->syncHealthList($id, $this->condition, 'condition_name', $normConditions);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($rawPost['allergies']) || isset($rawPost['allergies_touched'])) {
|
||||
$allTouched = ($rawPost['allergies_touched'] ?? '0') === '1';
|
||||
$allergies = $rawPost['allergies'] ?? '';
|
||||
if ($allTouched || (isset($rawPost['allergies']) && trim($allergies) !== '')) {
|
||||
$normAllergies = $this->normalizeHealthList($allergies, 100);
|
||||
$this->syncHealthList($id, $this->allergy, 'allergy', $normAllergies);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
$updated = $this->student->find($id);
|
||||
return $this->success($updated, 'Student updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Student update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update student: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy($id = null)
|
||||
{
|
||||
$student = $this->student->find($id);
|
||||
if (!$student) {
|
||||
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->student->delete($id);
|
||||
return $this->respondDeleted(null, 'Student deleted successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Student deletion error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete student', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/students/assign-class
|
||||
* Assign a student to a class section
|
||||
*/
|
||||
public function assignClassStudent(): JsonResponse
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$studentId = (int) ($data['student_id'] ?? 0);
|
||||
$classSectionId = (int) ($data['class_section_id'] ?? 0);
|
||||
$userId = $this->getCurrentUserId();
|
||||
$now = utc_now();
|
||||
|
||||
// Validate input
|
||||
if (!$studentId || !$classSectionId) {
|
||||
return $this->error('Missing required data (student/class section).', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Check student
|
||||
$student = $this->student->find($studentId);
|
||||
if (!$student) {
|
||||
return $this->error('Student not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Robust section lookup: allow id OR class_section_id
|
||||
$section = $this->classSection->newQuery()
|
||||
->where(function ($q) use ($classSectionId) {
|
||||
$q->where('id', $classSectionId)
|
||||
->orWhere('class_section_id', $classSectionId);
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$section) {
|
||||
return $this->error('Class/Section not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$displayName = $this->formatClassSectionDisplayName($section, $classSectionId);
|
||||
|
||||
// Derive parent class_id from the section row
|
||||
$parentClassId = (int) ($section['class_id'] ?? 0);
|
||||
if (!$parentClassId) {
|
||||
$row = DB::table('classSection')
|
||||
->select('class_id')
|
||||
->where(function ($q) use ($section, $classSectionId) {
|
||||
$q->where('id', $section['id'] ?? $classSectionId)
|
||||
->orWhere('class_section_id', $classSectionId);
|
||||
})
|
||||
->first();
|
||||
|
||||
if ($row && isset($row->class_id)) {
|
||||
$parentClassId = (int) $row->class_id;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Upsert student_class
|
||||
$payload = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'updated_by' => $userId,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$existing = $this->studentClass->newQuery()
|
||||
->where('student_id', $studentId)
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->studentClass->where('id', $existing['id'])->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$this->studentClass->insert($payload);
|
||||
}
|
||||
|
||||
// Update enrollment for current term (if exists)
|
||||
$enroll = $this->enrollment->newQuery()
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester)
|
||||
->first();
|
||||
|
||||
if ($enroll) {
|
||||
$this->enrollment->where('id', $enroll['id'])->update([
|
||||
'class_section_id' => $classSectionId,
|
||||
'enrollment_status' => 'payment pending',
|
||||
'admission_status' => 'accepted',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
// Re-tag attendance rows
|
||||
$attnStats = $this->updateStudentAttendanceSection(
|
||||
$studentId,
|
||||
$classSectionId,
|
||||
$parentClassId ?: 0,
|
||||
$this->semester,
|
||||
$this->schoolYear,
|
||||
$userId
|
||||
);
|
||||
|
||||
// Re-tag score rows
|
||||
$scoreStats = $this->updateStudentScoresSection(
|
||||
$studentId,
|
||||
$classSectionId,
|
||||
$this->semester,
|
||||
$this->schoolYear,
|
||||
$userId
|
||||
);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $this->success([
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'class_section_name' => $displayName,
|
||||
'attendance_updates' => $attnStats,
|
||||
'score_updates' => $scoreStats,
|
||||
], 'Assignment saved.');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Unable to assign class: ' . $e->getMessage());
|
||||
return $this->error('Unable to assign class: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/students/auto-distribute
|
||||
* Auto distribute students into lettered sections for a class
|
||||
*/
|
||||
public function autoDistributeSections(): JsonResponse
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$classId = (int) ($data['class_id'] ?? 0);
|
||||
$classSectionId = (int) ($data['class_section_id'] ?? 0);
|
||||
$perSec = (int) ($data['students_per_section'] ?? 0);
|
||||
$year = trim((string) ($data['school_year'] ?? $this->schoolYear));
|
||||
|
||||
if ($classId <= 0 && $classSectionId > 0) {
|
||||
$cid = $this->classSection->getClassId($classSectionId);
|
||||
$classId = (int) ($cid ?? 0);
|
||||
}
|
||||
|
||||
if ($classId <= 0 || $perSec <= 0) {
|
||||
return $this->error('Invalid class_id or students_per_section.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Candidates from promotion queue
|
||||
$cands = $this->promotionQueue->newQuery()
|
||||
->select('promotion_queue.*', 'students.gender')
|
||||
->join('students', 'students.id', '=', 'promotion_queue.student_id', 'left')
|
||||
->where('promotion_queue.to_class_id', $classId)
|
||||
->where('promotion_queue.school_year_to', $year)
|
||||
->whereIn('promotion_queue.status', ['queued', 'assigned'])
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
if (empty($cands)) {
|
||||
return $this->error('No students found in promotion queue for selected class/year.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Filter to those with enrollment
|
||||
$studentIds = array_map(fn($r) => (int) $r['student_id'], $cands);
|
||||
$enrolledIds = [];
|
||||
|
||||
if (!empty($studentIds)) {
|
||||
$rows = DB::table('enrollments')
|
||||
->select('student_id')
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('school_year', $year)
|
||||
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
|
||||
->groupBy('student_id')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$enrolledIds = array_map(fn($r) => (int) $r->student_id, $rows);
|
||||
}
|
||||
|
||||
$cands = array_values(array_filter($cands, fn($r) => in_array((int) $r['student_id'], $enrolledIds, true)));
|
||||
|
||||
if (empty($cands)) {
|
||||
return $this->error('No eligible enrolled students found to distribute.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$total = count($cands);
|
||||
$sectionsNeeded = (int) ceil($total / $perSec);
|
||||
|
||||
// Fetch lettered sections
|
||||
$letters = $this->classSection->getLetterSectionsByClassId($classId);
|
||||
|
||||
if (empty($letters)) {
|
||||
return $this->error('No lettered sections found for the selected class.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (count($letters) < $sectionsNeeded) {
|
||||
return $this->error('Not enough sections available. Needed: ' . $sectionsNeeded . ', available: ' . count($letters), Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$letters = array_slice($letters, 0, $sectionsNeeded);
|
||||
|
||||
// Prepare buckets
|
||||
$buckets = [];
|
||||
foreach ($letters as $idx => $sec) {
|
||||
$buckets[$idx] = [
|
||||
'class_section_id' => (int) $sec['class_section_id'],
|
||||
'assigned' => [],
|
||||
'male' => 0,
|
||||
'female' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Split by gender
|
||||
$males = [];
|
||||
$females = [];
|
||||
foreach ($cands as $r) {
|
||||
$g = strtolower((string) ($r['gender'] ?? ''));
|
||||
if ($g === 'female') {
|
||||
$females[] = $r;
|
||||
} else {
|
||||
$males[] = $r;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to pick next bucket
|
||||
$pickBucket = function (string $gender) use (&$buckets, $perSec): ?int {
|
||||
$bestIdx = null;
|
||||
$bestCnt = PHP_INT_MAX;
|
||||
foreach ($buckets as $i => $b) {
|
||||
if (count($b['assigned']) >= $perSec) {
|
||||
continue;
|
||||
}
|
||||
$cnt = ($gender === 'female') ? $b['female'] : $b['male'];
|
||||
if ($cnt < $bestCnt) {
|
||||
$bestCnt = $cnt;
|
||||
$bestIdx = $i;
|
||||
}
|
||||
}
|
||||
return $bestIdx;
|
||||
};
|
||||
|
||||
// Assign males then females
|
||||
foreach ($males as $r) {
|
||||
$bi = $pickBucket('male');
|
||||
if ($bi === null) {
|
||||
break;
|
||||
}
|
||||
$buckets[$bi]['assigned'][] = (int) $r['student_id'];
|
||||
$buckets[$bi]['male']++;
|
||||
}
|
||||
|
||||
foreach ($females as $r) {
|
||||
$bi = $pickBucket('female');
|
||||
if ($bi === null) {
|
||||
break;
|
||||
}
|
||||
$buckets[$bi]['assigned'][] = (int) $r['student_id'];
|
||||
$buckets[$bi]['female']++;
|
||||
}
|
||||
|
||||
// Persist assignments
|
||||
$promoIdsBySid = [];
|
||||
foreach ($cands as $r) {
|
||||
$promoIdsBySid[(int) $r['student_id']] = (int) $r['id'];
|
||||
}
|
||||
|
||||
$updatedBy = $this->getCurrentUserId();
|
||||
$now = utc_now();
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
foreach ($buckets as $b) {
|
||||
$secId = (int) $b['class_section_id'];
|
||||
foreach ($b['assigned'] as $sid) {
|
||||
// Update promotion queue
|
||||
if (isset($promoIdsBySid[$sid])) {
|
||||
$this->promotionQueue->where('id', $promoIdsBySid[$sid])->update([
|
||||
'to_class_section_id' => $secId,
|
||||
'status' => 'assigned',
|
||||
'updated_by' => $updatedBy,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
// Upsert student_class
|
||||
$exists = $this->studentClass->newQuery()
|
||||
->where('student_id', $sid)
|
||||
->where('school_year', $year)
|
||||
->where('semester', $this->semester)
|
||||
->first();
|
||||
|
||||
$payload = [
|
||||
'student_id' => $sid,
|
||||
'class_section_id' => $secId,
|
||||
'school_year' => $year,
|
||||
'semester' => $this->semester,
|
||||
'updated_by' => $updatedBy,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
$this->studentClass->where('id', $exists['id'])->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$this->studentClass->insert($payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
// Build summary
|
||||
$nameById = [];
|
||||
foreach ($letters as $secRow) {
|
||||
$nameById[(int) $secRow['class_section_id']] = (string) ($secRow['class_section_name'] ?? '');
|
||||
}
|
||||
|
||||
$summary = [];
|
||||
foreach ($buckets as $b) {
|
||||
$secId = (int) $b['class_section_id'];
|
||||
$summary[] = [
|
||||
'class_section_id' => $secId,
|
||||
'class_section_name' => $nameById[$secId] ?? (string) $secId,
|
||||
'total' => count($b['assigned']),
|
||||
'male' => $b['male'],
|
||||
'female' => $b['female'],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'sections' => $summary,
|
||||
], 'Auto distribution completed.');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Auto distribution failed: ' . $e->getMessage());
|
||||
return $this->error('Auto distribution failed: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/students/promotion-totals
|
||||
* Return totals per base class for promotion_queue in selected year
|
||||
*/
|
||||
public function promotionTotalsApi(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$year = trim((string) ($this->laravelRequest->query('school_year') ?? $this->schoolYear));
|
||||
|
||||
// Fetch base sections
|
||||
$bases = $this->classSection->newQuery()
|
||||
->whereRaw("class_section_name NOT LIKE '%-%'")
|
||||
->orderBy('class_id', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$wanted = [];
|
||||
foreach ($bases as $r) {
|
||||
$nameRaw = (string) ($r['class_section_name'] ?? '');
|
||||
$name = strtolower($nameRaw);
|
||||
if ($name === 'kg' || $name === 'youth') {
|
||||
$wanted[] = $r;
|
||||
continue;
|
||||
}
|
||||
if (ctype_digit($name)) {
|
||||
$num = (int) $name;
|
||||
if ($num >= 1 && $num <= 9) {
|
||||
$wanted[] = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($wanted as $r) {
|
||||
$classId = (int) $r['class_id'];
|
||||
|
||||
$cands = DB::table('promotion_queue as pq')
|
||||
->select('pq.student_id')
|
||||
->join('enrollments as e', function ($join) use ($year) {
|
||||
$join->on('e.student_id', '=', 'pq.student_id')
|
||||
->where('e.school_year', $year);
|
||||
}, 'left')
|
||||
->where('pq.to_class_id', $classId)
|
||||
->where('pq.school_year_to', $year)
|
||||
->whereIn('pq.status', ['queued', 'assigned', 'applied'])
|
||||
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
|
||||
->groupBy('pq.student_id')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$out[] = [
|
||||
'class_id' => $classId,
|
||||
'class_section_id' => (int) ($r['class_section_id'] ?? 0),
|
||||
'class_section_name' => (string) ($r['class_section_name'] ?? ''),
|
||||
'total' => count($cands),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'year' => $year,
|
||||
'rows' => $out,
|
||||
], 'Promotion totals retrieved');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Promotion totals error: ' . $e->getMessage());
|
||||
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update attendance tables for a student's new class section
|
||||
*/
|
||||
private function updateStudentAttendanceSection(
|
||||
int $studentId,
|
||||
int $newClassSectionId,
|
||||
int $newClassId,
|
||||
string $semester,
|
||||
string $schoolYear,
|
||||
?int $modifiedBy = null
|
||||
): array {
|
||||
$now = utc_now();
|
||||
|
||||
// Update attendance_data
|
||||
$dataUpdated = DB::table('attendance_data')
|
||||
->where('student_id', $studentId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->update([
|
||||
'class_section_id' => $newClassSectionId,
|
||||
'class_id' => $newClassId,
|
||||
'updated_at' => $now,
|
||||
'modified_by' => $modifiedBy,
|
||||
]);
|
||||
|
||||
// Update attendance_record
|
||||
$recUpdated = DB::table('attendance_record')
|
||||
->where('student_id', $studentId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->update([
|
||||
'class_section_id' => $newClassSectionId,
|
||||
'updated_at' => $now,
|
||||
'modified_by' => $modifiedBy,
|
||||
]);
|
||||
|
||||
return [
|
||||
'attendance_data_updated' => $dataUpdated,
|
||||
'attendance_record_updated' => $recUpdated,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all score-related tables for a student to the new class_section_id
|
||||
*/
|
||||
private function updateStudentScoresSection(
|
||||
int $studentId,
|
||||
int $newClassSectionId,
|
||||
string $semester,
|
||||
string $schoolYear,
|
||||
?int $modifiedBy = null
|
||||
): array {
|
||||
$now = utc_now();
|
||||
$tables = [
|
||||
'homework',
|
||||
'quiz',
|
||||
'project',
|
||||
'participation',
|
||||
'midterm_exam',
|
||||
'final_exam',
|
||||
'final_score',
|
||||
'semester_scores',
|
||||
];
|
||||
|
||||
$results = [];
|
||||
foreach ($tables as $tbl) {
|
||||
$updated = DB::table($tbl)
|
||||
->where('student_id', $studentId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->update([
|
||||
'class_section_id' => $newClassSectionId,
|
||||
'updated_at' => $now,
|
||||
'updated_by' => $modifiedBy,
|
||||
]);
|
||||
$results[$tbl . '_updated'] = $updated;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a display name from section row
|
||||
*/
|
||||
private function formatClassSectionDisplayName(array $row, int $fallbackId): string
|
||||
{
|
||||
if (!empty($row['class_section_name'])) {
|
||||
return (string) $row['class_section_name'];
|
||||
}
|
||||
if (!empty($row['section_name'])) {
|
||||
return (string) $row['section_name'];
|
||||
}
|
||||
if (!empty($row['name'])) {
|
||||
return (string) $row['name'];
|
||||
}
|
||||
if (!empty($row['title'])) {
|
||||
return (string) $row['title'];
|
||||
}
|
||||
|
||||
$grade = $row['grade_name'] ?? $row['grade'] ?? $row['class_name'] ?? null;
|
||||
$letter = $row['section'] ?? $row['section_letter'] ?? $row['letter'] ?? null;
|
||||
|
||||
if ($grade && $letter) {
|
||||
return "{$grade} - {$letter}";
|
||||
}
|
||||
if ($grade) {
|
||||
return (string) $grade;
|
||||
}
|
||||
|
||||
return 'Section #' . $fallbackId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a comma/semicolon/newline list
|
||||
*/
|
||||
private function normalizeHealthList(?string $s, int $maxLen = 100): array
|
||||
{
|
||||
$s = (string) $s;
|
||||
if ($s === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parts = preg_split('/[,\n;]+/u', $s, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$outAssoc = [];
|
||||
|
||||
foreach ($parts as $p) {
|
||||
$v = trim(preg_replace('/\s+/u', ' ', $p));
|
||||
if ($v === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^(none|n\/a|na|null|nil|no)$/i', $v)) {
|
||||
continue;
|
||||
}
|
||||
$v = mb_substr($v, 0, $maxLen, 'UTF-8');
|
||||
$outAssoc[mb_strtolower($v, 'UTF-8')] = $v;
|
||||
}
|
||||
|
||||
return array_values($outAssoc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff-sync helper for health lists
|
||||
*/
|
||||
private function syncHealthList(int $studentId, $model, string $field, array $newValues): void
|
||||
{
|
||||
// Fetch current values
|
||||
$rows = $model->newQuery()->where('student_id', $studentId)->select("id, {$field}")->get()->toArray();
|
||||
$current = [];
|
||||
$byId = [];
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$val = (string) ($r[$field] ?? '');
|
||||
$key = mb_strtolower(trim($val), 'UTF-8');
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$current[$key] = $val;
|
||||
$byId[$key] = (int) $r['id'];
|
||||
}
|
||||
|
||||
// Build new set
|
||||
$incoming = [];
|
||||
foreach ($newValues as $v) {
|
||||
$key = mb_strtolower(trim($v), 'UTF-8');
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$incoming[$key] = $v;
|
||||
}
|
||||
|
||||
// Compute diffs
|
||||
$toDeleteKeys = array_diff(array_keys($current), array_keys($incoming));
|
||||
$toInsertKeys = array_diff(array_keys($incoming), array_keys($current));
|
||||
|
||||
// Delete removed
|
||||
if (!empty($toDeleteKeys)) {
|
||||
$ids = array_map(fn($k) => $byId[$k], $toDeleteKeys);
|
||||
if (!empty($ids)) {
|
||||
$model->newQuery()->whereIn('id', $ids)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new
|
||||
if (!empty($toInsertKeys)) {
|
||||
$batch = [];
|
||||
foreach ($toInsertKeys as $k) {
|
||||
$batch[] = [
|
||||
'student_id' => $studentId,
|
||||
$field => $incoming[$k],
|
||||
];
|
||||
}
|
||||
if (!empty($batch)) {
|
||||
$model->newQuery()->insert($batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert string to title case
|
||||
*/
|
||||
private function titleCase(string $s): string
|
||||
{
|
||||
$s = strip_tags($s);
|
||||
$s = mb_strtolower($s ?: '', 'UTF-8');
|
||||
return mb_convert_case($s, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SupplierController extends BaseApiController
|
||||
{
|
||||
protected Supplier $supplier;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->supplier = model(Supplier::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$keyword = trim((string) ($this->request->getGet('q') ?? ''));
|
||||
|
||||
$query = $this->supplier->newQuery()->orderBy('name', 'ASC');
|
||||
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('name', 'LIKE', "%{$keyword}%")
|
||||
->orWhere('email', 'LIKE', "%{$keyword}%")
|
||||
->orWhere('phone', 'LIKE', "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
return $this->success($result, 'Suppliers retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$supplier = $this->supplier->find($id);
|
||||
if (!$supplier) {
|
||||
return $this->respondError('Supplier not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($supplier, 'Supplier retrieved successfully');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|max:255',
|
||||
'email' => 'nullable|email',
|
||||
'phone' => 'nullable|max:50',
|
||||
];
|
||||
|
||||
$validator = Validator::make($data, $rules);
|
||||
if ($validator->fails()) {
|
||||
return $this->respondValidationError($validator->errors()->toArray());
|
||||
}
|
||||
|
||||
try {
|
||||
$supplier = $this->supplier->create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'address' => $data['address'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
return $this->success($supplier->toArray(), 'Supplier created successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Supplier creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create supplier', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$supplier = $this->supplier->find($id);
|
||||
if (!$supplier) {
|
||||
return $this->respondError('Supplier not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowed = ['name', 'email', 'phone', 'address', 'notes'];
|
||||
$updateData = [];
|
||||
|
||||
foreach ($allowed as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->supplier->where('id', $id)->update($updateData);
|
||||
$updated = $this->supplier->find($id);
|
||||
return $this->success($updated, 'Supplier updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Supplier update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update supplier', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy($id = null)
|
||||
{
|
||||
$supplier = $this->supplier->find($id);
|
||||
if (!$supplier) {
|
||||
return $this->respondError('Supplier not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->supplier->delete($id);
|
||||
return $this->respondDeleted(null, 'Supplier deleted successfully');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Supplier deletion error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete supplier', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\SupplyCategory;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SupplyCategoryController extends BaseApiController
|
||||
{
|
||||
protected SupplyCategory $category;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->category = model(SupplyCategory::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$categories = $this->category->newQuery()
|
||||
->orderBy('name', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success($categories, 'Supply categories retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$category = $this->category->find($id);
|
||||
if (!$category) {
|
||||
return $this->respondError('Category not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success($category, 'Category retrieved successfully');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|max:255|unique:supply_categories,name',
|
||||
];
|
||||
|
||||
$validator = Validator::make($data, $rules);
|
||||
if ($validator->fails()) {
|
||||
return $this->respondValidationError($validator->errors()->toArray());
|
||||
}
|
||||
|
||||
try {
|
||||
$category = $this->category->create(['name' => $data['name']]);
|
||||
return $this->success($category->toArray(), 'Category created successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Category creation error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create category', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null)
|
||||
{
|
||||
$category = $this->category->find($id);
|
||||
if (!$category) {
|
||||
return $this->respondError('Category not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data) || !array_key_exists('name', $data)) {
|
||||
return $this->respondError('Name is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|max:255|unique:supply_categories,name,' . $id,
|
||||
];
|
||||
|
||||
$validator = Validator::make($data, $rules);
|
||||
if ($validator->fails()) {
|
||||
return $this->respondValidationError($validator->errors()->toArray());
|
||||
}
|
||||
|
||||
try {
|
||||
$this->category->where('id', $id)->update(['name' => $data['name']]);
|
||||
$updated = $this->category->find($id);
|
||||
return $this->success($updated, 'Category updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Category update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update category', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy($id = null)
|
||||
{
|
||||
$category = $this->category->find($id);
|
||||
if (!$category) {
|
||||
return $this->respondError('Category not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->category->delete($id);
|
||||
return $this->respondDeleted(null, 'Category deleted successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Category deletion error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete category', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\SupportRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SupportController extends BaseApiController
|
||||
{
|
||||
protected SupportRequest $supportRequest;
|
||||
protected User $user;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->supportRequest = model(SupportRequest::class);
|
||||
$this->user = model(User::class);
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('You must be logged in to submit a support request', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'subject' => 'required|min:3|max:255',
|
||||
'message' => 'required|min:10',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$userRecord = $this->user->find($user->id);
|
||||
if (!$userRecord) {
|
||||
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$fullName = trim(($userRecord['firstname'] ?? '') . ' ' . ($userRecord['lastname'] ?? ''));
|
||||
|
||||
$supportRequest = $this->supportRequest->create([
|
||||
'user_id' => $user->id,
|
||||
'subject' => $data['subject'],
|
||||
'message' => $data['message'],
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
$emailSent = $this->sendSupportEmail(
|
||||
$userRecord['email'] ?? null,
|
||||
$fullName,
|
||||
$data['subject'],
|
||||
$data['message']
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'request' => $supportRequest->toArray(),
|
||||
'email_sent' => $emailSent,
|
||||
], 'Support request submitted successfully', Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Support request error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to submit support request', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function requests()
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage= min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
$status = $this->request->getGet('status');
|
||||
|
||||
$query = $this->supportRequest->newQuery()
|
||||
->where('user_id', $user->id)
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if (!empty($status)) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
return $this->success($result, 'Support requests retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$request = $this->supportRequest->find($id);
|
||||
if (!$request) {
|
||||
return $this->respondError('Support request not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ((int) $request['user_id'] !== $user->id && !$this->isAdmin($user->id)) {
|
||||
return $this->respondError('Access denied', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
return $this->success($request, 'Support request retrieved successfully');
|
||||
}
|
||||
|
||||
protected function sendSupportEmail(?string $userEmail, string $fullName, string $subject, string $message): bool
|
||||
{
|
||||
if (empty($userEmail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::raw($message, function ($mail) use ($userEmail, $fullName, $subject) {
|
||||
$mail->from($userEmail, $fullName)
|
||||
->to('support@alrahmaisgl.org')
|
||||
->subject($subject);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Support email error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function isAdmin(int $userId): bool
|
||||
{
|
||||
try {
|
||||
return DB::table('user_roles as ur')
|
||||
->join('roles as r', 'r.id', '=', 'ur.role_id')
|
||||
->where('ur.user_id', $userId)
|
||||
->whereRaw('LOWER(r.name) = ?', ['admin'])
|
||||
->exists();
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to determine admin role: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,748 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use App\Models\Teacher;
|
||||
use App\Models\Student;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\StudentMedicalCondition;
|
||||
use App\Models\StudentAllergy;
|
||||
use App\Models\StaffAttendance;
|
||||
use App\Services\EmailService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TeacherController extends BaseApiController
|
||||
{
|
||||
protected User $user;
|
||||
protected TeacherClass $teacherClass;
|
||||
protected Configuration $config;
|
||||
protected Teacher $teacher;
|
||||
protected Student $student;
|
||||
protected ClassSection $classSection;
|
||||
protected StudentClass $studentClass;
|
||||
protected StudentMedicalCondition $medical;
|
||||
protected StudentAllergy $allergy;
|
||||
protected StaffAttendance $staffAttendance;
|
||||
protected EmailService $emailService;
|
||||
protected string $semester;
|
||||
protected string $schoolYear;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->user = model(User::class);
|
||||
$this->teacherClass = model(TeacherClass::class);
|
||||
$this->config = model(Configuration::class);
|
||||
$this->teacher = model(Teacher::class);
|
||||
$this->student = model(Student::class);
|
||||
$this->classSection = model(ClassSection::class);
|
||||
$this->studentClass = model(StudentClass::class);
|
||||
$this->medical = model(StudentMedicalCondition::class);
|
||||
$this->allergy = model(StudentAllergy::class);
|
||||
$this->staffAttendance = model(StaffAttendance::class);
|
||||
$this->emailService = app(EmailService::class);
|
||||
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
|
||||
$query = $this->user->newQuery()
|
||||
->select('users.*')
|
||||
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
|
||||
->join('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->whereIn('LOWER(roles.name)', ['teacher', 'teacher_assistant'])
|
||||
->groupBy('users.id')
|
||||
->orderBy('users.lastname', 'ASC');
|
||||
|
||||
$result = $this->paginate($query, $page, $perPage);
|
||||
|
||||
foreach ($result['data'] as &$teacher) {
|
||||
unset($teacher['password'], $teacher['token']);
|
||||
|
||||
$assignments = $this->teacherClass->newQuery()
|
||||
->where('teacher_id', $teacher['id'])
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$teacher['class_assignments'] = $assignments;
|
||||
}
|
||||
|
||||
return $this->success($result, 'Teachers retrieved successfully');
|
||||
}
|
||||
|
||||
public function show($id = null)
|
||||
{
|
||||
$teacher = $this->user->newQuery()
|
||||
->select('users.*')
|
||||
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
|
||||
->join('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->whereIn('LOWER(roles.name)', ['teacher', 'teacher_assistant'])
|
||||
->where('users.id', $id)
|
||||
->first();
|
||||
|
||||
if (!$teacher) {
|
||||
return $this->respondError('Teacher not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$teacherArray = $teacher->toArray();
|
||||
unset($teacherArray['password'], $teacherArray['token']);
|
||||
|
||||
$assignments = $this->teacherClass->newQuery()
|
||||
->where('teacher_id', $id)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$teacherArray['class_assignments'] = $assignments;
|
||||
|
||||
return $this->success($teacherArray, 'Teacher retrieved successfully');
|
||||
}
|
||||
|
||||
public function getClasses($id = null)
|
||||
{
|
||||
$assignments = $this->teacherClass->newQuery()
|
||||
->select([
|
||||
'tc.*',
|
||||
'cs.class_section_name',
|
||||
'c.name as class_name',
|
||||
])
|
||||
->from('teacher_class as tc')
|
||||
->leftJoin('class_sections as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
|
||||
->leftJoin('classes as c', 'c.id', '=', 'cs.class_id')
|
||||
->where('tc.teacher_id', $id)
|
||||
->where('tc.school_year', $this->schoolYear)
|
||||
->where('tc.semester', $this->semester)
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->success($assignments, 'Teacher classes retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/teachers/class-view
|
||||
* Get class view data for the logged-in teacher
|
||||
*/
|
||||
public function getClassView()
|
||||
{
|
||||
try {
|
||||
$userId = $this->getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return $this->respondError('User not logged in', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Fetch all assignments (as main or TA)
|
||||
$classes = $this->teacherClass->getClassAssignmentsByUserId($userId, $this->schoolYear);
|
||||
|
||||
if (empty($classes)) {
|
||||
return $this->success([
|
||||
'has_classes' => false,
|
||||
'message' => 'You do not have an assigned class yet. Please contact the administration.',
|
||||
], 'No classes found');
|
||||
}
|
||||
|
||||
// Identify user and their role
|
||||
$user = $this->user->find($userId);
|
||||
if (!$user) {
|
||||
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$roles = DB::table('user_roles ur')
|
||||
->select('r.name')
|
||||
->join('roles r', 'r.id', '=', 'ur.role_id')
|
||||
->where('ur.user_id', $userId)
|
||||
->get()
|
||||
->map(fn($r) => strtolower($r->name))
|
||||
->toArray();
|
||||
|
||||
$roleName = 'teacher';
|
||||
if (in_array('teacher', $roles)) {
|
||||
$roleName = 'teacher';
|
||||
} elseif (in_array('teacher_assistant', $roles)) {
|
||||
$roleName = 'teacher_assistant';
|
||||
} else {
|
||||
return $this->respondError('Access denied', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Collect class section IDs
|
||||
$classSectionIds = array_column($classes, 'class_section_id');
|
||||
$studentsData = [];
|
||||
|
||||
if (!empty($classSectionIds)) {
|
||||
// Fetch all students for the selected class sections
|
||||
$students = $this->studentClass->getStudentsByClassSectionIds($classSectionIds);
|
||||
|
||||
if (!empty($students)) {
|
||||
// Collect unique student IDs
|
||||
$studentIds = array_values(array_unique(array_map(
|
||||
static fn(array $row) => (int) ($row['student_id'] ?? 0),
|
||||
$students
|
||||
)));
|
||||
$studentIds = array_values(array_filter($studentIds));
|
||||
|
||||
// Bulk fetch medical conditions & allergies grouped by student_id
|
||||
$conditionsByStudent = !empty($studentIds)
|
||||
? $this->medical->getMedicalByStudentIds($studentIds)
|
||||
: [];
|
||||
|
||||
$allergiesByStudent = !empty($studentIds)
|
||||
? $this->allergy->getAllergiesByStudentIds($studentIds)
|
||||
: [];
|
||||
|
||||
// Attach medical/allergy data to each student
|
||||
foreach ($students as $student) {
|
||||
$sid = (int) ($student['student_id'] ?? 0);
|
||||
$cid = (int) ($student['class_section_id'] ?? 0);
|
||||
|
||||
$medList = array_column($conditionsByStudent[$sid] ?? [], 'condition_name');
|
||||
$algList = array_column($allergiesByStudent[$sid] ?? [], 'allergy');
|
||||
|
||||
$student['medical_conditions'] = $medList;
|
||||
$student['allergies'] = $algList;
|
||||
$student['medical_conditions_text'] = implode(', ', $medList);
|
||||
$student['allergies_text'] = implode(', ', $algList);
|
||||
|
||||
$studentsData[$cid][] = $student;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build assigned staff lists (all Main teachers and all TAs) per class section
|
||||
$assignedNames = [];
|
||||
if (!empty($classSectionIds)) {
|
||||
$rows = DB::table('teacher_class tc')
|
||||
->select('tc.class_section_id', 'tc.position', 'u.firstname', 'u.lastname')
|
||||
->join('users u', 'u.id', '=', 'tc.teacher_id')
|
||||
->whereIn('tc.class_section_id', $classSectionIds)
|
||||
->where('tc.school_year', $this->schoolYear)
|
||||
->where('tc.semester', $this->semester)
|
||||
->orderBy('tc.class_section_id', 'ASC')
|
||||
->orderByRaw("FIELD(tc.position,'main','ta')")
|
||||
->orderBy('u.firstname', 'ASC')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$sid = (int) ($r->class_section_id ?? 0);
|
||||
$pos = strtolower((string) ($r->position ?? ''));
|
||||
$name = trim(($r->firstname ?? '') . ' ' . ($r->lastname ?? ''));
|
||||
|
||||
if ($sid === 0 || $name === '') continue;
|
||||
|
||||
if (in_array($pos, ['main', 'teacher'], true)) {
|
||||
$assignedNames[$sid]['mains'][] = $name;
|
||||
} elseif (in_array($pos, ['ta', 'assistant', 'teacher_assistant'], true)) {
|
||||
$assignedNames[$sid]['tas'][] = $name;
|
||||
} else {
|
||||
$assignedNames[$sid]['others'][] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$classSectionId = $classSectionIds[0] ?? null;
|
||||
|
||||
return $this->success([
|
||||
'teacher_name' => trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')),
|
||||
'role_name' => $roleName,
|
||||
'classes' => $classes,
|
||||
'students' => $studentsData[$classSectionId] ?? [],
|
||||
'studentsData' => $studentsData,
|
||||
'class_section_id' => $classSectionId,
|
||||
'assignedNames' => $assignedNames,
|
||||
], 'Class view data retrieved successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in TeacherController::getClassView(): ' . $e->getMessage());
|
||||
return $this->respondError('An error occurred. Please try again later.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/teachers/class-assignments
|
||||
* Get teacher class assignment data for administration
|
||||
*/
|
||||
public function getTeacherClassAssignments()
|
||||
{
|
||||
$forYear = trim((string) ($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? ''));
|
||||
$payload = $this->buildTeacherClassAssignmentPayload($forYear !== '' ? $forYear : null);
|
||||
$payload['ok'] = true;
|
||||
return $this->success($payload, 'Teacher class assignments retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/teachers/class-assignments/assign
|
||||
* Assign a teacher to a class
|
||||
*/
|
||||
public function assignClassTeacher()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$result = $this->handleAssignClassTeacher($data);
|
||||
$statusCode = $result['ok'] ? Response::HTTP_OK : Response::HTTP_UNPROCESSABLE_ENTITY;
|
||||
return response()->json($result, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/teachers/class-assignments
|
||||
* Delete a teacher class assignment
|
||||
*/
|
||||
public function deleteAssignment()
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$result = $this->handleDeleteAssignment($data);
|
||||
$statusCode = $result['ok'] ? Response::HTTP_OK : Response::HTTP_UNPROCESSABLE_ENTITY;
|
||||
return response()->json($result, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/teachers/absence-form
|
||||
* Get absence form data for the logged-in teacher
|
||||
*/
|
||||
public function getAbsenceForm()
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return $this->respondError('Please log in first.', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$teacher = $this->user->find($userId);
|
||||
$displayName = $teacher ? trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')) : 'Unknown';
|
||||
|
||||
// Fetch existing self-reported staff attendance rows for this user (current term)
|
||||
$existing = $this->staffAttendance
|
||||
->where('user_id', $userId)
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->orderBy('date', 'DESC')
|
||||
->findAll();
|
||||
|
||||
return $this->success([
|
||||
'teacher_name' => $displayName,
|
||||
'semester' => $this->semester,
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'existing' => $existing,
|
||||
'availableDates' => $this->allowedAbsenceDates(),
|
||||
], 'Absence form data retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/teachers/absence
|
||||
* Submit absence/vacation request
|
||||
*/
|
||||
public function submitAbsence()
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return $this->respondError('Please log in first.', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if ($this->semester === '' || $this->schoolYear === '') {
|
||||
return $this->respondError('Semester or school year not configured.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
$dates = (array) ($data['dates'] ?? []);
|
||||
$reasonType = trim((string) ($data['reason_type'] ?? ''));
|
||||
$reasonText = trim((string) ($data['reason'] ?? ''));
|
||||
|
||||
if ($reasonText === '') {
|
||||
return $this->respondValidationError(['reason' => ['Reason is required.']]);
|
||||
}
|
||||
|
||||
// Build reason string
|
||||
$reasonBase = ($reasonType !== '') ? strtolower($reasonType) : '';
|
||||
$reason = $reasonBase !== '' ? ($reasonBase . ': ' . $reasonText) : $reasonText;
|
||||
|
||||
// Enforce allowed Sundays list
|
||||
$allowedDates = $this->allowedAbsenceDates();
|
||||
$allowedSet = array_fill_keys($allowedDates, true);
|
||||
|
||||
$roleName = $this->user->getUserRole($userId) ?: null;
|
||||
|
||||
// Validate dates and save
|
||||
$saved = 0;
|
||||
$invalid = [];
|
||||
$savedDates = [];
|
||||
|
||||
$dates = array_values(array_unique(array_map('strval', $dates)));
|
||||
|
||||
foreach ($dates as $d) {
|
||||
$d = trim((string) $d);
|
||||
if ($d === '') continue;
|
||||
|
||||
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $d);
|
||||
if (!$dt || $dt->format('Y-m-d') !== $d || empty($allowedSet[$d])) {
|
||||
$invalid[] = $d;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = $this->staffAttendance->upsertOne(
|
||||
userId: $userId,
|
||||
roleName: $roleName,
|
||||
date: $d,
|
||||
semester: $this->semester,
|
||||
schoolYear: $this->schoolYear,
|
||||
status: StaffAttendance::STATUS_ABSENT,
|
||||
reason: $reason,
|
||||
editorId: $userId
|
||||
);
|
||||
|
||||
if ($ok) {
|
||||
$saved++;
|
||||
$savedDates[] = $d;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($invalid)) {
|
||||
return $this->respondError('Invalid dates: ' . implode(', ', $invalid), Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Send notification email to principal
|
||||
try {
|
||||
$user = $this->user->find($userId) ?: [];
|
||||
$fullName = trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')) ?: 'Unknown';
|
||||
$userEmail = $user['email'] ?? '';
|
||||
$role = $roleName ?: 'staff';
|
||||
$dateList = !empty($savedDates) ? implode(', ', $savedDates) : implode(', ', $dates);
|
||||
|
||||
// If teacher/TA, include assigned class(es) for current term
|
||||
$assignedText = '-';
|
||||
if (in_array(strtolower((string) $roleName), ['teacher', 'teacher_assistant'], true)) {
|
||||
try {
|
||||
$assignments = $this->teacherClass->getClassAssignmentsByUserId($userId, $this->schoolYear, $this->semester);
|
||||
if (!empty($assignments)) {
|
||||
$parts = [];
|
||||
foreach ($assignments as $as) {
|
||||
$name = (string) ($as['class_section_name'] ?? '');
|
||||
$r = (string) ($as['teacher_role'] ?? '');
|
||||
if ($name !== '') {
|
||||
$parts[] = $r ? ($name . ' (' . $r . ')') : $name;
|
||||
}
|
||||
}
|
||||
if (!empty($parts)) {
|
||||
$assignedText = implode(', ', $parts);
|
||||
} else {
|
||||
$assignedText = 'No assigned class';
|
||||
}
|
||||
} else {
|
||||
$assignedText = 'No assigned class';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$assignedText = 'N/A';
|
||||
}
|
||||
}
|
||||
|
||||
$subject = sprintf('TimeOff Request: %s (%s) — %s', $fullName, ucfirst((string) $role), $dateList ?: 'No dates');
|
||||
$submittedAt = utc_now();
|
||||
|
||||
$body = '<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">'
|
||||
. '<h3 style="margin:0 0 8px;">New TimeOff Request</h3>'
|
||||
. '<p style="margin:0 0 12px;">A staff time-off request was submitted from the teacher portal.</p>'
|
||||
. '<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;">'
|
||||
. '<tr><td><strong>Name</strong></td><td>' . esc($fullName) . '</td></tr>'
|
||||
. '<tr><td><strong>Email</strong></td><td>' . esc($userEmail) . '</td></tr>'
|
||||
. '<tr><td><strong>Role</strong></td><td>' . esc((string) $role) . '</td></tr>'
|
||||
. '<tr><td><strong>Semester</strong></td><td>' . esc($this->semester) . '</td></tr>'
|
||||
. '<tr><td><strong>School Year</strong></td><td>' . esc($this->schoolYear) . '</td></tr>'
|
||||
. '<tr><td><strong>Reason Type</strong></td><td>' . esc($reasonType ?: '-') . '</td></tr>'
|
||||
. '<tr><td><strong>Reason</strong></td><td>' . esc($reasonText) . '</td></tr>'
|
||||
. '<tr><td><strong>Dates</strong></td><td>' . esc($dateList ?: '-') . '</td></tr>'
|
||||
. (in_array(strtolower((string) $roleName), ['teacher', 'teacher_assistant'], true)
|
||||
? '<tr><td><strong>Assigned Class(es)</strong></td><td>' . esc($assignedText) . '</td></tr>'
|
||||
: '')
|
||||
. '<tr><td><strong>Submitted At</strong></td><td>' . esc($submittedAt) . '</td></tr>'
|
||||
. '</table>'
|
||||
. '</div>';
|
||||
|
||||
// Note: StaffTimeOffLinkService not found, skipping link generation
|
||||
$principalEmail = env('PRINCIPAL_EMAIL') ?: 'principal@alrahmaisgl.org';
|
||||
$this->emailService->send($principalEmail, $subject, $body);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send TimeOff email: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'saved' => $saved,
|
||||
'savedDates' => $savedDates,
|
||||
], $saved . ' day(s) saved as absent.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build teacher class assignment payload
|
||||
*/
|
||||
private function buildTeacherClassAssignmentPayload(?string $forYear = null): array
|
||||
{
|
||||
$schoolYear = $forYear ?: $this->schoolYear;
|
||||
|
||||
$teachers = $this->teacher->getTeachersAndTAs();
|
||||
|
||||
// Prefer class sections for the selected year, fallback to all if none
|
||||
$classSections = $this->classSection
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
if (empty($classSections)) {
|
||||
$classSections = $this->classSection->orderBy('class_section_name', 'ASC')->findAll();
|
||||
}
|
||||
|
||||
$classNames = [];
|
||||
foreach ($classSections as $section) {
|
||||
$sectionId = (int) ($section['class_section_id'] ?? $section['id'] ?? 0);
|
||||
if ($sectionId <= 0) continue;
|
||||
$classNames[$sectionId] = $section['class_section_name'] ?? ($section['name'] ?? 'N/A');
|
||||
}
|
||||
|
||||
$assignments = $this->teacherClass
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$assignmentMap = [];
|
||||
foreach ($assignments as $assign) {
|
||||
$teacherId = (int) ($assign['teacher_id'] ?? 0);
|
||||
$sectionId = (int) ($assign['class_section_id'] ?? 0);
|
||||
if ($teacherId <= 0 || $sectionId <= 0) continue;
|
||||
|
||||
$role = strtolower((string) ($assign['position'] ?? ''));
|
||||
if (!in_array($role, ['main', 'ta'], true)) continue;
|
||||
|
||||
if (!isset($assignmentMap[$teacherId])) {
|
||||
$assignmentMap[$teacherId] = ['main' => [], 'ta' => []];
|
||||
}
|
||||
|
||||
$assignmentMap[$teacherId][$role][] = [
|
||||
'section_id' => $sectionId,
|
||||
'class_section_name' => $classNames[$sectionId] ?? 'N/A',
|
||||
'role' => $role,
|
||||
'semester' => (string) ($assign['semester'] ?? $this->semester ?? ''),
|
||||
'school_year' => (string) ($assign['school_year'] ?? $schoolYear),
|
||||
];
|
||||
}
|
||||
|
||||
$teacherData = [];
|
||||
foreach ($teachers as $teacher) {
|
||||
$tid = (int) ($teacher['id'] ?? 0);
|
||||
if ($tid <= 0) continue;
|
||||
|
||||
$assigned = $assignmentMap[$tid] ?? ['main' => [], 'ta' => []];
|
||||
$firstName = $teacher['firstname'] ?? '';
|
||||
$lastName = $teacher['lastname'] ?? '';
|
||||
$name = trim($firstName . ' ' . $lastName);
|
||||
if ($name === '') {
|
||||
$name = 'User#' . $tid;
|
||||
}
|
||||
|
||||
$teacherData[] = [
|
||||
'teacher_id' => $tid,
|
||||
'firstname' => $firstName,
|
||||
'lastname' => $lastName,
|
||||
'name' => $name,
|
||||
'email' => $teacher['email'] ?? '',
|
||||
'cellphone' => $teacher['cellphone'] ?? '',
|
||||
'role' => $teacher['role'] ?? '',
|
||||
'school_year' => $this->schoolYear,
|
||||
'main_assignments' => $assigned['main'],
|
||||
'ta_assignments' => $assigned['ta'],
|
||||
];
|
||||
}
|
||||
|
||||
$classesPayload = [];
|
||||
foreach ($classSections as $section) {
|
||||
$sectionId = (int) ($section['class_section_id'] ?? $section['id'] ?? 0);
|
||||
if ($sectionId <= 0) continue;
|
||||
|
||||
$classesPayload[] = [
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => $section['class_section_name'] ?? ($section['name'] ?? 'N/A'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'school_year' => $schoolYear,
|
||||
'teachers' => $teacherData,
|
||||
'classes' => $classesPayload,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle assign class teacher logic
|
||||
*/
|
||||
private function handleAssignClassTeacher(array $input): array
|
||||
{
|
||||
$teacherId = (int) ($input['teacher_id'] ?? 0);
|
||||
$classSectionId = (int) ($input['class_section_id'] ?? 0);
|
||||
$teacherRole = $input['teacher_role'] ?? null;
|
||||
$loggedInUserId = $this->getCurrentUserId() ?? 0;
|
||||
$currentDateTime = utc_now();
|
||||
|
||||
if ($teacherId <= 0 || $classSectionId <= 0) {
|
||||
return ['ok' => false, 'status' => 'error', 'message' => 'Missing required parameters for assignment.'];
|
||||
}
|
||||
|
||||
if (empty($this->semester) || empty($this->schoolYear)) {
|
||||
return ['ok' => false, 'status' => 'error', 'message' => 'Semester or school year not configured.'];
|
||||
}
|
||||
|
||||
if ($teacherRole === null || $teacherRole === '') {
|
||||
$teacherRole = $this->user->getUserRole($teacherId);
|
||||
}
|
||||
|
||||
$isAssistant = strtolower((string) $teacherRole) === 'teacher_assistant';
|
||||
$position = $isAssistant ? 'ta' : 'main';
|
||||
|
||||
$alreadyAssigned = $this->teacherClass->where([
|
||||
'teacher_id' => $teacherId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'position' => $position,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
])->first();
|
||||
|
||||
if ($alreadyAssigned) {
|
||||
return ['ok' => false, 'status' => 'info', 'message' => 'This teacher is already assigned to this class with the same role.'];
|
||||
}
|
||||
|
||||
$insertData = [
|
||||
'teacher_id' => $teacherId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'position' => $position,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'created_at' => $currentDateTime,
|
||||
'updated_at' => $currentDateTime,
|
||||
'updated_by' => $loggedInUserId,
|
||||
];
|
||||
|
||||
try {
|
||||
$this->teacherClass->insert($insertData);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('assignClassTeacher failed: ' . $e->getMessage());
|
||||
return ['ok' => false, 'status' => 'error', 'message' => 'Unable to assign class. Please try again.'];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'status' => 'success',
|
||||
'message' => 'Class assigned to ' . ($isAssistant ? 'Teacher Assistant' : 'Main Teacher') . ' successfully.',
|
||||
'position' => $position,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delete assignment logic
|
||||
*/
|
||||
private function handleDeleteAssignment(array $input): array
|
||||
{
|
||||
$teacherId = (int) ($input['teacher_id'] ?? 0);
|
||||
$classSectionId = (int) ($input['class_section_id'] ?? 0);
|
||||
$position = strtolower((string) ($input['position'] ?? ''));
|
||||
|
||||
if ($teacherId <= 0 || $classSectionId <= 0 || !in_array($position, ['main', 'ta'], true)) {
|
||||
return ['ok' => false, 'status' => 'error', 'message' => 'Missing or invalid data for deletion.'];
|
||||
}
|
||||
|
||||
if (empty($this->semester) || empty($this->schoolYear)) {
|
||||
return ['ok' => false, 'status' => 'error', 'message' => 'Semester or school year not configured.'];
|
||||
}
|
||||
|
||||
$requestedSemester = trim((string) ($input['semester'] ?? ''));
|
||||
$requestedSchoolYear = trim((string) ($input['school_year'] ?? ''));
|
||||
|
||||
$semester = $requestedSemester !== '' ? $requestedSemester : $this->semester;
|
||||
$schoolYear = $requestedSchoolYear !== '' ? $requestedSchoolYear : $this->schoolYear;
|
||||
|
||||
if ($semester === '' || $schoolYear === '') {
|
||||
return ['ok' => false, 'status' => 'error', 'message' => 'Semester or school year not configured.'];
|
||||
}
|
||||
|
||||
$assignment = $this->teacherClass
|
||||
->where('teacher_id', $teacherId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('position', $position)
|
||||
->first();
|
||||
|
||||
if (!$assignment) {
|
||||
return ['ok' => false, 'status' => 'error', 'message' => 'Assignment not found.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->teacherClass->delete($assignment['id']);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('deleteAssignment failed: ' . $e->getMessage());
|
||||
return ['ok' => false, 'status' => 'error', 'message' => 'Unable to remove assignment. Please try again.'];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'status' => 'success',
|
||||
'message' => ucfirst($position) . ' assignment removed successfully.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute all allowed absence dates (Sundays) for the configured school year
|
||||
*/
|
||||
private function allowedAbsenceDates(): array
|
||||
{
|
||||
$todayStr = local_date(utc_now(), 'Y-m-d');
|
||||
try {
|
||||
$today = new \DateTimeImmutable($todayStr);
|
||||
} catch (\Throwable $e) {
|
||||
$today = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
$sy = $this->schoolYear;
|
||||
$startYear = null;
|
||||
$endYear = null;
|
||||
|
||||
if (preg_match('/^(\d{4})\D+(\d{4})$/', $sy, $m)) {
|
||||
$startYear = (int) $m[1];
|
||||
$endYear = (int) $m[2];
|
||||
} else {
|
||||
// Fallback based on current date
|
||||
$cy = (int) date('Y');
|
||||
$cm = (int) date('n');
|
||||
if ($cm >= 9) { // Sep-Dec
|
||||
$startYear = $cy;
|
||||
$endYear = $cy + 1;
|
||||
} else { // Jan-Aug
|
||||
$startYear = $cy - 1;
|
||||
$endYear = $cy;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$start = new \DateTimeImmutable(sprintf('%04d-09-01', $startYear));
|
||||
$end = new \DateTimeImmutable(sprintf('%04d-05-31', $endYear));
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Clamp start to today if in range
|
||||
if ($start < $today) {
|
||||
$start = $today;
|
||||
}
|
||||
|
||||
// Iterate and collect Sundays in range [start..end]
|
||||
$dates = [];
|
||||
for ($cursor = $start; $cursor <= $end; $cursor = $cursor->modify('+1 day')) {
|
||||
if ((int) $cursor->format('w') === 0) { // Sunday
|
||||
$dates[] = $cursor->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TestDBController extends BaseApiController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
try {
|
||||
$result = DB::select('SELECT 1 AS ok');
|
||||
if (!empty($result)) {
|
||||
return $this->success(['message' => 'Database connection is successful.'], 'Database check completed');
|
||||
}
|
||||
|
||||
return $this->respondError('Database connection failed', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
} catch (QueryException $e) {
|
||||
return $this->respondError(
|
||||
'Database connection failed: ' . $e->getMessage(),
|
||||
Response::HTTP_INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user