add projet
This commit is contained in:
@@ -1,93 +1,59 @@
|
|||||||
# alrahma_sunday_school_api
|
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## About Laravel
|
||||||
|
|
||||||
## Getting started
|
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||||
|
|
||||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||||
|
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||||
|
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||||
|
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||||
|
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||||
|
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||||
|
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||||
|
|
||||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||||
|
|
||||||
## Add your files
|
## Learning Laravel
|
||||||
|
|
||||||
* [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||||
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
|
||||||
|
|
||||||
```
|
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||||
cd existing_repo
|
|
||||||
git remote add origin https://gitlab.rentaldrivego.ma/alrahma_school/alrahma_sunday_school_api.git
|
|
||||||
git branch -M main
|
|
||||||
git push -uf origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integrate with your tools
|
## Laravel Sponsors
|
||||||
|
|
||||||
* [Set up project integrations](https://gitlab.rentaldrivego.ma/alrahma_school/alrahma_sunday_school_api/-/settings/integrations)
|
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||||
|
|
||||||
## Collaborate with your team
|
### Premium Partners
|
||||||
|
|
||||||
* [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
- **[Vehikl](https://vehikl.com)**
|
||||||
* [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
- **[Tighten Co.](https://tighten.co)**
|
||||||
* [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||||
* [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
- **[64 Robots](https://64robots.com)**
|
||||||
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||||
|
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||||
## Test and Deploy
|
- **[Redberry](https://redberry.international/laravel-development)**
|
||||||
|
- **[Active Logic](https://activelogic.com)**
|
||||||
Use the built-in continuous integration in GitLab.
|
|
||||||
|
|
||||||
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
|
|
||||||
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
|
||||||
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
|
||||||
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
|
||||||
* [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
# Editing this README
|
|
||||||
|
|
||||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
|
||||||
|
|
||||||
## Suggestions for a good README
|
|
||||||
|
|
||||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
|
||||||
|
|
||||||
## Name
|
|
||||||
Choose a self-explaining name for your project.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
|
||||||
|
|
||||||
## Badges
|
|
||||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
|
||||||
|
|
||||||
## Visuals
|
|
||||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
State if you are open to contributions and what your requirements are for accepting them.
|
|
||||||
|
|
||||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||||
|
|
||||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
## Code of Conduct
|
||||||
|
|
||||||
## Authors and acknowledgment
|
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||||
Show your appreciation to those who have contributed to the project.
|
|
||||||
|
## Security Vulnerabilities
|
||||||
|
|
||||||
|
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
For open source projects, say how it is licensed.
|
|
||||||
|
|
||||||
## Project status
|
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
|
||||||
|
|||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Config;
|
||||||
|
|
||||||
|
use App\Models\CiDatabase;
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
public static function connect(): CiDatabase
|
||||||
|
{
|
||||||
|
return CiDatabase::instance();
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Config;
|
||||||
|
|
||||||
|
use App\Services\EmailService;
|
||||||
|
use App\Services\SemesterScoreService;
|
||||||
|
|
||||||
|
class Services
|
||||||
|
{
|
||||||
|
public static function email(): EmailService
|
||||||
|
{
|
||||||
|
return new EmailService();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function emailService(): EmailService
|
||||||
|
{
|
||||||
|
return static::email();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function semesterScoreService(): SemesterScoreService
|
||||||
|
{
|
||||||
|
return new SemesterScoreService();
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Config;
|
||||||
|
|
||||||
|
class SessionTimeout
|
||||||
|
{
|
||||||
|
public const TIMEOUT_DURATION = 1200; // 20 minutes
|
||||||
|
public const WARNING_THRESHOLD = 900; // 15 minutes
|
||||||
|
public const CHECK_INTERVAL = 60; // 1 minute
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Progress Report Status Labels
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'status_options' => [
|
||||||
|
// Keep these aligned with your existing values in DB
|
||||||
|
'draft' => 'Draft',
|
||||||
|
'submitted' => 'Submitted',
|
||||||
|
'reviewed' => 'Reviewed',
|
||||||
|
'approved' => 'Approved',
|
||||||
|
'rejected' => 'Rejected',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Subject Sections Mapping
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Replace with your exact CI ClassProgressController::SUBJECT_SECTIONS values
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'subject_sections' => [
|
||||||
|
// Example placeholders only:
|
||||||
|
// 'quran' => ['Memorization', 'Reading', 'Tajweed'],
|
||||||
|
// 'islamic_studies' => ['Aqeedah', 'Fiqh', 'Seerah'],
|
||||||
|
// 'arabic' => ['Reading', 'Writing', 'Vocabulary'],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
Executable
+120
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
if (!function_exists('log_message')) {
|
||||||
|
function log_message(string $level, string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$level = strtolower($level);
|
||||||
|
$logger = Log::channel(config('logging.default'));
|
||||||
|
$context = $context;
|
||||||
|
if (!empty($context)) {
|
||||||
|
$message = strtr($message, array_combine(
|
||||||
|
array_map(fn($k) => "{" . $k . "}", array_keys($context)),
|
||||||
|
array_map('strval', $context)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
$logger->{$level}($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('esc')) {
|
||||||
|
function esc($value): string
|
||||||
|
{
|
||||||
|
return e($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('utc_now')) {
|
||||||
|
function utc_now(): string
|
||||||
|
{
|
||||||
|
return CarbonImmutable::now('UTC')->toDateTimeString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('local_date')) {
|
||||||
|
function local_date(string $utcDateTime, string $format = 'Y-m-d H:i:s'): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$carbon = CarbonImmutable::parse($utcDateTime)->setTimezone(config('app.timezone'));
|
||||||
|
return $carbon->format($format);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return CarbonImmutable::parse($utcDateTime)->format($format);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('user_timezone')) {
|
||||||
|
function user_timezone(): string
|
||||||
|
{
|
||||||
|
return (string) (config('School')->attendance['timezone'] ?? config('app.timezone', 'America/New_York'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('model')) {
|
||||||
|
function model(string $class)
|
||||||
|
{
|
||||||
|
return app()->make($class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('pbkdf2_hash')) {
|
||||||
|
function pbkdf2_hash(string $password, string $algo = 'sha256', int $iterations = 100000, int $length = 64): string
|
||||||
|
{
|
||||||
|
$salt = random_bytes(16);
|
||||||
|
$saltHex = bin2hex($salt);
|
||||||
|
$derived = hash_pbkdf2($algo, $password, $salt, $iterations, $length, true);
|
||||||
|
$derivedHex = bin2hex($derived);
|
||||||
|
|
||||||
|
return implode('$', [$algo, $iterations, $saltHex, $derivedHex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('pbkdf2_verify')) {
|
||||||
|
function pbkdf2_verify(string $password, string $storedHash): bool
|
||||||
|
{
|
||||||
|
$delim = strpos($storedHash, '$') !== false ? '$' : ':';
|
||||||
|
$parts = explode($delim, $storedHash);
|
||||||
|
if (count($parts) !== 4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$algo, $iterations, $saltHex, $derivedHex] = $parts;
|
||||||
|
if ($saltHex === '' || $derivedHex === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$salt = ctype_xdigit($saltHex) ? hex2bin($saltHex) : false;
|
||||||
|
$derived = ctype_xdigit($derivedHex) ? hex2bin($derivedHex) : false;
|
||||||
|
if ($salt === false || $derived === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$calc = hash_pbkdf2($algo, $password, $salt, (int) $iterations, strlen($derived), true);
|
||||||
|
return hash_equals($derived, $calc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('legacy_password_verify')) {
|
||||||
|
function legacy_password_verify(string $password, ?string $storedHash): bool
|
||||||
|
{
|
||||||
|
return ci_password_verify($password, $storedHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('ci_password_verify')) {
|
||||||
|
function ci_password_verify(string $password, ?string $storedHash): bool
|
||||||
|
{
|
||||||
|
if (!$storedHash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($storedHash, ':') !== false || strpos($storedHash, '$') !== false) {
|
||||||
|
return pbkdf2_verify($password, $storedHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Hash::check($password, $storedHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?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 : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
+844
@@ -0,0 +1,844 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
<?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,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\AttendanceCommentTemplate\StoreAttendanceCommentTemplateRequest;
|
||||||
|
use App\Http\Requests\AttendanceCommentTemplate\UpdateAttendanceCommentTemplateRequest;
|
||||||
|
use App\Services\AttendanceCommentTemplateService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AttendanceCommentTemplateController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AttendanceCommentTemplateService $service
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/attendance-comment-templates
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$activeOnly = $request->boolean('active_only', false);
|
||||||
|
|
||||||
|
$templates = $this->service->list($activeOnly);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => $templates,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/attendance-comment-templates
|
||||||
|
*/
|
||||||
|
public function store(StoreAttendanceCommentTemplateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$template = $this->service->create($request->validated());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Template created successfully.',
|
||||||
|
'data' => $template,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/attendance-comment-templates/{id}
|
||||||
|
*/
|
||||||
|
public function show(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$template = $this->service->findOrFail($id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => $template,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+519
@@ -0,0 +1,519 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+1282
File diff suppressed because it is too large
Load Diff
+87
@@ -0,0 +1,87 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Api\CiRequestAdapter;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
|
||||||
|
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class BaseApiController extends Controller
|
||||||
|
{
|
||||||
|
protected Request $laravelRequest;
|
||||||
|
protected CiRequestAdapter $request;
|
||||||
|
protected ValidatorContract|null $validator = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->laravelRequest = request();
|
||||||
|
$this->request = new CiRequestAdapter($this->laravelRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function success($data = null, string $message = 'Success', int $code = Response::HTTP_OK): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'message' => $message,
|
||||||
|
'data' => $data,
|
||||||
|
], $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function error(string $message = 'An error occurred', int $code = Response::HTTP_BAD_REQUEST, ?array $errors = null): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
|
'status' => false,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
if (!empty($errors)) {
|
||||||
|
$payload['errors'] = $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($payload, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function respondSuccess($data = null, string $message = 'Success', int $code = Response::HTTP_OK): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->success($data, $message, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function respondCreated($data = null, string $message = 'Resource created successfully'): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->success($data, $message, Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function respondDeleted($data = null, string $message = 'Resource deleted successfully'): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->success($data, $message, Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function respondError(string $message = 'An error occurred', int $code = Response::HTTP_BAD_REQUEST): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->error($message, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function respondValidationError(array $errors, string $message = 'Validation failed'): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->error($message, Response::HTTP_UNPROCESSABLE_ENTITY, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validateRequest(array $data, array $rules): array
|
||||||
|
{
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($rules as $field => $ruleSet) {
|
||||||
|
$normalized[$field] = $this->normalizeRules($ruleSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = Validator::make($data, $normalized);
|
||||||
|
$this->validator = $validator;
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return $validator->errors()->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validate(array $rules): bool
|
||||||
|
{
|
||||||
|
$payload = $this->payloadData();
|
||||||
|
$errors = $this->validateRequest($payload, $rules);
|
||||||
|
return empty($errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function payloadData(): array
|
||||||
|
{
|
||||||
|
$json = json_decode($this->laravelRequest->getContent() ?: '', true);
|
||||||
|
if (is_array($json)) {
|
||||||
|
return $json;
|
||||||
|
}
|
||||||
|
return array_merge($this->laravelRequest->query->all(), $this->laravelRequest->request->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeRules(array|string $ruleSet): array|string
|
||||||
|
{
|
||||||
|
if (is_array($ruleSet)) {
|
||||||
|
return $ruleSet;
|
||||||
|
}
|
||||||
|
$parts = explode('|', $ruleSet);
|
||||||
|
$result = [];
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$part = trim($part);
|
||||||
|
if ($part === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^max_length\\[(\\d+)\\]$/', $part, $m)) {
|
||||||
|
$result[] = 'max:' . $m[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^min_length\\[(\\d+)\\]$/', $part, $m)) {
|
||||||
|
$result[] = 'min:' . $m[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^in_list\\[(.+)\\]$/', $part, $m)) {
|
||||||
|
$result[] = 'in:' . $m[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^greater_than_equal_to\\[(.+)\\]$/', $part, $m)) {
|
||||||
|
$result[] = 'gte:' . $m[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^less_than_equal_to\\[(.+)\\]$/', $part, $m)) {
|
||||||
|
$result[] = 'lte:' . $m[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^valid_date\\[(.+)\\]$/', $part, $m)) {
|
||||||
|
$result[] = 'date_format:' . $m[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($part === 'valid_email') {
|
||||||
|
$result[] = 'email';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($part === 'permit_empty') {
|
||||||
|
$result[] = 'nullable';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^is_unique\\[([^\\.]+)\\.([^\\]]+)\\]$/', $part, $m)) {
|
||||||
|
$result[] = 'unique:' . $m[1] . ',' . $m[2];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($part === 'string') {
|
||||||
|
$result[] = 'string';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($part === 'integer') {
|
||||||
|
$result[] = 'integer';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($part === 'numeric') {
|
||||||
|
$result[] = 'numeric';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($part === 'required') {
|
||||||
|
$result[] = 'required';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($part === 'alpha_numeric') {
|
||||||
|
$result[] = 'alpha_num';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($part === 'in_array') {
|
||||||
|
$result[] = 'in_array';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($part === 'valid_url') {
|
||||||
|
$result[] = 'url';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (strpos($part, 'max_size') === 0) {
|
||||||
|
$result[] = str_replace('max_size', 'max', $part);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result[] = $part;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function paginate($source, int $page = 1, int $perPage = 20): array
|
||||||
|
{
|
||||||
|
$page = max(1, $page);
|
||||||
|
$perPage = max(1, $perPage);
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
if ($source instanceof EloquentBuilder || $source instanceof \Illuminate\Database\Query\Builder) {
|
||||||
|
$total = $source->toBase()->getCountForPagination();
|
||||||
|
$items = $source->offset($offset)->limit($perPage)->get()->toArray();
|
||||||
|
} elseif (is_array($source)) {
|
||||||
|
$total = count($source);
|
||||||
|
$items = array_slice($source, $offset, $perPage);
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
'data' => [],
|
||||||
|
'pagination' => [
|
||||||
|
'current_page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total' => 0,
|
||||||
|
'total_pages' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => array_values($items),
|
||||||
|
'pagination' => [
|
||||||
|
'current_page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total' => $total,
|
||||||
|
'total_pages' => (int) ceil($total / $perPage),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getCurrentUserId(): ?int
|
||||||
|
{
|
||||||
|
$userId = (int) (session()->get('user_id') ?? 0);
|
||||||
|
return $userId > 0 ? $userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getCurrentUser(): ?object
|
||||||
|
{
|
||||||
|
$userId = $this->getCurrentUserId();
|
||||||
|
if (!$userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = app(User::class);
|
||||||
|
$row = $user->find($userId);
|
||||||
|
if (!$row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
$name = $row['email'] ?? 'User #' . $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = (array) (session()->get('roles') ?? []);
|
||||||
|
if (empty($roles)) {
|
||||||
|
try {
|
||||||
|
$roles = DB::table('user_roles ur')
|
||||||
|
->select('LOWER(r.name) AS name')
|
||||||
|
->join('roles r', 'r.id', '=', 'ur.role_id')
|
||||||
|
->where('ur.user_id', $userId)
|
||||||
|
->whereNull('ur.deleted_at')
|
||||||
|
->pluck('name')
|
||||||
|
->map(fn($name) => strtolower((string) $name))
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Unable to load user roles: ' . $e->getMessage());
|
||||||
|
$roles = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'id' => (int) $userId,
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $row['email'] ?? null,
|
||||||
|
'roles' => $roles,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
<?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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
class CiRequestAdapter
|
||||||
|
{
|
||||||
|
public function __construct(protected Request $request)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGet(string $key = null, $default = null)
|
||||||
|
{
|
||||||
|
if ($key === null) {
|
||||||
|
return $this->request->query->all();
|
||||||
|
}
|
||||||
|
return $this->request->query->get($key, $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPost(string $key = null, $default = null)
|
||||||
|
{
|
||||||
|
if ($key === null) {
|
||||||
|
return $this->request->request->all();
|
||||||
|
}
|
||||||
|
return $this->request->request->get($key, $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getJSON(bool $assoc = false)
|
||||||
|
{
|
||||||
|
$content = $this->request->getContent();
|
||||||
|
if ($content === '') {
|
||||||
|
return $assoc ? [] : null;
|
||||||
|
}
|
||||||
|
return json_decode($content, $assoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVar(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return $this->request->input($key, $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeader(string $key)
|
||||||
|
{
|
||||||
|
return $this->request->headers->get($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaderLine(string $key)
|
||||||
|
{
|
||||||
|
return $this->request->header($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFile(string $key)
|
||||||
|
{
|
||||||
|
return $this->request->file($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRawInput(): string
|
||||||
|
{
|
||||||
|
return $this->request->getContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_merge(
|
||||||
|
$this->request->query->all(),
|
||||||
|
$this->request->request->all(),
|
||||||
|
$this->request->json()->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+411
@@ -0,0 +1,411 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+351
@@ -0,0 +1,351 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+647
@@ -0,0 +1,647 @@
|
|||||||
|
<?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];
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+314
@@ -0,0 +1,314 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use PHPMailer\PHPMailer\PHPMailer;
|
||||||
|
use PHPMailer\PHPMailer\Exception;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EmailController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function send()
|
||||||
|
{
|
||||||
|
$data = $this->payloadData();
|
||||||
|
if (empty($data)) {
|
||||||
|
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'recipient' => 'required|valid_email',
|
||||||
|
'subject' => 'required|min_length[3]',
|
||||||
|
'html_message' => 'required',
|
||||||
|
];
|
||||||
|
|
||||||
|
$errors = $this->validateRequest($data, $rules);
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return $this->respondValidationError($errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipient = $data['recipient'];
|
||||||
|
$subject = $data['subject'];
|
||||||
|
$htmlMessage = $data['html_message'];
|
||||||
|
$profile = $data['profile'] ?? null;
|
||||||
|
$replyToEmail = $data['reply_to_email'] ?? null;
|
||||||
|
$replyToName = $data['reply_to_name'] ?? null;
|
||||||
|
$attachments = $data['attachments'] ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sent = $this->sendEmail(
|
||||||
|
$recipient,
|
||||||
|
$subject,
|
||||||
|
$htmlMessage,
|
||||||
|
$profile,
|
||||||
|
$replyToEmail,
|
||||||
|
$replyToName,
|
||||||
|
$attachments
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$sent) {
|
||||||
|
return $this->respondError('Failed to send email', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'recipient' => $recipient,
|
||||||
|
'subject' => $subject,
|
||||||
|
'sent' => true,
|
||||||
|
], 'Email sent successfully');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Email send error: ' . $e->getMessage());
|
||||||
|
return $this->respondError('Failed to send email', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an email using a named profile (e.g., 'communication', 'payment', 'default').
|
||||||
|
*
|
||||||
|
* @param string $recipient
|
||||||
|
* @param string $subject
|
||||||
|
* @param string $htmlMessage
|
||||||
|
* @param string|null $profile e.g. 'communication', 'payment'; if null uses MAIL_PROFILE_DEFAULT or 'default'
|
||||||
|
* @param string|null $replyToEmail optional override
|
||||||
|
* @param string|null $replyToName optional override
|
||||||
|
* @param array $attachments [['path'=>..., 'name'=>...], ...]
|
||||||
|
*/
|
||||||
|
public function sendEmail(
|
||||||
|
string $recipient,
|
||||||
|
string $subject,
|
||||||
|
string $htmlMessage,
|
||||||
|
?string $profile = null,
|
||||||
|
?string $replyToEmail = null,
|
||||||
|
?string $replyToName = null,
|
||||||
|
array $attachments = []
|
||||||
|
): bool {
|
||||||
|
$profile = $this->resolveProfile($profile);
|
||||||
|
$cfg = $this->getProfileConfig($profile);
|
||||||
|
|
||||||
|
// Guard rails: refuse to proceed with missing essentials
|
||||||
|
if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') {
|
||||||
|
Log::error("[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional env flags
|
||||||
|
$debugEnabled = (bool) env('MAIL_DEBUG', false);
|
||||||
|
$timeout = (int) env('MAIL_TIMEOUT', 15); // seconds
|
||||||
|
$keepAlive = (bool) env('MAIL_KEEPALIVE', false);
|
||||||
|
$verifyPeer = env('MAIL_VERIFY_PEER', 'true'); // 'true'|'false' (string to allow .env)
|
||||||
|
$verifyPeer = filter_var($verifyPeer, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
|
||||||
|
// Preflight DNS + socket (helps distinguish firewall/DNS vs. auth)
|
||||||
|
$targetHost = $cfg['host'];
|
||||||
|
$targetPort = (int) $cfg['port'];
|
||||||
|
|
||||||
|
$resolved = @gethostbyname($targetHost);
|
||||||
|
if (!$resolved || $resolved === $targetHost) {
|
||||||
|
// Not fatal (some environments block gethostbyname), but useful log
|
||||||
|
Log::debug("[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved");
|
||||||
|
}
|
||||||
|
|
||||||
|
$sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5);
|
||||||
|
if (!$sockOk) {
|
||||||
|
Log::error("[mail:$profile] Socket preflight failed to {$targetHost}:{$targetPort} (errno=$errno, err=$errstr). Likely firewall/port/encryption mismatch or wrong host.");
|
||||||
|
} else {
|
||||||
|
fclose($sockOk);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mail = new PHPMailer(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Capture PHPMailer's own debug if enabled
|
||||||
|
if ($debugEnabled) {
|
||||||
|
ob_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHPMailer core setup
|
||||||
|
$mail->isSMTP();
|
||||||
|
$mail->Host = $cfg['host'];
|
||||||
|
$mail->Port = $cfg['port'];
|
||||||
|
$mail->SMTPAuth = true;
|
||||||
|
$mail->Username = $cfg['user'];
|
||||||
|
$mail->Password = $cfg['pass'];
|
||||||
|
$mail->CharSet = 'UTF-8';
|
||||||
|
$mail->Timeout = $timeout; // socket timeout
|
||||||
|
$mail->SMTPKeepAlive = $keepAlive; // reuse connection for multiple sends
|
||||||
|
$mail->SMTPAutoTLS = true; // allow auto TLS upgrade when possible
|
||||||
|
|
||||||
|
// Encryption mapping: 'tls' => STARTTLS (587), 'ssl' => implicit TLS (465)
|
||||||
|
if ($cfg['encryption'] === 'ssl') {
|
||||||
|
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
||||||
|
} else {
|
||||||
|
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS verification options (can relax on demand via .env for on-prem/self-signed)
|
||||||
|
$mail->SMTPOptions = [
|
||||||
|
'ssl' => [
|
||||||
|
'verify_peer' => $verifyPeer,
|
||||||
|
'verify_peer_name' => $verifyPeer,
|
||||||
|
'allow_self_signed' => !$verifyPeer,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Optional verbose debugging (set MAIL_DEBUG=true in .env)
|
||||||
|
$mail->SMTPDebug = $debugEnabled ? 3 : 0;
|
||||||
|
$mail->Debugoutput = 'error_log';
|
||||||
|
|
||||||
|
// From / Return-Path
|
||||||
|
$mail->setFrom($cfg['fromEmail'], $cfg['fromName']);
|
||||||
|
if (!empty($cfg['returnPath'])) {
|
||||||
|
$mail->Sender = $cfg['returnPath'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configurable Reply-To: env overrides inputs/config; fallback to profile/defaults
|
||||||
|
$mail->clearReplyTos();
|
||||||
|
$rtEmail = env('MAIL_DEFAULT_REPLY_TO');
|
||||||
|
$rtName = env('MAIL_DEFAULT_REPLY_TO_NAME');
|
||||||
|
|
||||||
|
if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$rtName) {
|
||||||
|
$rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rtEmail) {
|
||||||
|
$mail->addReplyTo($rtEmail, $rtName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DKIM (optional)
|
||||||
|
if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) {
|
||||||
|
$mail->DKIM_domain = $cfg['dkim']['domain'];
|
||||||
|
$mail->DKIM_private = $cfg['dkim']['private']; // path to private key file
|
||||||
|
$mail->DKIM_selector = $cfg['dkim']['selector'];
|
||||||
|
$mail->DKIM_identity = $cfg['fromEmail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient(s)
|
||||||
|
$mail->addAddress($recipient);
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
foreach ($attachments as $att) {
|
||||||
|
if (!empty($att['path']) && is_file($att['path'])) {
|
||||||
|
$mail->addAttachment($att['path'], $att['name'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
$mail->isHTML(true);
|
||||||
|
$mail->Subject = $subject;
|
||||||
|
$mail->Body = $htmlMessage;
|
||||||
|
|
||||||
|
// Send!
|
||||||
|
$ok = $mail->send();
|
||||||
|
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
|
||||||
|
|
||||||
|
if ($ok) {
|
||||||
|
Log::info("[mail:$profile] Sent to {$recipient}, subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error("[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}");
|
||||||
|
return false;
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
|
||||||
|
Log::error("[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve null/alias profile names to a canonical env prefix. */
|
||||||
|
private function resolveProfile(?string $profile): string
|
||||||
|
{
|
||||||
|
$p = strtolower(trim((string) $profile));
|
||||||
|
|
||||||
|
if ($p === '' || $p === 'auto') {
|
||||||
|
$p = strtolower((string) getenv('MAIL_PROFILE_DEFAULT')) ?: 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($p) {
|
||||||
|
'comm', 'comms' => 'communication',
|
||||||
|
'sys', 'system' => 'default',
|
||||||
|
default => $p, // e.g., 'payment', 'admissions', etc.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn 'communication' into 'COMMUNICATION' for env lookups/logs. */
|
||||||
|
private function envKeyFromProfile(string $profile): string
|
||||||
|
{
|
||||||
|
return strtoupper(preg_replace('/[^A-Z0-9]+/i', '_', $profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve SMTP & identity for any profile with layered fallbacks:
|
||||||
|
* 1) MAIL_{PROFILE}_*
|
||||||
|
* 2) MAIL_DEFAULT_*
|
||||||
|
* 3) legacy SMTP_* (HOST/USER/PASS/PORT/ENCRYPTION)
|
||||||
|
* 4) hard defaults
|
||||||
|
*/
|
||||||
|
private function getProfileConfig(string $profile): array
|
||||||
|
{
|
||||||
|
$key = $this->envKeyFromProfile($profile);
|
||||||
|
|
||||||
|
// Helper: first non-empty env from a list
|
||||||
|
$envFirst = function (array $keys, $default = null) {
|
||||||
|
foreach ($keys as $k) {
|
||||||
|
$v = env($k);
|
||||||
|
if (is_string($v)) $v = trim($v);
|
||||||
|
if ($v !== null && $v !== '') return $v;
|
||||||
|
}
|
||||||
|
return $default;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Core SMTP
|
||||||
|
$host = $envFirst(["MAIL_{$key}_HOST", "MAIL_DEFAULT_HOST", "SMTP_HOST"], '');
|
||||||
|
$user = $envFirst(["MAIL_{$key}_USER", "MAIL_DEFAULT_USER", "SMTP_USER"], '');
|
||||||
|
$pass = $envFirst(["MAIL_{$key}_PASS", "MAIL_DEFAULT_PASS", "SMTP_PASS"], '');
|
||||||
|
$portRaw = $envFirst(["MAIL_{$key}_PORT", "MAIL_DEFAULT_PORT", "SMTP_PORT"], 587);
|
||||||
|
$encRaw = $envFirst(["MAIL_{$key}_ENCRYPTION", "MAIL_DEFAULT_ENCRYPTION", "SMTP_ENCRYPTION"], 'tls');
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
$fromEmail = $envFirst(["MAIL_{$key}_FROM_EMAIL", "MAIL_DEFAULT_FROM_EMAIL"], $user ?: 'no-reply@alrahmaisgl.org');
|
||||||
|
$fromName = $envFirst(["MAIL_{$key}_FROM_NAME", "MAIL_DEFAULT_FROM_NAME"], 'Al Rahma Sunday School');
|
||||||
|
$replyTo = $envFirst(["MAIL_{$key}_REPLY_TO", "MAIL_DEFAULT_REPLY_TO"], '');
|
||||||
|
$replyToNm = $envFirst(["MAIL_{$key}_REPLY_TO_NAME","MAIL_DEFAULT_REPLY_TO_NAME"], '');
|
||||||
|
$returnPath = $envFirst(["MAIL_{$key}_RETURN_PATH","MAIL_DEFAULT_RETURN_PATH"], '');
|
||||||
|
|
||||||
|
// DKIM (optional)
|
||||||
|
$dkim = [
|
||||||
|
'domain' => $envFirst(["MAIL_{$key}_DKIM_DOMAIN", "MAIL_DEFAULT_DKIM_DOMAIN"], ''),
|
||||||
|
'selector' => $envFirst(["MAIL_{$key}_DKIM_SELECTOR", "MAIL_DEFAULT_DKIM_SELECTOR"], ''),
|
||||||
|
'private' => $envFirst(["MAIL_{$key}_DKIM_PRIVATE", "MAIL_DEFAULT_DKIM_PRIVATE"], ''), // full path
|
||||||
|
];
|
||||||
|
|
||||||
|
// Normalize types/values
|
||||||
|
$port = (int) $portRaw ?: 587;
|
||||||
|
$encryption = strtolower((string) $encRaw);
|
||||||
|
$encryption = in_array($encryption, ['tls','ssl'], true) ? $encryption : 'tls';
|
||||||
|
|
||||||
|
// --- Autocorrect common mistakes (prevents silent handshake failures) ---
|
||||||
|
if ($port === 587 && $encryption === 'ssl') {
|
||||||
|
$encryption = 'tls';
|
||||||
|
}
|
||||||
|
if ($port === 465 && $encryption === 'tls') {
|
||||||
|
$encryption = 'ssl';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'host' => (string) $host,
|
||||||
|
'user' => (string) $user,
|
||||||
|
'pass' => (string) $pass,
|
||||||
|
'port' => $port,
|
||||||
|
// map used above: 'tls' => STARTTLS, 'ssl' => SMTPS
|
||||||
|
'encryption' => $encryption,
|
||||||
|
'fromEmail' => (string) $fromEmail,
|
||||||
|
'fromName' => (string) $fromName,
|
||||||
|
'replyTo' => (string) $replyTo,
|
||||||
|
'replyToName' => (string) $replyToNm,
|
||||||
|
'returnPath' => (string) $returnPath,
|
||||||
|
'dkim' => $dkim,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+382
@@ -0,0 +1,382 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+429
@@ -0,0 +1,429 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+446
@@ -0,0 +1,446 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+525
@@ -0,0 +1,525 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+651
@@ -0,0 +1,651 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+184
@@ -0,0 +1,184 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+230
@@ -0,0 +1,230 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+724
@@ -0,0 +1,724 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+399
@@ -0,0 +1,399 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+482
@@ -0,0 +1,482 @@
|
|||||||
|
<?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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+516
@@ -0,0 +1,516 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+1488
File diff suppressed because it is too large
Load Diff
+1085
File diff suppressed because it is too large
Load Diff
Executable
+205
@@ -0,0 +1,205 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+378
@@ -0,0 +1,378 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+318
@@ -0,0 +1,318 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+377
@@ -0,0 +1,377 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<?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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
class NotificationsController extends NotificationController
|
||||||
|
{
|
||||||
|
// Alias controller for route naming consistency
|
||||||
|
}
|
||||||
Executable
+156
@@ -0,0 +1,156 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+767
@@ -0,0 +1,767 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+327
@@ -0,0 +1,327 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+1445
File diff suppressed because it is too large
Load Diff
+493
@@ -0,0 +1,493 @@
|
|||||||
|
<?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 ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+162
@@ -0,0 +1,162 @@
|
|||||||
|
<?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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+624
@@ -0,0 +1,624 @@
|
|||||||
|
<?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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+621
@@ -0,0 +1,621 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+352
@@ -0,0 +1,352 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+656
@@ -0,0 +1,656 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+126
@@ -0,0 +1,126 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+357
@@ -0,0 +1,357 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+727
@@ -0,0 +1,727 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
|
}
|
||||||
+331
@@ -0,0 +1,331 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+379
@@ -0,0 +1,379 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Executable
+695
@@ -0,0 +1,695 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+317
@@ -0,0 +1,317 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Config\SessionTimeout;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SessionTimeoutController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function getTimeoutConfig(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'timeout' => SessionTimeout::TIMEOUT_DURATION,
|
||||||
|
'warning_time' => SessionTimeout::WARNING_THRESHOLD,
|
||||||
|
'check_interval' => SessionTimeout::CHECK_INTERVAL,
|
||||||
|
'logout_url' => url('/api/v1/auth/logout'),
|
||||||
|
'keep_alive_url' => url('/api/v1/session/pingActivity'),
|
||||||
|
'check_url' => url('/api/v1/session/checkTimeout'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkTimeout(): JsonResponse
|
||||||
|
{
|
||||||
|
$session = session();
|
||||||
|
|
||||||
|
// Verify session exists and has last_activity
|
||||||
|
if (!$session->has('last_activity')) {
|
||||||
|
return $this->expireSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastActivity = $session->get('last_activity');
|
||||||
|
$elapsed = time() - $lastActivity;
|
||||||
|
|
||||||
|
if ($elapsed > SessionTimeout::TIMEOUT_DURATION) {
|
||||||
|
return $this->expireSession();
|
||||||
|
} elseif ($elapsed > SessionTimeout::WARNING_THRESHOLD) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'warning',
|
||||||
|
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'active',
|
||||||
|
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pingActivity(): JsonResponse
|
||||||
|
{
|
||||||
|
$session = session();
|
||||||
|
|
||||||
|
// Only update if session is still valid
|
||||||
|
if (!$session->has('last_activity') ||
|
||||||
|
(time() - $session->get('last_activity') > SessionTimeout::TIMEOUT_DURATION)) {
|
||||||
|
return $this->expireSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->put('last_activity', time());
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'active',
|
||||||
|
'time_remaining' => SessionTimeout::TIMEOUT_DURATION,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expireSession(): JsonResponse
|
||||||
|
{
|
||||||
|
$this->destroySession();
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'expired',
|
||||||
|
'redirect' => url('/login'),
|
||||||
|
'message' => 'Your session has expired due to inactivity.',
|
||||||
|
], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function destroySession(): void
|
||||||
|
{
|
||||||
|
$session = session();
|
||||||
|
$session->flash('error', 'Your session has expired due to inactivity.');
|
||||||
|
|
||||||
|
// Clear session data
|
||||||
|
$session->forget('last_activity');
|
||||||
|
$session->invalidate();
|
||||||
|
|
||||||
|
// Regenerate session ID for security
|
||||||
|
$session->regenerate(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+615
@@ -0,0 +1,615 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+244
@@ -0,0 +1,244 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Models\Stats;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class StatsController extends BaseApiController
|
||||||
|
{
|
||||||
|
protected Stats $stats;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$this->stats = model(Stats::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stats = $this->stats->findAll();
|
||||||
|
return $this->success($stats, 'Statistics retrieved successfully');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Stats retrieval error: ' . $e->getMessage());
|
||||||
|
return $this->respondError('Failed to retrieve statistics', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+904
@@ -0,0 +1,904 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+748
@@ -0,0 +1,748 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<?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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class TimeController extends BaseApiController
|
||||||
|
{
|
||||||
|
protected string $defaultUserTimezone = 'America/New_York';
|
||||||
|
protected string $serverTimezone;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$this->serverTimezone = config('app.timezone', 'UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function detectUserTimezone(): string
|
||||||
|
{
|
||||||
|
$header = $this->laravelRequest->headers->get('X-Timezone');
|
||||||
|
if (!empty($header) && in_array($header, timezone_identifiers_list(), true)) {
|
||||||
|
return $header;
|
||||||
|
}
|
||||||
|
|
||||||
|
$param = $this->laravelRequest->get('timezone');
|
||||||
|
if (!empty($param) && in_array($param, timezone_identifiers_list(), true)) {
|
||||||
|
return $param;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->defaultUserTimezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function convert()
|
||||||
|
{
|
||||||
|
$data = $this->payloadData();
|
||||||
|
if (empty($data['time']) || empty($data['direction'])) {
|
||||||
|
return $this->respondValidationError(['time' => 'Required', 'direction' => 'Required']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userTz = $this->detectUserTimezone();
|
||||||
|
$time = trim((string) $data['time']);
|
||||||
|
$direction = strtolower((string) $data['direction']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($direction === 'toutc') {
|
||||||
|
$converted = Carbon::parse($time, $userTz)
|
||||||
|
->setTimezone('UTC')
|
||||||
|
->toDateTimeString();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'user_timezone' => $userTz,
|
||||||
|
'server_timezone' => 'UTC',
|
||||||
|
'original_time' => $time,
|
||||||
|
'converted_time' => $converted,
|
||||||
|
], 'Conversion completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($direction === 'tolocal') {
|
||||||
|
$converted = Carbon::parse($time, 'UTC')
|
||||||
|
->setTimezone($userTz)
|
||||||
|
->toDateTimeString();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'user_timezone' => $userTz,
|
||||||
|
'server_timezone' => 'UTC',
|
||||||
|
'original_time' => $time,
|
||||||
|
'converted_time' => $converted,
|
||||||
|
], 'Conversion completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->respondValidationError(['direction' => 'Must be toUTC or toLocal']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->respondError('Failed to convert time: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function now()
|
||||||
|
{
|
||||||
|
$userTz = $this->detectUserTimezone();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$local = Carbon::now($userTz)->toDateTimeString();
|
||||||
|
$utc = Carbon::now('UTC')->toDateTimeString();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'user_timezone' => $userTz,
|
||||||
|
'server_timezone' => 'UTC',
|
||||||
|
'local_time' => $local,
|
||||||
|
'utc_time' => $utc,
|
||||||
|
], 'Current time retrieved');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->respondError('Failed to determine current time', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+211
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class UiController extends BaseApiController
|
||||||
|
{
|
||||||
|
protected string $defaultUserTimezone = 'America/New_York';
|
||||||
|
|
||||||
|
protected function styleConfig(): array
|
||||||
|
{
|
||||||
|
$styleCfg = config('Style');
|
||||||
|
if (is_object($styleCfg)) {
|
||||||
|
return [
|
||||||
|
'stylePalettes' => $styleCfg->stylePalettes ?? [],
|
||||||
|
'menuPalettes' => $styleCfg->menuPalettes ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'stylePalettes' => $styleCfg['stylePalettes'] ?? [],
|
||||||
|
'menuPalettes' => $styleCfg['menuPalettes'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/ui/style
|
||||||
|
* Get current UI style preferences
|
||||||
|
*/
|
||||||
|
public function getStyle()
|
||||||
|
{
|
||||||
|
$user = $this->getCurrentUser();
|
||||||
|
if (!$user) {
|
||||||
|
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$session = session();
|
||||||
|
$cfg = $this->styleConfig();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'style_color' => $session->get('style_color'),
|
||||||
|
'menu_color' => $session->get('menu_color'),
|
||||||
|
'menu_custom_bg' => $session->get('menu_custom_bg'),
|
||||||
|
'menu_custom_text' => $session->get('menu_custom_text'),
|
||||||
|
'menu_custom_mode' => $session->get('menu_custom_mode'),
|
||||||
|
'available_palettes' => $cfg['stylePalettes'] ?? [],
|
||||||
|
'available_menus' => $cfg['menuPalettes'] ?? [],
|
||||||
|
], 'UI style preferences retrieved successfully');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'UI style error: ' . $e->getMessage());
|
||||||
|
return $this->respondError('Failed to retrieve UI style', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/ui/style
|
||||||
|
* Update UI style preferences via POST body
|
||||||
|
*/
|
||||||
|
public function updateStyle()
|
||||||
|
{
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->processStyleUpdate($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/ui/style (with query parameters)
|
||||||
|
* Update UI style preferences via GET parameters (for backward compatibility)
|
||||||
|
*/
|
||||||
|
public function style()
|
||||||
|
{
|
||||||
|
$user = $this->getCurrentUser();
|
||||||
|
if (!$user) {
|
||||||
|
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get parameters from query string
|
||||||
|
$accent = (string) $this->request->getGet('accent');
|
||||||
|
$menu = (string) $this->request->getGet('menu');
|
||||||
|
$menuBg = (string) $this->request->getGet('menu_bg');
|
||||||
|
$menuTx = (string) $this->request->getGet('menu_text');
|
||||||
|
$menuMd = (string) $this->request->getGet('menu_mode');
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
if ($accent !== '') {
|
||||||
|
$data['accent'] = $accent;
|
||||||
|
}
|
||||||
|
if ($menu !== '') {
|
||||||
|
$data['menu'] = $menu;
|
||||||
|
}
|
||||||
|
if ($menuBg !== '') {
|
||||||
|
$data['menu_bg'] = $menuBg;
|
||||||
|
}
|
||||||
|
if ($menuTx !== '') {
|
||||||
|
$data['menu_text'] = $menuTx;
|
||||||
|
}
|
||||||
|
if ($menuMd !== '') {
|
||||||
|
$data['menu_mode'] = $menuMd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($data)) {
|
||||||
|
// No parameters provided, return current style
|
||||||
|
return $this->getStyle();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->processStyleUpdate($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process style update from either GET or POST
|
||||||
|
*/
|
||||||
|
private function processStyleUpdate(array $data): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$session = session();
|
||||||
|
$cfg = $this->styleConfig();
|
||||||
|
|
||||||
|
if (isset($data['accent']) && $data['accent'] !== '' && isset(($cfg['stylePalettes'] ?? [])[$data['accent']])) {
|
||||||
|
$session->put('style_color', $data['accent']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['menu']) && $data['menu'] !== '') {
|
||||||
|
if (isset(($cfg['menuPalettes'] ?? [])[$data['menu']])) {
|
||||||
|
$session->put('menu_color', $data['menu']);
|
||||||
|
} elseif (strtolower($data['menu']) === 'custom' || (isset($data['menu_bg']) && $data['menu_bg'] !== '') || (isset($data['menu_text']) && $data['menu_text'] !== '')) {
|
||||||
|
// Allow custom menu colors
|
||||||
|
$bg = $this->normalizeHex($data['menu_bg'] ?? '');
|
||||||
|
$tx = $this->normalizeHex($data['menu_text'] ?? '');
|
||||||
|
|
||||||
|
if ($bg === '' && $tx === '') {
|
||||||
|
// no valid values; ignore
|
||||||
|
} else {
|
||||||
|
if ($bg === '') {
|
||||||
|
$bg = '#0f172a';
|
||||||
|
}
|
||||||
|
if ($tx === '') {
|
||||||
|
$tx = '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine mode if requested auto or missing
|
||||||
|
$mode = isset($data['menu_mode']) && in_array($data['menu_mode'], ['light', 'dark'], true)
|
||||||
|
? $data['menu_mode']
|
||||||
|
: $this->autoModeForBg($bg);
|
||||||
|
|
||||||
|
$session->put('menu_color', 'custom');
|
||||||
|
$session->put('menu_custom_bg', $bg);
|
||||||
|
$session->put('menu_custom_text', $tx);
|
||||||
|
$session->put('menu_custom_mode', $mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'style_color' => $session->get('style_color'),
|
||||||
|
'menu_color' => $session->get('menu_color'),
|
||||||
|
'menu_custom_bg' => $session->get('menu_custom_bg'),
|
||||||
|
'menu_custom_text' => $session->get('menu_custom_text'),
|
||||||
|
'menu_custom_mode' => $session->get('menu_custom_mode'),
|
||||||
|
], 'UI style updated successfully');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'UI style update error: ' . $e->getMessage());
|
||||||
|
return $this->respondError('Failed to update UI style', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeHex(string $hex): string
|
||||||
|
{
|
||||||
|
$h = trim($hex);
|
||||||
|
if ($h === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ($h[0] !== '#') {
|
||||||
|
$h = '#' . $h;
|
||||||
|
}
|
||||||
|
if (preg_match('/^#([0-9a-fA-F]{3})$/', $h, $m)) {
|
||||||
|
$r = $m[1][0];
|
||||||
|
$g = $m[1][1];
|
||||||
|
$b = $m[1][2];
|
||||||
|
return '#' . $r . $r . $g . $g . $b . $b;
|
||||||
|
}
|
||||||
|
if (preg_match('/^#([0-9a-fA-F]{6})$/', $h)) {
|
||||||
|
return strtoupper($h);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function autoModeForBg(string $hexBg): string
|
||||||
|
{
|
||||||
|
$hex = ltrim($hexBg, '#');
|
||||||
|
if (strlen($hex) !== 6) {
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
$r = hexdec(substr($hex, 0, 2));
|
||||||
|
$g = hexdec(substr($hex, 2, 2));
|
||||||
|
$b = hexdec(substr($hex, 4, 2));
|
||||||
|
|
||||||
|
$lum = (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) / 255;
|
||||||
|
return $lum < 0.5 ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user