add controllers, servoices
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use CodeIgniter\HTTP\CURLRequest;
|
||||
use Config\Api as ApiConfig;
|
||||
|
||||
class ApiClient
|
||||
{
|
||||
protected CURLRequest $http;
|
||||
protected ApiConfig $config;
|
||||
|
||||
public function __construct(CURLRequest $http, ApiConfig $config)
|
||||
{
|
||||
$this->http = $http;
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function get(string $uri, array $query = [], array $headers = []): array
|
||||
{
|
||||
return $this->request('get', $uri, [
|
||||
'headers' => $this->mergeHeaders($headers),
|
||||
'query' => $query,
|
||||
]);
|
||||
}
|
||||
|
||||
public function post(string $uri, array $data = [], array $headers = []): array
|
||||
{
|
||||
return $this->request('post', $uri, [
|
||||
'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
|
||||
'json' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function put(string $uri, array $data = [], array $headers = []): array
|
||||
{
|
||||
return $this->request('put', $uri, [
|
||||
'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
|
||||
'json' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(string $uri, array $data = [], array $headers = []): array
|
||||
{
|
||||
return $this->request('delete', $uri, [
|
||||
'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
|
||||
'json' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level access for uncommon HTTP methods or options
|
||||
*/
|
||||
public function request(string $method, string $uri, array $options = []): array
|
||||
{
|
||||
$url = $this->fullUrl($uri);
|
||||
$options['timeout'] = $options['timeout'] ?? $this->config->timeout;
|
||||
|
||||
$response = $this->http->request(strtoupper($method), $url, $options);
|
||||
|
||||
$status = $response->getStatusCode();
|
||||
$body = (string) $response->getBody();
|
||||
|
||||
$decoded = null;
|
||||
if ($this->isJson($body)) {
|
||||
$decoded = json_decode($body, true);
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $status >= 200 && $status < 300,
|
||||
'status' => $status,
|
||||
'headers' => $response->getHeaderLine('Content-Type'),
|
||||
'data' => $decoded,
|
||||
'raw' => $decoded === null ? $body : null,
|
||||
];
|
||||
}
|
||||
|
||||
protected function fullUrl(string $uri): string
|
||||
{
|
||||
if (preg_match('#^https?://#i', $uri)) {
|
||||
return $uri;
|
||||
}
|
||||
|
||||
$base = rtrim($this->config->baseURL ?? '', '/');
|
||||
if ($base === '') {
|
||||
return '/' . ltrim($uri, '/');
|
||||
}
|
||||
return $base . '/' . ltrim($uri, '/');
|
||||
}
|
||||
|
||||
protected function mergeHeaders(array $override = [], array $extra = []): array
|
||||
{
|
||||
$base = $this->config->defaultHeaders ?? [];
|
||||
return array_filter(array_merge($base, $extra, $override), static function ($v) {
|
||||
return $v !== null && $v !== '';
|
||||
});
|
||||
}
|
||||
|
||||
protected function isJson(string $string): bool
|
||||
{
|
||||
json_decode($string);
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\EmailService;
|
||||
|
||||
class BroadcastEmailController extends BaseController
|
||||
{
|
||||
protected UserModel $userModel;
|
||||
protected EmailService $mailer;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userModel = new UserModel();
|
||||
$this->mailer = new EmailService(); // use your existing mailer unmodified
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
helper(['form']);
|
||||
|
||||
// Parents via your role-based function
|
||||
$parents = $this->userModel->getParents();
|
||||
$parents = array_values(array_filter($parents, static fn($p) => !empty($p['email'])));
|
||||
|
||||
// Sender list from MAIL_SENDERS directly (no change to EmailService)
|
||||
$fromOptions = $this->senderOptionsFromEnv();
|
||||
|
||||
return view('administrator/broadcast_email', [
|
||||
'parents' => $parents,
|
||||
'fromOptions' => $fromOptions, // [['key'=>'general','label'=>'Al Rahma Office <office@...>'], ...]
|
||||
]);
|
||||
}
|
||||
|
||||
public function send()
|
||||
{
|
||||
helper(['form']);
|
||||
|
||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||||
return redirect()->to(site_url('admin/broadcast-email'));
|
||||
}
|
||||
|
||||
$isTestOnly = $this->request->getPost('send_test_only') !== null;
|
||||
|
||||
$mode = (string) $this->request->getPost('mode'); // 'personalized' | 'standard'
|
||||
$subject = trim((string) $this->request->getPost('subject'));
|
||||
$fromKey = trim((string) $this->request->getPost('from_key') ?: 'general');
|
||||
$body = (string) $this->request->getPost('body_html');
|
||||
$body = $this->sanitizeEmailHtml($body);
|
||||
|
||||
|
||||
// Layout options
|
||||
$wrapLayout = (bool) $this->request->getPost('wrap_layout');
|
||||
$preheader = (string) ($this->request->getPost('preheader') ?? '');
|
||||
$ctaText = (string) ($this->request->getPost('cta_text') ?? '');
|
||||
$ctaUrl = (string) ($this->request->getPost('cta_url') ?? '');
|
||||
|
||||
$testEmail = trim((string) $this->request->getPost('test_email'));
|
||||
|
||||
if ($subject === '' || $body === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'Subject and Body are required.');
|
||||
}
|
||||
|
||||
$isPersonalized = ($mode === 'personalized');
|
||||
|
||||
// --- TEST ONLY ---
|
||||
if ($isTestOnly) {
|
||||
if ($testEmail === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'Provide a test email address.');
|
||||
}
|
||||
|
||||
$recipientName = 'Parent';
|
||||
$html = $this->composeEmailHtml(
|
||||
$wrapLayout,
|
||||
$subject,
|
||||
$body,
|
||||
$recipientName,
|
||||
$preheader,
|
||||
$ctaText,
|
||||
$ctaUrl,
|
||||
$isPersonalized
|
||||
);
|
||||
|
||||
$ok = $this->mailer->send($testEmail, '[TEST] ' . $subject, $html, $fromKey);
|
||||
return redirect()->back()->with(
|
||||
$ok ? 'message' : 'error',
|
||||
$ok ? "Test email sent to {$testEmail}." : "Test email failed (mailer->send() returned false). Check logs."
|
||||
);
|
||||
}
|
||||
|
||||
// --- BROADCAST ---
|
||||
$rawIds = (array) ($this->request->getPost('parent_ids') ?? []);
|
||||
$ids = [];
|
||||
foreach ($rawIds as $v) {
|
||||
if (is_string($v) && strpos($v, ',') !== false) {
|
||||
$ids = array_merge($ids, array_map('intval', explode(',', $v)));
|
||||
} else {
|
||||
$ids[] = (int) $v;
|
||||
}
|
||||
}
|
||||
$ids = array_values(array_unique(array_filter($ids)));
|
||||
|
||||
if (empty($ids)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Please select at least one parent.');
|
||||
}
|
||||
|
||||
$rows = model(\App\Models\UserModel::class)
|
||||
->select('users.id, users.email, CONCAT(users.firstname, " ", users.lastname) AS name')
|
||||
->whereIn('users.id', $ids)
|
||||
->where('users.email IS NOT NULL AND users.email != ""')
|
||||
->findAll();
|
||||
|
||||
if (empty($rows)) {
|
||||
return redirect()->back()->withInput()->with('error', 'No valid parent emails found.');
|
||||
}
|
||||
|
||||
$stats = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'mode' => $mode];
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$stats['attempted']++;
|
||||
$recipientName = $r['name'] ?: 'Parent';
|
||||
|
||||
$html = $this->composeEmailHtml(
|
||||
$wrapLayout,
|
||||
$subject,
|
||||
$body,
|
||||
$recipientName,
|
||||
$preheader,
|
||||
$ctaText,
|
||||
$ctaUrl,
|
||||
$isPersonalized
|
||||
);
|
||||
|
||||
$ok = $this->mailer->send($r['email'], $subject, $html, $fromKey);
|
||||
$ok ? $stats['sent']++ : $stats['failed']++;
|
||||
}
|
||||
|
||||
$msg = "Broadcast finished. Mode: {$stats['mode']}. Sent: {$stats['sent']}/{$stats['attempted']}. Failures: {$stats['failed']}.";
|
||||
return redirect()->to(site_url('admin/broadcast-email'))
|
||||
->with($stats['failed'] > 0 ? 'error' : 'message', $msg);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build HTML: optionally wrap $body inside your view('layout/email_layout', ...).
|
||||
*/
|
||||
private function composeEmailHtml(
|
||||
bool $wrap,
|
||||
string $subject,
|
||||
string $body,
|
||||
string $recipientName,
|
||||
string $preheader = '',
|
||||
string $ctaText = '',
|
||||
string $ctaUrl = '',
|
||||
bool $doPersonalize = true
|
||||
): string {
|
||||
$content = $doPersonalize ? str_replace('{{name}}', $recipientName, $body) : $body;
|
||||
|
||||
if (!$wrap) {
|
||||
return $content; // raw body
|
||||
}
|
||||
|
||||
// Render a CHILD view that defines the section your layout expects
|
||||
return view('emails/broadcast_wrapper', [
|
||||
'subject' => $subject,
|
||||
'content' => $content,
|
||||
// pass more if you later wire them in your layout
|
||||
'preheader' => $preheader,
|
||||
'cta_text' => $ctaText,
|
||||
'cta_url' => $ctaUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
private function sanitizeEmailHtml(string $html): string
|
||||
{
|
||||
// Remove <script> and <iframe> blocks (cheap and effective for admin inputs)
|
||||
$html = preg_replace('#<(script|iframe)[^>]*>.*?</\1>#is', '', $html);
|
||||
// Optionally strip on* event handlers (onclick, etc.)
|
||||
$html = preg_replace('/\son\w+="[^"]*"/i', '', $html);
|
||||
$html = preg_replace("/\son\w+='[^']*'/i", '', $html);
|
||||
return $html;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Read MAIL_SENDERS from .env so we don’t need changes in EmailService.
|
||||
* Returns [['key' => 'general', 'label' => 'Al Rahma Office <office@...>'], ...]
|
||||
*/
|
||||
private function senderOptionsFromEnv(): array
|
||||
{
|
||||
$json = env('MAIL_SENDERS', '{}');
|
||||
$arr = json_decode($json, true);
|
||||
if (!is_array($arr) || empty($arr)) {
|
||||
// Fallback to SMTP_USER as a single "general" option
|
||||
$smtpUser = getenv('SMTP_USER') ?: '';
|
||||
$name = 'Al Rahma Sunday School';
|
||||
return [['key' => 'general', 'label' => $name . ($smtpUser ? " <{$smtpUser}>" : '')]];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($arr as $key => $info) {
|
||||
$nm = $info['name'] ?? 'Sender';
|
||||
$em = $info['email'] ?? '';
|
||||
$out[] = ['key' => (string)$key, 'label' => trim($nm . ($em ? " <{$em}>" : ''))];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function uploadImage()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||||
return $this->response->setStatusCode(405)->setJSON(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
$file = $this->request->getFile('image');
|
||||
if (!$file || !$file->isValid()) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['success' => false, 'error' => 'No image uploaded']);
|
||||
}
|
||||
|
||||
$mime = strtolower((string) $file->getMimeType());
|
||||
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
|
||||
if (!isset($allowed[$mime])) {
|
||||
return $this->response->setStatusCode(415)->setJSON(['success' => false, 'error' => 'Unsupported image type']);
|
||||
}
|
||||
if ($file->getSize() > 5 * 1024 * 1024) {
|
||||
return $this->response->setStatusCode(413)->setJSON(['success' => false, 'error' => 'Image too large (max 5MB)']);
|
||||
}
|
||||
|
||||
$targetDir = rtrim(FCPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'email';
|
||||
if (!is_dir($targetDir)) {
|
||||
@mkdir($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
$ext = $allowed[$mime];
|
||||
$newName = uniqid('em_', true) . '.' . $ext;
|
||||
$file->move($targetDir, $newName, true);
|
||||
|
||||
helper('url');
|
||||
$url = base_url('uploads/email/' . $newName);
|
||||
|
||||
// 👈 send the rotated token back both in JSON and a response header
|
||||
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
|
||||
return $this->response
|
||||
->setHeader('X-CSRF-HASH', (string) $newHash)
|
||||
->setJSON([
|
||||
'success' => true,
|
||||
'url' => $url,
|
||||
'csrf_hash' => $newHash,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
class ClassPrepController extends PrintablesBaseController
|
||||
{
|
||||
// Compute per-student sticker counts for entire schoolYear (optionally semester).
|
||||
protected function computeStickerCountsAll(\CodeIgniter\Database\BaseConnection $db, string $schoolYear, ?string $semester = null): array
|
||||
{
|
||||
return $this->computeStickerCounts($schoolYear, null, $semester);
|
||||
}
|
||||
|
||||
// Compute per-student sticker counts for one class section.
|
||||
protected function computeStickerCountsForClass(\CodeIgniter\Database\BaseConnection $db, string $schoolYear, int $classSectionId, ?string $semester = null): array
|
||||
{
|
||||
return $this->computeStickerCounts($schoolYear, $classSectionId, $semester);
|
||||
}
|
||||
|
||||
protected function computeStickerCounts(
|
||||
string $schoolYear,
|
||||
?int $classSectionId,
|
||||
?string $semester
|
||||
): array {
|
||||
$specialMap = ['KG' => 1, '1-A' => 3, '1-B' => 4, '2-A' => 5, '2-B' => 5, '9' => 2];
|
||||
$isUpperBlock = static fn(string $g) => (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
|
||||
$isGrade5 = static fn(string $g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||
|
||||
$b = $this->db->table('student_class sc')
|
||||
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
|
||||
->join('classes c', 'c.id = cs.class_id', 'inner')
|
||||
->where('sc.school_year', $schoolYear);
|
||||
|
||||
if (!empty($classSectionId)) {
|
||||
$b->where('sc.class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$rows = $b->get()->getResultArray();
|
||||
|
||||
// Exclude Youth and KG (case-insensitive; also ignore variants like youth-*, KG-*)
|
||||
$rows = array_values(array_filter($rows, static function ($r) {
|
||||
$g = trim((string) $r['grade_label']);
|
||||
return $g !== ''
|
||||
&& !preg_match('/^youth(?:\b|-)/i', $g)
|
||||
&& !preg_match('/^kg(?:\b|-)/i', $g);
|
||||
}));
|
||||
|
||||
$students = [];
|
||||
$total = 0;
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$g = trim((string) $r['grade_label']);
|
||||
$isNew = ((int) $r['is_new']) === 1;
|
||||
|
||||
// Primary-only rules
|
||||
if (array_key_exists($g, $specialMap)) {
|
||||
$p = $specialMap[$g];
|
||||
} elseif ($isUpperBlock($g)) {
|
||||
$p = $isNew ? 4 : ($isGrade5($g) ? 3 : 2);
|
||||
} else {
|
||||
$p = 4;
|
||||
}
|
||||
|
||||
$total += $p;
|
||||
|
||||
$students[] = [
|
||||
'student_id' => (int) $r['student_id'],
|
||||
'firstname' => trim($r['firstname']),
|
||||
'lastname' => trim($r['lastname']),
|
||||
'grade_label' => $g,
|
||||
'primary_count' => $p,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'totals' => [
|
||||
'stickers' => $total,
|
||||
'students' => count($students),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute sticker counts per student (no file I/O).
|
||||
*
|
||||
* @param BaseConnection $db
|
||||
* @param string $schoolYear e.g. "2025-2026"
|
||||
* @param string|null $semester e.g. "Fall" (optional filter)
|
||||
* @return array{
|
||||
* students: array<int, array{
|
||||
* student_id:int,
|
||||
* firstname:string,
|
||||
* lastname:string,
|
||||
* grade_label:string,
|
||||
* primary_count:int,
|
||||
* secondary_count:int
|
||||
* }>,
|
||||
* totals: array{primary:int, secondary:int, students:int}
|
||||
* }
|
||||
*/
|
||||
private function getStickerCountsPerStudent(string $schoolYear, ?string $semester = null): array
|
||||
{
|
||||
// --- Sticker rules helpers ----------------------------------------------
|
||||
$specialMap = [
|
||||
'KG' => 1,
|
||||
'1-A' => 3,
|
||||
'1-B' => 4,
|
||||
'2-A' => 5,
|
||||
'2-B' => 5,
|
||||
'9' => 2,
|
||||
];
|
||||
$isUpperBlock = static function (string $g): bool {
|
||||
// 3-A, 3-B, 4-A, 4-B, 5-A, 5-B, 6, 7, 8 (also allow plain 3/4/5/6/7/8)
|
||||
return (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
|
||||
};
|
||||
$isGrade5 = static function (string $g): bool {
|
||||
// "5" OR "5-A"/"5-B"
|
||||
return (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||
};
|
||||
|
||||
// --- Pull roster: student_class -> classSection -> classes -> students ---
|
||||
$builder = $this->db->table('student_class sc')
|
||||
->select([
|
||||
'sc.student_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.is_new',
|
||||
'cs.class_section_name AS grade_label',
|
||||
])
|
||||
->join('students s', 's.id = sc.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||
->join('classes c', 'c.id = cs.class_id')
|
||||
->where('sc.school_year', $schoolYear);
|
||||
|
||||
|
||||
$rows = $builder->get()->getResultArray();
|
||||
|
||||
// Filter out Youth (any case; includes youth-*), and empty labels
|
||||
$rows = array_values(array_filter($rows, static function ($r) {
|
||||
$g = trim((string) $r['grade_label']);
|
||||
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
|
||||
}));
|
||||
|
||||
$out = [];
|
||||
$totalPrimary = 0;
|
||||
$totalSecondary = 0;
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$first = trim((string) $r['firstname']);
|
||||
$last = trim((string) $r['lastname']);
|
||||
$grade = trim((string) $r['grade_label']);
|
||||
$isNew = ((int) $r['is_new']) === 1;
|
||||
|
||||
$primary = 0;
|
||||
$secondary = 0;
|
||||
|
||||
// 1) Special fixed grades
|
||||
if (array_key_exists($grade, $specialMap)) {
|
||||
$primary = (int) $specialMap[$grade];
|
||||
$secondary = 0;
|
||||
}
|
||||
// 2) Upper block 3..8
|
||||
elseif ($isUpperBlock($grade)) {
|
||||
if ($isNew) {
|
||||
$primary = 4;
|
||||
$secondary = 0;
|
||||
} else {
|
||||
$primary = $isGrade5($grade) ? 3 : 2;
|
||||
$secondary = max(0, 4 - $primary);
|
||||
}
|
||||
}
|
||||
// 3) Fallback for other non-Youth labels (e.g., "1", "2", "10", "11")
|
||||
else {
|
||||
$primary = 4;
|
||||
$secondary = 0;
|
||||
}
|
||||
|
||||
$totalPrimary += $primary;
|
||||
$totalSecondary += $secondary;
|
||||
|
||||
$out[] = [
|
||||
'student_id' => (int) $r['student_id'],
|
||||
'firstname' => $first,
|
||||
'lastname' => $last,
|
||||
'grade_label' => $grade,
|
||||
'primary_count' => $primary,
|
||||
'secondary_count' => $secondary,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'students' => $out,
|
||||
'totals' => [
|
||||
'primary' => $totalPrimary,
|
||||
'secondary' => $totalSecondary,
|
||||
'students' => count($out),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getStickerCountsPerStudentForClass(
|
||||
string $schoolYear,
|
||||
int $classSectionId,
|
||||
?string $semester = null
|
||||
): array {
|
||||
// Reuse exact helpers/rules from getStickerCountsPerStudent()
|
||||
$specialMap = [
|
||||
'1-A' => 3,
|
||||
'1-B' => 4,
|
||||
'2-A' => 5,
|
||||
'2-B' => 5,
|
||||
'9' => 2,
|
||||
];
|
||||
$isUpperBlock = static fn($g) => (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
|
||||
$isGrade5 = static fn($g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||
|
||||
$b = $this->db->table('student_class sc')
|
||||
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
|
||||
->join('students s', 's.id = sc.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||
->join('classes c', 'c.id = cs.class_id')
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->where('sc.class_section_id', $classSectionId);
|
||||
|
||||
|
||||
$rows = $b->get()->getResultArray();
|
||||
|
||||
$rows = array_values(array_filter($rows, static function ($r) {
|
||||
$g = trim((string) $r['grade_label']);
|
||||
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
|
||||
}));
|
||||
|
||||
$out = [];
|
||||
$tp = 0;
|
||||
$ts = 0;
|
||||
foreach ($rows as $r) {
|
||||
$g = trim((string) $r['grade_label']);
|
||||
$isNew = ((int) $r['is_new']) === 1;
|
||||
$p = 0;
|
||||
$s = 0;
|
||||
|
||||
if (isset($specialMap[$g])) {
|
||||
$p = $specialMap[$g];
|
||||
} elseif ($isUpperBlock($g)) {
|
||||
if ($isNew) {
|
||||
$p = 4;
|
||||
} else {
|
||||
$p = $isGrade5($g) ? 3 : 2;
|
||||
$s = max(0, 4 - $p);
|
||||
}
|
||||
} else {
|
||||
$p = 4;
|
||||
}
|
||||
|
||||
$tp += $p;
|
||||
$ts += $s;
|
||||
|
||||
$out[] = [
|
||||
'student_id' => (int) $r['student_id'],
|
||||
'firstname' => trim($r['firstname']),
|
||||
'lastname' => trim($r['lastname']),
|
||||
'grade_label' => $g,
|
||||
'primary_count' => $p,
|
||||
'secondary_count' => $s,
|
||||
];
|
||||
}
|
||||
|
||||
return ['students' => $out, 'totals' => ['primary' => $tp, 'secondary' => $ts, 'students' => count($out)]];
|
||||
}
|
||||
|
||||
public function previewStickerCounts()
|
||||
{
|
||||
// Optional: allow class_id filter; if present, preview only that class
|
||||
$classId = (int) ($this->request->getGet('class_id') ?? 0);
|
||||
|
||||
if ($classId > 0) {
|
||||
// same join as getStickerCountsPerStudent but add class filter
|
||||
$result = $this->getStickerCountsPerStudentForClass($this->schoolYear, $classId, $this->semester);
|
||||
} else {
|
||||
// whole school year
|
||||
$result = $this->getStickerCountsPerStudent($this->schoolYear, $this->semester);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'status' => 'ok',
|
||||
'data' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
public function studentsByClass(int $classSectionId)
|
||||
{
|
||||
$rows = $this->studentModel
|
||||
->select('students.id, students.firstname, students.lastname, students.gender, cs.class_section_name AS registration_grade')
|
||||
->join('student_class sc', 'sc.student_id = students.id', 'inner')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->groupBy('students.id, students.firstname, students.lastname, students.gender, cs.class_section_name')
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->findAll(1000);
|
||||
|
||||
return $this->response->setJSON($rows);
|
||||
}
|
||||
}
|
||||
@@ -1,616 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\ClassPreparationLogModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\ClassPrepAdjustmentModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
class ClassPreparationController extends BaseController
|
||||
{
|
||||
protected $studentClassModel;
|
||||
protected $prepLogModel;
|
||||
protected $classSectionModel;
|
||||
protected $configModel;
|
||||
protected $userModel;
|
||||
protected $db;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $adjustmentModel;
|
||||
/** cache for roster presence per term */
|
||||
private array $rosterPresenceCache = [];
|
||||
// Inside ClassPreparationController (class scope, not inside a method)
|
||||
private array $allowedPrepCategories = [
|
||||
'Grade Box',
|
||||
'Large Table',
|
||||
'Regular Chair',
|
||||
'Small Chair',
|
||||
'Small Table',
|
||||
'Teacher Chair',
|
||||
'Trash Bin',
|
||||
'White Board',
|
||||
];
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->studentClassModel = new StudentClassModel();
|
||||
$this->prepLogModel = new ClassPreparationLogModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->adjustmentModel = new ClassPrepAdjustmentModel();
|
||||
|
||||
$this->userModel = new UserModel();
|
||||
$this->db = \Config\Database::connect();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
$semParam = $this->request->getGet('semester');
|
||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
||||
$allowed = $this->allowedPrepCategories;
|
||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
||||
|
||||
// 1) Get student count per class-section (distinct students, correct semester)
|
||||
$scQ = $this->db->table('student_class sc')
|
||||
->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false)
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$scQ->where('sc.semester', $semester);
|
||||
}
|
||||
$classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray();
|
||||
|
||||
// 2) Inventory availability — prefer good_qty when present, else condition='good' quantity.
|
||||
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
|
||||
|
||||
// Seed totals with allowed categories for stable ordering
|
||||
$requiredTotals = array_fill_keys($allowed, 0);
|
||||
$prepResults = [];
|
||||
|
||||
// 3) Build prep per section (once!)
|
||||
foreach ($classSections as $section) {
|
||||
$classSectionId = $section['class_section_id'];
|
||||
$studentCount = (int)$section['student_count'];
|
||||
$classLevel = $this->getClassLevelBySection($classSectionId);
|
||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
||||
|
||||
// Calculate required items (whitelist inside)
|
||||
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
||||
|
||||
// --- Apply adjustments (only Small/Large Table), clamp >= 0 ---
|
||||
$rawAdjustments = $this->adjustmentModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('adjustable', 1)
|
||||
->findAll();
|
||||
|
||||
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
||||
|
||||
foreach ($rawAdjustments as $a) {
|
||||
$item = $a['item_name'];
|
||||
$delta = (int)$a['adjustment'];
|
||||
|
||||
if (array_key_exists($item, $adjMap)) {
|
||||
$adjMap[$item] = $delta;
|
||||
}
|
||||
|
||||
if (isset($baseItems[$item])) {
|
||||
$baseItems[$item] = max(0, (int)$baseItems[$item] + $delta);
|
||||
} elseif (in_array($item, $allowed, true)) {
|
||||
$baseItems[$item] = max(0, $delta);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Accumulate global totals (allowed only)
|
||||
foreach ($allowed as $cat) {
|
||||
$requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0);
|
||||
}
|
||||
|
||||
// 5) Compare with last snapshot; do not save here — only on print or explicit API
|
||||
$oldSnap = $this->prepLogModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first();
|
||||
|
||||
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
|
||||
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
|
||||
|
||||
// 6) Push exactly ONCE
|
||||
$prepResults[] = [
|
||||
'class_section' => $className,
|
||||
'class_section_id' => $classSectionId,
|
||||
'student_count' => $studentCount,
|
||||
'class_level' => $classLevel,
|
||||
'prep_items' => $baseItems,
|
||||
'needs_print' => $hasChanged,
|
||||
'last_printed_at' => $oldSnap['created_at'] ?? null,
|
||||
'adjustments' => $adjMap,
|
||||
];
|
||||
}
|
||||
|
||||
// 7) Shortages (compare totals vs inventory)
|
||||
$shortages = [];
|
||||
foreach ($requiredTotals as $item => $reqQty) {
|
||||
$have = (int)($inventoryMap[$item] ?? 0);
|
||||
if ($have < (int)$reqQty) {
|
||||
$shortages[$item] = (int)$reqQty - $have;
|
||||
}
|
||||
}
|
||||
|
||||
return view('class_prep/list', [
|
||||
'prepResults' => $prepResults,
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'shortages' => $shortages,
|
||||
'totalNeeded' => $requiredTotals,
|
||||
'available' => $inventoryMap,
|
||||
// For temporary debugging, you can pass these too:
|
||||
// 'joinGood' => $joinGood, 'nameGood' => $nameGood, 'joinCond' => $joinCond, 'nameCond' => $nameCond,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getTeacherCountForSection(string $classSectionId, ?string $schoolYear = null): int
|
||||
{
|
||||
$schoolYear = $schoolYear ?? $this->schoolYear;
|
||||
|
||||
// positions: 'main' and 'ta' (per your new schema)
|
||||
$row = $this->db->table('teacher_class')
|
||||
->select('COUNT(*) AS cnt')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('position', ['main', 'ta'])
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getRowArray();
|
||||
|
||||
return (int)($row['cnt'] ?? 0);
|
||||
}
|
||||
|
||||
private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array
|
||||
{
|
||||
// 1) Stable keys: your whitelist, all zero to start
|
||||
$allowed = $this->allowedPrepCategories;
|
||||
$items = array_fill_keys($allowed, 0);
|
||||
|
||||
// 2) Fetch only the classroom categories you care about (optionally with grade bounds)
|
||||
$categories = $this->db->table('inventory_categories')
|
||||
->select('name, grade_min, grade_max')
|
||||
->where('type', 'classroom')
|
||||
->whereIn('name', $allowed)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// 3) Rules
|
||||
$isLowerGrades = in_array($classLevel, [1, 2], true);
|
||||
$teacherCount = $this->getTeacherCountForSection($classSectionId, $this->schoolYear);
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$name = $cat['name']; // e.g., 'Small Table'
|
||||
$gradeMin = $cat['grade_min']; // may be null
|
||||
$gradeMax = $cat['grade_max']; // may be null
|
||||
|
||||
// Respect optional bounds
|
||||
if ($gradeMin !== null && $classLevel < (int)$gradeMin) {
|
||||
$items[$name] = 0;
|
||||
continue;
|
||||
}
|
||||
if ($gradeMax !== null && $classLevel > (int)$gradeMax) {
|
||||
$items[$name] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
$qty = 0;
|
||||
switch (strtolower($name)) {
|
||||
case 'small table':
|
||||
$qty = $isLowerGrades ? (int)ceil($students / 3) : 0;
|
||||
break;
|
||||
case 'large table':
|
||||
$qty = $isLowerGrades ? 0 : (int)ceil($students / 4);
|
||||
break;
|
||||
case 'small chair':
|
||||
$qty = $isLowerGrades ? $students : 0;
|
||||
break;
|
||||
case 'regular chair':
|
||||
$qty = $isLowerGrades ? 0 : $students;
|
||||
break;
|
||||
case 'teacher chair':
|
||||
$qty = (int)$teacherCount;
|
||||
break;
|
||||
case 'trash bin':
|
||||
case 'white board':
|
||||
case 'grade box':
|
||||
$qty = 1;
|
||||
break;
|
||||
default:
|
||||
$qty = 0;
|
||||
}
|
||||
|
||||
// Only set for allowed keys (guards against unexpected DB rows)
|
||||
if (array_key_exists($name, $items)) {
|
||||
$items[$name] = $qty;
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
|
||||
public function saveAdjustment()
|
||||
{
|
||||
$data = $this->request->getPost();
|
||||
$sectionId = $data['class_section_id'];
|
||||
$schoolYear = $data['school_year'];
|
||||
$adjustments = $data['adjustments'] ?? [];
|
||||
|
||||
foreach ($adjustments as $itemName => $adjustment) {
|
||||
$row = $this->adjustmentModel
|
||||
->where('class_section_id', $sectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('item_name', $itemName)
|
||||
->first();
|
||||
|
||||
if ($row) {
|
||||
// Update
|
||||
$this->adjustmentModel->update($row['id'], ['adjustment' => (int)$adjustment, 'adjustable' => 1]);
|
||||
} else {
|
||||
// Insert
|
||||
$this->adjustmentModel->insert([
|
||||
'class_section_id' => $sectionId,
|
||||
'item_name' => $itemName,
|
||||
'adjustment' => (int)$adjustment,
|
||||
'school_year' => $schoolYear,
|
||||
'adjustable' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('class-prep?school_year=' . urlencode($schoolYear)))
|
||||
->with('success', 'Adjustments saved successfully.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private function hasPrepChanged(array $new, array $old): bool
|
||||
{
|
||||
ksort($new);
|
||||
ksort($old);
|
||||
|
||||
return json_encode($new) !== json_encode($old);
|
||||
}
|
||||
|
||||
public function print($classSectionId, $schoolYear)
|
||||
{
|
||||
$semParam = $this->request->getGet('semester');
|
||||
$semester = (is_string($semParam) && $semParam !== '') ? (string) $semParam : (string) $this->semester;
|
||||
$limitToSemester = $this->hasRosterForSemester((string) $schoolYear, $semester);
|
||||
|
||||
// Friendly label (if you have this helper)
|
||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||
|
||||
// Distinct student count for this term
|
||||
$studentQ = $this->db->table('student_class sc')
|
||||
->select('COUNT(DISTINCT sc.student_id) AS cnt', false)
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->where('sc.school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$studentQ->where('sc.semester', $semester);
|
||||
}
|
||||
$studentRow = $studentQ->get()->getRowArray();
|
||||
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
||||
|
||||
// Live calc + adjustments
|
||||
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
|
||||
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
|
||||
|
||||
$rawAdjustments = $this->adjustmentModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('adjustable', 1)
|
||||
->findAll();
|
||||
foreach ($rawAdjustments as $a) {
|
||||
$item = $a['item_name'];
|
||||
$delta = (int)$a['adjustment'];
|
||||
if (isset($items[$item])) $items[$item] = max(0, (int)$items[$item] + $delta);
|
||||
}
|
||||
|
||||
// Record a snapshot at print time as the new baseline
|
||||
$this->prepLogModel->insert([
|
||||
'class_section_id' => (string)$classSectionId,
|
||||
'class_section' => $className,
|
||||
'school_year' => $schoolYear,
|
||||
'prep_data' => json_encode($items),
|
||||
'created_at' => utc_now(),
|
||||
]);
|
||||
|
||||
// Return PRINT view (HTML) that auto-opens the print dialog
|
||||
return view('class_prep/print', [
|
||||
'classSectionId' => $classSectionId,
|
||||
'classSection' => $className,
|
||||
'schoolYear' => $schoolYear,
|
||||
'prepItems' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Return class prep data and change flags for all sections in a year.
|
||||
* GET: school_year
|
||||
* Response: { ok, schoolYear, results:[{ class_section_id, class_section, student_count, class_level, prep_items, needs_print, last_printed_at }], totals, shortages }
|
||||
*/
|
||||
public function apiList()
|
||||
{
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
$semParam = $this->request->getGet('semester');
|
||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
||||
$allowed = $this->allowedPrepCategories;
|
||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
||||
|
||||
// Student counts
|
||||
$scQ = $this->db->table('student_class sc')
|
||||
->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false)
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$scQ->where('sc.semester', $semester);
|
||||
}
|
||||
$classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray();
|
||||
|
||||
// Build inventory availability maps
|
||||
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
|
||||
|
||||
$requiredTotals = array_fill_keys($allowed, 0);
|
||||
$results = [];
|
||||
|
||||
foreach ($classSections as $row) {
|
||||
$classSectionId = (string)$row['class_section_id'];
|
||||
$studentCount = (int)$row['student_count'];
|
||||
$classLevel = $this->getClassLevelBySection($classSectionId);
|
||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
||||
|
||||
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
||||
$rawAdjustments = $this->adjustmentModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('adjustable', 1)
|
||||
->findAll();
|
||||
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
||||
foreach ($rawAdjustments as $a) {
|
||||
$item = $a['item_name'];
|
||||
$delta = (int)$a['adjustment'];
|
||||
if (array_key_exists($item, $adjMap)) $adjMap[$item] = $delta;
|
||||
if (isset($baseItems[$item])) $baseItems[$item] = max(0, (int)$baseItems[$item] + $delta);
|
||||
}
|
||||
|
||||
foreach ($allowed as $cat) {
|
||||
$requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0);
|
||||
}
|
||||
|
||||
$oldSnap = $this->prepLogModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first();
|
||||
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
|
||||
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
|
||||
|
||||
$results[] = [
|
||||
'class_section' => $className,
|
||||
'class_section_id' => $classSectionId,
|
||||
'student_count' => $studentCount,
|
||||
'class_level' => $classLevel,
|
||||
'prep_items' => $baseItems,
|
||||
'needs_print' => $hasChanged,
|
||||
'last_printed_at' => $oldSnap['created_at'] ?? null,
|
||||
'adjustments' => $adjMap,
|
||||
];
|
||||
}
|
||||
|
||||
// Shortages
|
||||
$shortages = [];
|
||||
foreach ($requiredTotals as $item => $reqQty) {
|
||||
$have = (int)($inventoryMap[$item] ?? 0);
|
||||
if ($have < (int)$reqQty) $shortages[$item] = (int)$reqQty - $have;
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'schoolYear' => $schoolYear,
|
||||
'results' => $results,
|
||||
'totals' => $requiredTotals,
|
||||
'shortages' => $shortages,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Mark selected classes as printed (save baseline snapshot).
|
||||
* POST: school_year, class_section_ids[]
|
||||
*/
|
||||
public function apiMarkPrinted()
|
||||
{
|
||||
$schoolYear = (string)($this->request->getPost('school_year') ?? $this->schoolYear);
|
||||
$semParam = $this->request->getPost('semester');
|
||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
||||
$ids = $this->request->getPost('class_section_ids') ?? [];
|
||||
if (!is_array($ids)) $ids = $ids ? [$ids] : [];
|
||||
$ids = array_values(array_unique(array_filter(array_map('strval', $ids))));
|
||||
|
||||
$now = utc_now();
|
||||
$count = 0;
|
||||
foreach ($ids as $classSectionId) {
|
||||
$studentQ = $this->db->table('student_class sc')
|
||||
->select('COUNT(DISTINCT sc.student_id) AS cnt', false)
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->where('sc.school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$studentQ->where('sc.semester', $semester);
|
||||
}
|
||||
$studentRow = $studentQ->get()->getRowArray();
|
||||
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
||||
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
|
||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||
|
||||
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
|
||||
|
||||
$rawAdjustments = $this->adjustmentModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('adjustable', 1)
|
||||
->findAll();
|
||||
foreach ($rawAdjustments as $a) {
|
||||
$item = $a['item_name'];
|
||||
$delta = (int)$a['adjustment'];
|
||||
if (isset($items[$item])) $items[$item] = max(0, (int)$items[$item] + $delta);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->prepLogModel->insert([
|
||||
'class_section_id' => (string)$classSectionId,
|
||||
'class_section' => $className,
|
||||
'school_year' => $schoolYear,
|
||||
'prep_data' => json_encode($items),
|
||||
'created_at' => $now,
|
||||
]);
|
||||
$count++;
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and continue
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'updated' => $count,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
private function getClassLevelBySection(string $classSectionId): int
|
||||
{
|
||||
// Prefer the human-readable section name for grade parsing.
|
||||
$sectionName = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
||||
$label = $sectionName !== null && $sectionName !== '' ? (string) $sectionName : $classSectionId;
|
||||
|
||||
// If it's clearly Kindergarten (KG/K)
|
||||
if (preg_match('/^(kg|k)(\b|[^a-z0-9])/i', $label)) {
|
||||
return 1; // treat Kindergarten as lower grade
|
||||
}
|
||||
|
||||
// Remove non-digits, then infer by leading digit
|
||||
$numValue = (int) preg_replace('/\D/', '', $label);
|
||||
|
||||
if ($numValue > 0 && $numValue < 30) {
|
||||
$firstDigit = (int) substr((string) $numValue, 0, 1);
|
||||
return $firstDigit === 1 ? 1 : ($firstDigit === 2 ? 2 : 3);
|
||||
}
|
||||
|
||||
// Default to upper grades (3+)
|
||||
return 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if student_class has any rows for the given school year.
|
||||
* The semester argument is ignored because assignments are tracked per year.
|
||||
*/
|
||||
private function hasRosterForTerm(string $schoolYear, string $semester): bool
|
||||
{
|
||||
$year = trim((string)$schoolYear);
|
||||
if ($year === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists($year, $this->rosterPresenceCache)) {
|
||||
return $this->rosterPresenceCache[$year];
|
||||
}
|
||||
|
||||
$cnt = $this->db->table('student_class')
|
||||
->where('school_year', $year)
|
||||
->countAllResults();
|
||||
|
||||
$this->rosterPresenceCache[$year] = $cnt > 0;
|
||||
return $this->rosterPresenceCache[$year];
|
||||
}
|
||||
|
||||
private function hasRosterForSemester(string $schoolYear, string $semester): bool
|
||||
{
|
||||
$year = trim((string)$schoolYear);
|
||||
$sem = trim((string)$semester);
|
||||
if ($year === '' || $sem === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = sprintf('sem:%s:%s', $year, $sem);
|
||||
if (array_key_exists($key, $this->rosterPresenceCache)) {
|
||||
return $this->rosterPresenceCache[$key];
|
||||
}
|
||||
|
||||
$cnt = $this->db->table('student_class')
|
||||
->where('school_year', $year)
|
||||
->where('semester', $sem)
|
||||
->countAllResults();
|
||||
|
||||
$this->rosterPresenceCache[$key] = $cnt > 0;
|
||||
return $this->rosterPresenceCache[$key];
|
||||
}
|
||||
|
||||
private function buildInventoryAvailability(string $schoolYear, string $semester, bool $limitToSemester, array $allowed): array
|
||||
{
|
||||
$inventoryMap = array_fill_keys($allowed, 0);
|
||||
|
||||
$joinRows = $this->db->table('inventory_items ii')
|
||||
->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available')
|
||||
->join('inventory_categories ic', 'ic.id = ii.category_id', 'left')
|
||||
->where('ii.type', 'classroom')
|
||||
->where('ii.school_year', $schoolYear)
|
||||
->whereIn('ic.name', $allowed);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$joinRows->where('ii.semester', $semester);
|
||||
}
|
||||
$joinRows = $joinRows->groupBy('ic.name')->get()->getResultArray();
|
||||
|
||||
foreach ($joinRows as $r) {
|
||||
$name = (string)($r['item_name'] ?? '');
|
||||
if ($name !== '' && isset($inventoryMap[$name])) {
|
||||
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
$nameRows = $this->db->table('inventory_items')
|
||||
->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available')
|
||||
->where('type', 'classroom')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('name', $allowed);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$nameRows->where('semester', $semester);
|
||||
}
|
||||
$nameRows = $nameRows->groupBy('name')->get()->getResultArray();
|
||||
|
||||
foreach ($nameRows as $r) {
|
||||
$name = (string)($r['item_name'] ?? '');
|
||||
if ($name !== '' && isset($inventoryMap[$name])) {
|
||||
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
return $inventoryMap;
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
<?php
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\FamilyModel;
|
||||
use App\Models\FamilyStudentModel;
|
||||
use App\Models\FamilyGuardianModel;
|
||||
use App\Models\FamilyCommPrefModel; // optional
|
||||
use App\Models\EmailTemplateModel;
|
||||
use App\Models\CommunicationLogModel;
|
||||
|
||||
class CommunicationController extends BaseController
|
||||
{
|
||||
protected StudentModel $studentModel;
|
||||
protected FamilyModel $familyModel;
|
||||
protected FamilyStudentModel $fsModel;
|
||||
protected FamilyGuardianModel $fgModel;
|
||||
protected ?FamilyCommPrefModel $fcpModel = null;
|
||||
protected EmailTemplateModel $templateModel;
|
||||
protected CommunicationLogModel $logModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->familyModel = new FamilyModel();
|
||||
$this->fsModel = new FamilyStudentModel();
|
||||
$this->fgModel = new FamilyGuardianModel();
|
||||
if (class_exists(FamilyCommPrefModel::class)) {
|
||||
$this->fcpModel = new FamilyCommPrefModel();
|
||||
}
|
||||
$this->templateModel = new EmailTemplateModel();
|
||||
$this->logModel = new CommunicationLogModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$students = $this->studentModel->orderBy('lastname, firstname', 'asc')->findAll();
|
||||
$templates = $this->templateModel->getActiveTemplates();
|
||||
return view('communications/index', compact('students','templates'));
|
||||
}
|
||||
|
||||
// AJAX: families for a student
|
||||
public function families(int $studentId)
|
||||
{
|
||||
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
|
||||
$families = $this->fsModel->getFamiliesForStudent($studentId);
|
||||
return $this->response->setJSON(['data' => $families]);
|
||||
}
|
||||
|
||||
// AJAX: guardians for a family
|
||||
public function guardians(int $familyId)
|
||||
{
|
||||
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->query("SELECT u.id as user_id, u.firstname, u.lastname, u.email, fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms\n FROM family_guardians fg\n JOIN users u ON u.id = fg.user_id\n WHERE fg.family_id = ?", [$familyId])->getResultArray();
|
||||
return $this->response->setJSON(['data' => $rows]);
|
||||
}
|
||||
|
||||
public function preview()
|
||||
{
|
||||
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
|
||||
|
||||
$templateKey = (string) $this->request->getPost('template_key');
|
||||
$studentId = (int) $this->request->getPost('student_id');
|
||||
$familyId = (int) $this->request->getPost('family_id');
|
||||
$varsPost = $this->request->getPost('vars');
|
||||
$vars = is_array($varsPost) ? $varsPost : json_decode((string)$varsPost, true) ?? [];
|
||||
|
||||
$template = $this->templateModel->findByKey($templateKey);
|
||||
if (!$template) return $this->response->setStatusCode(404)->setJSON(['error' => 'Template not found']);
|
||||
|
||||
$student = $this->studentModel->getStudentBasic($studentId);
|
||||
if (!$student) return $this->response->setStatusCode(404)->setJSON(['error' => 'Student not found']);
|
||||
|
||||
// Build salutation from guardians in the chosen family
|
||||
$db = \Config\Database::connect();
|
||||
$gs = $db->query("SELECT u.firstname, u.lastname\n FROM family_guardians fg\n JOIN users u ON u.id = fg.user_id\n WHERE fg.family_id = ? AND fg.receive_emails = 1\n ORDER BY fg.is_primary DESC, u.lastname, u.firstname", [$familyId])->getResultArray();
|
||||
$sal = 'Parent/Guardian';
|
||||
if ($gs) {
|
||||
$names = array_map(fn($r)=> trim(($r['firstname']??'').' '.($r['lastname']??'')), $gs);
|
||||
$sal = implode(' & ', $names);
|
||||
}
|
||||
|
||||
$autoVars = [
|
||||
'student_fullname' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
|
||||
'student_grade' => $student['grade'] ?? '',
|
||||
'parent_salutation'=> $sal,
|
||||
'date' => local_date(utc_now(), 'Y-m-d'),
|
||||
'school_name' => 'Al Rahma Sunday School',
|
||||
'teacher_name' => (string)(session('display_name') ?? 'Teacher'),
|
||||
];
|
||||
$all = array_merge($autoVars, $vars);
|
||||
|
||||
$subject = $this->renderTwig($template['subject'], $all);
|
||||
$body = nl2br($this->renderTwig($template['body'], $all));
|
||||
|
||||
return $this->response->setJSON(['subject' => $subject, 'html' => $body]);
|
||||
}
|
||||
|
||||
public function send()
|
||||
{
|
||||
$rules = [
|
||||
'student_id' => 'required|integer',
|
||||
'family_id' => 'required|integer',
|
||||
'template_key' => 'required|string',
|
||||
'subject' => 'required|string',
|
||||
'body' => 'required|string',
|
||||
'recipients' => 'required|string' // JSON array
|
||||
];
|
||||
if (!$this->validate($rules)) {
|
||||
return redirect()->back()->with('error', 'Invalid form submission.');
|
||||
}
|
||||
|
||||
$studentId = (int) $this->request->getPost('student_id');
|
||||
$familyId = (int) $this->request->getPost('family_id');
|
||||
$templateKey = (string) $this->request->getPost('template_key');
|
||||
$subject = (string) $this->request->getPost('subject');
|
||||
$bodyHtml = (string) $this->request->getPost('body');
|
||||
$recipients = json_decode((string)$this->request->getPost('recipients'), true) ?? [];
|
||||
$cc = json_decode((string)($this->request->getPost('cc') ?? '[]'), true) ?? [];
|
||||
$bcc = json_decode((string)($this->request->getPost('bcc') ?? '[]'), true) ?? [];
|
||||
|
||||
$student = $this->studentModel->getStudentBasic($studentId);
|
||||
if (!$student) return redirect()->back()->with('error', 'Student not found');
|
||||
|
||||
// Send email (PHPMailer via service('mailer'))
|
||||
$sendOk = false; $error = null;
|
||||
try {
|
||||
$mailer = service('mailer');
|
||||
foreach (array_unique($recipients) as $to) { if ($to) $mailer->addAddress($to); }
|
||||
foreach (array_unique($cc) as $c) { if ($c) $mailer->addCC($c); }
|
||||
foreach (array_unique($bcc) as $b){ if ($b) $mailer->addBCC($b); }
|
||||
$mailer->Subject = $subject;
|
||||
$mailer->isHTML(true);
|
||||
$mailer->Body = $bodyHtml;
|
||||
$mailer->AltBody = strip_tags($bodyHtml);
|
||||
$sendOk = $mailer->send();
|
||||
} catch (\Throwable $t) {
|
||||
$error = $t->getMessage();
|
||||
}
|
||||
|
||||
// Log
|
||||
$this->logModel->insert([
|
||||
'student_id' => $studentId,
|
||||
'family_id' => $familyId,
|
||||
'student_name' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
|
||||
'template_key' => $templateKey,
|
||||
'subject' => $subject,
|
||||
'body' => $bodyHtml,
|
||||
'recipients' => json_encode(array_values($recipients)),
|
||||
'cc' => json_encode(array_values($cc)),
|
||||
'bcc' => json_encode(array_values($bcc)),
|
||||
'status' => $sendOk ? 'sent' : 'failed',
|
||||
'error_message'=> $error,
|
||||
'sent_by' => (int)(session('user_id') ?? 0),
|
||||
'metadata' => null,
|
||||
]);
|
||||
|
||||
return $sendOk
|
||||
? redirect()->to('/communications')->with('success', 'Email sent successfully.')
|
||||
: redirect()->back()->with('error', 'Failed to send email: '.($error ?? 'Unknown error'));
|
||||
}
|
||||
|
||||
private function renderTwig(string $template, array $vars): string
|
||||
{
|
||||
return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}/', function($m) use ($vars) {
|
||||
$key = $m[1];
|
||||
return htmlspecialchars((string)($vars[$key] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
}, $template);
|
||||
}
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\CompetitionClassWinnerModel;
|
||||
use App\Models\CompetitionModel;
|
||||
use App\Models\CompetitionScoreModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\TeacherClassModel;
|
||||
|
||||
class CompetitionScoresController extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected TeacherClassModel $teacherClassModel;
|
||||
protected ConfigurationModel $configModel;
|
||||
protected ClassSectionModel $classSectionModel;
|
||||
protected CompetitionClassWinnerModel $classWinnerModel;
|
||||
protected string $classStudentTable;
|
||||
protected bool $hasQuestionCount = false;
|
||||
protected bool $hasClassWinnerTable = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->classWinnerModel = new CompetitionClassWinnerModel();
|
||||
|
||||
if ($this->db->tableExists('class_student')) {
|
||||
$this->classStudentTable = 'class_student';
|
||||
} elseif ($this->db->tableExists('student_class')) {
|
||||
$this->classStudentTable = 'student_class';
|
||||
} else {
|
||||
$this->classStudentTable = '';
|
||||
}
|
||||
|
||||
$this->hasClassWinnerTable = $this->db->tableExists('competition_class_winners');
|
||||
$this->hasQuestionCount = $this->hasClassWinnerTable
|
||||
&& $this->db->fieldExists('question_count', 'competition_class_winners');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
[$assignments, $schoolYear, $semester] = $this->getTeacherContext();
|
||||
$activeClassId = $this->resolveActiveClassId($assignments);
|
||||
$activeClassName = $this->getActiveClassName($assignments, $activeClassId);
|
||||
|
||||
if (empty($assignments) || $activeClassId <= 0) {
|
||||
return view('teacher/competition_scores/index', [
|
||||
'competitions' => [],
|
||||
'activeClassId' => $activeClassId,
|
||||
'activeClassName' => $activeClassName,
|
||||
'questionCounts' => [],
|
||||
'scoreCounts' => [],
|
||||
'studentTotal' => 0,
|
||||
'sectionMap' => $this->getClassSectionMap(),
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'hasClasses' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$competitions = $this->getCompetitionsForClass($activeClassId, $schoolYear, $semester);
|
||||
$competitionIds = array_values(array_filter(array_map(static function ($row) {
|
||||
return (int) ($row['id'] ?? 0);
|
||||
}, $competitions)));
|
||||
|
||||
$questionCounts = $this->getQuestionCounts($competitionIds, $activeClassId);
|
||||
$scoreCounts = $this->getScoreCounts($competitionIds, $activeClassId);
|
||||
$studentTotal = $this->getClassStudentCount($activeClassId, $schoolYear);
|
||||
|
||||
return view('teacher/competition_scores/index', [
|
||||
'competitions' => $competitions,
|
||||
'activeClassId' => $activeClassId,
|
||||
'activeClassName' => $activeClassName,
|
||||
'questionCounts' => $questionCounts,
|
||||
'scoreCounts' => $scoreCounts,
|
||||
'studentTotal' => $studentTotal,
|
||||
'sectionMap' => $this->getClassSectionMap(),
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'hasClasses' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
[$assignments, $schoolYear, $semester] = $this->getTeacherContext();
|
||||
$allowedClassIds = $this->getAllowedClassIds($assignments);
|
||||
if (empty($allowedClassIds)) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'No class assignments found for your account.');
|
||||
}
|
||||
|
||||
$competitionModel = new CompetitionModel();
|
||||
$scoreModel = new CompetitionScoreModel();
|
||||
$competition = $competitionModel->find($id);
|
||||
if (!$competition) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'Competition not found.');
|
||||
}
|
||||
$isLocked = !empty($competition['is_locked']);
|
||||
|
||||
$activeClassId = $this->resolveActiveClassId($assignments);
|
||||
$lockedClassId = (int) ($competition['class_section_id'] ?? 0);
|
||||
$classSectionId = $lockedClassId > 0 ? $lockedClassId : $activeClassId;
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'Select a class section before entering scores.');
|
||||
}
|
||||
if (!in_array($classSectionId, $allowedClassIds, true)) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'You are not assigned to that class.');
|
||||
}
|
||||
|
||||
$students = $this->getStudentsForCompetition($competition, $classSectionId);
|
||||
$existingScores = $scoreModel
|
||||
->where('competition_id', $id)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
|
||||
$scoreMap = [];
|
||||
foreach ($existingScores as $row) {
|
||||
$scoreMap[$row['student_id']] = $row['score'];
|
||||
}
|
||||
|
||||
$questionCount = null;
|
||||
if ($this->hasQuestionCount && $this->hasClassWinnerTable) {
|
||||
$row = $this->classWinnerModel
|
||||
->select('question_count')
|
||||
->where('competition_id', $id)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->first();
|
||||
if ($row && $row['question_count'] !== null) {
|
||||
$questionCount = (int) $row['question_count'];
|
||||
}
|
||||
}
|
||||
|
||||
$classStudentCount = $this->getClassStudentCount(
|
||||
$classSectionId,
|
||||
$competition['school_year'] ?? $schoolYear
|
||||
);
|
||||
|
||||
$activeClassName = $this->getActiveClassName($assignments, $classSectionId);
|
||||
|
||||
return view('teacher/competition_scores/scores', [
|
||||
'competition' => $competition,
|
||||
'students' => $students,
|
||||
'scoreMap' => $scoreMap,
|
||||
'classSectionId' => $classSectionId,
|
||||
'classSectionName' => $activeClassName,
|
||||
'classStudentCount' => $classStudentCount,
|
||||
'questionCount' => $questionCount,
|
||||
'classSelectionLocked' => $lockedClassId > 0,
|
||||
'isLocked' => $isLocked,
|
||||
]);
|
||||
}
|
||||
|
||||
public function save($id)
|
||||
{
|
||||
[$assignments] = $this->getTeacherContext();
|
||||
$allowedClassIds = $this->getAllowedClassIds($assignments);
|
||||
if (empty($allowedClassIds)) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'No class assignments found for your account.');
|
||||
}
|
||||
|
||||
$competitionModel = new CompetitionModel();
|
||||
$scoreModel = new CompetitionScoreModel();
|
||||
$competition = $competitionModel->find($id);
|
||||
if (!$competition) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'Competition not found.');
|
||||
}
|
||||
if (!empty($competition['is_locked'])) {
|
||||
return redirect()->to("/teacher/competition-scores/{$id}")
|
||||
->with('error', 'Competition is locked. You cannot update scores.');
|
||||
}
|
||||
|
||||
$lockedClassId = (int) ($competition['class_section_id'] ?? 0);
|
||||
$classSectionId = $lockedClassId > 0
|
||||
? $lockedClassId
|
||||
: (int) $this->request->getPost('class_section_id');
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'Select a class section before saving scores.');
|
||||
}
|
||||
if (!in_array($classSectionId, $allowedClassIds, true)) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'You are not assigned to that class.');
|
||||
}
|
||||
|
||||
$scores = (array) $this->request->getPost('scores');
|
||||
$cleanScores = [];
|
||||
$invalidScores = [];
|
||||
|
||||
foreach ($scores as $studentId => $scoreValue) {
|
||||
$scoreValue = trim((string) $scoreValue);
|
||||
if ($scoreValue === '') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/^\d+$/', $scoreValue)) {
|
||||
$invalidScores[] = $studentId;
|
||||
continue;
|
||||
}
|
||||
$cleanScores[(int) $studentId] = (int) $scoreValue;
|
||||
}
|
||||
|
||||
if (!empty($invalidScores)) {
|
||||
return redirect()->to("/teacher/competition-scores/{$id}")
|
||||
->with('error', 'Scores must be whole numbers (no decimals).');
|
||||
}
|
||||
|
||||
foreach ($cleanScores as $studentId => $scoreValue) {
|
||||
$existing = $scoreModel
|
||||
->where('competition_id', $id)
|
||||
->where('student_id', (int) $studentId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$scoreModel->update($existing['id'], [
|
||||
'score' => $scoreValue,
|
||||
'class_section_id' => $classSectionId,
|
||||
]);
|
||||
} else {
|
||||
$scoreModel->insert([
|
||||
'competition_id' => (int) $id,
|
||||
'student_id' => (int) $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'score' => $scoreValue,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to("/teacher/competition-scores/{$id}")
|
||||
->with('success', 'Scores saved.');
|
||||
}
|
||||
|
||||
private function getTeacherContext(): array
|
||||
{
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
||||
$userId,
|
||||
$schoolYear,
|
||||
$semester
|
||||
);
|
||||
|
||||
return [$assignments, $schoolYear, $semester];
|
||||
}
|
||||
|
||||
private function getAllowedClassIds(array $assignments): array
|
||||
{
|
||||
$ids = array_map(static function ($row) {
|
||||
return (int) ($row['class_section_id'] ?? 0);
|
||||
}, $assignments);
|
||||
|
||||
$ids = array_values(array_filter(array_unique($ids)));
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function resolveActiveClassId(array $assignments): int
|
||||
{
|
||||
$allowed = $this->getAllowedClassIds($assignments);
|
||||
$active = (int) (session()->get('class_section_id') ?? 0);
|
||||
if ($active > 0 && in_array($active, $allowed, true)) {
|
||||
return $active;
|
||||
}
|
||||
|
||||
$active = $allowed[0] ?? 0;
|
||||
if ($active > 0) {
|
||||
session()->set('class_section_id', $active);
|
||||
}
|
||||
|
||||
return (int) $active;
|
||||
}
|
||||
|
||||
private function getActiveClassName(array $assignments, int $classSectionId): ?string
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($assignments as $row) {
|
||||
if ((int) ($row['class_section_id'] ?? 0) === $classSectionId) {
|
||||
return $row['class_section_name'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$row = $this->classSectionModel
|
||||
->select('class_section_name')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->first();
|
||||
|
||||
return $row['class_section_name'] ?? null;
|
||||
}
|
||||
|
||||
private function getCompetitionsForClass(int $classSectionId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$competitionModel = new CompetitionModel();
|
||||
$builder = $competitionModel->orderBy('id', 'DESC');
|
||||
|
||||
if ($classSectionId > 0) {
|
||||
$builder->groupStart()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->orWhere('class_section_id', 0)
|
||||
->orWhere('class_section_id IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$builder->groupStart()
|
||||
->where('school_year', $schoolYear)
|
||||
->orWhere('school_year', '')
|
||||
->orWhere('school_year IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if ($semester !== '') {
|
||||
$builder->groupStart()
|
||||
->where('semester', $semester)
|
||||
->orWhere('semester', '')
|
||||
->orWhere('semester IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
return $builder->findAll();
|
||||
}
|
||||
|
||||
private function getQuestionCounts(array $competitionIds, int $classSectionId): array
|
||||
{
|
||||
if (!$this->hasQuestionCount || !$this->hasClassWinnerTable || empty($competitionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->classWinnerModel
|
||||
->select('competition_id, question_count')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('competition_id', $competitionIds)
|
||||
->findAll();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$compId = (int) ($row['competition_id'] ?? 0);
|
||||
if ($compId > 0 && $row['question_count'] !== null) {
|
||||
$out[$compId] = (int) $row['question_count'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function getScoreCounts(array $competitionIds, int $classSectionId): array
|
||||
{
|
||||
if (empty($competitionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->db->table('competition_scores')
|
||||
->select('competition_id, COUNT(*) AS total')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('competition_id', $competitionIds)
|
||||
->groupBy('competition_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$compId = (int) ($row['competition_id'] ?? 0);
|
||||
if ($compId > 0) {
|
||||
$out[$compId] = (int) ($row['total'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function getClassStudentCount(int $classSectionId, ?string $schoolYear): int
|
||||
{
|
||||
if ($classSectionId <= 0 || $this->classStudentTable === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$builder = $this->db->table($this->classStudentTable)
|
||||
->select('COUNT(*) AS total')
|
||||
->where('class_section_id', $classSectionId);
|
||||
|
||||
if ($schoolYear && $this->db->fieldExists('school_year', $this->classStudentTable)) {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$row = $builder->get()->getRowArray();
|
||||
return (int) ($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
private function getStudentsForCompetition(array $competition, int $classSectionId): array
|
||||
{
|
||||
if ($classSectionId <= 0 || $this->classStudentTable === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builder = $this->db->table('students s')
|
||||
->select('s.id, s.school_id, s.firstname, s.lastname')
|
||||
->join($this->classStudentTable . ' cs', 'cs.student_id = s.id', 'inner')
|
||||
->where('cs.class_section_id', $classSectionId);
|
||||
|
||||
$hasSchoolYear = $this->db->fieldExists('school_year', $this->classStudentTable);
|
||||
if ($hasSchoolYear && !empty($competition['school_year'])) {
|
||||
$builder->where('cs.school_year', $competition['school_year']);
|
||||
}
|
||||
|
||||
$builder->distinct();
|
||||
$builder->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC');
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
private function getClassSectionMap(): array
|
||||
{
|
||||
$sections = $this->classSectionModel
|
||||
->select('class_section_id, class_section_name')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$map = [];
|
||||
foreach ($sections as $section) {
|
||||
$id = (int) ($section['class_section_id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$map[$id] = $section['class_section_name'] ?? (string) $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -1,906 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\DiscountVoucherModel;
|
||||
use App\Models\DiscountUsageModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Models\EventChargesModel;
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use CodeIgniter\Events\Events;
|
||||
|
||||
class DiscountController extends BaseController
|
||||
{
|
||||
protected $configModel;
|
||||
protected $voucherModel;
|
||||
protected $invoiceModel;
|
||||
protected $userModel;
|
||||
protected $db;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $paymentModel;
|
||||
protected $enrollmentModel;
|
||||
protected $eventChargesModel;
|
||||
protected $additionalChargeModel;
|
||||
protected $classSectionModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->voucherModel = new DiscountVoucherModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->enrollmentModel = new EnrollmentModel();
|
||||
$this->eventChargesModel = new EventChargesModel();
|
||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
}
|
||||
|
||||
public function applyVoucher()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
$voucherId = $this->request->getPost('voucher_id');
|
||||
$parentIds = $this->request->getPost('parent_ids') ?? [];
|
||||
$allowAdditional = (bool) $this->request->getPost('allow_additional');
|
||||
|
||||
if (empty($parentIds)) {
|
||||
return redirect()->back()->with('error', 'Please select at least one parent.');
|
||||
}
|
||||
|
||||
$voucher = $this->voucherModel->find($voucherId);
|
||||
if (!$voucher) {
|
||||
return redirect()->back()->with('error', 'Voucher not found.');
|
||||
}
|
||||
|
||||
// Normalize description from voucher (optional)
|
||||
$voucherDescription = trim((string) ($voucher['description'] ?? ''));
|
||||
$voucherDescription = preg_replace(
|
||||
'/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i',
|
||||
'',
|
||||
$voucherDescription
|
||||
);
|
||||
|
||||
$maxUsesRaw = $voucher['max_uses'] ?? null;
|
||||
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
|
||||
$timesUsed = (int) ($voucher['times_used'] ?? 0);
|
||||
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
|
||||
if ($remainingUses <= 0) {
|
||||
return redirect()->back()->with('error', 'This voucher has reached its maximum allowed uses.');
|
||||
}
|
||||
|
||||
// If not allowing additional discounts, filter out parents who already have discounts this school year.
|
||||
if (!$allowAdditional) {
|
||||
$rows = $this->db->table('discount_usages du')
|
||||
->select('i.parent_id')
|
||||
->join('invoices i', 'i.id = du.invoice_id')
|
||||
->whereIn('i.parent_id', $parentIds)
|
||||
->where('i.school_year', $this->schoolYear)
|
||||
->groupBy('i.parent_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$blockedParentIds = array_map(static fn($r) => (int) $r['parent_id'], $rows);
|
||||
if (!empty($blockedParentIds)) {
|
||||
$parentIds = array_values(array_diff(
|
||||
array_map('intval', $parentIds),
|
||||
$blockedParentIds
|
||||
));
|
||||
}
|
||||
if (empty($parentIds)) {
|
||||
return redirect()->back()->with('error', 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.');
|
||||
}
|
||||
}
|
||||
|
||||
$paymentDate = utc_now();
|
||||
|
||||
// Collect invoice IDs that end up fully covered by the voucher in this run
|
||||
$fullyCoveredInvoiceIds = [];
|
||||
$touchedInvoiceIds = [];
|
||||
$appliedCount = 0;
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
foreach ($parentIds as $parentId) {
|
||||
// Fetch invoices for this parent & school year
|
||||
$invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear);
|
||||
if (empty($invoices)) {
|
||||
// Do NOT early-return inside a transaction; just continue
|
||||
session()->setFlashdata('error', 'A selected parent has no invoice in record.');
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
if ($remainingUses <= 0) break 2; // out of parentIds loop too
|
||||
|
||||
// Snapshot current balance BEFORE applying
|
||||
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
||||
if ($initialPreBalance <= 0) {
|
||||
log_message(
|
||||
'error',
|
||||
'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}',
|
||||
[
|
||||
'vid' => (int)$voucherId,
|
||||
'pid' => (int)$parentId,
|
||||
'iid' => (int)$invoice['id'],
|
||||
'inum' => (string)($invoice['invoice_number'] ?? ''),
|
||||
'bal' => $initialPreBalance,
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already used this voucher on this invoice?
|
||||
if (!$allowAdditional) {
|
||||
$exists = $this->db->table('discount_usages du')
|
||||
->join('invoices i', 'du.invoice_id = i.id')
|
||||
->where('du.voucher_id', $voucherId)
|
||||
->where('du.invoice_id', $invoice['id'])
|
||||
->where('i.school_year', $this->schoolYear)
|
||||
->countAllResults();
|
||||
if ($exists) {
|
||||
log_message(
|
||||
'error',
|
||||
'applyVoucher skip: voucher already used | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum}',
|
||||
[
|
||||
'vid' => (int)$voucherId,
|
||||
'pid' => (int)$parentId,
|
||||
'iid' => (int)$invoice['id'],
|
||||
'inum' => (string)($invoice['invoice_number'] ?? ''),
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate discount
|
||||
$rawDiscount = ($voucher['discount_type'] === 'percent')
|
||||
? round(((float)$invoice['total_amount'] * (float)$voucher['discount_value']) / 100, 2)
|
||||
: (float) $voucher['discount_value'];
|
||||
|
||||
// Cap by CURRENT invoice balance snapshot
|
||||
$discount = min($rawDiscount, $initialPreBalance);
|
||||
|
||||
// Nothing to do if no discount
|
||||
if ($discount <= 0) {
|
||||
log_message(
|
||||
'error',
|
||||
'applyVoucher skip: discount <= 0 | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} raw_discount={raw} balance={bal}',
|
||||
[
|
||||
'vid' => (int)$voucherId,
|
||||
'pid' => (int)$parentId,
|
||||
'iid' => (int)$invoice['id'],
|
||||
'inum' => (string)($invoice['invoice_number'] ?? ''),
|
||||
'raw' => $rawDiscount,
|
||||
'bal' => $initialPreBalance,
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert discount usage
|
||||
$now = utc_now();
|
||||
$this->db->table('discount_usages')->insert([
|
||||
'voucher_id' => $voucherId,
|
||||
'invoice_id' => $invoice['id'],
|
||||
'parent_id' => $parentId,
|
||||
'discount_amount' => $discount,
|
||||
'description' => $voucherDescription,
|
||||
'school_year' => $this->schoolYear,
|
||||
'updated_by' => session()->get('user_id'),
|
||||
'used_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Update invoice balance based on pre-discount snapshot (supports multiple discounts)
|
||||
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
|
||||
$this->db->table('invoices')
|
||||
->where('id', $invoice['id'])
|
||||
->update([
|
||||
'balance' => $newBalance,
|
||||
'has_discount' => 1,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Compute post-balance based on pre-snapshot (more stable than $invoice['balance'])
|
||||
$postBalance = round($initialPreBalance - $discount, 2);
|
||||
if ($postBalance < 0) $postBalance = 0.0;
|
||||
|
||||
// (Optional) current balance re-check (in case of concurrent writes)
|
||||
$currentBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
||||
|
||||
// Increment voucher usage
|
||||
$this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->set('times_used', 'COALESCE(times_used,0) + 1', false)
|
||||
->update();
|
||||
|
||||
// Prepare and trigger payment event
|
||||
[$eventData, $studentData] = $this->buildPaymentEventData(
|
||||
$invoice['id'],
|
||||
$voucherId,
|
||||
$discount,
|
||||
'discount',
|
||||
$paymentDate,
|
||||
'',
|
||||
0,
|
||||
$initialPreBalance, // pre-payment snapshot
|
||||
$postBalance // computed post-payment
|
||||
);
|
||||
Events::trigger('paymentReceived', $eventData, $studentData);
|
||||
|
||||
$touchedInvoiceIds[] = (int) $invoice['id'];
|
||||
$appliedCount++;
|
||||
|
||||
// If voucher covered the entire current balance, mark to update enrollments
|
||||
// Use a small epsilon to tolerate cents rounding.
|
||||
$epsilon = 0.01;
|
||||
$fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon)
|
||||
|| ($postBalance <= $epsilon)
|
||||
|| ($currentBalance <= $epsilon);
|
||||
|
||||
if ($fullyCovered) {
|
||||
$fullyCoveredInvoiceIds[] = (int) $invoice['id'];
|
||||
}
|
||||
|
||||
$remainingUses--;
|
||||
|
||||
// Deactivate if we just hit the cap
|
||||
if ($remainingUses <= 0 && $maxUses !== null) {
|
||||
$this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->update([
|
||||
'is_active' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if ($this->db->transStatus() === false) {
|
||||
return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.');
|
||||
}
|
||||
|
||||
if ($appliedCount === 0) {
|
||||
return redirect()->back()->with('error', 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.');
|
||||
}
|
||||
|
||||
// ✅ Ensure voucher deactivates when max_uses reached (handles edge cases)
|
||||
if ($maxUses !== null) {
|
||||
$row = $this->db->table('discount_vouchers')
|
||||
->select('times_used')
|
||||
->where('id', $voucherId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$usedNow = (int) ($row['times_used'] ?? 0);
|
||||
if ($usedNow >= $maxUses) {
|
||||
$this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->update([
|
||||
'is_active' => 0,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund
|
||||
foreach (array_unique($touchedInvoiceIds) as $iid) {
|
||||
try {
|
||||
$this->recalculateInvoice($iid, $this->schoolYear);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'recalculateInvoice failed for invoice {iid}: {err}', [
|
||||
'iid' => $iid,
|
||||
'err' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ AFTER COMMIT: update enrollments for fully-covered invoices
|
||||
// Avoid nested transactions by doing this post-commit.
|
||||
$updatedTotal = 0;
|
||||
foreach (array_unique($fullyCoveredInvoiceIds) as $iid) {
|
||||
try {
|
||||
$updatedTotal += (int) $this->updateEnrollmentStatusIfPaid($iid);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [
|
||||
'iid' => $iid,
|
||||
'err' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($updatedTotal > 0) {
|
||||
return redirect()->back()->with('success', 'Voucher applied successfully and enrollments updated.');
|
||||
}
|
||||
return redirect()->back()->with('success', 'Voucher applied successfully.');
|
||||
}
|
||||
|
||||
// GET: load page
|
||||
$parents = $this->userModel->getParents();
|
||||
|
||||
foreach ($parents as &$parent) {
|
||||
$discount = $this->db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS total_discount')
|
||||
->where('parent_id', $parent['id'])
|
||||
->where('school_year', $this->schoolYear)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$parent['total_discount'] = $discount['total_discount'] ?? 0;
|
||||
$parent['has_discount'] = ($parent['total_discount'] > 0) ? 1 : 0;
|
||||
}
|
||||
unset($parent);
|
||||
|
||||
return view('discounts/apply_voucher', [
|
||||
'vouchers' => $this->voucherModel->where('is_active', 1)->findAll(),
|
||||
'parents' => $parents,
|
||||
]);
|
||||
}
|
||||
|
||||
private function updateEnrollmentStatusIfPaid(int $invoiceId): int
|
||||
{
|
||||
// 1) Fetch invoice
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2) Payment check (any payment recorded: balance < total)
|
||||
$total = (float) ($invoice['total_amount'] ?? 0);
|
||||
$balance = (float) ($invoice['balance'] ?? 0);
|
||||
if (!($total > 0 && $balance < $total)) {
|
||||
log_message('info', 'No payment yet. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||
$schoolYear = (string) $this->schoolYear;
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
log_message('warning', 'updateEnrollmentStatusIfPaid: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3) Try to limit to enrollments actually present on the invoice (optional)
|
||||
// If invoice_items.enrollment_id doesn't exist, this silently falls back to parent/year scope.
|
||||
$paidEnrollmentIds = [];
|
||||
try {
|
||||
$rows = $this->db->table('invoice_items')
|
||||
->select('enrollment_id')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('enrollment_id IS NOT NULL', null, false)
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$eid = (int) ($r['enrollment_id'] ?? 0);
|
||||
if ($eid > 0) $paidEnrollmentIds[] = $eid;
|
||||
}
|
||||
$paidEnrollmentIds = array_values(array_unique($paidEnrollmentIds));
|
||||
} catch (\Throwable $e) {
|
||||
// No-op: table/column might not exist in your schema
|
||||
}
|
||||
|
||||
$db = $this->db;
|
||||
$db->transBegin();
|
||||
|
||||
try {
|
||||
// 4) Preview IDs that will be updated (good for debugging “partials”)
|
||||
$previewBuilder = $db->table('enrollments')
|
||||
->select('id')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupStart()
|
||||
// tolerant match for 'payment pending' (case/space/nbsp)
|
||||
->where('enrollment_status', 'payment pending')
|
||||
->orWhere('enrollment_status', 'Payment pending')
|
||||
->orWhere('enrollment_status', 'Payment Pending')
|
||||
->orWhere('enrollment_status', 'PAYMENT PENDING')
|
||||
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false)
|
||||
->groupEnd();
|
||||
|
||||
if (!empty($paidEnrollmentIds)) {
|
||||
$previewBuilder->whereIn('id', $paidEnrollmentIds);
|
||||
}
|
||||
|
||||
$toUpdateIds = array_map(
|
||||
static fn($r) => (int)$r['id'],
|
||||
$previewBuilder->get()->getResultArray()
|
||||
);
|
||||
|
||||
if (empty($toUpdateIds)) {
|
||||
$db->transCommit();
|
||||
log_message('info', 'No enrollments matched pending status for parent {p}, year {y}, sem {s}.', [
|
||||
'p' => $parentId,
|
||||
'y' => $schoolYear,
|
||||
's' => 'ALL'
|
||||
]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 5) Perform update with same filters
|
||||
$builder = $db->table('enrollments');
|
||||
$builder->set('enrollment_status', 'enrolled')
|
||||
// Use DB date if you prefer: ->set('enrollment_date', 'CURRENT_DATE()', false)
|
||||
->set('enrollment_date', local_date(utc_now(), 'Y-m-d'))
|
||||
->whereIn('id', $toUpdateIds);
|
||||
|
||||
if ($builder->update() === false) {
|
||||
$err = $db->error();
|
||||
throw new \RuntimeException('Enrollments update failed: ' . ($err['message'] ?? 'unknown DB error'));
|
||||
}
|
||||
|
||||
$affected = $db->affectedRows();
|
||||
$db->transCommit();
|
||||
|
||||
log_message(
|
||||
'info',
|
||||
'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]',
|
||||
[
|
||||
'n' => $affected,
|
||||
'p' => $parentId,
|
||||
'y' => $schoolYear,
|
||||
's' => 'ALL',
|
||||
'ids' => implode(',', $toUpdateIds),
|
||||
]
|
||||
);
|
||||
|
||||
return $affected;
|
||||
} catch (\Throwable $e) {
|
||||
$db->transRollback();
|
||||
log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function listVouchers()
|
||||
{
|
||||
|
||||
$vouchers = $this->voucherModel->findAll();
|
||||
return view('discounts/list', ['vouchers' => $vouchers]);
|
||||
}
|
||||
|
||||
public function createVoucher()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
|
||||
// -------- Gather & normalize inputs --------
|
||||
$rawCode = (string) $this->request->getPost('code');
|
||||
// Keep A–Z, 0–9 and dashes; uppercase for consistency
|
||||
$code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim($rawCode)));
|
||||
|
||||
$discountType = strtolower((string) $this->request->getPost('discount_type')); // 'percent' | 'fixed'
|
||||
$discountValue = (float) ($this->request->getPost('discount_value') ?? 0);
|
||||
$maxUsesRaw = $this->request->getPost('max_uses');
|
||||
$maxUses = ($maxUsesRaw === '' || $maxUsesRaw === null) ? null : max(0, (int) $maxUsesRaw);
|
||||
|
||||
$validFrom = trim((string) ($this->request->getPost('valid_from') ?? ''));
|
||||
$validUntil = trim((string) ($this->request->getPost('valid_until') ?? ''));
|
||||
$isActive = $this->request->getPost('is_active') ? 1 : 0;
|
||||
|
||||
// NEW: Description / Reason
|
||||
$description = trim((string) ($this->request->getPost('description') ?? ''));
|
||||
|
||||
// -------- Basic validations (before model rules) --------
|
||||
$errors = [];
|
||||
|
||||
if ($code === '') {
|
||||
$errors[] = 'Voucher code is required.';
|
||||
}
|
||||
|
||||
if (!in_array($discountType, ['percent', 'fixed'], true)) {
|
||||
$errors[] = 'Discount type must be "percent" or "fixed".';
|
||||
}
|
||||
|
||||
if ($discountType === 'percent') {
|
||||
if ($discountValue <= 0 || $discountValue > 100) {
|
||||
$errors[] = 'Percentage discount must be between 0 and 100.';
|
||||
}
|
||||
} else { // fixed
|
||||
if ($discountValue < 0) {
|
||||
$errors[] = 'Fixed discount must be 0 or greater.';
|
||||
}
|
||||
}
|
||||
|
||||
// Dates come from <input type="date"> as YYYY-MM-DD
|
||||
$validFrom = $validFrom !== '' ? $validFrom : null;
|
||||
$validUntil = $validUntil !== '' ? $validUntil : null;
|
||||
|
||||
// Ensure date format is plausible
|
||||
$isDate = static function (?string $d): bool {
|
||||
if ($d === null) return true;
|
||||
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
|
||||
};
|
||||
if (!$isDate($validFrom)) $errors[] = 'Valid From must be a date (YYYY-MM-DD).';
|
||||
if (!$isDate($validUntil)) $errors[] = 'Valid Until must be a date (YYYY-MM-DD).';
|
||||
|
||||
if ($validFrom && $validUntil && $validFrom > $validUntil) {
|
||||
$errors[] = 'Valid Until must be the same as or after Valid From.';
|
||||
}
|
||||
|
||||
// Optional: require a reason when auto-generated codes are used
|
||||
// if ($description === '' && strlen($code) === 10) {
|
||||
// $errors[] = 'Please add a description/reason for this voucher.';
|
||||
// }
|
||||
|
||||
if (!empty($errors)) {
|
||||
return redirect()->back()->withInput()->with('error', implode(' ', $errors));
|
||||
}
|
||||
|
||||
// -------- Prepare payload for model --------
|
||||
$data = [
|
||||
'code' => $code,
|
||||
'discount_type' => $discountType,
|
||||
'discount_value' => $discountValue,
|
||||
'max_uses' => $maxUses,
|
||||
'valid_from' => $validFrom,
|
||||
'valid_until' => $validUntil,
|
||||
'is_active' => $isActive,
|
||||
'description' => $description, // <-- NEW
|
||||
];
|
||||
|
||||
if ($this->voucherModel->save($data)) {
|
||||
return redirect()->to('/discounts/list')->with('success', 'Voucher created successfully.');
|
||||
}
|
||||
|
||||
// Model-level errors (unique code, etc.)
|
||||
return redirect()->back()
|
||||
->withInput()
|
||||
->with('error', 'Failed to save voucher: ' . implode('; ', (array) $this->voucherModel->errors()));
|
||||
}
|
||||
|
||||
return view('discounts/create');
|
||||
}
|
||||
|
||||
|
||||
public function editVoucher($id)
|
||||
{
|
||||
$voucher = $this->voucherModel->find($id);
|
||||
|
||||
if (!$voucher) {
|
||||
return redirect()->to('discounts/list')->with('error', 'Voucher not found');
|
||||
}
|
||||
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
$data = [
|
||||
'id' => $id,
|
||||
'code' => $this->request->getPost('code'),
|
||||
'discount_type' => $this->request->getPost('discount_type'),
|
||||
'discount_value' => $this->request->getPost('discount_value'),
|
||||
'max_uses' => $this->request->getPost('max_uses'),
|
||||
'valid_from' => $this->request->getPost('valid_from') ?: null,
|
||||
'valid_until' => $this->request->getPost('valid_until') ?: null,
|
||||
'is_active' => $this->request->getPost('is_active') ? 1 : 0,
|
||||
];
|
||||
|
||||
$this->voucherModel->save($data);
|
||||
return redirect()->to('discounts/list')->with('success', 'Voucher updated successfully');
|
||||
}
|
||||
|
||||
return view('discounts/edit', ['voucher' => $voucher]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 Helper: Current invoice balance (school-year scoped) = total - payments - discounts - refundsPaid
|
||||
*/
|
||||
private function getCurrentInvoiceBalance($invoiceId, $schoolYear)
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) return 0.0;
|
||||
|
||||
// Payments (exclude void/refund/failed, honor year)
|
||||
$qb = $this->paymentModel
|
||||
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
$table = $this->paymentModel->table;
|
||||
$hasStatus = $this->db->fieldExists('status', $table);
|
||||
$hasVoid = $this->db->fieldExists('is_void', $table);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$rowPaid = $qb->first();
|
||||
$totalPaid = (float)($rowPaid['total_paid'] ?? 0);
|
||||
|
||||
// Discounts for this invoice in this year
|
||||
$rowDisc = $this->db->table('discount_usages du')
|
||||
->select('COALESCE(SUM(du.discount_amount),0) AS total_disc')
|
||||
->join('invoices i', 'i.id = du.invoice_id')
|
||||
->where('du.invoice_id', $invoiceId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->get()->getRowArray();
|
||||
$totalDisc = (float)($rowDisc['total_disc'] ?? 0);
|
||||
|
||||
// Refunds PAID for this invoice in this year
|
||||
$rowRefund = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->get()->getRowArray();
|
||||
$totalRefundPaid = (float)($rowRefund['total_refund_paid'] ?? 0);
|
||||
|
||||
$total = (float)($invoice['total_amount'] ?? 0);
|
||||
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 Recalculate invoice totals and status based on all payments for current school year
|
||||
*/
|
||||
private function recalculateInvoice($invoiceId, $schoolYear): void
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) return;
|
||||
|
||||
$parentId = (int)($invoice['parent_id'] ?? 0);
|
||||
if ($parentId <= 0) return;
|
||||
|
||||
// ---- Tuition (recompute from enrollments) ----
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$registered = [];
|
||||
$withdrawn = [];
|
||||
foreach ($enrollments as $e) {
|
||||
$row = [
|
||||
'student_id' => (int)($e['student_id'] ?? 0),
|
||||
'class_section_id' => $e['class_section_id'] ?? null,
|
||||
'enrollment_status'=> (string)($e['enrollment_status'] ?? ''),
|
||||
];
|
||||
if (in_array($row['enrollment_status'], ['enrolled','payment pending'], true)) {
|
||||
$registered[] = $row;
|
||||
} elseif (in_array($row['enrollment_status'], ['withdrawn','refund pending','withdraw under review'], true)) {
|
||||
$withdrawn[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
// Refund window check – if after deadline, withdrawn still billed
|
||||
$refundDeadline = (string)($this->configModel->getConfig('refund_deadline') ?? '');
|
||||
$refundAllowed = true;
|
||||
try {
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
|
||||
$refundAllowed = $today <= $deadline;
|
||||
} catch (\Throwable $e) {
|
||||
$refundAllowed = true;
|
||||
}
|
||||
|
||||
$tuitionStudents = $registered;
|
||||
if (!$refundAllowed) {
|
||||
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
|
||||
}
|
||||
|
||||
// Grade threshold and fees
|
||||
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
|
||||
$firstStudentFee = (float)($this->configModel->getConfig('first_student_fee') ?? 350);
|
||||
$secondStudentFee = (float)($this->configModel->getConfig('second_student_fee') ?? 200);
|
||||
$youthFee = (float)($this->configModel->getConfig('youth_fee') ?? 180);
|
||||
|
||||
// Normalize grades for tuition students
|
||||
foreach ($tuitionStudents as &$s) {
|
||||
$name = null;
|
||||
if (!empty($s['class_section_id'])) {
|
||||
$name = $this->classSectionModel->getClassSectionNameBySectionId($s['class_section_id']);
|
||||
}
|
||||
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
|
||||
}
|
||||
unset($s);
|
||||
|
||||
// Count regular vs youth and compute tuition
|
||||
$regularCount = 0;
|
||||
$youthCount = 0;
|
||||
foreach ($tuitionStudents as $s) {
|
||||
$lvl = $this->parseGradeLevel($s['grade_name']);
|
||||
if ($lvl > $gradeFee) $youthCount++; else $regularCount++;
|
||||
}
|
||||
|
||||
$tuitionSubtotal = 0.0;
|
||||
$tuitionSubtotal += $youthCount * $youthFee;
|
||||
if ($regularCount >= 2) {
|
||||
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
|
||||
} elseif ($regularCount === 1) {
|
||||
$tuitionSubtotal += $firstStudentFee;
|
||||
}
|
||||
|
||||
// ---- Event charges (parent-year) ----
|
||||
$eventSubtotal = 0.0;
|
||||
try {
|
||||
$events = $this->eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear) ?? [];
|
||||
foreach ($events as $ev) { $eventSubtotal += (float)($ev['charged'] ?? 0.0); }
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// ---- Additional charges (per-invoice) ----
|
||||
$additionalSubtotal = 0.0;
|
||||
try {
|
||||
$rows = $this->additionalChargeModel
|
||||
->select('charge_type, amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('status', 'applied')
|
||||
->findAll();
|
||||
foreach ($rows as $r) {
|
||||
$amt = (float)($r['amount'] ?? 0);
|
||||
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
|
||||
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
|
||||
$additionalSubtotal += $amt;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
|
||||
|
||||
// ---- Payments / Discounts / Refunds ----
|
||||
$db = $this->db;
|
||||
$table = $this->paymentModel->table;
|
||||
$hasStatus = $db->fieldExists('status', $table);
|
||||
$hasVoid = $db->fieldExists('is_void', $table);
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
|
||||
$qb = $this->paymentModel
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', $exclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$payments = $qb->findAll();
|
||||
$totalPaid = 0.0;
|
||||
foreach ($payments as $p) { $totalPaid += (float)($p['paid_amount'] ?? 0); }
|
||||
|
||||
$discRow = $this->db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()->getRowArray();
|
||||
$totalDisc = (float)($discRow['total_disc'] ?? 0);
|
||||
|
||||
$refundRow = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
|
||||
|
||||
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
|
||||
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$updateData = [
|
||||
'total_amount' => $newTotal,
|
||||
'paid_amount' => $totalPaid,
|
||||
'balance' => $newBalance,
|
||||
'status' => $newStatus,
|
||||
'has_discount' => ($totalDisc > 0.0) ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($this->db->fieldExists('discount', $this->invoiceModel->table)) {
|
||||
$updateData['discount'] = $totalDisc;
|
||||
}
|
||||
|
||||
$this->invoiceModel->update($invoiceId, $updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a grade name into an integer level for tuition rules.
|
||||
* Kindergarten -> 1, Youth -> > 9, numeric grades passthrough.
|
||||
*/
|
||||
private function parseGradeLevel($grade): int
|
||||
{
|
||||
if (is_numeric($grade)) return (int)$grade;
|
||||
if (!is_string($grade)) return 999;
|
||||
$g = strtoupper(trim((string)$grade));
|
||||
$g = preg_replace('/\s+/', ' ', $g);
|
||||
$g = str_replace(['.', '_', '-'], ['', '', ' '], $g);
|
||||
// KG/K/Kindergarten
|
||||
$kg = ['K','KG','K G','KINDER','KINDERGARTEN'];
|
||||
if (in_array($g, $kg, true)) return 1;
|
||||
// Pre-K -> treat below regular
|
||||
$pk = ['PK','P K','PREK','PRE K','PRE KINDER','PREKINDER'];
|
||||
if (in_array($g, $pk, true)) return -1;
|
||||
// Youth variants: Y, YOUTH, Y1, YOUTH2 -> map to 10+
|
||||
if (preg_match('/^Y(?:OUTH)?\s*(\d+)?$/', $g, $m)) {
|
||||
$n = isset($m[1]) && $m[1] !== '' ? max(1, (int)$m[1]) : 1;
|
||||
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
|
||||
return $gradeFee + $n;
|
||||
}
|
||||
if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $g, $m)) {
|
||||
return (int)$m[1];
|
||||
}
|
||||
return 999;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect parent/invoice/payment data to trigger handlePaymentReceived().
|
||||
*
|
||||
* @return array [$data, $studentdata]
|
||||
*/
|
||||
private function buildPaymentEventData(
|
||||
int $invoiceId,
|
||||
string $transactionIdOrRef,
|
||||
float $amount,
|
||||
string $paymentMethod,
|
||||
string $paymentDate,
|
||||
?string $checkNumber,
|
||||
int $installmentSeq,
|
||||
float $preBalance, // ✅ new param: initial (pre-payment) balance
|
||||
float $postBalance // ✅ new param: computed post-payment balance
|
||||
): array {
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
throw new \RuntimeException("Invoice {$invoiceId} not found");
|
||||
}
|
||||
|
||||
$parentRow = $this->db->table('users')
|
||||
->select('id, firstname, lastname, email')
|
||||
->where('id', $invoice['parent_id'])
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (!$parentRow) {
|
||||
throw new \RuntimeException("Parent not found for invoice {$invoiceId}");
|
||||
}
|
||||
|
||||
// Students (adjust joins to your schema)
|
||||
$studentRows = $this->db->table('students s')
|
||||
->select('s.id, s.firstname, s.lastname, sc.class_section_id')
|
||||
->join('student_class sc', 'sc.student_id = s.id', 'left')
|
||||
->where('s.parent_id', $invoice['parent_id'])
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$data = [
|
||||
'user_id' => (int) $parentRow['id'],
|
||||
'email' => $parentRow['email'],
|
||||
'firstname' => $parentRow['firstname'],
|
||||
'lastname' => $parentRow['lastname'],
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'invoice_id' => $invoiceId,
|
||||
'transaction_id' => $transactionIdOrRef,
|
||||
'payment_date' => $paymentDate,
|
||||
'amount' => $amount,
|
||||
'method' => $paymentMethod,
|
||||
'check_number' => $checkNumber,
|
||||
'installment_seq' => $installmentSeq,
|
||||
'invoice_total' => (float) $invoice['total_amount'],
|
||||
'pre_balance' => $preBalance, // ✅ the captured pre-payment balance
|
||||
'post_balance' => $postBalance, // ✅ computed post-payment balance
|
||||
'portalLink' => site_url('parent/invoices/' . $invoiceId),
|
||||
];
|
||||
|
||||
return [$data, $studentRows];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Controllers\View\EmailController;
|
||||
|
||||
class EmailService
|
||||
{
|
||||
protected array $senders;
|
||||
protected EmailController $mailer;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$json = env('MAIL_SENDERS', '{}');
|
||||
$decoded = json_decode($json, true);
|
||||
$this->senders = is_array($decoded) ? $decoded : [];
|
||||
$this->mailer = new EmailController();
|
||||
}
|
||||
|
||||
protected function getSenderDetails(string $fromKey = 'general'): array
|
||||
{
|
||||
$senders = $this->senders;
|
||||
if (isset($senders[$fromKey])) {
|
||||
return $senders[$fromKey];
|
||||
}
|
||||
return $senders['general'] ?? [
|
||||
'email' => getenv('SMTP_USER') ?: 'no-reply@alrahmaisgl.org',
|
||||
'name' => 'Al Rahma Sunday School',
|
||||
];
|
||||
}
|
||||
|
||||
/** NEW: expose configured sender keys to build a dropdown in the UI */
|
||||
public function listSenders(): array
|
||||
{
|
||||
// returns: ['general' => ['email' => '...', 'name' => '...'], 'finance' => [...], ...]
|
||||
return $this->senders ?: [
|
||||
'general' => [
|
||||
'email' => getenv('SMTP_USER') ?: 'no-reply@alrahmaisgl.org',
|
||||
'name' => 'Al Rahma Sunday School',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single HTML email.
|
||||
* Backward-compatible: you can ignore $attachments/$headers.
|
||||
*/
|
||||
public function send(string $to, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool
|
||||
{
|
||||
$attachmentsPayload = $this->normalizeAttachments($attachments);
|
||||
// Headers are ignored by EmailController but kept for interface compatibility.
|
||||
return $this->mailer->sendEmail($to, $subject, $message, $fromKey, null, null, $attachmentsPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* NEW: BCC bulk send in a single SMTP call (To must be non-empty; we use the SMTP user).
|
||||
*/
|
||||
public function sendBcc(array $bccList, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool
|
||||
{
|
||||
if (empty($bccList)) {
|
||||
return true; // nothing to send
|
||||
}
|
||||
|
||||
$attachmentsPayload = $this->normalizeAttachments($attachments);
|
||||
$allOk = true;
|
||||
foreach ($bccList as $addr) {
|
||||
$ok = $this->mailer->sendEmail($addr, $subject, $message, $fromKey, null, null, $attachmentsPayload);
|
||||
if (!$ok) {
|
||||
$allOk = false;
|
||||
log_message('error', 'EmailService::sendBcc failed for ' . $addr);
|
||||
}
|
||||
}
|
||||
return $allOk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize various attachment inputs to the format expected by EmailController.
|
||||
*/
|
||||
private function normalizeAttachments(array $attachments): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($attachments as $file) {
|
||||
if (is_object($file) && method_exists($file, 'isValid') && $file->isValid()) {
|
||||
$newName = $file->getRandomName();
|
||||
$path = WRITEPATH . 'uploads/' . $newName;
|
||||
$file->move(WRITEPATH . 'uploads', $newName, true);
|
||||
$out[] = ['path' => $path, 'name' => $file->getClientName()];
|
||||
} elseif (is_string($file) && is_file($file)) {
|
||||
$out[] = ['path' => $file, 'name' => basename($file)];
|
||||
} elseif (is_array($file) && !empty($file['path'])) {
|
||||
$out[] = ['path' => $file['path'], 'name' => $file['name'] ?? basename($file['path'])];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ExpenseModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
|
||||
class ExpenseController extends BaseController
|
||||
{
|
||||
protected $expenseModel;
|
||||
protected $userModel;
|
||||
protected $configModel;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $retailors;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->expenseModel = new ExpenseModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
|
||||
// Default list of common retailors; adjust as needed
|
||||
$this->retailors = [
|
||||
'Amazon',
|
||||
'Walmart',
|
||||
'Costco',
|
||||
'BJ\'s',
|
||||
'Market Basket',
|
||||
'Aldi',
|
||||
'Hannaford',
|
||||
'Sam\'s Club',
|
||||
'HomeGoods',
|
||||
'Hostinger',
|
||||
'Wicked Cheesy',
|
||||
'Shatila',
|
||||
'Brothers Pizzeria',
|
||||
'Paradise Biryani Pointe',
|
||||
'Emad Leiman',
|
||||
'Nova Trampoline Park',
|
||||
'Lubin\'s Awards',
|
||||
'Dollar Tree',
|
||||
'Stop & Shop',
|
||||
'Dunkin\' Donuts',
|
||||
'Giovanni\'s Pizza',
|
||||
'Trader Joes'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return staff users (admins/teachers/etc.) excluding parents/guests.
|
||||
*/
|
||||
private function staffUsers(): array
|
||||
{
|
||||
$rows = $this->userModel
|
||||
->select('users.id, users.firstname, users.lastname, roles.name AS role_name')
|
||||
->join('user_roles', 'user_roles.user_id = users.id', 'left')
|
||||
->join('roles', 'roles.id = user_roles.role_id', 'left')
|
||||
->where('roles.name IS NOT NULL', null, false)
|
||||
->findAll();
|
||||
|
||||
$excludedRoles = array_map('strtolower', ['parent', 'student', 'guest']);
|
||||
$staff = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$roleName = strtolower((string) ($row['role_name'] ?? ''));
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id <= 0 || in_array($roleName, $excludedRoles, true)) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($staff[$id])) {
|
||||
$staff[$id] = [
|
||||
'id' => $id,
|
||||
'firstname' => $row['firstname'] ?? '',
|
||||
'lastname' => $row['lastname'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
uasort($staff, static function ($a, $b) {
|
||||
$nameA = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
|
||||
$nameB = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
|
||||
return strcasecmp($nameA, $nameB);
|
||||
});
|
||||
|
||||
return array_values($staff);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$expenses = $this->expenseModel
|
||||
->select("
|
||||
expenses.*,
|
||||
u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname,
|
||||
approver.firstname AS approver_firstname, approver.lastname AS approver_lastname
|
||||
")
|
||||
->join('users u', 'u.id = expenses.purchased_by', 'left')
|
||||
->join('users approver', 'approver.id = expenses.approved_by', 'left')
|
||||
->orderBy('expenses.created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
// Enrich each row with a URL that goes through Files::receipt($name)
|
||||
// We store only the filename in 'receipt_path' (e.g., "1759...f62.png")
|
||||
$expenses = array_map(function ($row) {
|
||||
$name = $row['receipt_path'] ?? null;
|
||||
$row['receipt_url'] = $this->receiptUrl($name);
|
||||
return $row;
|
||||
}, $expenses);
|
||||
|
||||
return view('expenses/index', ['expenses' => $expenses]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$users = $this->staffUsers();
|
||||
|
||||
return view('expenses/create', [
|
||||
'users' => $users,
|
||||
'retailors' => $this->retailors,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$rules = [
|
||||
'category' => 'required|in_list[Expense,Purchase,Reimbursement,Donation]',
|
||||
'amount' => 'required|decimal|greater_than[0]',
|
||||
// Frontend sends purchased_by as "id|Full Name"
|
||||
'purchased_by' => 'required',
|
||||
// Optional extra fields
|
||||
'retailor' => 'permit_empty|max_length[255]',
|
||||
'date_of_purchase' => 'permit_empty',
|
||||
// allow JPG/JPEG/PNG/WEBP/GIF and PDF up to 2MB
|
||||
'receipt' => 'uploaded[receipt]'
|
||||
. '|max_size[receipt,2048]'
|
||||
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
|
||||
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]',
|
||||
];
|
||||
|
||||
$messages = [
|
||||
'receipt' => [
|
||||
'uploaded' => 'Receipt file is required.',
|
||||
'max_size' => 'Maximum file size is 2MB.',
|
||||
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
||||
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
||||
]
|
||||
];
|
||||
|
||||
if (!$this->validate($rules, $messages)) {
|
||||
return redirect()->back()->withInput()->with('error', $this->validator->listErrors());
|
||||
}
|
||||
|
||||
// Safe values
|
||||
$category = (string) $this->request->getPost('category');
|
||||
$amount = (string) $this->request->getPost('amount');
|
||||
$description = (string) $this->request->getPost('description');
|
||||
$retailor = trim((string) $this->request->getPost('retailor'));
|
||||
$datePurchase = (string) $this->request->getPost('date_of_purchase');
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
$isDonation = ($category === 'Donation');
|
||||
|
||||
// Parse "purchased_by" as "7|John Doe"
|
||||
$purchasedInfo = (string) $this->request->getPost('purchased_by');
|
||||
[$purchasedById, $purchasedByName] = array_pad(explode('|', $purchasedInfo, 2), 2, null);
|
||||
$purchasedById = (int) $purchasedById;
|
||||
|
||||
// School context
|
||||
$schoolYear = $this->schoolYear ?: date('Y');
|
||||
$semester = $this->semester ?: 'Fall';
|
||||
|
||||
// Handle upload: store under writable/uploads/receipts and save only the filename
|
||||
$receiptName = null;
|
||||
$file = $this->request->getFile('receipt');
|
||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
||||
$stored = $file->store('receipts'); // -> writable/uploads/receipts/<randomname>.ext
|
||||
$receiptName = basename($stored);
|
||||
}
|
||||
|
||||
$status = $isDonation ? 'approved' : 'pending';
|
||||
$statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null;
|
||||
|
||||
$this->expenseModel->insert([
|
||||
'category' => $category,
|
||||
'amount' => $amount,
|
||||
'receipt_path' => $receiptName, // filename only
|
||||
'description' => $description,
|
||||
'retailor' => ($retailor !== '') ? $retailor : null,
|
||||
'date_of_purchase' => ($datePurchase !== '') ? $datePurchase : null,
|
||||
'purchased_by' => $purchasedById,
|
||||
'added_by' => $userId,
|
||||
'status' => $status,
|
||||
'status_reason'=> $statusReason,
|
||||
'approved_by' => $isDonation ? $userId : null,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
|
||||
return redirect()->to('/expenses/index')->with('success', 'Record added successfully!');
|
||||
}
|
||||
|
||||
|
||||
public function updateStatus()
|
||||
{
|
||||
$data = $this->request->getJSON(true);
|
||||
$id = isset($data['id']) ? (int)$data['id'] : null;
|
||||
$status = $data['status'] ?? null;
|
||||
$reason = $data['reason'] ?? '';
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
if (!$id || !in_array($status, ['approved', 'denied'], true)) {
|
||||
log_message('error', 'Invalid status or ID');
|
||||
return $this->response->setJSON(['error' => 'Invalid data']);
|
||||
}
|
||||
|
||||
$expense = $this->expenseModel->find($id);
|
||||
if (!$expense) {
|
||||
log_message('error', 'Expense not found for ID ' . $id);
|
||||
return $this->response->setJSON(['error' => 'Expense not found']);
|
||||
}
|
||||
|
||||
$success = $this->expenseModel->update($id, [
|
||||
'status' => $status,
|
||||
'status_reason' => $reason,
|
||||
'approved_by' => $userId,
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
|
||||
if (!$success) {
|
||||
log_message('error', 'Expense update failed for ID ' . $id);
|
||||
return $this->response->setJSON(['error' => 'Update failed']);
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a public URL for a receipt filename through Files::receipt($name).
|
||||
* Expects just the filename (e.g., "1759113425_1c443e607e1900f92f62.png").
|
||||
*/
|
||||
private function receiptUrl(?string $filename): ?string
|
||||
{
|
||||
if (!$filename) {
|
||||
return null;
|
||||
}
|
||||
// Route should be defined as: $routes->get('receipts/(:any)', 'Files::receipt/$1');
|
||||
return site_url('receipts/' . $filename);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function edit(int $id)
|
||||
{
|
||||
$expense = $this->expenseModel->find($id);
|
||||
if (!$expense) {
|
||||
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
||||
}
|
||||
|
||||
// same user list you use in create()
|
||||
$users = $this->staffUsers();
|
||||
|
||||
return view('expenses/edit', [
|
||||
'expense' => $expense,
|
||||
'users' => $users,
|
||||
'retailors' => $this->retailors,
|
||||
'receipt_url' => $expense['receipt_path'] ? site_url('receipts/' . basename($expense['receipt_path'])) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id)
|
||||
{
|
||||
helper(['form']);
|
||||
|
||||
$expense = $this->expenseModel->find($id);
|
||||
if (!$expense) {
|
||||
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
||||
}
|
||||
|
||||
// Base rules
|
||||
$rules = [
|
||||
'category' => 'required|in_list[Expense,Purchase,Reimbursement,Donation]',
|
||||
'amount' => 'required|decimal|greater_than[0]',
|
||||
'purchased_by' => 'required', // still "id|Full Name"
|
||||
'retailor' => 'permit_empty|max_length[255]',
|
||||
'date_of_purchase' => 'permit_empty',
|
||||
];
|
||||
|
||||
// Optional new receipt validation (only if provided)
|
||||
$file = $this->request->getFile('receipt');
|
||||
if ($file && $file->isValid() && ($file->getSize() ?? 0) > 0) {
|
||||
$rules['receipt'] = 'max_size[receipt,2048]'
|
||||
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
|
||||
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]';
|
||||
}
|
||||
|
||||
if (!$this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
// Parse "id|Name"
|
||||
[$purchasedById] = array_pad(explode('|', (string) $this->request->getPost('purchased_by'), 2), 2, null);
|
||||
$purchasedById = (int) $purchasedById;
|
||||
$category = (string) $this->request->getPost('category');
|
||||
$isDonation = ($category === 'Donation');
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
// Keep old receipt unless replaced or removed
|
||||
$receiptName = $expense['receipt_path'];
|
||||
if ($file && $file->isValid() && !$file->hasMoved() && ($file->getSize() ?? 0) > 0) {
|
||||
$stored = $file->store('receipts');
|
||||
$receiptName = basename($stored);
|
||||
}
|
||||
if ($this->request->getPost('remove_receipt') === '1') {
|
||||
$receiptName = null;
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'category' => $category,
|
||||
'amount' => (string) $this->request->getPost('amount'),
|
||||
'description' => (string) $this->request->getPost('description'),
|
||||
'retailor' => trim((string) $this->request->getPost('retailor')) ?: null,
|
||||
'date_of_purchase' => (string) $this->request->getPost('date_of_purchase') ?: null,
|
||||
'purchased_by' => $purchasedById,
|
||||
'receipt_path' => $receiptName,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
|
||||
if ($isDonation) {
|
||||
$updateData['status'] = 'approved';
|
||||
$updateData['status_reason'] = 'Marked as Donation (non-reimbursable).';
|
||||
$updateData['approved_by'] = $userId ?: null;
|
||||
$updateData['reimbursement_id'] = null;
|
||||
} elseif (($expense['category'] ?? '') === 'Donation') {
|
||||
// Moving a donation back to a reimbursable category: clear the marker.
|
||||
$updateData['status_reason'] = null;
|
||||
$updateData['approved_by'] = $expense['approved_by'] ?? null;
|
||||
$updateData['status'] = $expense['status'] ?? 'pending';
|
||||
}
|
||||
|
||||
$this->expenseModel->update($id, $updateData);
|
||||
|
||||
return redirect()->to('/expenses/index')->with('success', 'Expense updated.');
|
||||
}
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Models\InvoiceModel;
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
class ExtraChargesController extends BaseController
|
||||
{
|
||||
protected $additionalChargeModel;
|
||||
protected $db;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $attendanceDataModel;
|
||||
protected $studentModel;
|
||||
protected $emergencyContactModel;
|
||||
protected $userModel;
|
||||
protected $teacherClassSection;
|
||||
protected $invoiceModel;
|
||||
protected $classSectionModel;
|
||||
protected $studentClassModel;
|
||||
protected $enableAttendance;
|
||||
protected $attendanceDayModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Initialize the database connection in the constructor
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
// API-first: delegate to JSON list endpoint
|
||||
return $this->apiList();
|
||||
}
|
||||
|
||||
/** Render HTML management page */
|
||||
public function page()
|
||||
{
|
||||
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
||||
$status = $this->request->getGet('status') ?: null;
|
||||
$yearSelect = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
|
||||
$rows = [];
|
||||
if ($parentId > 0) {
|
||||
$rows = $this->additionalChargeModel
|
||||
->byParentTerm($parentId, $this->schoolYear, $this->semester, $status);
|
||||
}
|
||||
|
||||
// Load ALL parents for current view
|
||||
$parents = $this->userModel->select('users.id, users.firstname, users.lastname')
|
||||
->join('user_roles', 'user_roles.user_id = users.id')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->where('LOWER(roles.name) =', 'parent')
|
||||
->where('user_roles.deleted_at IS NULL', null, false)
|
||||
->orderBy('users.lastname', 'ASC')
|
||||
->orderBy('users.firstname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
// Map parent id -> "Lastname, Firstname" (no email/ID shown)
|
||||
$parentMap = [];
|
||||
foreach ($parents as $p) {
|
||||
$label = trim(($p['lastname'] ?? '') . ', ' . ($p['firstname'] ?? ''));
|
||||
$parentMap[(int)$p['id']] = $label !== '' && $label !== ',' ? $label : ('User #' . (int)$p['id']);
|
||||
}
|
||||
|
||||
// 🔹 Get ALL invoices for ALL parents this year, group by parent_id
|
||||
$parentIds = array_map(fn($r) => (int)$r['id'], $parents);
|
||||
$invoicesByParent = [];
|
||||
if (!empty($parentIds)) {
|
||||
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $yearSelect);
|
||||
foreach ($all as $inv) {
|
||||
$pid = (int)$inv['parent_id'];
|
||||
$invoicesByParent[$pid][] = [
|
||||
'id' => (int)$inv['id'],
|
||||
'invoice_number' => (string)$inv['invoice_number'],
|
||||
'status' => (string)($inv['status'] ?? ''),
|
||||
'balance' => (float)($inv['balance'] ?? 0),
|
||||
'issue_date' => $inv['issue_date'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Label for preselect (if filtering by a parent)
|
||||
$selectedParentLabel = ($parentId > 0 && isset($parentMap[$parentId])) ? $parentMap[$parentId] : '';
|
||||
|
||||
// Optional filters
|
||||
$status = $this->request->getGet('status') ?: null;
|
||||
$q = trim((string)($this->request->getGet('q') ?? ''));
|
||||
|
||||
// ✅ Always pull all charges for the selected year & current semester (all parents)
|
||||
$rows = $this->additionalChargeModel->listAllForTerm(
|
||||
$yearSelect,
|
||||
$this->semester,
|
||||
$status,
|
||||
$q,
|
||||
50 // per-page
|
||||
);
|
||||
$pager = $this->additionalChargeModel->pager;
|
||||
|
||||
// Build school year options from data (additional_charges + invoices)
|
||||
$schoolYears = [];
|
||||
try {
|
||||
$q1 = $this->db->table('additional_charges')->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')->get()->getResultArray();
|
||||
foreach ($q1 as $r) {
|
||||
$val = (string)($r['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
try {
|
||||
$q2 = $this->db->table('invoices')->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')->get()->getResultArray();
|
||||
foreach ($q2 as $r) {
|
||||
$val = (string)($r['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
if (empty($schoolYears) && is_string($this->schoolYear) && $this->schoolYear !== '') {
|
||||
// fallback: generate recent years around configured schoolYear
|
||||
$schoolYears[] = $this->schoolYear;
|
||||
// Optionally add previous/next
|
||||
[$start, $end] = explode('-', $this->schoolYear) + [0 => date('Y'), 1 => date('Y')+1];
|
||||
$start = (int)$start;
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$schoolYears[] = ($start - $i) . '-' . (($start - $i) + 1);
|
||||
}
|
||||
}
|
||||
rsort($schoolYears);
|
||||
|
||||
return view('payment/extra_charges', [
|
||||
'q' => $q,
|
||||
'pager' => $pager,
|
||||
'rows' => $rows,
|
||||
'parents' => $parents,
|
||||
'parentMap' => $parentMap,
|
||||
'invoicesByParent' => $invoicesByParent, // 🔹 pass the grouped map
|
||||
'parentId' => $parentId,
|
||||
'selectedParentLabel' => $selectedParentLabel,
|
||||
'status' => $status,
|
||||
'schoolYear' => $yearSelect,
|
||||
'schoolYears' => $schoolYears,
|
||||
'semester' => $this->semester,
|
||||
]);
|
||||
}
|
||||
|
||||
private function wantsJson(): bool
|
||||
{
|
||||
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
|
||||
return $this->request->isAJAX() || str_contains($accept, 'application/json');
|
||||
}
|
||||
|
||||
/** Used by Select2 AJAX: returns {results:[{id,text}]} */
|
||||
public function parentOptions()
|
||||
{
|
||||
$q = trim((string)($this->request->getGet('q') ?? ''));
|
||||
$limit = 20;
|
||||
|
||||
$builder = $this->userModel
|
||||
->select('users.id, users.firstname, users.lastname, users.email')
|
||||
->join('user_roles', 'user_roles.user_id = users.id')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
// If your role name may have different case, use LOWER(...):
|
||||
// ->where('LOWER(roles.name) =', 'parent')
|
||||
->where('roles.name', 'parent')
|
||||
// Make sure this produces IS NULL; if not, use the raw condition line below
|
||||
->where('user_roles.deleted_at', null);
|
||||
|
||||
// Alternative guaranteed-IS-NULL:
|
||||
// $builder->where('user_roles.deleted_at IS NULL', null, false);
|
||||
|
||||
if ($q !== '') {
|
||||
$builder->groupStart()
|
||||
->like('users.firstname', $q)
|
||||
->orLike('users.lastname', $q)
|
||||
->orLike('users.email', $q)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$rows = $builder
|
||||
->orderBy('users.lastname', 'ASC')
|
||||
->orderBy('users.firstname', 'ASC')
|
||||
->limit($limit)
|
||||
->find();
|
||||
|
||||
$results = array_map(function ($r) {
|
||||
$name = trim(($r['lastname'] ?? '') . ', ' . ($r['firstname'] ?? ''));
|
||||
$email = $r['email'] ?? '';
|
||||
$label = $name !== ',' ? $name : ('User #' . $r['id']);
|
||||
if ($email) $label .= ' — ' . $email;
|
||||
$label .= ' (ID: ' . $r['id'] . ')';
|
||||
return ['id' => (int)$r['id'], 'text' => $label];
|
||||
}, $rows);
|
||||
|
||||
return $this->response->setJSON(['results' => $results]);
|
||||
}
|
||||
|
||||
/** Helper for preselect label in the modal */
|
||||
private function getParentLabel(int $id): string
|
||||
{
|
||||
$p = $this->userModel->select('firstname, lastname, email')->find($id);
|
||||
if (!$p) return 'User #' . $id;
|
||||
$name = trim(($p['lastname'] ?? '') . ', ' . ($p['firstname'] ?? ''));
|
||||
$email = $p['email'] ?? '';
|
||||
$label = $name !== ',' ? $name : ('User #' . $id);
|
||||
if ($email) $label .= ' — ' . $email;
|
||||
return $label . ' (ID: ' . $id . ')';
|
||||
}
|
||||
|
||||
public function update($id)
|
||||
{
|
||||
$row = $this->additionalChargeModel->find($id);
|
||||
if (!$row) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
|
||||
$data = $this->request->getPost();
|
||||
$newAmount = isset($data['amount']) ? (float)$data['amount'] : (float)$row['amount'];
|
||||
$delta = $newAmount - (float)$row['amount'];
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$db->transStart();
|
||||
|
||||
// Update the charge first
|
||||
$this->additionalChargeModel->update($id, [
|
||||
'title' => trim($data['title'] ?? $row['title']),
|
||||
'description' => trim($data['description'] ?? $row['description']),
|
||||
'amount' => $newAmount,
|
||||
'due_date' => $data['due_date'] ?? $row['due_date'],
|
||||
'charge_type' => $data['charge_type'] ?? $row['charge_type'],
|
||||
// keep status as-is
|
||||
]);
|
||||
|
||||
// If it’s already applied on an invoice and the amount changed, reflect the delta
|
||||
if ($row['status'] === 'applied' && !empty($row['invoice_id']) && abs($delta) > 0.00001) {
|
||||
if ($delta > 0) {
|
||||
$this->invoiceModel->applyAdditionalCharge((int)$row['invoice_id'], $delta);
|
||||
} else {
|
||||
$this->invoiceModel->reverseAdditionalCharge((int)$row['invoice_id'], -$delta);
|
||||
}
|
||||
}
|
||||
|
||||
$db->transComplete();
|
||||
|
||||
if (!$db->transStatus()) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to update charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('error', 'Failed to update charge.');
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true, 'id' => (int)$id, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('status', 'Charge updated.');
|
||||
}
|
||||
|
||||
// (removed duplicate model-like listAllForTerm method; use AdditionalChargeModel::listAllForTerm)
|
||||
|
||||
// ExtraChargesController.php
|
||||
public function invoicesForParent()
|
||||
{
|
||||
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
|
||||
$rows = ($parentId > 0)
|
||||
? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? [])
|
||||
: [];
|
||||
|
||||
$results = array_map(function ($inv) {
|
||||
$id = (int)($inv['id'] ?? 0);
|
||||
$num = (string)($inv['invoice_number'] ?? ('INV-' . $id));
|
||||
$status = strtolower((string)($inv['status'] ?? ''));
|
||||
$balance = (float)($inv['balance'] ?? 0);
|
||||
$issue = $inv['issue_date'] ?? null;
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'invoice_number' => $num,
|
||||
'status' => $status,
|
||||
'balance' => $balance,
|
||||
'issue_date' => $issue,
|
||||
'text' => $num
|
||||
. ($status ? ' — ' . ucfirst($status) : '')
|
||||
. ' (Bal: $' . number_format($balance, 2) . ')',
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->response->setJSON(['results' => $results]);
|
||||
}
|
||||
|
||||
|
||||
public function store()
|
||||
{
|
||||
$data = $this->request->getPost();
|
||||
|
||||
$rules = [
|
||||
'parent_id' => 'required|integer', // this is actually the user_id of the parent
|
||||
'title' => 'required|string|min_length[2]',
|
||||
'amount' => 'required|decimal',
|
||||
'charge_type' => 'required|in_list[add,deduct]',
|
||||
'due_date' => 'permit_empty|valid_date',
|
||||
'invoice_id' => 'permit_empty|integer',
|
||||
];
|
||||
if (!$this->validate($rules)) {
|
||||
$msg = implode(' ', $this->validator->getErrors());
|
||||
if ($this->wantsJson()) {
|
||||
return $this->response->setJSON([
|
||||
'ok' => false,
|
||||
'error' => $msg,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
return redirect()->back()->withInput()->with('error', $msg);
|
||||
}
|
||||
|
||||
$invoiceId = !empty($data['invoice_id']) ? (int)$data['invoice_id'] : null;
|
||||
$chargeType = (string)$data['charge_type'];
|
||||
|
||||
$amountAbs = round(abs((float)$data['amount']), 2);
|
||||
$signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs;
|
||||
|
||||
$payload = [
|
||||
'parent_id' => (int)$data['parent_id'], // ← users.id of the parent
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $data['school_year'] ?? $this->schoolYear,
|
||||
'semester' => $data['semester'] ?? $this->semester,
|
||||
'charge_type' => $chargeType,
|
||||
'title' => trim($data['title']),
|
||||
'description' => trim($data['description'] ?? ''),
|
||||
'amount' => $signedAmount,
|
||||
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
|
||||
'status' => $invoiceId ? 'applied' : 'pending',
|
||||
'created_by' => (int)(session()->get('user_id') ?? 0),
|
||||
'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC
|
||||
];
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
// BEFORE
|
||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
|
||||
|
||||
// Insert charge
|
||||
$this->additionalChargeModel->insert($payload);
|
||||
$chargeId = (int)$this->additionalChargeModel->getInsertID();
|
||||
|
||||
// Apply to invoice if present
|
||||
if ($invoiceId) {
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
$this->invoiceModel->deductAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'apply/deductAdditionalCharge failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER
|
||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
|
||||
|
||||
// Parent USER (not parent table)
|
||||
$parentUser = $this->userModel->getUserInfoById($data['parent_id']);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
$err = $this->db->error();
|
||||
log_message('error', 'Transaction failed in ExtraChargesController::store: ' . json_encode($err));
|
||||
if ($this->wantsJson()) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => 'Failed to save charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
}
|
||||
return redirect()->back()->withInput()->with('error', 'Failed to save charge.');
|
||||
}
|
||||
|
||||
// Build event payload (same style as your payment handler)
|
||||
// allow either first_name/last_name OR firstname/lastname from users table
|
||||
$first = $parentUser['first_name'] ?? $parentUser['firstname'] ?? '';
|
||||
$last = $parentUser['last_name'] ?? $parentUser['lastname'] ?? '';
|
||||
$parentName = trim($first . ' ' . $last);
|
||||
|
||||
$invoiceBefore = $this->normalizeRow($invoiceBefore);
|
||||
$invoiceAfter = $this->normalizeRow($invoiceAfter);
|
||||
|
||||
$invoiceTotal = $invoiceAfter ? (float)($invoiceAfter['total_amount'] ?? 0) : 0.0;
|
||||
$preBalance = $invoiceBefore ? (float)($invoiceBefore['balance'] ?? 0) : 0.0;
|
||||
$postBalance = $invoiceAfter ? (float)($invoiceAfter['balance'] ?? 0) : 0.0;
|
||||
|
||||
$eventData = [
|
||||
// user identity (parent user)
|
||||
'user_id' => (int)($parentUser['id'] ?? 0),
|
||||
'email' => $parentUser['email'] ?? null,
|
||||
'firstname' => $first,
|
||||
'lastname' => $last,
|
||||
'parentName' => $parentName,
|
||||
|
||||
// context
|
||||
'school_year' => $payload['school_year'],
|
||||
'semester' => $payload['semester'],
|
||||
|
||||
// invoice
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_number' => $invoiceAfter['invoice_number'] ?? ($invoiceId ? 'INV-' . $invoiceId : null),
|
||||
|
||||
// charge details
|
||||
'charge_id' => $chargeId,
|
||||
'charge_title' => $payload['title'],
|
||||
'charge_desc' => $payload['description'],
|
||||
'charge_type' => $payload['charge_type'], // add|deduct
|
||||
'amount_signed' => $signedAmount,
|
||||
'amount_abs' => $amountAbs,
|
||||
'due_date' => $payload['due_date'],
|
||||
'created_at' => $payload['created_at'],
|
||||
|
||||
// money snapshot
|
||||
'pre_balance' => $preBalance,
|
||||
'post_balance' => $postBalance,
|
||||
'invoice_total' => $invoiceTotal,
|
||||
|
||||
// links
|
||||
'portal_link' => base_url('/login'),
|
||||
'invoice_link' => $invoiceId ? site_url('parent/invoices/view/' . $invoiceId) : site_url('parent/invoices'),
|
||||
];
|
||||
|
||||
$students = []; // keep [] unless you want to include children under this user
|
||||
Events::trigger('extraCharge', $eventData, $students);
|
||||
|
||||
if ($this->wantsJson()) {
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'id' => $chargeId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => (int)$data['parent_id'],
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
return redirect()->to(site_url('admin/charges'))->with('status', 'Charge recorded.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a charge as void and roll back its impact on the invoice if applied.
|
||||
*/
|
||||
public function void($id)
|
||||
{
|
||||
$row = $this->additionalChargeModel->find((int)$id);
|
||||
if (!$row) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
|
||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
||||
$status = (string)($row['status'] ?? 'pending');
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) {
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
// voiding a deduction -> add back
|
||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'void(): invoice adjust failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => 'void',
|
||||
]);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to void charge']);
|
||||
return redirect()->back()->with('error', 'Failed to void charge.');
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge voided.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse a previously applied charge: undo invoice impact and return to pending state.
|
||||
*/
|
||||
public function reverse($id)
|
||||
{
|
||||
$row = $this->additionalChargeModel->find((int)$id);
|
||||
if (!$row) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
|
||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
||||
$status = (string)($row['status'] ?? 'pending');
|
||||
|
||||
if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Nothing to reverse']);
|
||||
return redirect()->back()->with('error', 'Nothing to reverse.');
|
||||
}
|
||||
|
||||
$this->db->transStart();
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'reverse(): invoice adjust failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => 'pending',
|
||||
'invoice_id' => null,
|
||||
]);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to reverse charge']);
|
||||
return redirect()->back()->with('error', 'Failed to reverse charge.');
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge reversed to pending.');
|
||||
}
|
||||
|
||||
/** JSON: list charges for the current term (with optional filters). */
|
||||
public function apiList()
|
||||
{
|
||||
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
$sem = (string)($this->request->getGet('semester') ?? $this->semester);
|
||||
$status = $this->request->getGet('status') ?: null;
|
||||
$q = trim((string)($this->request->getGet('q') ?? '')) ?: null;
|
||||
$per = (int)($this->request->getGet('per_page') ?? 50);
|
||||
|
||||
$rows = $this->additionalChargeModel->listAllForTerm($year, $sem, $status, $q, $per);
|
||||
$pager = $this->additionalChargeModel->pager;
|
||||
|
||||
$meta = [
|
||||
'perPage' => method_exists($pager, 'getPerPage') ? $pager->getPerPage() : $per,
|
||||
'page' => method_exists($pager, 'getCurrentPage') ? $pager->getCurrentPage() : null,
|
||||
'total' => method_exists($pager, 'getTotal') ? $pager->getTotal() : null,
|
||||
'pageCount' => method_exists($pager, 'getPageCount') ? $pager->getPageCount() : null,
|
||||
];
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'school_year' => $year,
|
||||
'semester' => $sem,
|
||||
'rows' => $rows,
|
||||
'pager' => $meta,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Normalize query result into a single associative row
|
||||
private function normalizeRow($row): ?array
|
||||
{
|
||||
if (!$row) return null;
|
||||
if (is_array($row) && isset($row[0]) && is_array($row[0])) {
|
||||
return $row[0]; // unwrap first row from a result set
|
||||
}
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
|
||||
class FeeCalculationService
|
||||
{
|
||||
public function calculateRefund(array $students, int $parentId): float
|
||||
{
|
||||
$configModel = new ConfigurationModel();
|
||||
$paymentModel = new PaymentModel();
|
||||
$invoiceModel = new InvoiceModel();
|
||||
$classSectionModel = new ClassSectionModel();
|
||||
|
||||
$schoolYear = $configModel->getConfig('school_year');
|
||||
$refundDeadline = date('Y-m-d', strtotime($configModel->getConfig('refund_deadline')));
|
||||
$weekOfStudy = (float) ($configModel->getConfig('weeks_study') ?? 8);
|
||||
$schoolEndDate = date('Y-m-d', strtotime($configModel->getConfig('last_school_day')));
|
||||
$totalPaid = $paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
|
||||
|
||||
if ($totalPaid <= 0) {
|
||||
log_message('info', "No payments made. Refund = 0.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Classify and enrich student data
|
||||
$registeredStudents = [];
|
||||
$withdrawnStudents = [];
|
||||
|
||||
foreach ($students as &$student) {
|
||||
$gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']);
|
||||
$student['grade'] = strtoupper(trim($gradeName));
|
||||
|
||||
if (in_array($student['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) {
|
||||
$withdrawnStudents[] = $student;
|
||||
} elseif (
|
||||
in_array($student['enrollment_status'], ['enrolled', 'payment pending']) &&
|
||||
$student['admission_status'] === 'accepted'
|
||||
) {
|
||||
$registeredStudents[] = $student;
|
||||
}
|
||||
}
|
||||
unset($student);
|
||||
|
||||
if (empty($withdrawnStudents)) {
|
||||
log_message('info', "No withdrawn students found. Refund = 0.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Combine all students for proper fee tiering
|
||||
$allStudents = array_merge($registeredStudents, $withdrawnStudents);
|
||||
|
||||
// Sort all students by grade for correct tiering
|
||||
usort($allStudents, function ($a, $b) {
|
||||
return $this->compareGrades($a['grade'], $b['grade']);
|
||||
});
|
||||
|
||||
// Retrieve fee configs
|
||||
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
|
||||
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
|
||||
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180);
|
||||
|
||||
// Assign tuition_fee to all students (before filtering refunds)
|
||||
$regularCount = 0;
|
||||
foreach ($allStudents as &$student) {
|
||||
$gradeLevel = $this->getGradeLevel($student['grade']);
|
||||
|
||||
if ($gradeLevel > 9) {
|
||||
$studentFee = $youthFee;
|
||||
} else {
|
||||
$studentFee = ($regularCount === 0) ? $firstStudentFee : $secondStudentFee;
|
||||
$regularCount++;
|
||||
}
|
||||
}
|
||||
unset($student);
|
||||
|
||||
// Calculate refund for withdrawn students
|
||||
$refundAmount = 0;
|
||||
|
||||
foreach ($withdrawnStudents as $student) {
|
||||
if (empty($student['withdrawal_date'])) {
|
||||
log_message('warning', "Missing withdraw date for student ID: {$student['student_id']}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$withdrawDate = date('Y-m-d', strtotime($student['withdrawal_date']));
|
||||
if (strtotime($withdrawDate) > strtotime($refundDeadline)) {
|
||||
log_message('info', "Withdraw date {$withdrawDate} is after refund deadline {$refundDeadline}. No refund for this student.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$withdrawDateObj = new \DateTime($withdrawDate);
|
||||
$schoolEndDateObj = new \DateTime($schoolEndDate);
|
||||
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
|
||||
$weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7)));
|
||||
|
||||
//$studentFee = $student['tuition_fee'];
|
||||
$proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining;
|
||||
$refundAmount += $proportionalRefund;
|
||||
|
||||
log_message('info', "Student ID {$student['student_id']} refund portion: {$proportionalRefund} of {$studentFee} for {$weeksRemaining} weeks.");
|
||||
}
|
||||
|
||||
if ($refundAmount > $totalPaid) {
|
||||
log_message('info', "Refund capped at total paid amount: {$totalPaid}");
|
||||
return $totalPaid;
|
||||
}
|
||||
|
||||
log_message('info', "Final calculated refund: {$refundAmount}");
|
||||
return $refundAmount;
|
||||
}
|
||||
|
||||
|
||||
private function compareGrades($gradeA, $gradeB)
|
||||
{
|
||||
$valA = $this->getGradeLevel($gradeA);
|
||||
$valB = $this->getGradeLevel($gradeB);
|
||||
|
||||
if ($valA !== $valB) return $valA <=> $valB;
|
||||
|
||||
// Same level, compare suffix
|
||||
preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeA), $suffixA);
|
||||
preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeB), $suffixB);
|
||||
|
||||
return strcmp($suffixA[1] ?? '', $suffixB[1] ?? '');
|
||||
}
|
||||
|
||||
private function getGradeLevel($grade)
|
||||
{
|
||||
if (strtoupper($grade) === 'K') return 0;
|
||||
if (strtoupper($grade) === 'Y') return 99;
|
||||
|
||||
if (preg_match('/^(\d+)([A-Z]*)$/i', $grade, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
return 999; // fallback for unknown/malformed grades
|
||||
}
|
||||
|
||||
|
||||
private function calculateTotalTuitionFee(array $students): float
|
||||
{
|
||||
$configModel = new ConfigurationModel();
|
||||
$classSectionModel = new \App\Models\ClassSectionModel();
|
||||
|
||||
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
|
||||
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
|
||||
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180);
|
||||
|
||||
// ✅ Pre-fetch and assign grade/class section names before sorting
|
||||
foreach ($students as &$student) {
|
||||
$gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']);
|
||||
$student['grade'] = strtoupper(trim($gradeName));
|
||||
}
|
||||
unset($student); // break reference
|
||||
|
||||
// ✅ Sort students by grade
|
||||
usort($students, function ($a, $b) {
|
||||
return $this->compareGrades($a['grade'], $b['grade']);
|
||||
});
|
||||
|
||||
$regularCount = 0;
|
||||
$totalFee = 0;
|
||||
|
||||
// ✅ Calculate fee
|
||||
foreach ($students as $student) {
|
||||
$gradeLevel = $this->getGradeLevel($student['grade']);
|
||||
|
||||
if ($gradeLevel > 9) {
|
||||
$totalFee += $youthFee;
|
||||
} else {
|
||||
$totalFee += ($regularCount === 0) ? $firstStudentFee : $secondStudentFee;
|
||||
$regularCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $totalFee;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
use Config\Database;
|
||||
|
||||
class FilesController extends Controller
|
||||
{
|
||||
public function receipt(string $name)
|
||||
{
|
||||
// 1) Path traversal guard
|
||||
if ($name !== basename($name)) {
|
||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
// 2) Allow-list extensions (optional but safer)
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 3) Build path under writable
|
||||
$path = WRITEPATH . 'uploads/receipts/' . $name;
|
||||
if (!is_file($path)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 4) MIME detection (finfo is more reliable than mime_content_type)
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($fi) {
|
||||
$detected = finfo_file($fi, $path);
|
||||
if ($detected) $mime = $detected;
|
||||
finfo_close($fi);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Caching (ETag + Last-Modified) and 304 support
|
||||
$mtime = filemtime($path) ?: time();
|
||||
$etag = md5($name . '|' . $mtime . '|' . filesize($path));
|
||||
|
||||
$ifNoneMatch = $this->request->getHeaderLine('If-None-Match');
|
||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
||||
|
||||
if ($ifNoneMatch === $etag || (strtotime($ifModifiedSince) >= $mtime)) {
|
||||
return $this->response
|
||||
->setStatusCode(304)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||
}
|
||||
|
||||
// 6) Stream the file inline
|
||||
return $this->response
|
||||
->setStatusCode(200)
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
|
||||
->setHeader('Content-Length', (string) filesize($path))
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
||||
->setBody(file_get_contents($path));
|
||||
}
|
||||
|
||||
public function reimb(string $name)
|
||||
{
|
||||
// 1) Path traversal guard
|
||||
if ($name !== basename($name)) {
|
||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
// 2) Allow-list extensions
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 3) Build path under writable (REIMBURSEMENTS)
|
||||
$path = WRITEPATH . 'uploads/reimbursements/' . $name;
|
||||
if (!is_file($path)) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 4) MIME detection (prefer finfo)
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($fi) {
|
||||
$detected = finfo_file($fi, $path);
|
||||
if ($detected) {
|
||||
$mime = $detected;
|
||||
}
|
||||
finfo_close($fi);
|
||||
}
|
||||
} elseif (function_exists('mime_content_type')) {
|
||||
$mime = mime_content_type($path) ?: $mime;
|
||||
}
|
||||
|
||||
// 5) Caching (ETag + Last-Modified) and 304 support
|
||||
$mtime = filemtime($path) ?: time();
|
||||
$size = filesize($path) ?: 0;
|
||||
$etag = md5($name . '|' . $mtime . '|' . $size);
|
||||
|
||||
$ifNoneMatch = trim($this->request->getHeaderLine('If-None-Match'), '"');
|
||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
||||
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
|
||||
|
||||
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
|
||||
($imsTime !== false && $imsTime >= $mtime)
|
||||
) {
|
||||
return $this->response
|
||||
->setStatusCode(304)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||
}
|
||||
|
||||
// 6) Stream the file inline
|
||||
return $this->response
|
||||
->setStatusCode(200)
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('X-Content-Type-Options', 'nosniff')
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
|
||||
->setHeader('Content-Length', (string) $size)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
||||
->setBody(file_get_contents($path));
|
||||
}
|
||||
|
||||
public function earlyDismissalSignature(string $name)
|
||||
{
|
||||
// 1) Path traversal guard
|
||||
if ($name !== basename($name)) {
|
||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
// 2) Allow-list extensions
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 3) Build path under writable (EARLY DISMISSAL SIGNATURES)
|
||||
$path = WRITEPATH . 'uploads/early_dismissal_signatures/' . $name;
|
||||
if (!is_file($path)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 4) MIME detection (prefer finfo)
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($fi) {
|
||||
$detected = finfo_file($fi, $path);
|
||||
if ($detected) {
|
||||
$mime = $detected;
|
||||
}
|
||||
finfo_close($fi);
|
||||
}
|
||||
} elseif (function_exists('mime_content_type')) {
|
||||
$mime = mime_content_type($path) ?: $mime;
|
||||
}
|
||||
|
||||
// 5) Caching (ETag + Last-Modified) and 304 support
|
||||
$mtime = filemtime($path) ?: time();
|
||||
$size = filesize($path) ?: 0;
|
||||
$etag = md5($name . '|' . $mtime . '|' . $size);
|
||||
|
||||
$ifNoneMatch = trim($this->request->getHeaderLine('If-None-Match'), '"');
|
||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
||||
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
|
||||
|
||||
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
|
||||
($imsTime !== false && $imsTime >= $mtime)
|
||||
) {
|
||||
return $this->response
|
||||
->setStatusCode(304)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||
}
|
||||
|
||||
// 6) Stream the file inline
|
||||
return $this->response
|
||||
->setStatusCode(200)
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('X-Content-Type-Options', 'nosniff')
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
|
||||
->setHeader('Content-Length', (string) $size)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
||||
->setBody(file_get_contents($path));
|
||||
}
|
||||
|
||||
public function examDraftTeacher(string $name)
|
||||
{
|
||||
return $this->serveExamDraftFile($name, 'drafts');
|
||||
}
|
||||
|
||||
public function examDraftFinal(string $name)
|
||||
{
|
||||
return $this->serveExamDraftFile($name, 'finals');
|
||||
}
|
||||
|
||||
private function serveExamDraftFile(string $name, string $subdir)
|
||||
{
|
||||
if ($name !== basename($name)) {
|
||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
$allowed = ['doc', 'docx', 'pdf'];
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
$path = WRITEPATH . 'uploads/exams/' . trim($subdir, '/') . '/' . $name;
|
||||
if (!is_file($path)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($fi) {
|
||||
$detected = finfo_file($fi, $path);
|
||||
if ($detected) {
|
||||
$mime = $detected;
|
||||
}
|
||||
finfo_close($fi);
|
||||
}
|
||||
} elseif (function_exists('mime_content_type')) {
|
||||
$mime = mime_content_type($path) ?: $mime;
|
||||
}
|
||||
|
||||
$mtime = filemtime($path) ?: time();
|
||||
$size = filesize($path) ?: 0;
|
||||
$etag = md5($name . '|' . $mtime . '|' . $size);
|
||||
|
||||
$ifNoneMatch = trim($this->request->getHeaderLine('If-None-Match'), '"');
|
||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
||||
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
|
||||
|
||||
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
|
||||
($imsTime !== false && $imsTime >= $mtime)
|
||||
) {
|
||||
return $this->response
|
||||
->setStatusCode(304)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||
}
|
||||
|
||||
$downloadName = $this->buildDraftDownloadName($name, $subdir);
|
||||
|
||||
return $this->response
|
||||
->setStatusCode(200)
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('X-Content-Type-Options', 'nosniff')
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $downloadName . '.' . $ext . '"')
|
||||
->setHeader('Content-Length', (string) $size)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
||||
->setBody(file_get_contents($path));
|
||||
}
|
||||
|
||||
private function buildDraftDownloadName(string $filename, string $subdir): string
|
||||
{
|
||||
$column = $subdir === 'finals' ? 'final_file' : 'teacher_file';
|
||||
$db = Database::connect();
|
||||
$row = $db->table('exam_drafts ed')
|
||||
->select('ed.version, ed.exam_type, ed.class_section_id, cs.class_section_name')
|
||||
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left')
|
||||
->where('ed.' . $column, $filename)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$classLabel = trim((string) ($row['class_section_name'] ?? 'Class' . ($row['class_section_id'] ?? '0')));
|
||||
$typeLabel = trim((string) ($row['exam_type'] ?? 'Exam'));
|
||||
$version = 'v' . max(1, (int) ($row['version'] ?? 1));
|
||||
|
||||
$parts = array_filter([
|
||||
$this->slugify($classLabel),
|
||||
$this->slugify($typeLabel),
|
||||
$version,
|
||||
]);
|
||||
|
||||
return implode('_', $parts);
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$value = preg_replace('/[^\p{L}\p{N}]+/u', '_', $value);
|
||||
$value = trim($value, '_');
|
||||
return $value === '' ? 'Exam' : mb_strtolower($value);
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
class FinalController extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $configModel;
|
||||
protected $studentModel;
|
||||
protected $teacherClassModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
}
|
||||
|
||||
public function add()
|
||||
{
|
||||
// Accept class_section_id from POST/GET with session fallback
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
// Persist for downstream calls
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->schoolYear;
|
||||
try {
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'final_exam');
|
||||
|
||||
return view('teacher/add_final_exam', [
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'missingOkMap' => $missingOkMap,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function showFinalMngt()
|
||||
{
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->schoolYear;
|
||||
try {
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
return view('grading/final', [
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid class section id.');
|
||||
}
|
||||
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// Subquery: pick ONE final exam row per student (latest by id)
|
||||
$latestFinalSub = $this->db->table('final_exam')
|
||||
->select('student_id, MAX(id) AS max_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('student_id')
|
||||
->getCompiledSelect();
|
||||
|
||||
// Main query: roster + LEFT JOIN the single final exam row
|
||||
$qb = $this->studentModel
|
||||
->select('
|
||||
s.id AS student_id,
|
||||
s.school_id,
|
||||
s.firstname,
|
||||
s.lastname,
|
||||
fe.score
|
||||
')
|
||||
->from('students s')
|
||||
->join(
|
||||
'student_class sc',
|
||||
'sc.student_id = s.id
|
||||
AND sc.class_section_id = ' . (int) $classSectionId . '
|
||||
AND sc.school_year = ' . $this->db->escape($schoolYear),
|
||||
'inner',
|
||||
false
|
||||
)
|
||||
->join('(' . $latestFinalSub . ') fl', 'fl.student_id = s.id', 'left', false)
|
||||
->join('final_exam fe', 'fe.id = fl.max_id', 'left')
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC');
|
||||
|
||||
$qb->distinct();
|
||||
$rows = $qb->get()->getResultArray();
|
||||
|
||||
// Ensure one row per student
|
||||
$unique = [];
|
||||
foreach ($rows as $r) {
|
||||
$unique[(int)$r['student_id']] = $r;
|
||||
}
|
||||
$students = array_values($unique);
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
private function getTeacherSelectedSemester(): string
|
||||
{
|
||||
helper('semester_selection_helper');
|
||||
return selected_teacher_semester();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,568 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\CurrentFlagModel;
|
||||
use App\Models\FlagModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
|
||||
|
||||
class FlagController extends Controller
|
||||
{
|
||||
protected $userModel;
|
||||
protected $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
//$this->userModel = new UserModel();
|
||||
helper(['url', 'form']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
$classSectionModel = new ClassSectionModel();
|
||||
$studentClassModel = new StudentClassModel();
|
||||
$configModel = new ConfigurationModel();
|
||||
|
||||
// Format grades with id and name for passing to the view
|
||||
$grades = [];
|
||||
|
||||
// Retrieve flags and class sections that currently have active students
|
||||
$flags = $currentFlagModel->findAll();
|
||||
$schoolYear = $configModel->getConfig('school_year');
|
||||
$studentCounts = $studentClassModel->getStudentCountsBySection($schoolYear);
|
||||
$classSections = [];
|
||||
|
||||
if (!empty($studentCounts)) {
|
||||
$classSectionIdsWithStudents = array_keys($studentCounts);
|
||||
$classSections = $classSectionModel
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->whereIn('class_section_id', $classSectionIdsWithStudents)
|
||||
->findAll();
|
||||
}
|
||||
|
||||
foreach ($classSections as $section) {
|
||||
$grades[] = [
|
||||
'id' => $section['class_section_id'],
|
||||
'name' => $section['class_section_name']
|
||||
];
|
||||
}
|
||||
|
||||
// Map updater user IDs to display names for the view
|
||||
$updaterIds = [];
|
||||
foreach ($flags as $flag) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
if (!empty($flag[$key])) {
|
||||
$updaterIds[(int)$flag[$key]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$updaterMap = [];
|
||||
if (!empty($updaterIds)) {
|
||||
$rows = $this->db->table('users')
|
||||
->select('id, firstname, lastname')
|
||||
->whereIn('id', array_keys($updaterIds))
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||
$updaterMap[(int)$row['id']] = $name !== '' ? $name : ('User #' . $row['id']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($flags as &$flag) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
$id = (int)($flag[$key] ?? 0);
|
||||
$flag[$key . '_name'] = $id > 0 ? ($updaterMap[$id] ?? null) : null;
|
||||
}
|
||||
}
|
||||
unset($flag);
|
||||
|
||||
// Pass flags and formatted grades to the view
|
||||
return view('flags/flags_management', [
|
||||
'flags' => $flags,
|
||||
'grades' => $grades // Now grades include both id and class_section_name
|
||||
]);
|
||||
}
|
||||
|
||||
public function history()
|
||||
{
|
||||
$flagModel = new FlagModel();
|
||||
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? '');
|
||||
$semester = (string)($this->request->getGet('semester') ?? '');
|
||||
|
||||
$builder = $flagModel->orderBy('flag_datetime', 'DESC');
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($semester !== '') {
|
||||
$builder->where('semester', $semester);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'flags' => $builder->findAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function processedFlags()
|
||||
{
|
||||
$flagModel = new FlagModel();
|
||||
$classSectionModel = new ClassSectionModel();
|
||||
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? '');
|
||||
$semester = (string)($this->request->getGet('semester') ?? '');
|
||||
|
||||
$builder = $flagModel->orderBy('flag_datetime', 'DESC');
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($semester !== '') {
|
||||
$builder->where('semester', $semester);
|
||||
}
|
||||
|
||||
$flags = $builder->findAll();
|
||||
|
||||
$gradeIds = [];
|
||||
foreach ($flags as $flag) {
|
||||
$gradeValue = $flag['grade'] ?? '';
|
||||
if (is_numeric($gradeValue)) {
|
||||
$gradeIds[(int)$gradeValue] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$gradeMap = [];
|
||||
if (!empty($gradeIds)) {
|
||||
$rows = $classSectionModel
|
||||
->select('class_section_id, class_section_name')
|
||||
->whereIn('class_section_id', array_keys($gradeIds))
|
||||
->findAll();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$gradeMap[(int)$row['class_section_id']] = (string)($row['class_section_name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// Map updater user IDs to display names for the view
|
||||
$updaterIds = [];
|
||||
foreach ($flags as $flag) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
if (!empty($flag[$key])) {
|
||||
$updaterIds[(int)$flag[$key]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$updaterMap = [];
|
||||
if (!empty($updaterIds)) {
|
||||
$rows = $this->db->table('users')
|
||||
->select('id, firstname, lastname')
|
||||
->whereIn('id', array_keys($updaterIds))
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||
$updaterMap[(int)$row['id']] = $name !== '' ? $name : ('User #' . $row['id']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($flags as &$flag) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
$id = (int)($flag[$key] ?? 0);
|
||||
$flag[$key . '_name'] = $id > 0 ? ($updaterMap[$id] ?? null) : null;
|
||||
}
|
||||
|
||||
$gradeValue = $flag['grade'] ?? '';
|
||||
if (is_numeric($gradeValue)) {
|
||||
$flag['grade'] = $gradeMap[(int)$gradeValue] ?? (string)$gradeValue;
|
||||
}
|
||||
}
|
||||
unset($flag);
|
||||
|
||||
return view('flags/processed_flags', [
|
||||
'flags' => $flags
|
||||
]);
|
||||
}
|
||||
|
||||
public function incidentAnalysis()
|
||||
{
|
||||
$flagModel = new FlagModel();
|
||||
$classSectionModel = new ClassSectionModel();
|
||||
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? '');
|
||||
$semester = (string)($this->request->getGet('semester') ?? '');
|
||||
|
||||
$builder = $flagModel->orderBy('flag_datetime', 'DESC');
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($semester !== '') {
|
||||
$builder->where('semester', $semester);
|
||||
}
|
||||
|
||||
$flags = $builder->findAll();
|
||||
|
||||
$gradeIds = [];
|
||||
foreach ($flags as $flag) {
|
||||
$gradeValue = $flag['grade'] ?? '';
|
||||
if (is_numeric($gradeValue)) {
|
||||
$gradeIds[(int)$gradeValue] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$gradeMap = [];
|
||||
if (!empty($gradeIds)) {
|
||||
$rows = $classSectionModel
|
||||
->select('class_section_id, class_section_name')
|
||||
->whereIn('class_section_id', array_keys($gradeIds))
|
||||
->findAll();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$gradeMap[(int)$row['class_section_id']] = (string)($row['class_section_name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$students = [];
|
||||
foreach ($flags as $flag) {
|
||||
$studentId = (string)($flag['student_id'] ?? '');
|
||||
$studentName = (string)($flag['student_name'] ?? '');
|
||||
$studentKey = $studentId !== '' ? 'id:' . $studentId : 'name:' . strtolower(trim($studentName));
|
||||
$gradeValue = $flag['grade'] ?? '';
|
||||
$gradeLabel = $gradeValue;
|
||||
if (is_numeric($gradeValue)) {
|
||||
$gradeLabel = $gradeMap[(int)$gradeValue] ?? (string)$gradeValue;
|
||||
}
|
||||
|
||||
if (!isset($students[$studentKey])) {
|
||||
$students[$studentKey] = [
|
||||
'student_id' => $studentId,
|
||||
'student_name' => $studentName,
|
||||
'grade' => (string)$gradeLabel,
|
||||
'total' => 0,
|
||||
'open' => 0,
|
||||
'closed' => 0,
|
||||
'canceled' => 0,
|
||||
'last_incident' => null,
|
||||
'logs' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$students[$studentKey]['total'] += 1;
|
||||
|
||||
$state = (string)($flag['flag_state'] ?? '');
|
||||
if ($state === 'Open') {
|
||||
$students[$studentKey]['open'] += 1;
|
||||
} elseif ($state === 'Closed') {
|
||||
$students[$studentKey]['closed'] += 1;
|
||||
} elseif ($state === 'Canceled') {
|
||||
$students[$studentKey]['canceled'] += 1;
|
||||
}
|
||||
|
||||
if ($students[$studentKey]['last_incident'] === null) {
|
||||
$students[$studentKey]['last_incident'] = $flag['flag_datetime'] ?? null;
|
||||
}
|
||||
|
||||
$students[$studentKey]['logs'][] = [
|
||||
'flag' => $flag['flag'] ?? '',
|
||||
'flag_state' => $state,
|
||||
'flag_datetime' => $flag['flag_datetime'] ?? null,
|
||||
'open_description' => $flag['open_description'] ?? null,
|
||||
'close_description' => $flag['close_description'] ?? null,
|
||||
'cancel_description' => $flag['cancel_description'] ?? null,
|
||||
'action_taken' => $flag['action_taken'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$studentRows = array_values($students);
|
||||
usort($studentRows, function ($a, $b) {
|
||||
if ($a['total'] === $b['total']) {
|
||||
return strcasecmp($a['student_name'], $b['student_name']);
|
||||
}
|
||||
return $b['total'] <=> $a['total'];
|
||||
});
|
||||
|
||||
return view('flags/incident_analysis', [
|
||||
'students' => $studentRows,
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getStudentsByGrade($gradeId)
|
||||
{
|
||||
$studentClassModel = new StudentClassModel(); // Model for the student_class table
|
||||
$studentModel = new StudentModel(); // Model for the students table
|
||||
|
||||
// Fetch student IDs for the selected grade
|
||||
$studentIds = $studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $gradeId)
|
||||
->findColumn('student_id');
|
||||
|
||||
// If no student IDs are found, return an empty JSON array
|
||||
if (empty($studentIds)) {
|
||||
return $this->response->setJSON([]);
|
||||
}
|
||||
|
||||
// Fetch student details from students table using the retrieved IDs
|
||||
$students = $studentModel
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->findAll();
|
||||
|
||||
// Prepare data for JSON response with both ID and full name
|
||||
$studentData = [];
|
||||
foreach ($students as $student) {
|
||||
$studentData[] = [
|
||||
'id' => $student['id'], // Student ID
|
||||
'name' => $student['firstname'] . ' ' . $student['lastname'] // Full name
|
||||
];
|
||||
}
|
||||
|
||||
// Return JSON response with student ID and full name
|
||||
return $this->response->setJSON($studentData);
|
||||
}
|
||||
|
||||
public function addFlag()
|
||||
{
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
$studentModel = new StudentModel();
|
||||
$configModel = new ConfigurationModel();
|
||||
|
||||
// Get the data from the form submission
|
||||
$studentId = $this->request->getPost('student');
|
||||
$flagType = $this->request->getPost('flag');
|
||||
$description = $this->request->getPost('description');
|
||||
$flagState = $this->request->getPost('flag_state');
|
||||
$stateDescription = $this->request->getPost('state_description'); // Additional description for "Closed" or "Canceled"
|
||||
|
||||
// Retrieve student information from the database
|
||||
$student = $studentModel->find($studentId);
|
||||
if (!$student) {
|
||||
session()->setFlashdata('error', 'Student not found.');
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
// Get the user_id from the session for updated_by
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
// Get the semester and school year from configuration
|
||||
$semester = $configModel->getConfig('semester');
|
||||
$schoolYear = $configModel->getConfig('school_year');
|
||||
|
||||
// Set the current system date and time for flag_datetime
|
||||
$currentDateTime = utc_now();
|
||||
|
||||
// Check if a flag of the same type already exists for this student
|
||||
$existingFlag = $currentFlagModel->where('student_id', $studentId)
|
||||
->where('flag', $flagType)
|
||||
->first();
|
||||
|
||||
if ($existingFlag) {
|
||||
// If the flag exists, update it based on the new state and description
|
||||
$data = [
|
||||
'flag_datetime' => $currentDateTime,
|
||||
'updated_by_open' => $userId
|
||||
];
|
||||
|
||||
// Append the new description to the appropriate field based on flag state
|
||||
if ($flagState === 'Closed') {
|
||||
$data['flag_state'] = 'Closed';
|
||||
$data['close_description'] = ($existingFlag['close_description'] ?? '') . PHP_EOL . $stateDescription;
|
||||
} elseif ($flagState === 'Canceled') {
|
||||
$data['flag_state'] = 'Canceled';
|
||||
$data['cancel_description'] = ($existingFlag['cancel_description'] ?? '') . PHP_EOL . $stateDescription;
|
||||
} else {
|
||||
$data['open_description'] = $existingFlag['open_description'] . PHP_EOL . $description;
|
||||
}
|
||||
|
||||
// Update the existing flag
|
||||
$currentFlagModel->update($existingFlag['id'], $data);
|
||||
session()->setFlashdata('success', 'Flag updated successfully!');
|
||||
} else {
|
||||
// If no existing flag is found, prepare the data for insertion
|
||||
$data = [
|
||||
'student_id' => $student['id'],
|
||||
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
|
||||
'grade' => $this->request->getPost('grade'),
|
||||
'flag' => $flagType,
|
||||
'flag_datetime' => $currentDateTime,
|
||||
'flag_state' => 'Open', // Set initial state to 'Open'
|
||||
'updated_by_open' => $userId,
|
||||
'open_description' => $description,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear
|
||||
];
|
||||
|
||||
// Insert the new flag
|
||||
if ($currentFlagModel->insert($data)) {
|
||||
session()->setFlashdata('success', 'New incident added successfully!');
|
||||
} else {
|
||||
session()->setFlashdata('error', 'Failed to add new incident.');
|
||||
}
|
||||
}
|
||||
// Redirect to the list view
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
public function updateState($id)
|
||||
{
|
||||
log_message('debug', 'updateState method called for ID: ' . $id);
|
||||
log_message('debug', 'Flag state: ' . $this->request->getPost('flag_state'));
|
||||
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
|
||||
// Get the new flag state from the form
|
||||
$newState = $this->request->getPost('flag_state');
|
||||
|
||||
if (!$newState) {
|
||||
session()->setFlashdata('error', 'incident state not provided.');
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
// Update the flag state in the database
|
||||
if ($currentFlagModel->update($id, ['flag_state' => $newState])) {
|
||||
session()->setFlashdata('success', 'Incident state updated successfully!');
|
||||
} else {
|
||||
$errors = $currentFlagModel->errors();
|
||||
log_message('error', 'Failed to update incident state: ' . print_r($errors, true));
|
||||
session()->setFlashdata('error', 'Failed to update incident state.');
|
||||
}
|
||||
|
||||
// Redirect back to the flags list
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
public function closeFlag($flagId)
|
||||
{
|
||||
// Log the whole session array
|
||||
log_message('debug', json_encode(session()->get()));
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
// Get the current flag data
|
||||
$flagData = $currentFlagModel->find($flagId);
|
||||
|
||||
// Log and check if flag data is found
|
||||
if (!$flagData) {
|
||||
log_message('error', "incident with ID {$flagId} not found in database.");
|
||||
session()->setFlashdata('error', 'Incident not found.');
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// Proceed only if flag is not closed
|
||||
if ($flagData['flag_state'] !== 'Closed') {
|
||||
// Retrieve 'close_description' from POST request
|
||||
$closeDescription = $this->request->getPost('state_description');
|
||||
$actionTaken = $this->request->getPost('action_taken');
|
||||
|
||||
// Debugging: Check if the close description is received
|
||||
if (empty($closeDescription)) {
|
||||
session()->setFlashdata('error', 'Close description is missing.');
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// Update the flag state to Closed
|
||||
$currentFlagModel->update($flagId, [
|
||||
'flag_state' => 'Closed',
|
||||
'updated_by_closed' => $userId,
|
||||
'close_description' => $closeDescription, // Use dynamic description from form
|
||||
'action_taken' => $actionTaken
|
||||
]);
|
||||
|
||||
$flagData = $currentFlagModel->find($flagId);
|
||||
return $this->moveToHistory($flagData);
|
||||
} else {
|
||||
session()->setFlashdata('error', 'Incident is already closed.');
|
||||
}
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
public function cancelFlag($flagId)
|
||||
{
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
// Retrieve 'cancel_description' from POST request
|
||||
$cancelDescription = $this->request->getPost('state_description');
|
||||
$actionTaken = $this->request->getPost('action_taken');
|
||||
|
||||
// Get the current flag data
|
||||
$flagData = $currentFlagModel->find($flagId);
|
||||
|
||||
// Check if flag data is found
|
||||
if (!$flagData) {
|
||||
log_message('error', "Incident with ID {$flagId} not found in database.");
|
||||
session()->setFlashdata('error', 'Incident not found.');
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// Check if the flag is not already canceled
|
||||
if ($flagData['flag_state'] !== 'Canceled') {
|
||||
// Update the flag state to Canceled
|
||||
$currentFlagModel->update($flagId, [
|
||||
'flag_state' => 'Canceled',
|
||||
'updated_by_canceled' => $userId,
|
||||
'cancel_description' => $cancelDescription,
|
||||
'action_taken' => $actionTaken
|
||||
]);
|
||||
|
||||
// Retrieve updated flag data to ensure latest state
|
||||
$flagData = $currentFlagModel->find($flagId);
|
||||
return $this->moveToHistory($flagData);
|
||||
} else {
|
||||
session()->setFlashdata('error', 'Incident is already canceled.');
|
||||
}
|
||||
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
|
||||
private function moveToHistory($flagData)
|
||||
{
|
||||
$flagModel = new FlagModel();
|
||||
|
||||
// Prepare data for the flag table
|
||||
$dataToInsert = [
|
||||
'student_id' => $flagData['student_id'],
|
||||
'student_name' => $flagData['student_name'],
|
||||
'grade' => $flagData['grade'],
|
||||
'flag' => $flagData['flag'],
|
||||
'flag_datetime' => $flagData['flag_datetime'],
|
||||
'flag_state' => $flagData['flag_state'],
|
||||
'updated_by_open' => $flagData['updated_by_open'],
|
||||
'open_description' => $flagData['open_description'],
|
||||
'updated_by_closed' => $flagData['updated_by_closed'],
|
||||
'close_description' => $flagData['close_description'],
|
||||
'action_taken' => $flagData['action_taken'],
|
||||
'updated_by_canceled' => $flagData['updated_by_canceled'],
|
||||
'cancel_description' => $flagData['cancel_description'],
|
||||
'semester' => $flagData['semester'],
|
||||
'school_year' => $flagData['school_year'],
|
||||
'created_at' => $flagData['created_at'],
|
||||
'updated_at' => $flagData['updated_at']
|
||||
];
|
||||
|
||||
// Insert data into the flag table
|
||||
if ($flagModel->insert($dataToInsert)) {
|
||||
// Delete the entry from the current_flag table
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
$currentFlagModel->delete($flagData['id']);
|
||||
session()->setFlashdata('success', 'Incident has been moved to history.');
|
||||
} else {
|
||||
session()->setFlashdata('error', 'Failed to move incident to history.');
|
||||
}
|
||||
|
||||
return $this->index();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,656 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\HomeworkModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\UserModel;
|
||||
use RuntimeException;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Config\Services;
|
||||
use App\Controllers\View\GradingController;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
class HomeworkController extends Controller
|
||||
{
|
||||
protected $db;
|
||||
protected $semesterScoreService;
|
||||
protected $configModel;
|
||||
protected $homeworkModel;
|
||||
protected $userModel;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $studentClassModel;
|
||||
protected $studentModel;
|
||||
protected $teacherClassModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->homeworkModel = new HomeworkModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->studentClassModel = new StudentClassModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
|
||||
// Log the service initialization
|
||||
log_message('debug', 'Initializing SemesterScoreService');
|
||||
|
||||
//$this->semesterScoreService = Services::SemesterScoreService();
|
||||
$this->semesterScoreService = service('semesterScoreService');
|
||||
// $this->semesterScoreService = \Config\Services::semesterScoreService();
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
|
||||
// Check if the service is null
|
||||
if ($this->semesterScoreService === null) {
|
||||
log_message('error', 'SemesterScoreService is null');
|
||||
}
|
||||
}
|
||||
|
||||
public function updateHomeworkScores(array $scores = null, int $updatedBy = null, int $classSectionId = null)
|
||||
{
|
||||
$scores = $this->request->getPost('scores');
|
||||
$semester = $this->request->getPost('semester') ?? $this->semester;
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
|
||||
if ($updatedBy === null) {
|
||||
$updatedBy = session()->get('user_id');
|
||||
}
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id');
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
$studentIds = is_array($scores) ? array_keys($scores) : array_keys((array) $missingOk);
|
||||
$indexSet = [];
|
||||
if (is_array($scores)) {
|
||||
foreach ($scores as $studentId => $hwData) {
|
||||
if (!is_array($hwData)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($hwData as $index => $score) {
|
||||
if (is_numeric($index)) {
|
||||
$indexSet[(int)$index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $indexes) {
|
||||
if (!is_array($indexes)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($indexes as $index => $flag) {
|
||||
if (!is_numeric($index)) {
|
||||
continue;
|
||||
}
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => (int) $index,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
'homework',
|
||||
$studentIds,
|
||||
array_keys($indexSet),
|
||||
$checkedItems,
|
||||
$updatedBy
|
||||
);
|
||||
|
||||
if (is_array($scores)) {
|
||||
foreach ($scores as $studentId => $hwData) {
|
||||
if (!is_numeric($studentId)) continue;
|
||||
|
||||
$student = $this->studentModel->find($studentId);
|
||||
if (!$student) continue;
|
||||
|
||||
foreach ($hwData as $index => $score) {
|
||||
$rawScore = $score ?? null;
|
||||
$isBlank = $rawScore === null || (is_string($rawScore) && trim($rawScore) === '');
|
||||
$normalizedScore = (!$isBlank && is_numeric($rawScore)) ? (float) $rawScore : null;
|
||||
|
||||
// STEP 1: Always fetch the existing record
|
||||
$existing = $this->homeworkModel->where([
|
||||
'student_id' => $studentId,
|
||||
'homework_index' => $index,
|
||||
'class_section_id' => $classSectionId,
|
||||
//'teacher_id' => $updatedBy,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
])->first();
|
||||
|
||||
// STEP 2: Save numeric score (blank -> null)
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $student['school_id'] ?? '',
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'homework_index' => $index,
|
||||
'score' => $normalizedScore,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($isBlank) {
|
||||
if ($existing) {
|
||||
$this->homeworkModel->update($existing['id'], $data);
|
||||
log_message('debug', "Updated homework ID {$existing['id']} with score null");
|
||||
} else {
|
||||
$data['created_at'] = utc_now();
|
||||
$this->homeworkModel->insert($data);
|
||||
log_message('debug', "Inserted blank homework for student $studentId index $index");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$this->homeworkModel->update($existing['id'], $data);
|
||||
log_message('debug', "Updated homework ID {$existing['id']} with score {$normalizedScore}");
|
||||
} else {
|
||||
$data['created_at'] = utc_now();
|
||||
$this->homeworkModel->insert($data);
|
||||
log_message('debug', "Inserted score for student $studentId index $index");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$studentUserInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentUserInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
return redirect()->to(base_url('/teacher/addHomework'))->with('status', 'Homework scores updated successfully.');
|
||||
}
|
||||
|
||||
|
||||
public function addNextHomeworkColumn()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
$selectedSemester = $this->getSelectedSemester();
|
||||
$normalized = $this->normalizeSemesterSelection($selectedSemester);
|
||||
$semesterLabel = $normalized !== '' ? ucfirst($normalized) : $selectedSemester;
|
||||
|
||||
// Step 1: Get the highest existing homework_index
|
||||
$existingIndexes = $this->homeworkModel
|
||||
->select('homework_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
//->where('teacher_id', $updatedBy)
|
||||
->whereIn('semester', $this->getSemesterVariants($semesterLabel))
|
||||
->where('school_year', $this->schoolYear)
|
||||
->groupBy('homework_index')
|
||||
->orderBy('homework_index', 'DESC')
|
||||
->findAll();
|
||||
|
||||
$maxIndex = 0;
|
||||
foreach ($existingIndexes as $row) {
|
||||
if (isset($row['homework_index']) && is_numeric($row['homework_index'])) {
|
||||
$maxIndex = max($maxIndex, (int)$row['homework_index']);
|
||||
}
|
||||
}
|
||||
|
||||
$nextIndex = $maxIndex + 1;
|
||||
|
||||
// Step 2: Get all students in the class
|
||||
$students = $this->studentClassModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
|
||||
// Step 3: Insert a new homework row for each student if not already exists
|
||||
$firstInsertedId = null;
|
||||
foreach ($students as $i => $student) {
|
||||
$studentId = $student['student_id'];
|
||||
|
||||
// Check if record already exists
|
||||
$existing = $this->homeworkModel
|
||||
->where('student_id', $studentId)
|
||||
->where('homework_index', $nextIndex)
|
||||
->where('class_section_id', $classSectionId)
|
||||
//->where('teacher_id', $updatedBy)
|
||||
->whereIn('semester', $this->getSemesterVariants($semesterLabel))
|
||||
->where('school_year', $this->schoolYear)
|
||||
->first();
|
||||
|
||||
if ($existing) continue;
|
||||
|
||||
$insertData = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => '', // Optional: populate if needed
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'homework_index' => $nextIndex,
|
||||
'score' => null,
|
||||
'semester' => $semesterLabel,
|
||||
'school_year' => $this->schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
];
|
||||
|
||||
$id = $this->homeworkModel->insert($insertData, true);
|
||||
if ($i === 0) {
|
||||
$firstInsertedId = $id;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Return index and ID to frontend
|
||||
return $this->response->setJSON([
|
||||
'status' => 'success',
|
||||
'homework_index' => $nextIndex,
|
||||
'new_homework_id' => $firstInsertedId
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function showHomework()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$classSectionId = $this->getClassSectionIdForTeacher($updatedBy);
|
||||
if (!$classSectionId) {
|
||||
return redirect()->back()->with('status', 'No class section found for the current teacher.');
|
||||
}
|
||||
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
$semester = $this->getSelectedSemester();
|
||||
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $semester, $this->schoolYear);
|
||||
if (empty($homeworkHeaders)) {
|
||||
$homeworkHeaders = [1];
|
||||
}
|
||||
$students = $this->getStudentsWithHomeworkScores(
|
||||
$classSectionId,
|
||||
$homeworkHeaders,
|
||||
$semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $this->schoolYear, 'homework');
|
||||
|
||||
return view('teacher/add_homework', [
|
||||
'students' => $students,
|
||||
'homeworkHeaders' => $homeworkHeaders,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'class_section_id' => $classSectionId,
|
||||
'missingOkMap' => $missingOkMap,
|
||||
]);
|
||||
}
|
||||
|
||||
// In your GradingController (or the controller that owns this action)
|
||||
public function showHomeworkMngt()
|
||||
{
|
||||
// Accept POST first, then GET (query string), then session fallback
|
||||
$incomingId = $this->request->getPost('class_section_id');
|
||||
if (!$incomingId) {
|
||||
$incomingId = $this->request->getGet('class_section_id');
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'No class section found for the current teacher or selection.');
|
||||
}
|
||||
$updatedBy = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
$semesterParam = $this->request->getPost('semester')
|
||||
?? $this->request->getGet('semester')
|
||||
?? $this->request->getGet('filter');
|
||||
$normalizedSemester = $this->normalizeSemesterSelection($semesterParam);
|
||||
if ($normalizedSemester !== '') {
|
||||
$label = ucfirst($normalizedSemester);
|
||||
$session = session();
|
||||
$session->set('teacher_scores_selected_semester', $label);
|
||||
$session->set('semester', $label);
|
||||
}
|
||||
|
||||
$schoolYearParam = $this->request->getPost('school_year') ?? $this->request->getGet('school_year');
|
||||
$schoolYear = $this->normalizeSchoolYear($schoolYearParam) ?? $this->schoolYear;
|
||||
/*
|
||||
// OPTIONAL (recommended): ensure the classSectionId belongs to this teacher for the active term
|
||||
$updatedBy = (int) (session()->get('user_id') ?? 0);
|
||||
if ($updatedBy > 0) {
|
||||
// If you have a TeacherClassModel with new schema (position = 'main' or 'ta'):
|
||||
$isOwned = $this->teacherClassModel
|
||||
->where('teacher_id', $updatedBy)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester)
|
||||
->countAllResults() > 0;
|
||||
|
||||
if (!$isOwned) {
|
||||
return redirect()->back()->with('status', 'You do not have access to that class section.');
|
||||
}
|
||||
}
|
||||
*/
|
||||
// Persist chosen class section in the session
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// Load data for the view
|
||||
$semester = $this->getSelectedSemester();
|
||||
$homeworkScores = $this->getHomeworkScoresByStudent($classSectionId, $semester, $schoolYear);
|
||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $schoolYear);
|
||||
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
// Render
|
||||
return view('grading/homework', [
|
||||
'homeworkScores' => $homeworkScores,
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
'homeworkHeaders' => $homeworkHeaders,
|
||||
'classSectionId' => $classSectionId,
|
||||
'hasHomeworkCols' => !empty($homeworkHeaders),
|
||||
'isManagement' => true,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function updateHomework()
|
||||
{
|
||||
$scores = $this->request->getPost('scores');
|
||||
$studentIds = $this->request->getPost('student_ids');
|
||||
$semester = $this->request->getPost('semester') ?? $this->getSelectedSemester();
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
|
||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id'); // persisted in showHomeworkMngt
|
||||
$classSectionIdInt = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionIdInt > 0 && $this->isScoresLocked($classSectionIdInt, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
if ($scores && $studentIds && $semester && $schoolYear && $classSectionId) {
|
||||
$this->updateHomeworkScores($scores, $updatedBy, $classSectionId);
|
||||
session()->setFlashdata('status', 'Homework scores updated successfully.');
|
||||
|
||||
// ⬇️ Directly call the method and return its view
|
||||
return $this->showHomeworkMngt($updatedBy);
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
return redirect()->back()->with('status', 'Failed to update scores.');
|
||||
}
|
||||
|
||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
||||
{
|
||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
}
|
||||
|
||||
// ──────────────── Helpers ────────────────
|
||||
|
||||
private function saveHomeworkScores(array $scores, array $studentIds, string $semester, string $schoolYear, int $updatedBy, int $classSectionId)
|
||||
{
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($studentIds as $studentId) {
|
||||
if (!isset($scores[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($scores[$studentId] as $index => $score) {
|
||||
$isBlank = $score === null || (is_string($score) && trim($score) === '');
|
||||
|
||||
// Check if the record exists
|
||||
$existing = $this->homeworkModel
|
||||
->where('student_id', $studentId)
|
||||
->where('homework_index', $index)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('class_section_id', $classSectionId)
|
||||
//->where('teacher_id', $updatedBy)
|
||||
->first();
|
||||
|
||||
if ($isBlank) {
|
||||
if ($existing) {
|
||||
$this->homeworkModel->update($existing['id'], [
|
||||
'score' => null,
|
||||
'updated_by' => $updatedBy,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
$this->homeworkModel->insert([
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'homework_index' => $index,
|
||||
'score' => null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'homework_index' => $index,
|
||||
'score' => is_numeric($score) ? floatval($score) : null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$this->homeworkModel->update($existing['id'], $data);
|
||||
} else {
|
||||
$data['created_at'] = $now;
|
||||
$data['school_id'] = ''; // Set if needed
|
||||
$this->homeworkModel->insert($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
||||
{
|
||||
// Step 1: Get student IDs from student_class table
|
||||
$studentClassRows = $this->studentClassModel
|
||||
->select('student_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentIds = array_column($studentClassRows, 'student_id');
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return []; // No students found
|
||||
}
|
||||
|
||||
// Step 2: Get student data from students table
|
||||
$students = $this->studentModel
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getClassSectionIdForTeacher($updatedBy)
|
||||
{
|
||||
$class = $this->teacherClassModel->where('teacher_id', $updatedBy)->first();
|
||||
return $class['class_section_id'] ?? null;
|
||||
}
|
||||
|
||||
|
||||
private function getHomeworkScoresByStudent($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$semVariants = $this->getSemesterVariants($semester);
|
||||
$rows = $this->homeworkModel
|
||||
->select('student_id, homework_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('semester', $semVariants)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentScores = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = $row['student_id'];
|
||||
$index = $row['homework_index'];
|
||||
$score = $row['score'];
|
||||
|
||||
$studentScores[$studentId]['scores'][$index] = $score;
|
||||
}
|
||||
|
||||
return $studentScores;
|
||||
}
|
||||
|
||||
private function getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders, $semester, $schoolYear)
|
||||
{
|
||||
$semVariants = $this->getSemesterVariants($semester);
|
||||
$studentClasses = $this->studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
$students = [];
|
||||
|
||||
foreach ($studentClasses as $sc) {
|
||||
$student = $this->studentModel
|
||||
->where('id', $sc['student_id'])
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
if (!$student) continue;
|
||||
|
||||
$scores = [];
|
||||
foreach ($homeworkHeaders as $ids) {
|
||||
$entry = $this->homeworkModel->where('homework_index', $ids)
|
||||
->where('student_id', $sc['student_id'])
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('semester', $semVariants)
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
// Keep null/blank when no entry exists yet
|
||||
$scores[$ids] = $entry['score'] ?? null;
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $sc['student_id'],
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'scores' => $scores
|
||||
];
|
||||
}
|
||||
|
||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getHomeworkHeaders($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
|
||||
$semVariants = $this->getSemesterVariants($semester);
|
||||
$rows = $this->homeworkModel
|
||||
->select('homework_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
//->where('teacher_id', $updatedBy)
|
||||
->whereIn('semester', $semVariants)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('homework_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$headers = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$index = $row['homework_index'];
|
||||
if (!is_array($index)) {
|
||||
$headers[$index] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($headers);
|
||||
return array_keys($headers);
|
||||
}
|
||||
|
||||
private function getSemesterVariants(?string $semester): array
|
||||
{
|
||||
$raw = trim((string) $semester);
|
||||
$variants = [
|
||||
$raw,
|
||||
strtolower($raw),
|
||||
ucfirst(strtolower($raw)),
|
||||
];
|
||||
$normalized = $this->normalizeSemesterSelection($raw);
|
||||
if ($normalized === 'fall') {
|
||||
$variants[] = 'First Semester';
|
||||
$variants[] = 'Semester 1';
|
||||
$variants[] = '1st Semester';
|
||||
$variants[] = 'Fall Semester';
|
||||
} elseif ($normalized === 'spring') {
|
||||
$variants[] = 'Second Semester';
|
||||
$variants[] = 'Semester 2';
|
||||
$variants[] = '2nd Semester';
|
||||
$variants[] = 'Spring Semester';
|
||||
}
|
||||
return array_values(array_unique(array_filter($variants, static fn($v) => $v !== '')));
|
||||
}
|
||||
|
||||
private function getSelectedSemester(): string
|
||||
{
|
||||
$session = session();
|
||||
$selected = $session->get('teacher_scores_selected_semester');
|
||||
if (!empty($selected)) {
|
||||
return (string) $selected;
|
||||
}
|
||||
return $session->get('semester') ?? $this->semester;
|
||||
}
|
||||
|
||||
private function normalizeSemesterSelection(?string $input): string
|
||||
{
|
||||
$value = strtolower(trim((string) $input));
|
||||
if ($value === 'fall' || str_contains($value, '1')) {
|
||||
return 'fall';
|
||||
}
|
||||
if ($value === 'spring' || str_contains($value, '2')) {
|
||||
return 'spring';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function normalizeSchoolYear(?string $input): ?string
|
||||
{
|
||||
$value = trim((string) $input);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^\\d{4}\\s*[-\\/]\\s*\\d{4}$/', $value)) {
|
||||
return preg_replace('/\\s+/', '', str_replace('/', '-', $value));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\CalendarModel;
|
||||
use App\Models\HomeworkModel;
|
||||
|
||||
class HomeworkTrackingController extends BaseController
|
||||
{
|
||||
protected $configModel;
|
||||
protected $calendarModel;
|
||||
protected $homeworkModel;
|
||||
protected $db;
|
||||
|
||||
protected string $semester;
|
||||
protected string $schoolYear;
|
||||
/** cache for teacher assignment presence per term */
|
||||
private array $teacherAssignmentCache = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->calendarModel = new CalendarModel();
|
||||
$this->homeworkModel = new HomeworkModel();
|
||||
$this->db = \Config\Database::connect();
|
||||
|
||||
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
// Semester-based date range (Fall or Spring) derived from school year start.
|
||||
[$start, $end] = $this->getDateRangeForSemester($this->schoolYear, $this->semester);
|
||||
|
||||
// Move start to the first Sunday on/after start
|
||||
if ((int)$start->format('w') !== 0) {
|
||||
$start = (clone $start)->modify('next sunday');
|
||||
}
|
||||
|
||||
// Collect all Sundays within range
|
||||
$sundays = [];
|
||||
$d = clone $start;
|
||||
while ($d <= $end) {
|
||||
$sundays[] = $d->format('Y-m-d');
|
||||
$d->modify('+7 days');
|
||||
}
|
||||
|
||||
// Map "no school" events by date (Y-m-d) using explicit DB flag
|
||||
$events = $this->calendarModel->getEventsBySchoolYear($this->schoolYear) ?? [];
|
||||
$eventDays = [];
|
||||
foreach ($events as $ev) {
|
||||
$ymd = substr((string)($ev['date'] ?? ''), 0, 10);
|
||||
$no = (int)($ev['no_school'] ?? 0);
|
||||
if ($ymd && $no === 1) {
|
||||
$eventDays[$ymd] = true; // yellow highlight for no-school day
|
||||
}
|
||||
}
|
||||
|
||||
// Map Sundays to ordinal homework_index, skipping event Sundays
|
||||
$dateToIndex = [];
|
||||
$idx = 0;
|
||||
foreach ($sundays as $ymd) {
|
||||
if (!isset($eventDays[$ymd])) {
|
||||
$idx++;
|
||||
$dateToIndex[$ymd] = $idx;
|
||||
} else {
|
||||
$dateToIndex[$ymd] = null; // event day (no homework expected)
|
||||
}
|
||||
}
|
||||
|
||||
$limitToSemester = $this->hasTeacherAssignments($this->schoolYear, $this->semester);
|
||||
|
||||
// Aggregate homework presence + first entered date per class_section_id + homework_index
|
||||
$hwQ = $this->db->table('homework')
|
||||
->select('class_section_id, homework_index, MIN(created_at) AS first_created, COUNT(*) AS cnt')
|
||||
->where('school_year', $this->schoolYear);
|
||||
if ($limitToSemester && $this->semester !== '') {
|
||||
$hwQ->where('semester', $this->semester);
|
||||
}
|
||||
$rows = $hwQ->groupBy('class_section_id, homework_index')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$hasHomework = [];
|
||||
$hwEnteredAt = [];
|
||||
foreach ($rows as $r) {
|
||||
$csid = (int)($r['class_section_id'] ?? 0);
|
||||
$hi = (int)($r['homework_index'] ?? 0);
|
||||
$cnt = (int)($r['cnt'] ?? 0);
|
||||
if ($csid > 0 && $hi > 0 && $cnt > 0) {
|
||||
$hasHomework[$csid][$hi] = true;
|
||||
$dateStr = substr((string)($r['first_created'] ?? ''), 0, 10);
|
||||
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
// Build date-based presence mapped to the nearest prior non-NoSchool Sunday.
|
||||
$hb = $this->db->table('homework')
|
||||
->select("class_section_id, DATE(created_at) AS hw_date, MIN(created_at) AS first_created, COUNT(*) AS cnt", false)
|
||||
->where('school_year', $this->schoolYear);
|
||||
if ($limitToSemester && $this->semester !== '') {
|
||||
$hb->where('semester', $this->semester);
|
||||
}
|
||||
$rowsByDate = $hb->groupBy('class_section_id, DATE(created_at)', '', false)
|
||||
->orderBy('hw_date', 'ASC', false)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$hasHomeworkByDate = [];
|
||||
$hwEnteredAtByDate = [];
|
||||
foreach ($rowsByDate as $r) {
|
||||
$csid = (int)($r['class_section_id'] ?? 0);
|
||||
$d = substr((string)($r['hw_date'] ?? ''), 0, 10);
|
||||
$cnt = (int)($r['cnt'] ?? 0);
|
||||
$firstCreated = substr((string)($r['first_created'] ?? ''), 0, 10);
|
||||
if ($csid <= 0 || !$d || $cnt <= 0) { continue; }
|
||||
|
||||
// Find the index of the last Sunday on or before $d
|
||||
$baseIndex = -1;
|
||||
for ($i = count($sundays) - 1; $i >= 0; $i--) {
|
||||
if ($sundays[$i] <= $d) { $baseIndex = $i; break; }
|
||||
}
|
||||
if ($baseIndex < 0) { continue; }
|
||||
|
||||
// Map this homework day to only the nearest prior non–No School Sunday
|
||||
$j = $baseIndex;
|
||||
while ($j >= 0 && !empty($eventDays[$sundays[$j]])) { $j--; }
|
||||
if ($j < 0) { continue; }
|
||||
$sd = $sundays[$j];
|
||||
|
||||
// Mark homework on that Sunday for this class_section (single mapping)
|
||||
$hasHomeworkByDate[$csid][$sd] = true;
|
||||
if (empty($hwEnteredAtByDate[$csid][$sd])) {
|
||||
$hwEnteredAtByDate[$csid][$sd] = $firstCreated ?: $d;
|
||||
} else {
|
||||
// Keep the earliest date for display
|
||||
$existing = (string)$hwEnteredAtByDate[$csid][$sd];
|
||||
$candidate = $firstCreated ?: $d;
|
||||
if ($candidate && (!$existing || $candidate < $existing)) {
|
||||
$hwEnteredAtByDate[$csid][$sd] = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch teachers and TAs per section for this term
|
||||
$tb = $this->db->table('teacher_class tc')
|
||||
->select('tc.class_section_id, tc.position, cs.class_section_name, cs.class_id, u.firstname, u.lastname')
|
||||
->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left')
|
||||
->join('users u', 'u.id = tc.teacher_id', 'left')
|
||||
->where('tc.school_year', $this->schoolYear);
|
||||
$teacherRows = $tb->where('tc.class_section_id IS NOT NULL', null, false)
|
||||
->whereIn('tc.position', ['main','ta'])
|
||||
->orderBy('cs.class_id', 'ASC')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy("FIELD(tc.position, 'main','ta')", '', false)
|
||||
->orderBy('u.firstname', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$bySection = [];
|
||||
foreach ($teacherRows as $r) {
|
||||
$sid = (int)($r['class_section_id'] ?? 0);
|
||||
if ($sid <= 0) continue;
|
||||
if (!isset($bySection[$sid])) {
|
||||
$bySection[$sid] = [
|
||||
'class_section_id' => $sid,
|
||||
'class_id' => (int)($r['class_id'] ?? 0),
|
||||
'class_section_name' => (string)($r['class_section_name'] ?? ''),
|
||||
'teachers' => [],
|
||||
'tas' => [],
|
||||
];
|
||||
}
|
||||
$name = trim(($r['firstname'] ?? '') . ' ' . ($r['lastname'] ?? ''));
|
||||
$pos = strtolower((string)($r['position'] ?? ''));
|
||||
if ($name !== '') {
|
||||
if ($pos === 'main') { $bySection[$sid]['teachers'][] = $name; }
|
||||
elseif ($pos === 'ta') { $bySection[$sid]['tas'][] = $name; }
|
||||
}
|
||||
}
|
||||
|
||||
$teachers = array_values($bySection);
|
||||
|
||||
// Sort by grade order: KG(13) → Grade 1..11 → Youth(12) → others
|
||||
usort($teachers, function ($a, $b) {
|
||||
$ai = (int)($a['class_id'] ?? 0);
|
||||
$bi = (int)($b['class_id'] ?? 0);
|
||||
$ord = function ($cid) {
|
||||
if ($cid === 13) return 0; // KG first
|
||||
if ($cid >= 1 && $cid <= 11) return $cid; // Grades 1..11
|
||||
if ($cid === 12) return 99; // Youth last of known
|
||||
return 200 + $cid; // any others after
|
||||
};
|
||||
$cmp = $ord($ai) <=> $ord($bi);
|
||||
if ($cmp !== 0) return $cmp;
|
||||
return strnatcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? '');
|
||||
});
|
||||
|
||||
// Simple server-side pagination for rows (8 lines per page)
|
||||
$perPage = 8;
|
||||
$totalRows = count($teachers);
|
||||
$totalPages = max(1, (int)ceil($totalRows / $perPage));
|
||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
||||
if ($page < 1) $page = 1;
|
||||
if ($page > $totalPages) $page = $totalPages;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$teachersPage = array_slice($teachers, $offset, $perPage);
|
||||
|
||||
return view('grading/homework_tracking', [
|
||||
'semester' => $this->semester,
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'sundays' => $sundays,
|
||||
'eventDays' => $eventDays,
|
||||
'dateToIndex' => $dateToIndex,
|
||||
'teachers' => $teachersPage,
|
||||
'hasHomework' => $hasHomework,
|
||||
'hwEnteredAt' => $hwEnteredAt,
|
||||
'hasHomeworkByDate' => $hasHomeworkByDate,
|
||||
'hwEnteredAtByDate' => $hwEnteredAtByDate,
|
||||
'page' => $page,
|
||||
'totalPages' => $totalPages,
|
||||
'perPage' => $perPage,
|
||||
'totalRows' => $totalRows,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if teacher_class has any rows for the given school year.
|
||||
* The semester argument is ignored because assignments are tracked per year.
|
||||
*/
|
||||
private function hasTeacherAssignments(string $schoolYear, string $semester): bool
|
||||
{
|
||||
$year = trim((string)$schoolYear);
|
||||
if ($year === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists($year, $this->teacherAssignmentCache)) {
|
||||
return $this->teacherAssignmentCache[$year];
|
||||
}
|
||||
|
||||
$cnt = $this->db->table('teacher_class')
|
||||
->where('school_year', $year)
|
||||
->countAllResults();
|
||||
|
||||
$this->teacherAssignmentCache[$year] = $cnt > 0;
|
||||
return $this->teacherAssignmentCache[$year];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute start/end dates for the given semester within the school year.
|
||||
* Fall: 09/21/{startYear} to 01/18/{startYear+1}
|
||||
* Spring: 01/25/{startYear+1} to 05/31/{startYear+1}
|
||||
* Defaults to today if parsing fails.
|
||||
*/
|
||||
private function getDateRangeForSemester(string $schoolYear, string $semester): array
|
||||
{
|
||||
$startYear = null;
|
||||
if (preg_match('/\b(20\d{2})\b/', $schoolYear, $m)) {
|
||||
$startYear = (int)$m[1];
|
||||
}
|
||||
if ($startYear === null) {
|
||||
$today = new \DateTime('today');
|
||||
return [$today, $today];
|
||||
}
|
||||
|
||||
$nextYear = $startYear + 1;
|
||||
$sem = strtolower(trim($semester));
|
||||
|
||||
$start = $end = null;
|
||||
try {
|
||||
if ($sem === 'spring') {
|
||||
$start = new \DateTime("{$nextYear}-01-25");
|
||||
$end = new \DateTime("{$nextYear}-05-31");
|
||||
} else {
|
||||
// default/fall
|
||||
$start = new \DateTime("{$startYear}-09-21");
|
||||
$end = new \DateTime("{$nextYear}-01-18");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$today = new \DateTime('today');
|
||||
return [$today, $today];
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,364 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\MidtermExamModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use RuntimeException;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Config\Services;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
|
||||
class MidtermController extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected $semesterScoreService;
|
||||
protected $schoolYear; // Declare global variable for the controller
|
||||
protected $semester;
|
||||
protected $configModel;
|
||||
protected $studentModel;
|
||||
protected $teacherClassModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->semesterScoreService = service('semesterScoreService');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
}
|
||||
|
||||
public function add()
|
||||
{
|
||||
// 1) class_section_id from the view (POST/GET) with session fallback
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
// (optional) persist for downstream use
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// (optional) access check if you restrict by teacher assignments
|
||||
// if (!$this->canAccessClassSection((int)(session()->get('user_id') ?? 0), $classSectionId)) {
|
||||
// return redirect()->back()->with('status', 'You do not have access to that class section.');
|
||||
// }
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
try {
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'midterm_exam');
|
||||
|
||||
return view('teacher/add_midterm_exam', [
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'missingOkMap' => $missingOkMap,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function update()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$scores = $this->request->getPost('final_score');
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return redirect()->back()->with('error', 'No scores submitted.');
|
||||
}
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
$this->saveExamScores('midterm_exam', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
$studentIds = array_keys($scores);
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $flags) {
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
'midterm_exam',
|
||||
$studentIds,
|
||||
null,
|
||||
$checkedItems,
|
||||
$updatedBy,
|
||||
true
|
||||
);
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
return redirect()->to(base_url('/teacher/addMidtermExam'))
|
||||
->with('status', 'Midterm exam scores updated successfully.');
|
||||
}
|
||||
|
||||
public function updateMngt()
|
||||
{
|
||||
$scores = $this->request->getPost('final_score');
|
||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id'); // default to session user
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return redirect()->back()->with('error', 'No scores submitted.');
|
||||
}
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
$this->saveExamScores('midterm_exam', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
||||
|
||||
session()->setFlashdata('status', 'Midterm exam scores updated successfully.');
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
// ⬇️ Directly call the method and return its view
|
||||
return $this->showMidtermMngt();
|
||||
}
|
||||
|
||||
|
||||
public function showMidtermMngt()
|
||||
{
|
||||
// 1) Get class_section_id from POST -> GET -> session
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
try {
|
||||
// 2) Load roster + saved scores for this class/term
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
// 3) Render view (no teacherId anymore)
|
||||
return view('grading/midterm', [
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
||||
{
|
||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function saveExamScores(string $table, array $scores, int $updatedBy, int $classSectionId, string $semester, string $schoolYear): void
|
||||
{
|
||||
$builder = $this->db->table($table);
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($scores as $studentId => $data) {
|
||||
if (!is_numeric($studentId) || !isset($data['score'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = is_numeric($data['score']) ? (float) $data['score'] : null;
|
||||
|
||||
$existing = $builder
|
||||
->where('student_id', $studentId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->getRow();
|
||||
|
||||
$dataToSave = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'score' => $score,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$builder->where('id', $existing->id)->update($dataToSave);
|
||||
} else {
|
||||
$dataToSave['created_at'] = $now;
|
||||
$builder->insert($dataToSave);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get roster + saved midterm scores for a specific classSection/term.
|
||||
*/
|
||||
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid class section id.');
|
||||
}
|
||||
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// Subquery: pick ONE midterm row per student (latest by id).
|
||||
// If your table doesn't have 'id', swap to MAX(updated_at) or MAX(exam_date).
|
||||
$latestMidtermSub = $this->db->table('midterm_exam')
|
||||
->select('student_id, MAX(id) AS max_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('student_id')
|
||||
->getCompiledSelect();
|
||||
|
||||
// Main query: roster for this class/term + LEFT JOIN the single midterm row
|
||||
$qb = $this->studentModel
|
||||
->select('
|
||||
s.id AS student_id,
|
||||
s.school_id,
|
||||
s.firstname,
|
||||
s.lastname,
|
||||
me.score
|
||||
')
|
||||
->from('students s')
|
||||
->join(
|
||||
'student_class sc',
|
||||
'sc.student_id = s.id
|
||||
AND sc.class_section_id = ' . (int) $classSectionId . '
|
||||
AND sc.school_year = ' . $this->db->escape($schoolYear),
|
||||
'inner',
|
||||
false
|
||||
)
|
||||
// Join the "latest midterm per student" subquery, then the actual midterm row
|
||||
->join('(' . $latestMidtermSub . ') ml', 'ml.student_id = s.id', 'left', false)
|
||||
->join('midterm_exam me', 'me.id = ml.max_id', 'left')
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC');
|
||||
|
||||
// DISTINCT protects against rare duplicate sc rows that match the same select columns
|
||||
$qb->distinct();
|
||||
|
||||
$rows = $qb->get()->getResultArray();
|
||||
|
||||
// Final safety net: ensure one row per student_id
|
||||
$unique = [];
|
||||
foreach ($rows as $r) {
|
||||
$unique[(int)$r['student_id']] = $r;
|
||||
}
|
||||
$students = array_values($unique);
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
private function getTeacherSelectedSemester(): string
|
||||
{
|
||||
helper('semester_selection_helper');
|
||||
return selected_teacher_semester();
|
||||
}
|
||||
|
||||
private function getSelectedSemesterFromRequest(): string
|
||||
{
|
||||
$semesterParam = $this->request->getPost('semester')
|
||||
?? $this->request->getGet('semester')
|
||||
?? $this->request->getGet('filter');
|
||||
$normalized = $this->normalizeSemesterSelection($semesterParam);
|
||||
if ($normalized !== '') {
|
||||
$label = ucfirst($normalized);
|
||||
$session = session();
|
||||
$session->set('teacher_scores_selected_semester', $label);
|
||||
$session->set('semester', $label);
|
||||
}
|
||||
|
||||
return $this->getTeacherSelectedSemester();
|
||||
}
|
||||
|
||||
private function getSelectedSchoolYearFromRequest(): ?string
|
||||
{
|
||||
$yearParam = $this->request->getPost('school_year') ?? $this->request->getGet('school_year');
|
||||
return $this->normalizeSchoolYear($yearParam);
|
||||
}
|
||||
|
||||
private function normalizeSemesterSelection(?string $input): string
|
||||
{
|
||||
$value = strtolower(trim((string) $input));
|
||||
if ($value === 'fall' || str_contains($value, '1')) {
|
||||
return 'fall';
|
||||
}
|
||||
if ($value === 'spring' || str_contains($value, '2')) {
|
||||
return 'spring';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function normalizeSchoolYear(?string $input): ?string
|
||||
{
|
||||
$value = trim((string) $input);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^\\d{4}\\s*[-\\/]\\s*\\d{4}$/', $value)) {
|
||||
return preg_replace('/\\s+/', '', str_replace('/', '-', $value));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\NavItemModel;
|
||||
use App\Models\RoleNavItemModel;
|
||||
use CodeIgniter\Cache\CacheInterface;
|
||||
|
||||
class NavbarService
|
||||
{
|
||||
public function __construct(
|
||||
protected NavItemModel $navModel = new NavItemModel(),
|
||||
protected RoleNavItemModel $mapModel = new RoleNavItemModel(),
|
||||
protected ?CacheInterface $cache = null
|
||||
){
|
||||
$this->cache ??= cache();
|
||||
}
|
||||
|
||||
public function getMenuForRoles(array $roles): array
|
||||
{
|
||||
$roles = array_map(fn($r)=>strtolower(trim((string)$r)), $roles);
|
||||
$cacheKey = 'navbar_' . md5(json_encode($roles));
|
||||
|
||||
if ($menu = $this->cache->get($cacheKey)) {
|
||||
return $menu;
|
||||
}
|
||||
|
||||
// Which items this user can see
|
||||
$allowedIds = $this->mapModel->getNavItemIdsForRoles($roles);
|
||||
if (empty($allowedIds)) return [];
|
||||
|
||||
// Load all enabled items, then filter
|
||||
$rows = $this->navModel->where('is_enabled', 1)
|
||||
->orderBy('sort_order', 'ASC')
|
||||
->findAll();
|
||||
|
||||
// index by id
|
||||
$byId = [];
|
||||
foreach ($rows as $r) {
|
||||
if (in_array($r['id'], $allowedIds, true)) {
|
||||
$r['children'] = [];
|
||||
$byId[$r['id']] = $r;
|
||||
}
|
||||
}
|
||||
|
||||
// Build tree
|
||||
$tree = [];
|
||||
foreach ($byId as $id => &$node) {
|
||||
$pid = $node['menu_parent_id'];
|
||||
if ($pid && isset($byId[$pid])) {
|
||||
$byId[$pid]['children'][] = &$node;
|
||||
} else {
|
||||
$tree[] = &$node;
|
||||
}
|
||||
}
|
||||
|
||||
$this->cache->save($cacheKey, $tree, 300); // 5 minutes
|
||||
return $tree;
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
// simplest: flush; or if you have a tagged cache, clear only keys
|
||||
cache()->clean();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use CodeIgniter\Events\Events;
|
||||
|
||||
class NotificationService
|
||||
{
|
||||
/**
|
||||
* Trigger a notification event by type and payload.
|
||||
*
|
||||
* @param string $eventType The name of the event (e.g., 'userRegistered')
|
||||
* @param array $payload The data to pass to the listener
|
||||
* @return void
|
||||
*/
|
||||
public static function trigger(string $eventType, array $payload): void
|
||||
{
|
||||
Events::trigger($eventType, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for sending to a specific user.
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $title
|
||||
* @param string $message
|
||||
* @param array $channels
|
||||
* @return void
|
||||
*/
|
||||
public static function toUser(int $userId, string $title, string $message, array $channels = ['in_app']): void
|
||||
{
|
||||
self::trigger('customNotification', [
|
||||
'user_id' => $userId,
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'channels' => $channels
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\ParticipationModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use RuntimeException;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Config\Services;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
|
||||
class ParticipationController extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected $semesterScoreService;
|
||||
protected $schoolYear; // Declare global variable for the controller
|
||||
protected $semester;
|
||||
protected $configModel;
|
||||
protected $studentModel;
|
||||
protected $teacherClassModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
|
||||
$this->semesterScoreService = service('semesterScoreService');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
}
|
||||
|
||||
public function add()
|
||||
{
|
||||
// 1) Get class_section_id from the view (POST/GET) with session fallback
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
// (Optional) persist for downstream pages
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// (Optional) access check if you’re restricting by teacher/TA roles
|
||||
// if (!$this->canAccessClassSection((int)(session()->get('user_id') ?? 0), $classSectionId)) {
|
||||
// return redirect()->back()->with('status', 'You do not have access to that class section.');
|
||||
// }
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
try {
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'participation');
|
||||
|
||||
return view('teacher/add_participation', [
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'missingOkMap' => $missingOkMap,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function update()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$scores = $this->request->getPost('final_score');
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return redirect()->back()->with('error', 'No scores submitted.');
|
||||
}
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
$this->saveParticipationScores('participation', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
$studentIds = array_keys($scores);
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $flags) {
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
'participation',
|
||||
$studentIds,
|
||||
null,
|
||||
$checkedItems,
|
||||
$updatedBy,
|
||||
true
|
||||
);
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
return redirect()->to(base_url('/teacher/addParticipation'))
|
||||
->with('status', 'Participation scores updated successfully.');
|
||||
}
|
||||
|
||||
public function updateMngt()
|
||||
{
|
||||
$scores = $this->request->getPost('final_score');
|
||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id'); // default to session user
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return redirect()->back()->with('error', 'No scores submitted.');
|
||||
}
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
$this->saveParticipationScores('participation', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
||||
|
||||
session()->setFlashdata('status', 'Participation scores updated successfully.');
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
// ⬇️ Directly call the method and return its view
|
||||
return $this->showParticipationMngt();
|
||||
}
|
||||
|
||||
|
||||
public function showParticipationMngt()
|
||||
{
|
||||
// Get class_section_id from view (POST/GET) with session fallback
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
// OPTIONAL: enforce access like we did for homework/midterm
|
||||
// if (!$this->canAccessClassSection((int)(session()->get('user_id') ?? 0), $classSectionId)) {
|
||||
// return redirect()->back()->with('status', 'You do not have access to that class section.');
|
||||
// }
|
||||
|
||||
try {
|
||||
// Pull roster + saved (participation/midterm) scores for this section/term
|
||||
// If you have a dedicated participation table, call its getter here instead.
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
return view('grading/participation', [
|
||||
'students' => $result['students'], // already includes school_id
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
||||
{
|
||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function saveParticipationScores(string $table, array $scores, int $updatedBy, int $classSectionId, string $semester, string $schoolYear): void
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table($table);
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($scores as $studentId => $data) {
|
||||
if (!is_numeric($studentId) || !isset($data['score'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = is_numeric($data['score']) ? (float) $data['score'] : null;
|
||||
|
||||
$existing = $builder
|
||||
->where('student_id', $studentId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->getRow();
|
||||
|
||||
$dataToSave = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'score' => $score,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$builder->where('id', $existing->id)->update($dataToSave);
|
||||
} else {
|
||||
$dataToSave['created_at'] = $now;
|
||||
$builder->insert($dataToSave);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid class section id.');
|
||||
}
|
||||
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// Build a DISTINCT roster subquery correctly (no 'DISTINCT' inside select string)
|
||||
$rosterSub = $this->db->table('student_class')
|
||||
->select('student_id') // <-- just the column
|
||||
->distinct() // <-- ask CI4 to add DISTINCT
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->getCompiledSelect();
|
||||
|
||||
// Main query: Students + LEFT JOIN participation for same section/term
|
||||
$students = $this->db->table('students s')
|
||||
->select('
|
||||
s.id AS student_id,
|
||||
s.school_id,
|
||||
s.firstname,
|
||||
s.lastname,
|
||||
p.score
|
||||
')
|
||||
->join("({$rosterSub}) sc", 'sc.student_id = s.id', 'inner', false)
|
||||
->join(
|
||||
'participation p',
|
||||
'p.student_id = s.id
|
||||
AND p.class_section_id = ' . (int)$classSectionId . '
|
||||
AND p.semester = ' . $this->db->escape($semester) . '
|
||||
AND p.school_year = ' . $this->db->escape($schoolYear),
|
||||
'left',
|
||||
false
|
||||
)
|
||||
->where('s.is_active', 1)
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
private function getTeacherSelectedSemester(): string
|
||||
{
|
||||
helper('semester_selection_helper');
|
||||
return selected_teacher_semester();
|
||||
}
|
||||
|
||||
private function getSelectedSemesterFromRequest(): string
|
||||
{
|
||||
$semesterParam = $this->request->getPost('semester')
|
||||
?? $this->request->getGet('semester')
|
||||
?? $this->request->getGet('filter');
|
||||
$normalized = $this->normalizeSemesterSelection($semesterParam);
|
||||
if ($normalized !== '') {
|
||||
$label = ucfirst($normalized);
|
||||
$session = session();
|
||||
$session->set('teacher_scores_selected_semester', $label);
|
||||
$session->set('semester', $label);
|
||||
}
|
||||
|
||||
return $this->getTeacherSelectedSemester();
|
||||
}
|
||||
|
||||
private function getSelectedSchoolYearFromRequest(): ?string
|
||||
{
|
||||
$yearParam = $this->request->getPost('school_year') ?? $this->request->getGet('school_year');
|
||||
return $this->normalizeSchoolYear($yearParam);
|
||||
}
|
||||
|
||||
private function normalizeSemesterSelection(?string $input): string
|
||||
{
|
||||
$value = strtolower(trim((string) $input));
|
||||
if ($value === 'fall' || str_contains($value, '1')) {
|
||||
return 'fall';
|
||||
}
|
||||
if ($value === 'spring' || str_contains($value, '2')) {
|
||||
return 'spring';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function normalizeSchoolYear(?string $input): ?string
|
||||
{
|
||||
$value = trim((string) $input);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^\\d{4}\\s*[-\\/]\\s*\\d{4}$/', $value)) {
|
||||
return preg_replace('/\\s+/', '', str_replace('/', '-', $value));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,365 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\PaymentNotificationLogModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\FamilyGuardianModel;
|
||||
use App\Services\EmailService;
|
||||
use App\Services\NotificationService;
|
||||
|
||||
class PaymentNotificationController extends BaseController
|
||||
{
|
||||
protected PaymentNotificationLogModel $logModel;
|
||||
protected UserModel $userModel;
|
||||
protected ConfigurationModel $configModel;
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected PaymentModel $paymentModel;
|
||||
protected FamilyGuardianModel $familyGuardianModel;
|
||||
protected EmailService $emailService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->logModel = new PaymentNotificationLogModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->familyGuardianModel = new FamilyGuardianModel();
|
||||
$this->emailService = new EmailService();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
helper(['form', 'url']);
|
||||
|
||||
$from = $this->request->getGet('from');
|
||||
$to = $this->request->getGet('to');
|
||||
$type = $this->request->getGet('type');
|
||||
|
||||
$rows = $this->logModel->listLogs($from, $to, $type);
|
||||
|
||||
// Preload parent names
|
||||
$parents = [];
|
||||
foreach ($rows as $r) {
|
||||
$pid = (int) ($r['parent_id'] ?? 0);
|
||||
if ($pid && !isset($parents[$pid])) {
|
||||
$u = $this->userModel->select('firstname,lastname')->find($pid);
|
||||
$parents[$pid] = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
return view('payment/notification_management', [
|
||||
'logs' => $rows,
|
||||
'parentsById' => $parents,
|
||||
'filters' => ['from' => $from, 'to' => $to, 'type' => $type],
|
||||
]);
|
||||
}
|
||||
|
||||
private function wantsJson(): bool
|
||||
{
|
||||
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
|
||||
$fmt = strtolower((string)($this->request->getGet('format') ?? $this->request->getPost('format') ?? ''));
|
||||
return $this->request->isAJAX() || str_contains($accept, 'application/json') || $fmt === 'json';
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/payments/notifications/send
|
||||
* Body: parent_id? (int), type? ('no_payment'|'installment'), school_year?
|
||||
* - When parent_id provided: send for that parent only (if balance > 0 or force=1)
|
||||
* - Otherwise: send to all parents with positive balance for selected school year
|
||||
* respecting idempotency (1 email per month/type) unless force=1.
|
||||
*/
|
||||
public function send()
|
||||
{
|
||||
$parentId = (int)($this->request->getPost('parent_id') ?? 0);
|
||||
$typeInput = (string)($this->request->getPost('type') ?? '');
|
||||
$force = (int)($this->request->getPost('force') ?? 0) === 1;
|
||||
$schoolYear = (string)($this->request->getPost('school_year') ?? ($this->configModel->getConfig('school_year') ?? date('Y')));
|
||||
$returnTo = (string)($this->request->getPost('return_to') ?? '');
|
||||
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$now = new \DateTime('now', $tz);
|
||||
$year = (int)$now->format('Y');
|
||||
$month= (int)$now->format('n');
|
||||
|
||||
// Helper: compute current total balance across invoices for this parent/year
|
||||
$computeBalance = function (int $pid) use ($schoolYear): array {
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('invoices')
|
||||
->select('id, total_amount')
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getResultArray();
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
||||
|
||||
$sumBalance = 0.0; $latestId = null;
|
||||
foreach ($rows as $ir) {
|
||||
$iid = (int)$ir['id'];
|
||||
if ($latestId === null) $latestId = $iid;
|
||||
$total = (float)($ir['total_amount'] ?? 0);
|
||||
|
||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paid = (float)($qb->get()->getRowArray()['tot'] ?? 0);
|
||||
|
||||
$disc = (float)($db->table('discount_usages')->select('COALESCE(SUM(discount_amount),0) AS tot')->where('invoice_id', $iid)->get()->getRowArray()['tot'] ?? 0);
|
||||
$rfnd = (float)($db->table('refunds')->select('COALESCE(SUM(refund_paid_amount),0) AS tot')->where('invoice_id', $iid)->whereIn('status', ['Partial','Paid'])->get()->getRowArray()['tot'] ?? 0);
|
||||
|
||||
$sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2));
|
||||
}
|
||||
return [$sumBalance, $latestId];
|
||||
};
|
||||
|
||||
// Helper: get secondary guardian email
|
||||
$getSecondary = function (int $primaryUserId): ?string {
|
||||
$row = $this->familyGuardianModel->where('user_id', $primaryUserId)->first();
|
||||
if (!$row || empty($row['family_id'])) return null;
|
||||
$others = $this->familyGuardianModel
|
||||
->where('family_id', (int)$row['family_id'])
|
||||
->where('user_id !=', $primaryUserId)
|
||||
->where('receive_emails', 1)
|
||||
->findAll();
|
||||
foreach ($others as $g) {
|
||||
$email = $this->userModel->select('email')->find((int)$g['user_id'])['email'] ?? null;
|
||||
if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) return $email;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Compose subject/body using same content as CLI command
|
||||
$compose = function (int $pid, float $balance) use ($schoolYear, $now): array {
|
||||
$u = $this->userModel->find($pid) ?: [];
|
||||
$parentName = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
|
||||
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
|
||||
$monthYear = $now->format('F Y');
|
||||
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
|
||||
$balanceFmt = '$' . number_format($balance, 2);
|
||||
$intro = 'This is your monthly installment reminder for ' . $schoolYear . '.';
|
||||
// Compute remaining/max installments similar to Manual Payment UI
|
||||
$installmentEndRaw = (string)$this->configModel->getConfig('installment_date');
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; }
|
||||
$remMonths = 0;
|
||||
if ($end) {
|
||||
$y = (int)$end->format('Y') - (int)$today->format('Y');
|
||||
$m = (int)$end->format('n') - (int)$today->format('n');
|
||||
$remMonths = $y * 12 + $m;
|
||||
if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1;
|
||||
if ($remMonths < 0) $remMonths = 0;
|
||||
}
|
||||
$remainingInst = max(1, $remMonths);
|
||||
$maxInst = max(($remMonths ?: 0), ($balance > 0 ? 2 : 0));
|
||||
$instDueFmt = '$' . number_format($remainingInst > 0 ? ($balance / $remainingInst) : 0, 2);
|
||||
$bodyHtml = <<<HTML
|
||||
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
|
||||
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder</h2>
|
||||
<p>{$greeting}</p>
|
||||
<p>{$intro}</p>
|
||||
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
|
||||
<ul>
|
||||
<li><strong>Remaining installments:</strong> {$remainingInst}</li>
|
||||
<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>
|
||||
<li><strong>Maximum installments available:</strong> {$maxInst}</li>
|
||||
</ul>
|
||||
<p>As a friendly reminder, our tuition installments are due at the beginning of each month.</p>
|
||||
<p>You can review your invoice and payment options by logging in to your parent portal.</p>
|
||||
<p>Thank you for your prompt attention.</p>
|
||||
<p><em>Reminder Period:</em> {$monthYear}</p>
|
||||
</div>
|
||||
HTML;
|
||||
$body = view('emails/_wrap_layout', [ 'title' => 'Monthly Tuition Reminder', 'body_html' => $bodyHtml ], ['saveData' => true]);
|
||||
return [$subject, $body];
|
||||
};
|
||||
|
||||
$results = [ 'sent' => 0, 'skipped' => 0, 'failed' => 0, 'details' => [] ];
|
||||
|
||||
if ($parentId > 0) {
|
||||
[$balance, $latestInv] = $computeBalance($parentId);
|
||||
|
||||
// detect type if not provided
|
||||
if ($typeInput === 'no_payment' || $typeInput === 'installment') {
|
||||
$type = $typeInput;
|
||||
} else {
|
||||
$hasPayments = $this->paymentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->countAllResults() > 0;
|
||||
$type = $hasPayments ? 'installment' : 'no_payment';
|
||||
}
|
||||
|
||||
// Idempotency: skip if already sent this month for this type
|
||||
if (!$force && $this->logModel->existsForPeriod($parentId, $year, $month, $type)) {
|
||||
$results['skipped']++;
|
||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'already_sent'];
|
||||
} else {
|
||||
|
||||
if ($balance <= 0 && !$force) {
|
||||
$results['skipped']++;
|
||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'no_balance'];
|
||||
// For HTML submit, redirect back with details
|
||||
if (!$this->wantsJson()) {
|
||||
$dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear));
|
||||
return redirect()->to($dest)->with('status', 'No balance to remind this parent.');
|
||||
}
|
||||
} else {
|
||||
// Compute current-month installment suggestion
|
||||
$installmentEndRaw = (string)$this->configModel->getConfig('installment_date');
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$nowD = new \DateTimeImmutable('today', $tz);
|
||||
$endD = null; try { if ($installmentEndRaw) $endD = new \DateTimeImmutable($installmentEndRaw, $tz); } catch (\Throwable $e) { $endD = null; }
|
||||
$remMonths = 0;
|
||||
if ($endD) {
|
||||
$y = (int)$endD->format('Y') - (int)$nowD->format('Y');
|
||||
$m = (int)$endD->format('n') - (int)$nowD->format('n');
|
||||
$remMonths = $y * 12 + $m;
|
||||
if ((int)$endD->format('j') > (int)$nowD->format('j')) $remMonths += 1;
|
||||
if ($remMonths < 0) $remMonths = 0;
|
||||
}
|
||||
$remInst = max(1, $remMonths);
|
||||
$instDue = round($balance / $remInst, 2);
|
||||
$toEmail = $this->userModel->select('email')->find($parentId)['email'] ?? null;
|
||||
$toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null;
|
||||
$ccEmail = $getSecondary($parentId);
|
||||
[$subject, $body] = $compose($parentId, $balance);
|
||||
|
||||
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
|
||||
if ($ok && $ccEmail && strcasecmp($ccEmail, (string)$toEmail) !== 0) {
|
||||
$this->emailService->send($ccEmail, $subject, $body, 'finance');
|
||||
}
|
||||
|
||||
$this->logModel->insert([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $latestInv,
|
||||
'school_year' => $schoolYear,
|
||||
'period_year' => $year,
|
||||
'period_month' => $month,
|
||||
'type' => $type,
|
||||
'to_email' => $toEmail,
|
||||
'cc_email' => $ccEmail,
|
||||
'head_fa_notified' => 0,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $ok ? 'sent' : 'failed',
|
||||
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
|
||||
'balance_snapshot' => $balance,
|
||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if ($ok) { $results['sent']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'sent', 'balance' => $balance, 'installment_due' => $instDue, 'remaining' => $remInst]; }
|
||||
else { $results['failed']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'failed']; }
|
||||
|
||||
// For HTML submit, redirect back with detailed message
|
||||
if (!$this->wantsJson()) {
|
||||
$dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear));
|
||||
$msg = sprintf('Reminder sent. Remaining balance: $%s. Current installment due: $%s. Remaining installments: %d',
|
||||
number_format($balance, 2), number_format($instDue, 2), (int)$remInst);
|
||||
return redirect()->to($dest)->with('status', $msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-parent branch handled; otherwise do bulk
|
||||
} else {
|
||||
|
||||
// Bulk: parents with positive balance in selected year
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('invoices')
|
||||
->select('DISTINCT parent_id', false)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getResultArray();
|
||||
$pids = array_values(array_unique(array_map(static fn($r) => (int)$r['parent_id'], $rows)));
|
||||
|
||||
foreach ($pids as $pid) {
|
||||
[$balance, $latestInv] = $computeBalance($pid);
|
||||
if ($balance <= 0 && !$force) { $results['skipped']++; continue; }
|
||||
|
||||
$hasPayments = $this->paymentModel
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $schoolYear)
|
||||
->countAllResults() > 0;
|
||||
$type = $hasPayments ? 'installment' : 'no_payment';
|
||||
|
||||
if (!$force && $this->logModel->existsForPeriod($pid, $year, $month, $type)) { $results['skipped']++; continue; }
|
||||
|
||||
$toEmail = $this->userModel->select('email')->find($pid)['email'] ?? null;
|
||||
$toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null;
|
||||
$ccEmail = $getSecondary($pid);
|
||||
[$subject, $body] = $compose($pid, $balance);
|
||||
|
||||
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
|
||||
if ($ok && $ccEmail && strcasecmp($ccEmail, (string)$toEmail) !== 0) {
|
||||
$this->emailService->send($ccEmail, $subject, $body, 'finance');
|
||||
}
|
||||
|
||||
// Optional: notify head of finance in-app
|
||||
foreach ($this->getHeadOfFinanceUsers() as $u) {
|
||||
NotificationService::toUser((int)$u['id'], 'Payment Reminder Sent',
|
||||
sprintf('A %s reminder was sent to parent #%d (balance: $%0.2f).', $type, $pid, $balance),
|
||||
['in_app']
|
||||
);
|
||||
}
|
||||
|
||||
$this->logModel->insert([
|
||||
'parent_id' => $pid,
|
||||
'invoice_id' => $latestInv,
|
||||
'school_year' => $schoolYear,
|
||||
'period_year' => $year,
|
||||
'period_month' => $month,
|
||||
'type' => $type,
|
||||
'to_email' => $toEmail,
|
||||
'cc_email' => $ccEmail,
|
||||
'head_fa_notified' => 1,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $ok ? 'sent' : 'failed',
|
||||
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
|
||||
'balance_snapshot' => $balance,
|
||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if ($ok) $results['sent']++; else $results['failed']++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Respond
|
||||
if ($this->wantsJson()) {
|
||||
return $this->response->setJSON(['ok' => true] + $results + [
|
||||
'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
$msg = sprintf('Reminders — sent: %d, skipped: %d, failed: %d', (int)$results['sent'], (int)$results['skipped'], (int)$results['failed']);
|
||||
$dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear));
|
||||
return redirect()->to($dest)->with('status', $msg);
|
||||
}
|
||||
|
||||
private function getHeadOfFinanceUsers(): array
|
||||
{
|
||||
$heads = $this->userModel->getUsersByRole('head of department (finance)');
|
||||
if (!empty($heads)) return $heads;
|
||||
$acct = $this->userModel->getUsersByRole('accountant');
|
||||
return $acct ?: [];
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\PaymentTransactionModel;
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
|
||||
class PaymentTransactionController extends ResourceController
|
||||
{
|
||||
protected $paymentTransactionModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->paymentTransactionModel = new PaymentTransactionModel();
|
||||
}
|
||||
|
||||
// API: Create a new payment transaction (installment)
|
||||
public function createAPI()
|
||||
{
|
||||
$data = [
|
||||
'transaction_id' => $this->request->getPost('transaction_id'),
|
||||
'payment_id' => $this->request->getPost('payment_id'),
|
||||
'transaction_date' => $this->request->getPost('transaction_date'),
|
||||
'amount' => $this->request->getPost('amount'),
|
||||
'payment_method' => $this->request->getPost('payment_method'),
|
||||
'payment_status' => 'Pending',
|
||||
'transaction_fee' => $this->request->getPost('transaction_fee'),
|
||||
'payment_reference' => $this->request->getPost('payment_reference'),
|
||||
'is_full_payment' => $this->request->getPost('is_full_payment') ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($this->paymentTransactionModel->save($data)) {
|
||||
return $this->respondCreated($data);
|
||||
} else {
|
||||
return $this->failValidationErrors($this->paymentTransactionModel->errors());
|
||||
}
|
||||
}
|
||||
|
||||
// API: Get all transactions for a specific payment
|
||||
public function getByPaymentAPI($paymentId)
|
||||
{
|
||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
||||
if ($transactions) {
|
||||
return $this->respond($transactions);
|
||||
} else {
|
||||
return $this->failNotFound('Transactions not found for the payment.');
|
||||
}
|
||||
}
|
||||
|
||||
// View: Get all transactions for a specific payment (for web views)
|
||||
public function getByPayment($paymentId)
|
||||
{
|
||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
||||
return view('payment_transaction_list', ['transactions' => $transactions]);
|
||||
}
|
||||
|
||||
// View: Create a new payment transaction
|
||||
public function create()
|
||||
{
|
||||
return view('payment_transaction_create');
|
||||
}
|
||||
|
||||
// API: Update payment status by transaction ID
|
||||
public function updateStatusAPI($transactionId)
|
||||
{
|
||||
$status = $this->request->getPost('status');
|
||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
||||
return $this->respond(['status' => 'success']);
|
||||
} else {
|
||||
return $this->failNotFound('Transaction not found.');
|
||||
}
|
||||
}
|
||||
|
||||
// View: Update payment status by transaction ID
|
||||
public function updateStatus($transactionId)
|
||||
{
|
||||
$status = $this->request->getPost('status');
|
||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
||||
return redirect()->to('/payment_transactions');
|
||||
} else {
|
||||
return redirect()->back()->with('error', 'Failed to update status.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\PayPalPaymentModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class PaypalTransactionsController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$model = new PayPalPaymentModel();
|
||||
|
||||
$keyword = $this->request->getGet('q');
|
||||
$perPage = 10;
|
||||
|
||||
if ($keyword) {
|
||||
$transactions = $model
|
||||
->groupStart()
|
||||
->like('transaction_id', $keyword)
|
||||
->orLike('payer_email', $keyword)
|
||||
->orLike('event_type', $keyword)
|
||||
->orLike('order_id', $keyword)
|
||||
->orLike('parent_school_id', $keyword)
|
||||
->groupEnd()
|
||||
->orderBy('created_at', 'DESC')
|
||||
->paginate($perPage);
|
||||
} else {
|
||||
$transactions = $model
|
||||
->orderBy('created_at', 'DESC')
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
return view('administrator/paypal_transactions', [
|
||||
'transactions' => $transactions,
|
||||
'pager' => $model->pager,
|
||||
'keyword' => $keyword
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportCsv()
|
||||
{
|
||||
$model = new PayPalPaymentModel();
|
||||
$keyword = $this->request->getGet('q');
|
||||
|
||||
if ($keyword) {
|
||||
$transactions = $model
|
||||
->groupStart()
|
||||
->like('transaction_id', $keyword)
|
||||
->orLike('payer_email', $keyword)
|
||||
->orLike('event_type', $keyword)
|
||||
->orLike('order_id', $keyword)
|
||||
->orLike('parent_school_id', $keyword)
|
||||
->groupEnd()
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
} else {
|
||||
$transactions = $model->orderBy('created_at', 'DESC')->findAll();
|
||||
}
|
||||
|
||||
$filename = 'paypal_transactions_' . date('Ymd_His') . '.csv';
|
||||
|
||||
header('Content-Type: text/csv');
|
||||
header("Content-Disposition: attachment; filename=\"$filename\"");
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// CSV headers
|
||||
fputcsv($output, [
|
||||
'ID', 'Transaction ID', 'Order ID', 'Parent School ID',
|
||||
'Email', 'Amount', 'Net Amount', 'Currency',
|
||||
'Status', 'Event Type', 'Created At'
|
||||
]);
|
||||
|
||||
foreach ($transactions as $t) {
|
||||
fputcsv($output, [
|
||||
$t['id'],
|
||||
$t['transaction_id'],
|
||||
$t['order_id'],
|
||||
$t['parent_school_id'],
|
||||
$t['payer_email'],
|
||||
$t['amount'],
|
||||
$t['net_amount'],
|
||||
$t['currency'],
|
||||
$t['status'],
|
||||
$t['event_type'],
|
||||
$t['created_at'],
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class PhoneFormatterService
|
||||
{
|
||||
/**
|
||||
* Format a raw phone number to (xxx)-xxx-xxxx format
|
||||
*
|
||||
* @param string $number
|
||||
* @return string|null
|
||||
*/
|
||||
public function formatPhoneNumber(string $number): ?string
|
||||
{
|
||||
// Remove non-digit characters
|
||||
$digits = preg_replace('/\D/', '', $number);
|
||||
|
||||
// Ensure it's exactly 10 digits
|
||||
if (strlen($digits) === 10) {
|
||||
return sprintf(
|
||||
'%s-%s-%s',
|
||||
substr($digits, 0, 3),
|
||||
substr($digits, 3, 3),
|
||||
substr($digits, 6)
|
||||
);
|
||||
}
|
||||
|
||||
// Invalid number
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\ProjectModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use RuntimeException;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Config\Services;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
protected $db;
|
||||
protected ConfigurationModel $configModel;
|
||||
protected string $schoolYear;
|
||||
protected $semesterScoreService;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->semesterScoreService = service('semesterScoreService');
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
}
|
||||
|
||||
public function addProject()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$teacherClassModel = new TeacherClassModel();
|
||||
$studentClassModel = new StudentClassModel();
|
||||
$studentModel = new StudentModel();
|
||||
$projectModel = new ProjectModel();
|
||||
|
||||
$teacherClass = $teacherClassModel->where('teacher_id', $updatedBy)->first();
|
||||
if (!$teacherClass) {
|
||||
return redirect()->back()->with('status', 'No class section found for the current teacher.');
|
||||
}
|
||||
|
||||
$classSectionId = $teacherClass['class_section_id'];
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->getTeacherSchoolYear();
|
||||
|
||||
$projectRows = $projectModel
|
||||
->select('id, project_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('project_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$projectHeaders = [];
|
||||
foreach ($projectRows as $row) {
|
||||
$projectHeaders[$row['project_index']][] = $row['id'];
|
||||
}
|
||||
|
||||
$headerLabels = array_keys($projectHeaders);
|
||||
|
||||
$studentsClasses = $studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
$students = [];
|
||||
|
||||
foreach ($studentsClasses as $studentClass) {
|
||||
$studentId = $studentClass['student_id'];
|
||||
$student = $studentModel
|
||||
->where('id', $studentId)
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
|
||||
if ($student) {
|
||||
$scores = [];
|
||||
foreach ($projectHeaders as $index => $ids) {
|
||||
$entry = $projectModel
|
||||
->where('student_id', $studentId)
|
||||
->where('project_index', $index)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
$scores[$index] = $entry['score'] ?? '';
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $studentId,
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'scores' => $scores
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
||||
|
||||
return view('/teacher/add_project', [
|
||||
'students' => $students,
|
||||
'projectHeaders' => $headerLabels,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
'missingOkMap' => $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'project'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateProjectScores(array $scores = null, int $updatedBy = null, int $classSectionId = null)
|
||||
{
|
||||
$scores = $this->request->getPost('scores');
|
||||
$projectModel = new ProjectModel();
|
||||
$studentModel = new StudentModel();
|
||||
|
||||
if ($updatedBy === null) {
|
||||
$updatedBy = session()->get('user_id');
|
||||
}
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->getTeacherSchoolYear();
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return redirect()->back()->with('error', 'No project scores submitted.');
|
||||
}
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
$studentIds = array_keys($scores);
|
||||
$indexSet = [];
|
||||
foreach ($scores as $studentId => $projects) {
|
||||
if (!is_array($projects)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($projects as $index => $score) {
|
||||
if (is_numeric($index)) {
|
||||
$indexSet[(int)$index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $indexes) {
|
||||
if (!is_array($indexes)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($indexes as $index => $flag) {
|
||||
if (!is_numeric($index)) {
|
||||
continue;
|
||||
}
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => (int) $index,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
'project',
|
||||
$studentIds,
|
||||
array_keys($indexSet),
|
||||
$checkedItems,
|
||||
$updatedBy
|
||||
);
|
||||
|
||||
foreach ($scores as $studentId => $projects) {
|
||||
if (!is_numeric($studentId)) continue;
|
||||
|
||||
$student = $studentModel->find($studentId);
|
||||
if (!$student) continue;
|
||||
|
||||
foreach ($projects as $index => $score) {
|
||||
$normalizedScore = is_numeric($score) ? (float) $score : null;
|
||||
|
||||
$existing = $projectModel->where([
|
||||
'student_id' => $studentId,
|
||||
'project_index' => $index,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester
|
||||
])->first();
|
||||
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $student['school_id'],
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'project_index' => $index,
|
||||
'score' => $normalizedScore,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$projectModel->update($existing['id'], $data);
|
||||
} else {
|
||||
$data['created_at'] = utc_now();
|
||||
$projectModel->insert($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
return redirect()->to(base_url('/teacher/addProject'))->with('status', 'Project scores updated successfully.');
|
||||
}
|
||||
|
||||
public function addNextProjectColumn()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->getTeacherSchoolYear();
|
||||
|
||||
$projectModel = new ProjectModel();
|
||||
$studentClassModel = new StudentClassModel();
|
||||
|
||||
$existingIndexes = $projectModel
|
||||
->select('project_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('updated_by', $updatedBy)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('project_index')
|
||||
->orderBy('project_index', 'DESC')
|
||||
->findAll();
|
||||
|
||||
$maxIndex = 0;
|
||||
foreach ($existingIndexes as $row) {
|
||||
if (isset($row['project_index']) && is_numeric($row['project_index'])) {
|
||||
$maxIndex = max($maxIndex, (int)$row['project_index']);
|
||||
}
|
||||
}
|
||||
|
||||
$nextIndex = $maxIndex + 1;
|
||||
|
||||
$students = $studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
|
||||
foreach ($students as $student) {
|
||||
$exists = $projectModel->where([
|
||||
'student_id' => $student['student_id'],
|
||||
'project_index' => $nextIndex,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
])->first();
|
||||
|
||||
if (!$exists) {
|
||||
$projectModel->insert([
|
||||
'student_id' => $student['student_id'],
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'project_index' => $nextIndex,
|
||||
'score' => null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'status' => 'success',
|
||||
'project_index' => $nextIndex
|
||||
]);
|
||||
}
|
||||
|
||||
public function showProjectMngt()
|
||||
{
|
||||
// Accept POST first, then GET (query string), then session fallback
|
||||
$incomingId = $this->request->getPost('class_section_id');
|
||||
if (!$incomingId) {
|
||||
$incomingId = $this->request->getGet('class_section_id');
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'No class section found for the current teacher or selection.');
|
||||
}
|
||||
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->getTeacherSchoolYear();
|
||||
$projectScores = $this->getProjectScoresByStudent($classSectionId, $semester, $schoolYear);
|
||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $schoolYear);
|
||||
$projectHeaders = $this->getProjectHeaders($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
return view('grading/project', [
|
||||
'projectScores' => $projectScores,
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
'projectHeaders' => $projectHeaders,
|
||||
'classSectionId' => $classSectionId,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateProject()
|
||||
{
|
||||
$studentModel = new StudentModel();
|
||||
$scores = $this->request->getPost('scores');
|
||||
$studentIds = $this->request->getPost('student_ids');
|
||||
$semester = $this->request->getPost('semester') ?? $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->getTeacherSchoolYear();
|
||||
|
||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id'); // already stored in showProjectMngt
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
if ($scores && $studentIds && $semester && $schoolYear && $classSectionId) {
|
||||
$this->updateProjectScores($scores, $updatedBy, $classSectionId);
|
||||
session()->setFlashdata('status', 'Project scores updated successfully.');
|
||||
|
||||
// ⬇️ Directly call the method and return its view
|
||||
return $this->showProjectMngt($updatedBy);
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
return redirect()->back()->with('status', 'Failed to update scores.');
|
||||
}
|
||||
|
||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
||||
{
|
||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
}
|
||||
|
||||
/**____________________helpers________________________ */
|
||||
|
||||
private function saveProjectScores(array $scores, array $studentIds, string $semester, string $schoolYear, int $updatedBy, int $classSectionId)
|
||||
{
|
||||
$projectModel = new \App\Models\ProjectModel();
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($studentIds as $studentId) {
|
||||
if (!isset($scores[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($scores[$studentId] as $index => $score) {
|
||||
// Check if the record exists
|
||||
$existing = $projectModel
|
||||
->where('student_id', $studentId)
|
||||
->where('project_index', $index)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('updated_by', $updatedBy)
|
||||
->first();
|
||||
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'project_index' => $index,
|
||||
'score' => is_numeric($score) ? floatval($score) : null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing && isset($existing['id'])) {
|
||||
$projectModel->update($existing['id'], $data);
|
||||
} else {
|
||||
$data['created_at'] = $now;
|
||||
$data['school_id'] = ''; // Set if needed
|
||||
$projectModel->insert($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getProjectHeaders($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$projectModel = new ProjectModel();
|
||||
$rows = $projectModel
|
||||
->select('project_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('project_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$headers = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$index = $row['project_index'];
|
||||
if (!is_array($index)) {
|
||||
$headers[$index] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($headers);
|
||||
return array_keys($headers);
|
||||
}
|
||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
||||
{
|
||||
$studentClassModel = new \App\Models\StudentClassModel();
|
||||
$studentModel = new \App\Models\StudentModel();
|
||||
|
||||
// Step 1: Get student IDs from student_class table
|
||||
$studentClassRows = $studentClassModel
|
||||
->select('student_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentIds = array_column($studentClassRows, 'student_id');
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return []; // No students found
|
||||
}
|
||||
|
||||
// Step 2: Get student data from students table
|
||||
$students = $studentModel
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getClassSectionIdForTeacher($updatedBy)
|
||||
{
|
||||
$teacherClassModel = new TeacherClassModel();
|
||||
$class = $teacherClassModel->where('teacher_id', $updatedBy)->first();
|
||||
return $class['class_section_id'] ?? null;
|
||||
}
|
||||
|
||||
private function getProjectScoresByStudent($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$projectModel = new ProjectModel();
|
||||
|
||||
$rows = $projectModel
|
||||
->select('student_id, project_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentScores = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = $row['student_id'];
|
||||
$index = $row['project_index'];
|
||||
$score = $row['score'];
|
||||
|
||||
$studentScores[$studentId]['scores'][$index] = $score;
|
||||
}
|
||||
|
||||
return $studentScores;
|
||||
}
|
||||
|
||||
private function getStudentsWithProjectScores($classSectionId, $projectHeaders)
|
||||
{
|
||||
$studentClassModel = new StudentClassModel();
|
||||
$studentModel = new StudentModel();
|
||||
$projectModel = new ProjectModel();
|
||||
|
||||
$studentClasses = $studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
$students = [];
|
||||
|
||||
foreach ($studentClasses as $sc) {
|
||||
$student = $studentModel
|
||||
->where('id', $sc['student_id'])
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
if (!$student) continue;
|
||||
|
||||
$scores = [];
|
||||
foreach ($projectHeaders as $index => $ids) {
|
||||
$entry = $projectModel->where('project_index', $index)
|
||||
->where('student_id', $sc['student_id'])
|
||||
->first();
|
||||
$scores[$index] = $entry['score'] ?? '';
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $sc['student_id'],
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'scores' => $scores
|
||||
];
|
||||
}
|
||||
|
||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getTeacherSelectedSemester(): string
|
||||
{
|
||||
helper('semester_selection_helper');
|
||||
return selected_teacher_semester();
|
||||
}
|
||||
|
||||
private function getTeacherSchoolYear(): string
|
||||
{
|
||||
return (string) $this->schoolYear;
|
||||
}
|
||||
}
|
||||
@@ -1,577 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\QuizModel;
|
||||
use App\Models\HomeworkModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Controllers\View\GradingController;
|
||||
use RuntimeException;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Config\Services;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
class QuizController extends Controller
|
||||
{
|
||||
protected $db;
|
||||
protected $semesterScoreService;
|
||||
protected $quizModel;
|
||||
protected $configModel;
|
||||
protected $homeworkModel;
|
||||
protected $userModel;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $studentClassModel;
|
||||
protected $studentModel;
|
||||
protected $teacherClassModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->homeworkModel = new HomeworkModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->studentClassModel = new StudentClassModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->quizModel = new QuizModel();
|
||||
$this->semesterScoreService = service('semesterScoreService');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
}
|
||||
|
||||
public function updateQuizScores(array $scores = null, int $updatedBy = null, int $classSectionId = null)
|
||||
{
|
||||
$scores = $this->request->getPost('scores');
|
||||
log_message('error', '✅ Raw Scores: ' . print_r($scores, true));
|
||||
|
||||
if ($updatedBy === null) {
|
||||
$updatedBy = session()->get('user_id');
|
||||
}
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id');
|
||||
$semester = $this->request->getPost('semester') ?? $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
$studentIds = is_array($scores) ? array_keys($scores) : array_keys((array) $missingOk);
|
||||
$indexSet = [];
|
||||
if (is_array($scores)) {
|
||||
foreach ($scores as $studentId => $quizData) {
|
||||
if (!is_array($quizData)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($quizData as $index => $score) {
|
||||
if (is_numeric($index)) {
|
||||
$indexSet[(int)$index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $indexes) {
|
||||
if (!is_array($indexes)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($indexes as $index => $flag) {
|
||||
if (!is_numeric($index)) {
|
||||
continue;
|
||||
}
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => (int) $index,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
'quiz',
|
||||
$studentIds,
|
||||
array_keys($indexSet),
|
||||
$checkedItems,
|
||||
$updatedBy
|
||||
);
|
||||
|
||||
if (is_array($scores)) {
|
||||
foreach ($scores as $studentId => $quizData) {
|
||||
if (!is_numeric($studentId) || $studentId <= 0) continue;
|
||||
|
||||
// Normalize: if single input submitted
|
||||
if (!is_array($quizData)) {
|
||||
$quizData = [1 => $quizData];
|
||||
}
|
||||
|
||||
foreach ($quizData as $quizNumber => $score) {
|
||||
if (!is_numeric($quizNumber)) continue;
|
||||
|
||||
$isBlank = $score === null || (is_string($score) && trim($score) === '');
|
||||
$normalizedScore = (!$isBlank && is_numeric($score)) ? (float) $score : null;
|
||||
|
||||
$existing = $this->quizModel->where([
|
||||
'student_id' => $studentId,
|
||||
'quiz_index' => $quizNumber,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
])->first();
|
||||
|
||||
if ($isBlank) {
|
||||
if ($existing) {
|
||||
$this->quizModel->update($existing['id'], [
|
||||
'score' => null,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
} else {
|
||||
$this->quizModel->insert([
|
||||
'student_id' => $studentId,
|
||||
'school_id' => '', // Optional
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'quiz_index' => $quizNumber,
|
||||
'score' => null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$this->quizModel->update($existing['id'], [
|
||||
'score' => $normalizedScore,
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
log_message('debug', "✅ Updated quiz ID {$existing['id']} with score {$normalizedScore}");
|
||||
} else {
|
||||
$this->quizModel->insert([
|
||||
'student_id' => $studentId,
|
||||
'school_id' => '', // Optional
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'quiz_index' => $quizNumber,
|
||||
'score' => $normalizedScore,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
log_message('debug', "✅ Inserted quiz for student $studentId quiz $quizNumber with score $score");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
return redirect()->to(base_url('/teacher/addQuiz'))->with('status', 'Quiz scores updated successfully.');
|
||||
}
|
||||
|
||||
public function addQuiz()
|
||||
{
|
||||
// 1) class_section_id from view (POST/GET) with session fallback
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// 3) Headers: distinct quiz_index for this class/term
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->schoolYear;
|
||||
$quizHeaderRows = $this->quizModel->select('quiz_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->groupBy('quiz_index')
|
||||
->orderBy('quiz_index', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
$quizHeaders = array_map(static fn($r) => (int)$r['quiz_index'], $quizHeaderRows); // [1,2,3,...]
|
||||
|
||||
// 4) Roster (distinct students for this section/term)
|
||||
$roster = $this->db->table('student_class sc')
|
||||
->select('s.id AS student_id, s.firstname, s.lastname, s.school_id')
|
||||
->distinct() // ✅ apply DISTINCT correctly
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
|
||||
// 5) All existing quiz scores (single query) -> pivot
|
||||
$scoreRows = $this->quizModel->select('id, student_id, quiz_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->get()->getResultArray();
|
||||
|
||||
$scoresMap = []; // [student_id][quiz_index] => score
|
||||
$idMap = []; // [student_id][quiz_index] => id (if you need to update existing rows)
|
||||
|
||||
foreach ($scoreRows as $r) {
|
||||
$sid = (int)$r['student_id'];
|
||||
$qi = (int)$r['quiz_index'];
|
||||
$scoresMap[$sid][$qi] = $r['score'];
|
||||
$idMap[$sid][$qi] = (int)$r['id'];
|
||||
}
|
||||
|
||||
// 6) Build students array with per-quiz scores
|
||||
$students = [];
|
||||
foreach ($roster as $st) {
|
||||
$sid = (int)$st['student_id'];
|
||||
|
||||
$quizScores = [];
|
||||
foreach ($quizHeaders as $qi) {
|
||||
$quizScores[$qi] = $scoresMap[$sid][$qi] ?? ''; // empty if none yet
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $sid,
|
||||
'firstname' => $st['firstname'],
|
||||
'lastname' => $st['lastname'],
|
||||
'school_id' => $st['school_id'] ?? null,
|
||||
'scores' => $quizScores,
|
||||
// If your view needs existing row ids to decide update vs insert:
|
||||
'existingIds' => $idMap[$sid] ?? [], // optional
|
||||
];
|
||||
}
|
||||
|
||||
// 7) Render
|
||||
return view('teacher/add_quiz', [
|
||||
'students' => $students,
|
||||
'quizHeaders' => $quizHeaders, // e.g., [1,2,3]
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'classSectionId' => $classSectionId,
|
||||
'missingOkMap' => $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $this->schoolYear, 'quiz'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function addNextQuizColumn()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
|
||||
if (!$updatedBy || !$classSectionId) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => 'Missing teacher or class section data.'
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->schoolYear;
|
||||
$existingQuizNumbers = $this->quizModel
|
||||
->select('quiz_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('quiz_index')
|
||||
->orderBy('quiz_index', 'DESC')
|
||||
->findAll();
|
||||
|
||||
$maxQuizNumber = 0;
|
||||
foreach ($existingQuizNumbers as $row) {
|
||||
if (is_numeric($row['quiz_index'])) {
|
||||
$maxQuizNumber = max($maxQuizNumber, (int)$row['quiz_index']);
|
||||
}
|
||||
}
|
||||
|
||||
$nextQuizNumber = $maxQuizNumber + 1;
|
||||
|
||||
if ($nextQuizNumber <= 0) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => '🚫 Invalid quiz number',
|
||||
'calculated' => $nextQuizNumber
|
||||
]);
|
||||
}
|
||||
|
||||
$students = $this->studentClassModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
|
||||
if (empty($students)) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => 'No students found in this class section.'
|
||||
]);
|
||||
}
|
||||
|
||||
$firstInsertedId = null;
|
||||
|
||||
foreach ($students as $i => $student) {
|
||||
$exists = $this->quizModel
|
||||
->where([
|
||||
'student_id' => $student['student_id'],
|
||||
'quiz_index' => $nextQuizNumber,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($exists) continue;
|
||||
|
||||
$insertData = [
|
||||
'student_id' => $student['student_id'],
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'quiz_index' => $nextQuizNumber,
|
||||
'score' => null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
log_message('error', '✅ Inserting quiz row: ' . json_encode($insertData));
|
||||
|
||||
try {
|
||||
$id = $this->quizModel->insert($insertData, true);
|
||||
if ($i === 0) $firstInsertedId = $id;
|
||||
} catch (\Throwable $e) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => '❌ Insert failed: ' . $e->getMessage(),
|
||||
'debug' => $insertData
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'status' => 'success',
|
||||
'quiz_index' => $nextQuizNumber,
|
||||
'new_quiz_id' => $firstInsertedId
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => '❌ Unexpected error: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function showQuizMngt()
|
||||
{
|
||||
// Accept POST first, then GET (query string), then session fallback
|
||||
$incomingId = $this->request->getPost('class_section_id');
|
||||
if (!$incomingId) {
|
||||
$incomingId = $this->request->getGet('class_section_id');
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'No class section found for the current teacher or selection.');
|
||||
}
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->schoolYear;
|
||||
$quizScores = $this->getquizScoresByStudent($classSectionId, $semester, $schoolYear);
|
||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $schoolYear);
|
||||
$quizHeaders = $this->getquizHeaders($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
return view('grading/quiz', [
|
||||
'quizScores' => $quizScores,
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
'quizHeaders' => $quizHeaders,
|
||||
'classSectionId' => $classSectionId,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateQuiz()
|
||||
{
|
||||
$scores = $this->request->getPost('scores');
|
||||
$studentIds = $this->request->getPost('student_ids');
|
||||
$semester = $this->request->getPost('semester') ?? $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
|
||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id'); // or query it
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
if ($scores && $studentIds && $semester && $schoolYear && $classSectionId) {
|
||||
$this->updateQuizScores($scores, $updatedBy, $classSectionId);
|
||||
session()->setFlashdata('status', 'quiz scores updated successfully.');
|
||||
|
||||
// ⬇️ Directly call the method and return its view
|
||||
return $this->showQuizMngt();
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
return redirect()->back()->with('status', 'Failed to update scores.');
|
||||
}
|
||||
|
||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
||||
{
|
||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
}
|
||||
|
||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
||||
{
|
||||
// Step 1: Get student IDs from student_class table
|
||||
$studentClassRows = $this->studentClassModel
|
||||
->select('student_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentIds = array_column($studentClassRows, 'student_id');
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return []; // No students found
|
||||
}
|
||||
|
||||
// Step 2: Get student data from students table
|
||||
$students = $this->studentModel
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getquizHeaders($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$rows = $this->quizModel
|
||||
->select('quiz_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('quiz_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$headers = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$index = $row['quiz_index'];
|
||||
if (!is_array($index)) {
|
||||
$headers[$index] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($headers);
|
||||
return array_keys($headers);
|
||||
}
|
||||
|
||||
private function getClassSectionIdForTeacher($updatedBy)
|
||||
{
|
||||
$teacherClassModel = new TeacherClassModel();
|
||||
$class = $teacherClassModel->where('teacher_id', $updatedBy)->first();
|
||||
return $class['class_section_id'] ?? null;
|
||||
}
|
||||
|
||||
|
||||
private function getquizScoresByStudent($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$rows = $this->quizModel
|
||||
->select('student_id, quiz_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentScores = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = $row['student_id'];
|
||||
$index = $row['quiz_index'];
|
||||
$score = $row['score'];
|
||||
|
||||
$studentScores[$studentId]['scores'][$index] = $score;
|
||||
}
|
||||
|
||||
return $studentScores;
|
||||
}
|
||||
|
||||
private function getStudentsWithquizScores($classSectionId, $quizHeaders)
|
||||
{
|
||||
$studentClasses = $this->studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
$students = [];
|
||||
|
||||
foreach ($studentClasses as $sc) {
|
||||
$student = $this->studentModel
|
||||
->where('id', $sc['student_id'])
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
if (!$student) continue;
|
||||
|
||||
$scores = [];
|
||||
foreach ($quizHeaders as $index => $ids) {
|
||||
$entry = $this->quizModel->where('quiz_index', $ids)
|
||||
->where('student_id', $sc['student_id'])
|
||||
->first();
|
||||
$scores[$index] = $entry['score'] ?? '';
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $sc['student_id'],
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'scores' => $scores
|
||||
];
|
||||
}
|
||||
|
||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getTeacherSelectedSemester(): string
|
||||
{
|
||||
helper('semester_selection_helper');
|
||||
return selected_teacher_semester();
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\AuthorizedUserModel;
|
||||
use App\Models\RoleModel;
|
||||
use App\Models\ParentModel;
|
||||
use App\Models\UserRoleModel;
|
||||
use App\Services\EmailService;
|
||||
use App\Services\SchoolIdService;
|
||||
use App\Services\PhoneFormatterService;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
protected $db;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $userRoleModel;
|
||||
protected $roleModel;
|
||||
protected $userModel;
|
||||
protected $parentModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Load the database service
|
||||
$this->db = \Config\Database::connect();
|
||||
// Check if the database connection is established
|
||||
if (!$this->db->connect()) {
|
||||
log_message('error', 'Database connection failed.');
|
||||
throw new \Exception('Database connection failed.');
|
||||
} else {
|
||||
log_message('info', 'Database connection successful.');
|
||||
}
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->userRoleModel = new UserRoleModel();
|
||||
$this->roleModel = new RoleModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->parentModel = new ParentModel();
|
||||
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
log_message('debug', 'CAPTCHA session value: ' . session()->get('captcha_answer'));
|
||||
|
||||
// Only generate CAPTCHA if not already set (first visit or after submission)
|
||||
if (!session()->has('captcha_answer')) {
|
||||
$length = rand(4, 8);
|
||||
$captchaText = $this->generateCaptchaText($length);
|
||||
session()->set('captcha_answer', $captchaText);
|
||||
}
|
||||
|
||||
$data['captchaQuestion'] = session()->get('captcha_answer');
|
||||
|
||||
// ✅ NO need to check policy_accepted here
|
||||
return view('user/register', $data);
|
||||
}
|
||||
|
||||
private function generateCaptchaText($length)
|
||||
{
|
||||
$characters = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghjkmnopqrstuvwxyz0123456789';
|
||||
$captchaText = '';
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$captchaText .= $characters[rand(0, strlen($characters) - 1)];
|
||||
}
|
||||
|
||||
return $captchaText;
|
||||
}
|
||||
|
||||
public function success()
|
||||
{
|
||||
// Start session
|
||||
$session = session();
|
||||
|
||||
// Check if the session has 'user_email'
|
||||
if (!$session->has('user_email')) {
|
||||
// If not, redirect to the registration page
|
||||
return redirect()->to('/register');
|
||||
}
|
||||
|
||||
// Get 'user_email' from session
|
||||
$email = $session->get('user_email');
|
||||
|
||||
// Pass the 'email' variable to the success view
|
||||
return view('success', ['email' => $email]);
|
||||
}
|
||||
|
||||
|
||||
public function register()
|
||||
{
|
||||
helper(['url', 'form']);
|
||||
log_message('debug', 'CAPTCHA session value: ' . session()->get('captcha_answer'));
|
||||
|
||||
/* ───────────── 1. Load services ───────────── */
|
||||
$schoolIdService = new SchoolIdService();
|
||||
|
||||
/* ───────────── 2. Sanitize & raw data ───────────── */
|
||||
$post = $this->formatUserInput($this->request); // safe / formatted
|
||||
$rawPost = $this->request->getPost(); // raw (for flags etc.)
|
||||
$captcha = $rawPost['captcha'] ?? '';
|
||||
|
||||
/* ───────────── 3. Validation rules ───────────── */
|
||||
$rules = [
|
||||
'firstname' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
|
||||
'lastname' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
|
||||
'gender' => 'required|in_list[Male,Female]',
|
||||
'email' => 'required|valid_email|max_length[50]',
|
||||
'confirm_email' => 'required|matches[email]',
|
||||
'cellphone' => 'required|regex_match[/^[\d\s\-\(\)\.]+$/]|min_length[10]|max_length[20]',
|
||||
'address_street' => 'permit_empty|regex_match[/^[a-zA-Z0-9\s\-]+$/]|max_length[150]',
|
||||
'apt' => 'permit_empty|regex_match[/^[a-zA-Z0-9\s-]+$/]|max_length[10]',
|
||||
'city' => 'required|alpha_space|min_length[2]|max_length[30]',
|
||||
'state' => 'required|in_list[CT,ME,MA,NH,NY,RI,VT]',
|
||||
'zip' => 'required|regex_match[/^\d{5}$/]',
|
||||
'accept_school_policy' => 'required',
|
||||
'captcha' => 'required|alpha_numeric|min_length[4]|max_length[10]',
|
||||
];
|
||||
|
||||
// 1. Check if "I am a parent" checkbox is ticked
|
||||
$isParent = isset($rawPost['is_parent']) && $rawPost['is_parent'] == '1';
|
||||
|
||||
// 2. If parent, handle second parent logic
|
||||
if ($isParent) {
|
||||
$noSecondParent = isset($rawPost['no_second_parent_info']) && $rawPost['no_second_parent_info'] == '1';
|
||||
|
||||
$secondParentProvided = !empty($rawPost['second_firstname']) ||
|
||||
!empty($rawPost['second_lastname']) ||
|
||||
!empty($rawPost['second_gender']) ||
|
||||
!empty($rawPost['second_email']) ||
|
||||
!empty($rawPost['second_cellphone']);
|
||||
|
||||
// 3. Error: must provide second parent or check "no second parent"
|
||||
if (!$noSecondParent && !$secondParentProvided) {
|
||||
return redirect()->back()->withInput()->with('errors', [
|
||||
'second_parent_info' => 'As a parent, please provide second parent information or check "No second parent info".'
|
||||
]);
|
||||
}
|
||||
|
||||
// 4. Add second parent field rules only if "no second parent" is NOT checked
|
||||
if (!$noSecondParent) {
|
||||
$rules += [
|
||||
'second_firstname' => 'required|regex_match[/^[a-zA-Z\s\-]+$/]|min_length[2]|max_length[30]',
|
||||
'second_lastname' => 'required|regex_match[/^[a-zA-Z\s\-]+$/]|min_length[2]|max_length[30]',
|
||||
'second_gender' => 'required|in_list[Male,Female]',
|
||||
'second_email' => 'required|valid_email|max_length[50]',
|
||||
'second_cellphone' => 'required|regex_match[/^[\d\s\-\(\)\.]+$/]|min_length[10]|max_length[20]',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
log_message('debug', '🧪 Starting validation check...');
|
||||
if (!$this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
/* ───────────── 4. CAPTCHA check ───────────── */
|
||||
if ($captcha !== session()->get('captcha_answer')) {
|
||||
return redirect()->back()
|
||||
->withInput()
|
||||
->with('errors', ['captcha' => '']) // keeps input marked as invalid
|
||||
->with('top_error', 'Incorrect CAPTCHA answer. Please try again.');
|
||||
}
|
||||
|
||||
/* ───────────── 5. Unique e-mail ───────────── */
|
||||
// Step 1: Check if a user with this email exists
|
||||
$existingUser = $this->userModel->where('email', $post['email'])->first();
|
||||
|
||||
if ($existingUser) {
|
||||
// Step 2: Check if the user has a token (i.e., not verified yet)
|
||||
if (!empty($existingUser['token']) && $existingUser['is_verified'] == 0) {
|
||||
// User exists and is unverified
|
||||
return redirect()->back()->withInput()->with('error',
|
||||
'This email address is already registered and is pending activation. Please check your email to activate your account.');
|
||||
} else {
|
||||
// User exists and is already active or has no token
|
||||
return redirect()->back()->withInput()->with('error',
|
||||
'The email address you entered is already in use. Please try a different one.');
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────── 6. Determine role ───────────── */
|
||||
$roleName = !empty($rawPost['is_parent']) ? 'parent' : 'guest';
|
||||
$role = $this->roleModel->where('name', $roleName)->first();
|
||||
if (!$role) {
|
||||
return redirect()->back()->withInput()->with('errors', ['role' => 'Role not found']);
|
||||
}
|
||||
|
||||
/* ───────────── 7. Build & insert user ───────────── */
|
||||
$token = bin2hex(random_bytes(48));
|
||||
$userData = [
|
||||
'firstname' => $post['firstname'],
|
||||
'lastname' => $post['lastname'],
|
||||
'gender' => $post['gender'],
|
||||
'email' => $post['email'],
|
||||
'cellphone' => $post['cellphone'],
|
||||
'address_street' => $post['address_street'] ?? null,
|
||||
'apt' => $post['apt'] ?? null,
|
||||
'city' => $post['city'],
|
||||
'state' => $post['state'],
|
||||
'zip' => $post['zip'],
|
||||
'token' => $token,
|
||||
'is_verified'=> 0,
|
||||
'accept_school_policy' => (int) $post['accept_school_policy'],
|
||||
'status' => 'Inactive',
|
||||
'school_id' => $schoolIdService->generateUserSchoolId(),
|
||||
'semester' => $this->semester,
|
||||
'school_year'=> $this->schoolYear,
|
||||
];
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
if (!$this->userModel->insert($userData)) {
|
||||
$this->db->transRollback();
|
||||
return redirect()->back()->withInput()->with('errors', $this->userModel->errors());
|
||||
}
|
||||
$firstParentId = $this->userModel->getInsertID();
|
||||
|
||||
$this->userRoleModel->insert([
|
||||
'user_id' => $firstParentId,
|
||||
'role_id' => $role['id'],
|
||||
'created_at' => utc_now(),
|
||||
]);
|
||||
|
||||
/* ───────────── 8. Optional second-parent insert ───────────── */
|
||||
if ($isParent) {
|
||||
if (empty($rawPost['no_second_parent_info'])) {
|
||||
$this->parentModel->insert([
|
||||
'secondparent_firstname' => $post['second_firstname'],
|
||||
'secondparent_lastname' => $post['second_lastname'],
|
||||
'secondparent_gender' => $post['second_gender'],
|
||||
'secondparent_email' => $post['second_email'],
|
||||
'secondparent_phone' => $post['second_cellphone'],
|
||||
'firstparent_id' => $firstParentId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
if (!$this->db->transStatus()) {
|
||||
return redirect()->back()->withInput()->with('error', 'Registration failed, please try again.');
|
||||
}
|
||||
|
||||
/* ───────────── 9. Confirmation e-mail ───────────── */
|
||||
$recipientName = $post['firstname'] . ' ' . $post['lastname'];
|
||||
$activationLink = site_url('/user/confirm/' . $token);
|
||||
$orgName = 'Al Rahma Sunday School';
|
||||
$contactInfo = 'alrahma.isgl@gmail.com';
|
||||
$logoUrl = 'https://alrahmaisgl.org/assets/images/alrahma_logo.png';
|
||||
|
||||
$emailData = [
|
||||
'recipientName' => $recipientName,
|
||||
'activationLink' => $activationLink,
|
||||
'orgName' => $orgName,
|
||||
'contactInfo' => $contactInfo,
|
||||
'logoUrl' => $logoUrl,
|
||||
'subject' => 'Email Confirmation'
|
||||
];
|
||||
|
||||
if ($isParent) {
|
||||
$html = view('emails/welcome_parent', $emailData);
|
||||
} else {
|
||||
$html = view('emails/welcome_staff', $emailData);
|
||||
}
|
||||
|
||||
(new EmailService())->send($post['email'], 'Email Confirmation', $html, 'general');
|
||||
|
||||
|
||||
/* ───────────── 10. Redirect to success ───────────── */
|
||||
session()->set('user_email', $post['email']);
|
||||
session()->remove('captcha_answer'); // Clear CAPTCHA after success
|
||||
return redirect()->to('/register/success');
|
||||
}
|
||||
|
||||
private function formatName(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
return ucwords(strtolower($name), " -");
|
||||
}
|
||||
|
||||
private function formatEmail(?string $email): string
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
/*
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new \InvalidArgumentException('Please enter a valid email address.');
|
||||
}*/
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function formatAddress(string $address): string
|
||||
{
|
||||
return ucwords(strtolower(trim($address)));
|
||||
}
|
||||
|
||||
private function formatUserInput($request): array
|
||||
{
|
||||
$formatter = new PhoneFormatterService();
|
||||
$post = (array) $request->getPost();
|
||||
|
||||
// tiny helper: safe getter + trim + cast to string
|
||||
$g = static function (string $key) use ($post): string {
|
||||
return trim((string) ($post[$key] ?? ''));
|
||||
};
|
||||
|
||||
$data = [
|
||||
'firstname' => $this->formatName($g('firstname')),
|
||||
'lastname' => $this->formatName($g('lastname')),
|
||||
'email' => $this->formatEmail($g('email')),
|
||||
'gender' => $g('gender'),
|
||||
'city' => $this->formatName($g('city')),
|
||||
'cellphone' => $formatter->formatPhoneNumber($g('cellphone')), // ✅ safe
|
||||
'address_street' => $this->formatAddress($g('address_street')),
|
||||
'apt' => strtoupper($g('apt')),
|
||||
'state' => strtoupper($g('state')),
|
||||
'zip' => $g('zip'),
|
||||
'accept_school_policy' => (int) ($post['accept_school_policy'] ?? 0),
|
||||
];
|
||||
|
||||
// if user says "no second parent info", skip entirely
|
||||
$noSecondInfo = !empty($post['no_second_parent_info']);
|
||||
|
||||
// collect (safely) and see if anything was provided
|
||||
$second_firstname = $this->formatName($g('second_firstname'));
|
||||
$second_lastname = $this->formatName($g('second_lastname'));
|
||||
$second_gender = $g('second_gender');
|
||||
$second_email = $this->formatEmail($g('second_email'));
|
||||
$second_cellphone = $formatter->formatPhoneNumber($g('second_cellphone')); // ✅ safe
|
||||
|
||||
$secondProvided = !$noSecondInfo && (
|
||||
$second_firstname !== '' ||
|
||||
$second_lastname !== '' ||
|
||||
$second_gender !== '' ||
|
||||
$second_email !== '' ||
|
||||
preg_replace('/\D/', '', $second_cellphone) !== ''
|
||||
);
|
||||
|
||||
if ($secondProvided) {
|
||||
$data['second_firstname'] = $second_firstname;
|
||||
$data['second_lastname'] = $second_lastname;
|
||||
$data['second_gender'] = $second_gender;
|
||||
$data['second_email'] = $second_email;
|
||||
$data['second_cellphone'] = $second_cellphone;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\RoleModel;
|
||||
|
||||
class RoleService
|
||||
{
|
||||
public function __construct(private RoleModel $roles = new RoleModel()) {}
|
||||
|
||||
/** @param string[] $roleKeys names or slugs */
|
||||
public function bestDashboardRouteFor(array $roleKeys): string
|
||||
{
|
||||
$rows = $this->roles->findByNamesOrSlugs($roleKeys);
|
||||
if (!empty($rows)) {
|
||||
// first row is highest priority due to orderBy('priority','ASC')
|
||||
return $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard';
|
||||
}
|
||||
return '/landing_page/guest_dashboard';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\UserModel;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class SchoolIdService
|
||||
{
|
||||
protected $db;
|
||||
protected $userModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->userModel = new UserModel();
|
||||
}
|
||||
|
||||
public function assignSchoolIdToUser($userId)
|
||||
{
|
||||
$user = $this->userModel->find($userId);
|
||||
if (!$user || $user['school_id']) {
|
||||
return $user['school_id'] ?? null;
|
||||
}
|
||||
|
||||
$schoolId = $this->generateUserSchoolId();
|
||||
|
||||
$this->userModel->update($userId, ['school_id' => $schoolId]);
|
||||
|
||||
return $schoolId;
|
||||
}
|
||||
|
||||
public function generateStudentSchoolId()
|
||||
{
|
||||
$year = date('y');
|
||||
$maxId = 0;
|
||||
|
||||
// Match the real prefix: STU + year
|
||||
$studentMax = $this->db->table('students')
|
||||
->select('MAX(school_id) AS max_id')
|
||||
->like('school_id', "STU{$year}", 'after')
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (!empty($studentMax['max_id'])) {
|
||||
// Skip "STUyy" (5 chars)
|
||||
$numericPart = substr($studentMax['max_id'], 5);
|
||||
$maxId = (int) $numericPart;
|
||||
}
|
||||
|
||||
$nextId = str_pad($maxId + 1, 5, '0', STR_PAD_LEFT);
|
||||
return "STU{$year}{$nextId}";
|
||||
}
|
||||
|
||||
|
||||
public function generateUserSchoolId()
|
||||
{
|
||||
$year = date('y');
|
||||
$maxId = 0;
|
||||
|
||||
// Get maximum user ID for current year
|
||||
$userMax = $this->db->table('users')->select('MAX(school_id) AS max_id')
|
||||
->notLike('school_id', 'S%') // Exclude student IDs
|
||||
->like('school_id', $year, 'after')->get()->getRowArray();
|
||||
|
||||
if (!empty($userMax['max_id'])) {
|
||||
$numericPart = substr($userMax['max_id'], 2); // Skip 2-digit year
|
||||
$maxId = max($maxId, (int)$numericPart);
|
||||
}
|
||||
|
||||
$nextId = str_pad($maxId + 1, 5, '0', STR_PAD_LEFT);
|
||||
return "{$year}{$nextId}";
|
||||
}
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ScoreCommentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\SemesterScoreModel;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
class ScoreCommentController extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $scoreCommentModel;
|
||||
protected $studentModel;
|
||||
protected $semesterScoreModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Load the database service
|
||||
$this->db = \Config\Database::connect();
|
||||
// Check if the database connection is established
|
||||
if (!$this->db->connect()) {
|
||||
log_message('error', 'Database connection failed.');
|
||||
throw new \Exception('Database connection failed.');
|
||||
} else {
|
||||
log_message('info', 'Database connection successful.');
|
||||
}
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->scoreCommentModel = new ScoreCommentModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->semesterScoreModel = new SemesterScoreModel();
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
|
||||
public function saveComments()
|
||||
{
|
||||
$comments = $this->request->getPost('comments');
|
||||
$classSectionId = (int)($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0);
|
||||
$schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear);
|
||||
$rawSelectedSemester = $this->request->getPost('selected_semester');
|
||||
$normalizedSelected = $this->normalizeSemesterSelection($rawSelectedSemester);
|
||||
if ($normalizedSelected !== '') {
|
||||
$semester = ucfirst($normalizedSelected);
|
||||
} else {
|
||||
$semester = session()->get('teacher_scores_selected_semester')
|
||||
?? session()->get('semester')
|
||||
?? $this->semester;
|
||||
}
|
||||
$teacherId = session()->get('user_id');
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('error', 'Missing class section.');
|
||||
}
|
||||
if ($this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
if (!is_array($comments)) {
|
||||
return redirect()->back()->with('error', 'No comments were submitted.');
|
||||
}
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
|
||||
$studentIds = array_map('intval', array_keys($comments));
|
||||
$studentNames = $this->getStudentFirstNames($studentIds);
|
||||
|
||||
$isFall = strtolower((string) $semester) === 'fall';
|
||||
$isSpring = strtolower((string) $semester) === 'spring';
|
||||
$commentTypes = ['ptap_comment'];
|
||||
if ($isFall) {
|
||||
$commentTypes[] = 'midterm_comment';
|
||||
}
|
||||
if ($isSpring) {
|
||||
$commentTypes[] = 'final_comment';
|
||||
}
|
||||
foreach ($commentTypes as $type) {
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $types) {
|
||||
if (!is_array($types) || empty($types[$type])) {
|
||||
continue;
|
||||
}
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
$type,
|
||||
$studentIds,
|
||||
null,
|
||||
$checkedItems,
|
||||
$teacherId,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$payload = [];
|
||||
|
||||
foreach ($comments as $studentId => $commentTypes) {
|
||||
foreach ($commentTypes as $scoreType => $comment) {
|
||||
$normalizedScoreType = strtolower((string)$scoreType);
|
||||
$trimmedComment = trim((string)$comment);
|
||||
if ($trimmedComment === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$validationError = $this->validateComment($trimmedComment, (int)$studentId, $normalizedScoreType, $studentNames);
|
||||
if ($validationError !== null) {
|
||||
$errors[] = $validationError;
|
||||
log_message(
|
||||
'warning',
|
||||
"ScoreComment validation failed: teacher_id={$teacherId}, student_id={$studentId}, score_type={$normalizedScoreType}, error={$validationError}, length=" . mb_strlen($trimmedComment, 'UTF-8')
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[] = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'score_type' => $normalizedScoreType, // 'ptap', 'midterm', 'final'
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'comment' => $trimmedComment,
|
||||
'commented_by' => $teacherId,
|
||||
'created_at' => utc_now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return redirect()->back()->with('errors', $errors)->withInput();
|
||||
}
|
||||
|
||||
foreach ($payload as $data) {
|
||||
$existing = $this->scoreCommentModel
|
||||
->where('student_id', $data['student_id'])
|
||||
->groupStart()
|
||||
->where('class_section_id', $data['class_section_id'])
|
||||
->orWhere('class_section_id', null)
|
||||
->groupEnd()
|
||||
->where('score_type', $data['score_type'])
|
||||
->where('semester', $data['semester'])
|
||||
->where('school_year', $data['school_year'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->scoreCommentModel->update($existing['id'], $data);
|
||||
} else {
|
||||
$this->scoreCommentModel->insert($data);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->back()->with('status', 'Comments saved successfully.');
|
||||
}
|
||||
|
||||
|
||||
public function viewCommentsMngt()
|
||||
{
|
||||
$rawSelectedSemester = $this->request->getGet('semester')
|
||||
?? $this->request->getPost('semester')
|
||||
?? session()->get('teacher_scores_selected_semester')
|
||||
?? session()->get('semester')
|
||||
?? $this->semester;
|
||||
$normalizedSelected = $this->normalizeSemesterSelection($rawSelectedSemester);
|
||||
if ($normalizedSelected !== '') {
|
||||
$activeSemester = ucfirst($normalizedSelected);
|
||||
} else {
|
||||
$activeSemester = $this->semester;
|
||||
}
|
||||
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$isAllSections = is_string($incoming) && strtolower(trim($incoming)) === 'allsections';
|
||||
$classSectionId = $isAllSections ? null : (int) ($incoming ?: 0);
|
||||
|
||||
if (!$isAllSections && $classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
session()->set('class_section_id', $isAllSections ? 'allsections' : $classSectionId);
|
||||
|
||||
$builder = $this->db->table('student_class sc')
|
||||
->distinct('SELECT s.id AS student_id, s.firstname, s.lastname, s.school_id')
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('sc.school_year', $this->schoolYear);
|
||||
|
||||
if (!$isAllSections) {
|
||||
$builder->where('sc.class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$students = $builder
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// 3) Load only comments for these students in this term (ptap, midterm, final)
|
||||
$comments = [];
|
||||
if (!empty($students)) {
|
||||
$studentIds = array_map(static fn($r) => (int)$r['student_id'], $students);
|
||||
|
||||
$rawCommentsBuilder = $this->scoreCommentModel
|
||||
->whereIn('student_id', $studentIds)
|
||||
->whereIn('score_type', ['midterm', 'final', 'ptap', 'attendance'])
|
||||
->where('semester', $activeSemester)
|
||||
->where('school_year', $this->schoolYear);
|
||||
|
||||
if (!$isAllSections) {
|
||||
$rawCommentsBuilder->groupStart()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->orWhere('class_section_id', null); // legacy rows without class_section_id
|
||||
$rawCommentsBuilder->groupEnd();
|
||||
}
|
||||
|
||||
$rawComments = $rawCommentsBuilder->findAll();
|
||||
|
||||
$existingAttendance = [];
|
||||
foreach ($rawComments as $c) {
|
||||
$sid = (int)$c['student_id'];
|
||||
$typ = (string)$c['score_type'];
|
||||
$comments[$sid][$typ] = [
|
||||
'comment' => $c['comment'] ?? null,
|
||||
'comment_review' => $c['comment_review'] ?? null,
|
||||
'commented_by' => $c['commented_by'] ?? null,
|
||||
'reviewed_by' => $c['reviewed_by'] ?? null,
|
||||
];
|
||||
if ($typ === 'attendance') {
|
||||
$existingAttendance[$sid] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
helper('attendance_comment');
|
||||
// Autogenerate attendance comments for display when missing
|
||||
$attendanceScores = [];
|
||||
$scoreRows = $this->semesterScoreModel
|
||||
->select(['student_id', 'attendance_score'])
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('semester', $activeSemester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->findAll();
|
||||
foreach ($scoreRows as $row) {
|
||||
$attendanceScores[(int)$row['student_id']] = isset($row['attendance_score'])
|
||||
? (float)$row['attendance_score']
|
||||
: null;
|
||||
}
|
||||
$attendanceInserts = [];
|
||||
$attendanceUpdates = [];
|
||||
foreach ($students as $student) {
|
||||
$sid = (int)$student['student_id'];
|
||||
$hasComment = isset($comments[$sid]['attendance']['comment']) && trim((string)$comments[$sid]['attendance']['comment']) !== '';
|
||||
if ($hasComment) {
|
||||
continue;
|
||||
}
|
||||
$score = $attendanceScores[$sid] ?? null;
|
||||
if ($score === null) {
|
||||
continue;
|
||||
}
|
||||
$auto = attendance_comment_from_score($score, $student['firstname'] ?? '');
|
||||
if ($auto !== null) {
|
||||
$comments[$sid]['attendance'] = [
|
||||
'comment' => $auto,
|
||||
'comment_review' => $comments[$sid]['attendance']['comment_review'] ?? null,
|
||||
'commented_by' => $comments[$sid]['attendance']['commented_by'] ?? null,
|
||||
'reviewed_by' => $comments[$sid]['attendance']['reviewed_by'] ?? null,
|
||||
];
|
||||
if (isset($existingAttendance[$sid])) {
|
||||
$attendanceUpdates[] = [
|
||||
'id' => $existingAttendance[$sid]['id'],
|
||||
'comment' => $auto,
|
||||
'commented_by' => $existingAttendance[$sid]['commented_by'] ?? null,
|
||||
'class_section_id' => $classSectionId,
|
||||
];
|
||||
} else {
|
||||
$attendanceInserts[] = [
|
||||
'student_id' => $sid,
|
||||
'score_type' => 'attendance',
|
||||
'semester' => $activeSemester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'comment' => $auto,
|
||||
'commented_by'=> null,
|
||||
'class_section_id' => $classSectionId,
|
||||
'created_at' => utc_now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($attendanceUpdates)) {
|
||||
foreach ($attendanceUpdates as $row) {
|
||||
$id = $row['id'];
|
||||
unset($row['id']);
|
||||
$this->scoreCommentModel->update($id, $row);
|
||||
}
|
||||
}
|
||||
if (!empty($attendanceInserts)) {
|
||||
$this->scoreCommentModel->insertBatch($attendanceInserts);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Render
|
||||
return view('grading/comments', [
|
||||
'students' => $students,
|
||||
'semester' => $activeSemester,
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'classSectionId' => $isAllSections ? 'allsections' : $classSectionId,
|
||||
'comments' => $comments,
|
||||
'scoresLocked' => (!$isAllSections && $classSectionId > 0)
|
||||
? $this->gradingLockModel->isLocked($classSectionId, $activeSemester, $this->schoolYear)
|
||||
: false,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function updateComments()
|
||||
{
|
||||
$semester = $this->request->getPost('semester');
|
||||
$schoolYear = $this->request->getPost('school_year');
|
||||
$teacherId = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id');
|
||||
$comments = $this->request->getPost('comments') ?? [];
|
||||
$reviews = $this->request->getPost('reviews') ?? [];
|
||||
|
||||
$studentIds = array_unique(array_map(
|
||||
'intval',
|
||||
array_merge(array_keys($comments), array_keys($reviews))
|
||||
));
|
||||
$studentNames = $this->getStudentFirstNames($studentIds);
|
||||
$isReviewer = $this->isReviewer();
|
||||
$classSectionId = is_numeric($classSectionId) ? (int)$classSectionId : $classSectionId;
|
||||
if (is_int($classSectionId) && $classSectionId > 0) {
|
||||
if ($this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
}
|
||||
|
||||
// Pull attendance scores once for auto-generated attendance comments
|
||||
$attendanceScores = [];
|
||||
if (!empty($studentIds)) {
|
||||
$attendanceBuilder = $this->semesterScoreModel
|
||||
->select(['student_id', 'attendance_score'])
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('student_id', $studentIds);
|
||||
|
||||
if (is_int($classSectionId) && $classSectionId > 0) {
|
||||
$attendanceBuilder->where('class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$rows = $attendanceBuilder->findAll();
|
||||
foreach ($rows as $row) {
|
||||
$attendanceScores[(int)$row['student_id']] = isset($row['attendance_score'])
|
||||
? (float)$row['attendance_score']
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
helper('attendance_comment');
|
||||
|
||||
$errors = [];
|
||||
$operations = [];
|
||||
|
||||
foreach ($comments as $studentId => $types) {
|
||||
foreach ($types as $scoreType => $commentText) {
|
||||
$normalizedScoreType = strtolower((string)$scoreType);
|
||||
$commentText = trim((string)$commentText);
|
||||
|
||||
// Auto-generate attendance comment when empty based on the student's attendance score
|
||||
if ($normalizedScoreType === 'attendance' && $commentText === '') {
|
||||
$attendanceScore = $attendanceScores[$studentId] ?? null;
|
||||
$auto = attendance_comment_from_score($attendanceScore, $studentNames[$studentId] ?? '');
|
||||
if ($auto !== null) {
|
||||
$commentText = $auto;
|
||||
}
|
||||
}
|
||||
|
||||
$reviewText = isset($reviews[$studentId][$scoreType])
|
||||
? trim((string)$reviews[$studentId][$scoreType])
|
||||
: '';
|
||||
// Skip empty comment & empty review to avoid null inserts
|
||||
if ($commentText === '' && $reviewText === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($commentText !== '') {
|
||||
$validationError = $this->validateComment($commentText, (int)$studentId, $normalizedScoreType, $studentNames);
|
||||
if ($validationError !== null) {
|
||||
$errors[] = $validationError;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$operations[] = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => is_int($classSectionId) ? $classSectionId : null,
|
||||
'score_type' => $normalizedScoreType,
|
||||
'comment' => $commentText === '' ? null : $commentText,
|
||||
'review' => $reviewText === '' ? null : $reviewText,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return redirect()->back()->with('errors', $errors)->withInput();
|
||||
}
|
||||
|
||||
foreach ($operations as $operation) {
|
||||
$existingQuery = $this->scoreCommentModel
|
||||
->where('student_id', $operation['student_id'])
|
||||
->where('score_type', $operation['score_type'])
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if (is_int($classSectionId) && $classSectionId > 0) {
|
||||
$existingQuery->groupStart()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->orWhere('class_section_id', null); // pick up legacy rows
|
||||
$existingQuery->groupEnd();
|
||||
}
|
||||
|
||||
$existing = $existingQuery->first();
|
||||
|
||||
$data = [
|
||||
'comment' => $operation['comment'],
|
||||
'commented_by' => $teacherId,
|
||||
'class_section_id' => $operation['class_section_id'],
|
||||
];
|
||||
|
||||
if ($isReviewer && $operation['review'] !== null) {
|
||||
$data['comment_review'] = $operation['review'];
|
||||
$data['reviewed_by'] = $teacherId;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
// Update
|
||||
$this->scoreCommentModel->update($existing['id'], $data);
|
||||
} else {
|
||||
// Insert
|
||||
$this->scoreCommentModel->insert(array_merge([
|
||||
'student_id' => $operation['student_id'],
|
||||
'score_type' => $operation['score_type'],
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'class_section_id' => $operation['class_section_id'],
|
||||
], $data));
|
||||
}
|
||||
}
|
||||
return redirect()->back()->with('status', 'Comments updated successfully!');
|
||||
}
|
||||
|
||||
// Helper function to check if current user is a reviewer
|
||||
private function isReviewer()
|
||||
{
|
||||
$currentUserRole = session()->get('role'); // Make sure this matches your session role key
|
||||
|
||||
// Get reviewer roles from configuration table
|
||||
$reviewerRoles = $this->configModel->getConfig('comment_reviewer');
|
||||
|
||||
if (!$reviewerRoles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert comma-separated string to array and trim whitespace
|
||||
$allowedRoles = array_map('trim', explode(',', $reviewerRoles));
|
||||
|
||||
// Check if current user role is in the allowed roles
|
||||
return in_array($currentUserRole, $allowedRoles);
|
||||
}
|
||||
|
||||
private function getStudentFirstNames(array $studentIds): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_unique(array_map('intval', $studentIds)), static function ($id) {
|
||||
return $id > 0;
|
||||
}));
|
||||
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->studentModel
|
||||
->select(['id', 'firstname'])
|
||||
->whereIn('id', $ids)
|
||||
->findAll();
|
||||
|
||||
$names = [];
|
||||
foreach ($rows as $row) {
|
||||
$names[(int)($row['id'] ?? 0)] = trim((string)($row['firstname'] ?? ''));
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
private function validateComment(string $comment, int $studentId, string $scoreType, array $studentNames): ?string
|
||||
{
|
||||
$trimmed = trim($comment);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$firstName = $studentNames[$studentId] ?? '';
|
||||
if ($firstName === '') {
|
||||
return "Missing student first name for student ID {$studentId}.";
|
||||
}
|
||||
|
||||
$length = mb_strlen($trimmed, 'UTF-8');
|
||||
if ($length < 100) {
|
||||
return "{$firstName}'s {$scoreType} comment must be at least 100 characters.";
|
||||
}
|
||||
if ($length > 400) {
|
||||
return "{$firstName}'s {$scoreType} comment must be at most 400 characters.";
|
||||
}
|
||||
|
||||
$pattern = '/^' . preg_quote($firstName, '/') . '\\b/i';
|
||||
if (!preg_match($pattern, $trimmed)) {
|
||||
return "{$firstName}'s {$scoreType} comment must start with the student's first name.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeSemesterSelection(?string $input): string
|
||||
{
|
||||
$value = strtolower(trim((string)$input));
|
||||
if ($value === 'fall' || str_contains($value, '1')) {
|
||||
return 'fall';
|
||||
}
|
||||
if ($value === 'spring' || str_contains($value, '2')) {
|
||||
return 'spring';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,260 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
|
||||
class ScorePredictor extends Controller
|
||||
{
|
||||
protected $db;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $classSectionModel;
|
||||
protected $studentClassModel;
|
||||
protected $teacherModel;
|
||||
protected $userModel;
|
||||
protected $targetTrophy;
|
||||
protected $targetLow;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Load the database service
|
||||
$this->db = \Config\Database::connect();
|
||||
// Check if the database connection is established
|
||||
if (!$this->db->connect()) {
|
||||
log_message('error', 'Database connection failed.');
|
||||
throw new \Exception('Database connection failed.');
|
||||
} else {
|
||||
log_message('info', 'Database connection successful.');
|
||||
}
|
||||
|
||||
// Initialize the config model
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
|
||||
// Retrieve the configuration values
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->targetTrophy = $this->configModel->getConfig('trophy_score');
|
||||
$this->targetLow = $this->configModel->getConfig('pass_score');
|
||||
}
|
||||
|
||||
// Calculate the normal cumulative distribution function (CDF)
|
||||
public static function normalCDF($z)
|
||||
{
|
||||
$t = 1 / (1 + 0.3275911 * abs($z));
|
||||
$a1 = 0.254829592;
|
||||
$a2 = -0.284496736;
|
||||
$a3 = 1.421413741;
|
||||
$a4 = -1.453152027;
|
||||
$a5 = 1.061405429;
|
||||
$erf = 1 - ((((($a5 * $t + $a4) * $t + $a3) * $t + $a2) * $t + $a1) * $t) * exp(-$z * $z);
|
||||
$sign = $z < 0 ? -1 : 1;
|
||||
return 0.5 * (1 + $sign * $erf);
|
||||
}
|
||||
|
||||
public function combinedReport()
|
||||
{
|
||||
$request = service('request');
|
||||
$currentSchoolYear = $this->schoolYear;
|
||||
|
||||
$selectedYear = $request->getVar('school_year') ?? $currentSchoolYear;
|
||||
$classSectionId = $request->getVar('class_section_id');
|
||||
// Only show class sections that have at least one student in the selected year
|
||||
$classSections = $this->db->table('classSection cs')
|
||||
->select('cs.*')
|
||||
->join('student_class sc', 'sc.class_section_id = cs.class_section_id', 'inner')
|
||||
->where('sc.school_year', $selectedYear)
|
||||
->notLike('cs.class_section_name', 'KG', 'after')
|
||||
->groupBy('cs.id')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$builder = $this->db->table('students s');
|
||||
$builder->select('
|
||||
s.id as student_id,
|
||||
s.school_id,
|
||||
s.firstname,
|
||||
s.lastname,
|
||||
fall.semester_score as fall_score,
|
||||
spring.semester_score as spring_score');
|
||||
// Also select class section for per-class trophy decision
|
||||
$builder->select('sc.class_section_id as class_section_id');
|
||||
$yearEsc = $this->db->escape($selectedYear);
|
||||
$builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'left');
|
||||
$builder->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left');
|
||||
$builder->join('semester_scores fall', 'fall.student_id = s.id AND fall.semester = "fall" AND fall.school_year = "' . $selectedYear . '"', 'left');
|
||||
$builder->join('semester_scores spring', 'spring.student_id = s.id AND spring.semester = "spring" AND spring.school_year = "' . $selectedYear . '"', 'left');
|
||||
$builder->where('s.is_active', 1);
|
||||
$builder->groupStart()
|
||||
->where('cs.class_section_name IS NULL')
|
||||
->orNotLike('cs.class_section_name', 'KG', 'after')
|
||||
->groupEnd();
|
||||
|
||||
if (!empty($classSectionId)) {
|
||||
$builder->where('sc.class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$builder->groupBy('s.id');
|
||||
$students = $builder->get()->getResultArray();
|
||||
|
||||
// Stats for spring
|
||||
$springStatsQuery = $this->db->table('semester_scores')
|
||||
->select('AVG(semester_scores.semester_score) as mean, STDDEV(semester_scores.semester_score) as std')
|
||||
->join('student_class', 'student_class.student_id = semester_scores.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id = student_class.class_section_id', 'left')
|
||||
->where('semester_scores.semester', 'spring')
|
||||
->where('semester_scores.school_year', $selectedYear)
|
||||
->where('student_class.school_year', $selectedYear)
|
||||
->groupStart()
|
||||
->where('cs.class_section_name IS NULL')
|
||||
->orNotLike('cs.class_section_name', 'KG', 'after')
|
||||
->groupEnd();
|
||||
|
||||
if (!empty($classSectionId)) {
|
||||
$springStatsQuery->where('student_class.class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$springStats = $springStatsQuery->get()->getRow();
|
||||
$mean = floatval($springStats->mean ?? 70);
|
||||
$std = floatval($springStats->std ?? 0);
|
||||
|
||||
$results = [];
|
||||
// Use explicit trophy parameters per new requirements
|
||||
// Trophy threshold: minimum 94.0; allow higher configured value if set
|
||||
$trophyThreshold = max(94.0, (float)($this->targetTrophy ?? 94.0));
|
||||
foreach ($students as $s) {
|
||||
$fallScore = is_numeric($s['fall_score']) ? (float)$s['fall_score'] : null;
|
||||
$springScore = is_numeric($s['spring_score']) ? (float)$s['spring_score'] : null;
|
||||
|
||||
$scoreParts = array_values(array_filter([$fallScore, $springScore], static function ($v) {
|
||||
return $v !== null;
|
||||
}));
|
||||
$finalAvg = !empty($scoreParts) ? round(array_sum($scoreParts) / count($scoreParts), 1) : null;
|
||||
|
||||
// When we already have a spring score, the year is effectively finished for trophy/pass logic.
|
||||
$waitingForSpring = ($fallScore !== null && $springScore === null);
|
||||
|
||||
// Compute required spring score for trophy/pass only if spring is missing.
|
||||
$requiredHigh = $waitingForSpring ? round(2 * $trophyThreshold - $fallScore, 2) : null;
|
||||
$requiredLow = $waitingForSpring ? round(2 * $this->targetLow - $fallScore, 2) : null;
|
||||
|
||||
$zHigh = ($requiredHigh !== null && $std > 0) ? ($requiredHigh - $mean) / $std : null;
|
||||
$zLow = ($requiredLow !== null && $std > 0) ? ($requiredLow - $mean) / $std : null;
|
||||
|
||||
$probHigh = $zHigh !== null ? 1 - self::normalCDF($zHigh) : null;
|
||||
$probLow = $zLow !== null ? self::normalCDF($zLow) : null;
|
||||
|
||||
// Trophy determination: only award when the student's final/available average meets the threshold.
|
||||
$riskHigh = match (true) {
|
||||
$finalAvg !== null && !$waitingForSpring && $finalAvg >= $trophyThreshold => 'Achieved Trophy',
|
||||
$finalAvg !== null && !$waitingForSpring => 'Unreachable',
|
||||
$requiredHigh !== null && $requiredHigh > 100 => 'Unreachable',
|
||||
is_null($probHigh) => 'New student',
|
||||
$probHigh >= 0.66 => 'Low',
|
||||
$probHigh >= 0.33 => 'Medium',
|
||||
default => 'High'
|
||||
};
|
||||
|
||||
// Fail risk
|
||||
$riskLow = match (true) {
|
||||
$finalAvg !== null && !$waitingForSpring && $finalAvg < $this->targetLow => 'High',
|
||||
$finalAvg !== null && !$waitingForSpring => 'Low',
|
||||
$requiredLow !== null && $requiredLow > 100 => 'Unlikely to pass',
|
||||
is_null($probLow) => 'New student',
|
||||
$probLow >= 0.66 => 'High',
|
||||
$probLow >= 0.33 => 'Medium',
|
||||
default => 'Low'
|
||||
};
|
||||
|
||||
if ($finalAvg === null) {
|
||||
$status = 'Undetermined';
|
||||
} elseif ($waitingForSpring) {
|
||||
$status = in_array($riskLow, ['High', 'Unlikely to pass'], true) ? 'May fail' : 'Can pass';
|
||||
} else {
|
||||
$status = $finalAvg >= 60 ? 'Passed' : 'Failed';
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'student_id' => (int)($s['student_id'] ?? 0),
|
||||
'school_id' => $s['school_id'],
|
||||
'firstname' => $s['firstname'],
|
||||
'lastname' => $s['lastname'],
|
||||
'class_section_id' => $s['class_section_id'] ?? null,
|
||||
'fall_score' => $s['fall_score'] ?? 'N/A',
|
||||
'spring_score' => $s['spring_score'] ?? 'N/A',
|
||||
'final_average' => $finalAvg ?? 'N/A',
|
||||
'required_spring_score' => $requiredHigh ?? 'N/A',
|
||||
'winning_risk' => $riskHigh,
|
||||
'required_spring_to_pass' => $requiredLow ?? 'N/A',
|
||||
'failure_risk' => $riskLow,
|
||||
'status' => $status,
|
||||
'trophy_awarded' => ($riskHigh === 'Achieved Trophy'),
|
||||
'trophy_reason' => ($riskHigh === 'Achieved Trophy') ? 'threshold' : '',
|
||||
];
|
||||
}
|
||||
|
||||
// If a class section has zero threshold trophies, award trophies to top 3 ranked students
|
||||
$bySection = [];
|
||||
$hasThresholdTrophy = [];
|
||||
foreach ($results as $idx => $row) {
|
||||
$cid = (string)($row['class_section_id'] ?? '');
|
||||
$bySection[$cid][] = $idx;
|
||||
if (($row['trophy_reason'] ?? '') === 'threshold') {
|
||||
$hasThresholdTrophy[$cid] = true;
|
||||
}
|
||||
}
|
||||
foreach ($bySection as $cid => $indices) {
|
||||
if (!empty($hasThresholdTrophy[$cid])) {
|
||||
continue;
|
||||
}
|
||||
$candidates = [];
|
||||
foreach ($indices as $idx) {
|
||||
$val = $results[$idx]['final_average'] ?? null;
|
||||
if (!is_numeric($val)) {
|
||||
$fall = $results[$idx]['fall_score'] ?? null;
|
||||
$spring = $results[$idx]['spring_score'] ?? null;
|
||||
$parts = [];
|
||||
if (is_numeric($fall)) $parts[] = (float)$fall;
|
||||
if (is_numeric($spring)) $parts[] = (float)$spring;
|
||||
if (!empty($parts)) {
|
||||
$val = array_sum($parts) / count($parts);
|
||||
}
|
||||
}
|
||||
if (is_numeric($val)) {
|
||||
$candidates[] = ['idx' => $idx, 'avg' => (float)$val];
|
||||
}
|
||||
}
|
||||
if (empty($candidates)) {
|
||||
continue;
|
||||
}
|
||||
usort($candidates, static fn($a, $b) => $b['avg'] <=> $a['avg']);
|
||||
$top = array_slice($candidates, 0, 3);
|
||||
foreach ($top as $entry) {
|
||||
$i = $entry['idx'];
|
||||
$results[$i]['trophy_awarded'] = true;
|
||||
$results[$i]['trophy_reason'] = 'top3';
|
||||
$results[$i]['winning_risk'] = 'Top 3 Trophy';
|
||||
}
|
||||
}
|
||||
|
||||
// Sort students by final average descending
|
||||
usort($results, function ($a, $b) {
|
||||
return ($b['final_average'] ?? 0) <=> ($a['final_average'] ?? 0);
|
||||
});
|
||||
|
||||
return view('score_analysis/score_prediction', [
|
||||
'students' => $results,
|
||||
'school_year' => $selectedYear,
|
||||
'classSections' => $classSections,
|
||||
'selectedClassSectionId' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PreferencesModel;
|
||||
use App\Models\SettingsModel;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
class TimeService
|
||||
{
|
||||
private string $defaultUserTimezone = 'America/New_York';
|
||||
private string $serverTimezone = 'UTC';
|
||||
|
||||
// Cached per-request user timezone
|
||||
private ?string $cachedUserTz = null;
|
||||
|
||||
/**
|
||||
* Prime detection cache from the Request (optional call, lazy by default).
|
||||
*/
|
||||
public function primeFromRequest(?RequestInterface $request = null): void
|
||||
{
|
||||
$this->cachedUserTz = $this->detectUserTimezone($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resolved user timezone (detect lazily if needed).
|
||||
*/
|
||||
public function userTimezone(?RequestInterface $request = null): string
|
||||
{
|
||||
if ($this->cachedUserTz !== null) {
|
||||
return $this->cachedUserTz;
|
||||
}
|
||||
|
||||
$this->cachedUserTz = $this->detectUserTimezone($request);
|
||||
return $this->cachedUserTz;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core detection logic (headers > user pref > settings > school config > default).
|
||||
*/
|
||||
public function detectUserTimezone(?RequestInterface $request = null): string
|
||||
{
|
||||
$request = $request ?: service('request');
|
||||
|
||||
// 1) Explicit client header
|
||||
try {
|
||||
$tzHeader = $request->getHeaderLine('X-Timezone')
|
||||
?: $request->getHeaderLine('Timezone')
|
||||
?: $request->getHeaderLine('Accept-Timezone')
|
||||
?: null;
|
||||
|
||||
if ($tzHeader && in_array($tzHeader, timezone_identifiers_list(), true)) {
|
||||
return $tzHeader;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 2) User preference (if logged-in)
|
||||
try {
|
||||
$userId = (int) (session('user_id') ?: 0);
|
||||
if ($userId > 0) {
|
||||
// Do not select a specific column; some DBs may not have it yet
|
||||
$pref = (new PreferencesModel())
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
$tz = $pref['timezone'] ?? null;
|
||||
if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
|
||||
return $tz;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 3) Global settings timezone (if available)
|
||||
try {
|
||||
$settings = (new SettingsModel())->getSettings();
|
||||
$tz = $settings['timezone'] ?? null;
|
||||
if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
|
||||
return $tz;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 4) School attendance timezone (if defined)
|
||||
try {
|
||||
$tz = (string) (config('School')->attendance['timezone'] ?? '');
|
||||
if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
|
||||
return $tz;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 5) Default
|
||||
return $this->defaultUserTimezone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server timezone (for storage/UTC operations).
|
||||
*/
|
||||
public function serverTimezone(): string
|
||||
{
|
||||
return $this->serverTimezone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current time in user timezone as CI Time.
|
||||
*/
|
||||
public function nowLocal(?string $tz = null): Time
|
||||
{
|
||||
$tz = $tz ?: $this->userTimezone();
|
||||
return Time::now($tz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current time in UTC as CI Time.
|
||||
*/
|
||||
public function nowUTC(): Time
|
||||
{
|
||||
return Time::now($this->serverTimezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any supported input to UTC string (Y-m-d H:i:s by default).
|
||||
*
|
||||
* @param string|Time|\DateTimeInterface|null $value
|
||||
*/
|
||||
public function toUTC($value, ?string $fromTz = null, string $format = 'Y-m-d H:i:s'): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fromTz = $fromTz ?: $this->userTimezone();
|
||||
|
||||
try {
|
||||
if ($value instanceof Time) {
|
||||
return $value->setTimezone($this->serverTimezone)->toDateTimeString();
|
||||
}
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return Time::createFromInstance($value)
|
||||
->setTimezone($this->serverTimezone)
|
||||
->format($format);
|
||||
}
|
||||
|
||||
// assume string
|
||||
return Time::parse((string)$value, $fromTz)
|
||||
->setTimezone($this->serverTimezone)
|
||||
->format($format);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert UTC (or provided timezone) to user-local string.
|
||||
*
|
||||
* @param string|Time|\DateTimeInterface|null $value
|
||||
*/
|
||||
public function toLocal($value, ?string $sourceTz = null, ?string $targetTz = null, string $format = 'Y-m-d H:i:s'): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$targetTz = $targetTz ?: $this->userTimezone();
|
||||
if ($sourceTz === null && $this->isDateOnlyString($value)) {
|
||||
// Date-only strings should not shift across timezones.
|
||||
$sourceTz = $targetTz;
|
||||
} else {
|
||||
$sourceTz = $sourceTz ?: $this->serverTimezone;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($value instanceof Time) {
|
||||
return $value->setTimezone($targetTz)->format($format);
|
||||
}
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return Time::createFromInstance($value)
|
||||
->setTimezone($targetTz)
|
||||
->format($format);
|
||||
}
|
||||
|
||||
// assume string
|
||||
return Time::parse((string)$value, $sourceTz)
|
||||
->setTimezone($targetTz)
|
||||
->format($format);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience formatter for user-local.
|
||||
*/
|
||||
public function formatLocal($value, string $format = 'Y-m-d H:i', ?string $sourceTz = null): string
|
||||
{
|
||||
return (string) ($this->toLocal($value, $sourceTz, null, $format) ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience formatter for UTC.
|
||||
*/
|
||||
public function formatUTC($value, string $format = 'Y-m-d H:i:s', ?string $fromTz = null): string
|
||||
{
|
||||
return (string) ($this->toUTC($value, $fromTz, $format) ?? '');
|
||||
}
|
||||
|
||||
private function isDateOnlyString($value): bool
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value);
|
||||
}
|
||||
}
|
||||
@@ -1,874 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\UserModel; // Assuming you have a UserModel
|
||||
use App\Models\PasswordResetModel; // Model for the password_resets table
|
||||
use CodeIgniter\I18n\Time;
|
||||
use App\Models\RoleModel;
|
||||
use App\Models\UserRoleModel;
|
||||
use App\Models\PasswordResetRequestModel;
|
||||
use App\Models\RolePermissionModel;
|
||||
use App\Models\IpAttemptModel;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Controllers\View\EmailController;
|
||||
use App\Models\LoginActivityModel; // Make sure this import is present
|
||||
require_once APPPATH . 'Helpers/pbkdf2_helper.php';
|
||||
|
||||
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
protected $userModel;
|
||||
protected $roleModel;
|
||||
protected $userRoleModel;
|
||||
protected $db;
|
||||
protected $passwordResetModel;
|
||||
protected $loginActivityModel;
|
||||
protected $resetRequestModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Load the database service
|
||||
$this->db = \Config\Database::connect();
|
||||
// Check if the database connection is established
|
||||
if (!$this->db->connect()) {
|
||||
log_message('error', 'Database connection failed.');
|
||||
throw new \Exception('Database connection failed.');
|
||||
} else {
|
||||
log_message('info', 'Database connection successful.');
|
||||
}
|
||||
helper('auth');
|
||||
$this->userModel = new UserModel();
|
||||
$this->roleModel = new RoleModel();
|
||||
$this->userRoleModel = new UserRoleModel();
|
||||
$this->passwordResetModel = new PasswordResetModel();
|
||||
$this->loginActivityModel = new LoginActivityModel();
|
||||
$this->resetRequestModel = new PasswordResetRequestModel();
|
||||
}
|
||||
|
||||
// Method to show the home page
|
||||
public function home()
|
||||
{
|
||||
return view('/index');
|
||||
}
|
||||
|
||||
// Method to show the about page
|
||||
public function about()
|
||||
{
|
||||
return view('/about');
|
||||
}
|
||||
|
||||
// Method to show the classes page
|
||||
public function classes()
|
||||
{
|
||||
return view('/classes');
|
||||
}
|
||||
|
||||
// Method to show the contact page
|
||||
public function contact()
|
||||
{
|
||||
return view('/contact');
|
||||
}
|
||||
|
||||
public function userList()
|
||||
{
|
||||
helper('url');
|
||||
|
||||
return view('user/user_list', [
|
||||
'userListEndpoint' => site_url('api/users'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Method to show the list of users
|
||||
public function index()
|
||||
{
|
||||
// Fetch users along with their assigned roles
|
||||
$builder = $this->db->table('users');
|
||||
$builder->select('users.id, users.firstname, users.lastname, users.email, user_roles.role_id, roles.name as role, users.status, users.updated_at');
|
||||
$builder->join('user_roles', 'users.id = user_roles.user_id', 'left');
|
||||
$builder->join('roles', 'user_roles.role_id = roles.id', 'left');
|
||||
|
||||
$sort = $this->request->getGet('sort');
|
||||
$order = $this->request->getGet('order') === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
// Define allowed fields for sorting
|
||||
$allowedFields = ['id', 'firstname', 'lastname', 'email', 'role'];
|
||||
|
||||
if (in_array($sort, $allowedFields)) {
|
||||
$sort_column = $sort === 'role' ? 'roles.name' : 'users.' . $sort;
|
||||
$builder->orderBy($sort_column, $order);
|
||||
} else {
|
||||
$builder->orderBy('users.id', 'asc');
|
||||
}
|
||||
|
||||
$query = $builder->get();
|
||||
$users = $query->getResultArray();
|
||||
|
||||
// Fetch available roles
|
||||
$roleBuilder = $this->db->table('roles');
|
||||
$roleQuery = $roleBuilder->get();
|
||||
$roles = $roleQuery->getResultArray();
|
||||
|
||||
return view('user/user_list', [
|
||||
'users' => $users,
|
||||
'roles' => $roles,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
public function userListData()
|
||||
{
|
||||
return $this->response->setJSON([
|
||||
'users' => $this->buildUsersWithRoles(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildUsersWithRoles(): array
|
||||
{
|
||||
// Fetch all users
|
||||
$users = $this->userModel->findAll();
|
||||
if (!is_array($users)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Aggregate guardian types (primary/secondary) by user_id
|
||||
$gmap = [];
|
||||
try {
|
||||
$rows = $this->db->table('family_guardians')
|
||||
->select('user_id, MAX(is_primary) AS max_primary, COUNT(*) AS cnt', false)
|
||||
->groupBy('user_id')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$uid = (int)($r['user_id'] ?? 0);
|
||||
if ($uid <= 0) continue;
|
||||
$maxp = (int)($r['max_primary'] ?? 0);
|
||||
$cnt = (int)($r['cnt'] ?? 0);
|
||||
if ($cnt > 0) {
|
||||
$gmap[$uid] = $maxp > 0 ? 'Primary' : 'Secondary';
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore if table missing; leave map empty
|
||||
}
|
||||
|
||||
// Aggregate "second parent presence" from legacy parents table
|
||||
$secondByUserId = [];
|
||||
$secondByEmail = [];
|
||||
try {
|
||||
$parentFields = $this->db->getFieldNames('parents');
|
||||
$hasSecondUid = in_array('secondparent_user_id', $parentFields, true);
|
||||
$hasSecondEmail = in_array('secondparent_email', $parentFields, true);
|
||||
|
||||
if ($hasSecondUid) {
|
||||
// Map: user_id present in parents.secondparent_user_id
|
||||
$uidRows = $this->db->table('parents')
|
||||
->select('secondparent_user_id AS uid, COUNT(*) AS cnt', false)
|
||||
->where('secondparent_user_id !=', 0)
|
||||
->groupBy('secondparent_user_id')
|
||||
->get()->getResultArray();
|
||||
foreach ($uidRows as $r) {
|
||||
$u = (int)($r['uid'] ?? 0);
|
||||
if ($u > 0) {
|
||||
$secondByUserId[$u] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasSecondEmail) {
|
||||
// Map: email present in parents.secondparent_email
|
||||
$emailRows = $this->db->table('parents')
|
||||
->select('LOWER(TRIM(secondparent_email)) AS em, COUNT(*) AS cnt', false)
|
||||
->where('secondparent_email !=', '')
|
||||
->groupBy('secondparent_email')
|
||||
->get()->getResultArray();
|
||||
foreach ($emailRows as $r) {
|
||||
$em = trim(strtolower((string)($r['em'] ?? '')));
|
||||
if ($em !== '') {
|
||||
$secondByEmail[$em] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore if legacy table or fields are missing
|
||||
}
|
||||
|
||||
// Prepare an array to store user data along with their roles
|
||||
$usersWithRoles = [];
|
||||
|
||||
// Loop through each user and get their roles
|
||||
foreach ($users as $user) {
|
||||
if (!is_array($user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the user's roles from the user_roles table
|
||||
$userRoles = $this->userRoleModel->where('user_id', $user['id'])->findAll() ?? [];
|
||||
|
||||
// Initialize arrays to hold role names/ids for this user
|
||||
$roleNames = [];
|
||||
$roleIds = [];
|
||||
|
||||
// Fetch role names from the roles table
|
||||
foreach ($userRoles as $userRole) {
|
||||
if (!is_array($userRole) || empty($userRole['role_id'])) {
|
||||
continue;
|
||||
}
|
||||
$role = $this->roleModel->find($userRole['role_id']);
|
||||
if ($role) {
|
||||
$roleIds[] = (int) $role['id'];
|
||||
$roleNames[] = $role['name'];
|
||||
}
|
||||
}
|
||||
|
||||
// Parent type if user is a guardian in any family
|
||||
$parentType = $gmap[(int)($user['id'] ?? 0)] ?? '';
|
||||
|
||||
// Second-from-parents flag: by secondparent_user_id or secondparent_email
|
||||
$isSecond = false;
|
||||
$uid = (int)($user['id'] ?? 0);
|
||||
if ($uid > 0 && !empty($secondByUserId[$uid])) {
|
||||
$isSecond = true;
|
||||
} else {
|
||||
$em = trim(strtolower((string)($user['email'] ?? '')));
|
||||
if ($em !== '' && !empty($secondByEmail[$em])) $isSecond = true;
|
||||
}
|
||||
|
||||
// Add the user data along with role names to the result array
|
||||
$usersWithRoles[] = [
|
||||
'user' => $user,
|
||||
'roles' => $roleNames,
|
||||
'role_ids' => $roleIds,
|
||||
'parent_type' => $parentType, // 'Primary', 'Secondary', or ''
|
||||
'second_from_parents' => $isSecond ? 'Yes' : '',
|
||||
];
|
||||
}
|
||||
|
||||
return $usersWithRoles;
|
||||
}
|
||||
|
||||
// Method to store a new user
|
||||
public function store()
|
||||
{
|
||||
// Validate input data
|
||||
$validation = \Config\Services::validation();
|
||||
$validation->setRules([
|
||||
'username' => 'required',
|
||||
'password' => 'required|min_length[6]',
|
||||
'lastname' => 'required',
|
||||
'firstname' => 'required',
|
||||
'cellphone' => 'required',
|
||||
'email' => 'required|valid_email',
|
||||
'address_street' => 'required',
|
||||
'city' => 'required',
|
||||
'state' => 'required',
|
||||
'zip' => 'required',
|
||||
'accept_school_policy' => 'required',
|
||||
'role_id' => 'required|integer'
|
||||
]);
|
||||
|
||||
if (!$this->validate($validation->getRules())) {
|
||||
$errors = $validation->getErrors();
|
||||
return redirect()->back()->withInput()->with('errors', $errors);
|
||||
}
|
||||
|
||||
// Prepare user data for insertion
|
||||
$userData = [
|
||||
'username' => $this->request->getPost('username'),
|
||||
'password' => pbkdf2_hash($this->request->getPost('password')),
|
||||
'lastname' => ucfirst($this->request->getPost('lastname')),
|
||||
'firstname' => ucfirst($this->request->getPost('firstname')),
|
||||
'cellphone' => $this->request->getPost('cellphone'),
|
||||
'email' => strtolower($this->request->getPost('email')),
|
||||
'address_street' => $this->request->getPost('address_street'),
|
||||
'city' => ucfirst($this->request->getPost('city')),
|
||||
'state' => $this->request->getPost('state'),
|
||||
'zip' => $this->request->getPost('zip'),
|
||||
'accept_school_policy' => $this->request->getPost('accept_school_policy'),
|
||||
];
|
||||
|
||||
// Insert user data into the database
|
||||
if ($this->userModel->save($userData) === false) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->userModel->errors());
|
||||
}
|
||||
|
||||
// Retrieve the newly created user ID
|
||||
$userId = $this->userModel->getInsertID();
|
||||
|
||||
// Assign the role to the user in the user_roles table
|
||||
$roleData = [
|
||||
'user_id' => $userId,
|
||||
'role_id' => $this->request->getPost('role_id'),
|
||||
'created_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($this->userRoleModel->save($roleData) === false) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->userRoleModel->errors());
|
||||
}
|
||||
|
||||
return redirect()->to('/user');
|
||||
}
|
||||
|
||||
// Method to show the form for editing an existing user
|
||||
public function edit($id)
|
||||
{
|
||||
$data['user'] = $this->userModel->find($id);
|
||||
$data['roles'] = $this->roleModel->findAll();
|
||||
$userRoles = $this->userRoleModel->where('user_id', $id)->findAll();
|
||||
$data['assignedRoles'] = array_column($userRoles, 'role_id');
|
||||
|
||||
return view('user/edit', $data);
|
||||
}
|
||||
|
||||
|
||||
// Method to delete an existing user
|
||||
public function delete($id)
|
||||
{
|
||||
$this->userModel->delete($id);
|
||||
|
||||
// Delete the user's roles from the user_roles table
|
||||
$this->userRoleModel->where('user_id', $id)->delete();
|
||||
|
||||
return redirect()->to('/user');
|
||||
}
|
||||
|
||||
|
||||
//This function is necessary to display the initial forgot password form where the user enters their email address.
|
||||
public function forgotPassword()
|
||||
{
|
||||
return view('/user/forgot_password');
|
||||
}
|
||||
|
||||
//This function is crucial for processing the forgot password request, generating a reset token, storing it in the database, and sending an email with the reset link.
|
||||
public function processForgotPassword()
|
||||
{
|
||||
log_message('debug', 'Entered processForgotPassword');
|
||||
|
||||
$ip = $this->request->getIPAddress();
|
||||
|
||||
$recentAttempts = $this->resetRequestModel
|
||||
->where('ip_address', $ip)
|
||||
->where('requested_at >=', Time::now()->subHours(24)->toDateTimeString())
|
||||
->countAllResults();
|
||||
|
||||
if ($recentAttempts >= 3) {
|
||||
log_message('warning', "IP {$ip} blocked due to too many reset attempts.");
|
||||
session()->setFlashdata('show_block_modal', true);
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// Log this attempt
|
||||
$this->resetRequestModel->insert([
|
||||
'ip_address' => $ip,
|
||||
'requested_at' => Time::now(),
|
||||
]);
|
||||
|
||||
$email = strtolower($this->request->getPost('email'));
|
||||
$user = $this->userModel->where('email', $email)->first();
|
||||
|
||||
// --- Handle unknown email ---
|
||||
if (!$user) {
|
||||
session()->setFlashdata('error', 'If this email is registered, you will receive a reset link.');
|
||||
log_message('info', "Password reset requested for non-existing user {$email}");
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// --- Handle unverified accounts ---
|
||||
if ((int) $user['is_verified'] === 0) {
|
||||
session()->setFlashdata('error', 'Please check your email and complete the account activation process before resetting your password.');
|
||||
log_message('info', "Password reset blocked for unverified user {$email}");
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// --- Verified user: continue with reset ---
|
||||
$token = bin2hex(random_bytes(48));
|
||||
$expires_at = Time::now()->addHours(1);
|
||||
|
||||
$this->passwordResetModel->insert([
|
||||
'email' => $email,
|
||||
'token' => $token,
|
||||
'created_at' => Time::now(),
|
||||
'expires_at' => $expires_at,
|
||||
]);
|
||||
|
||||
$this->sendResetEmail($email, $token);
|
||||
|
||||
session()->setFlashdata('success', 'A password reset link has been sent to your email.');
|
||||
log_message('info', "Password reset email sent to {$email}");
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
|
||||
public function blocked()
|
||||
{
|
||||
session()->setFlashdata('show_block_modal', true);
|
||||
return redirect()->back(); // Show modal on the previous page
|
||||
}
|
||||
|
||||
//This function is needed to display a confirmation page after the reset email has been sent, informing the user to check their email.
|
||||
public function passwordResetConfirmation()
|
||||
{
|
||||
$email = session()->getFlashdata('reset_email');
|
||||
|
||||
return view('user/password_reset_confirmation', ['email' => $email]);
|
||||
}
|
||||
|
||||
//This is a private helper function that handles sending the reset email. It’s separated out to keep the processForgotPassword function clean and focused.
|
||||
public function sendResetEmail($email, $token)
|
||||
{
|
||||
$resetLink = site_url('/reset_password?token=' . $token);
|
||||
|
||||
// Render email template with data
|
||||
$message = view('emails/reset_password', ['resetLink' => $resetLink]);
|
||||
|
||||
$emailController = new EmailController();
|
||||
$subject = 'Password Reset Request';
|
||||
|
||||
if ($emailController->sendEmail($email, $subject, $message)) {
|
||||
log_message('info', 'Password reset email successfully sent to ' . $email);
|
||||
} else {
|
||||
log_message('error', 'Failed to send password reset email to ' . $email);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//This function is necessary to display the form where the user can enter a new password, after clicking on the reset link in their email.
|
||||
public function resetPassword()
|
||||
{
|
||||
// Retrieve the token from the query string
|
||||
$token = $this->request->getGet('token');
|
||||
|
||||
if (!$token) {
|
||||
return redirect()->to('/')->with('error', 'Invalid password reset link.');
|
||||
}
|
||||
|
||||
// You may want to validate the token here
|
||||
$resetEntry = $this->passwordResetModel->where('token', $token)
|
||||
->where('expires_at >=', Time::now())
|
||||
->first();
|
||||
|
||||
if (!$resetEntry) {
|
||||
return redirect()->to('/')->with('error', 'Invalid or expired reset link.');
|
||||
}
|
||||
|
||||
// Load the reset password view with the token
|
||||
return view('user/reset_password', ['token' => $token]);
|
||||
}
|
||||
|
||||
//This function processes the new password submission, validating the token, updating the user's password, and cleaning up the reset entry.
|
||||
public function processResetPassword()
|
||||
{
|
||||
$token = $this->request->getPost('token');
|
||||
$newPassword = $this->request->getPost('password');
|
||||
$passConfirm = $this->request->getPost('pass_confirm');
|
||||
|
||||
// Validate the input
|
||||
if (!$this->validate([
|
||||
'password' => [
|
||||
'label' => 'Password',
|
||||
'rules' => 'required|min_length[8]|regex_match[/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\-=\+*#$%&!?])[A-Za-z\d@\-=\+*#$%&!?]{8,}$/]',
|
||||
'errors' => [
|
||||
'required' => 'Password is required.',
|
||||
'min_length' => 'Password must be at least 8 characters.',
|
||||
'regex_match' => 'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character (@, -, =, +, *, #, $, %, &, !, ?).'
|
||||
]
|
||||
],
|
||||
'pass_confirm' => [
|
||||
'label' => 'Confirm Password',
|
||||
'rules' => 'required|matches[password]',
|
||||
'errors' => [
|
||||
'required' => 'Password confirmation is required.',
|
||||
'matches' => 'Passwords do not match.'
|
||||
]
|
||||
]
|
||||
])) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
// Find the password reset entry
|
||||
$resetEntry = $this->passwordResetModel->where('token', $token)
|
||||
->where('expires_at >=', Time::now())
|
||||
->first();
|
||||
|
||||
if (!$resetEntry) {
|
||||
return redirect()->to('/')->with('error', 'Invalid or expired reset link.');
|
||||
}
|
||||
|
||||
// Find the user by email
|
||||
$user = $this->userModel->where('email', $resetEntry['email'])->first();
|
||||
|
||||
if (!$user) {
|
||||
return redirect()->to('/')->with('error', 'User not found.');
|
||||
}
|
||||
|
||||
// 🛠 Hash the password using PBKDF2
|
||||
$hashedPassword = pbkdf2_hash($newPassword);
|
||||
|
||||
// Update the password in the users table and set the account status to 'Active'
|
||||
// Reset failed login attempts, suspension status, and last failed login time
|
||||
$this->userModel->update($user['id'], [
|
||||
'failed_attempts' => 0, // Reset failed attempts
|
||||
'is_suspended' => 0, // Unsuspend the account
|
||||
'last_failed_at' => null, // Reset the last failed login timestamp
|
||||
'password' => $hashedPassword,
|
||||
'status' => 'Active'
|
||||
]);
|
||||
|
||||
// Delete the used token from the password reset table
|
||||
$this->passwordResetModel->where('token', $token)->delete();
|
||||
|
||||
// Retrieve the user's IP address from the request
|
||||
$ipAddress = $this->request->getIPAddress();
|
||||
|
||||
// Delete the corresponding entry from the ip_attempts table, only if it exists
|
||||
$ipAttemptModel = new IpAttemptModel();
|
||||
$existingAttempt = $ipAttemptModel->where('ip_address', $ipAddress)->first();
|
||||
|
||||
if ($existingAttempt) {
|
||||
// IP address entry exists, so delete it
|
||||
$ipAttemptModel->where('ip_address', $ipAddress)->delete();
|
||||
}
|
||||
|
||||
// Redirect to a success page with a success message
|
||||
return view('user/password_set_success');
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function passwordSetSuccess()
|
||||
{
|
||||
return view('user/password_set_success');
|
||||
}
|
||||
|
||||
public function confirm($token)
|
||||
{
|
||||
log_message('info', 'Processing email confirmation with token: ' . $token);
|
||||
|
||||
|
||||
$user = $this->userModel->where('token', $token)->first();
|
||||
|
||||
if (!$user || $user['is_verified'] == 1) {
|
||||
return redirect()->to('/invalid_token');
|
||||
}
|
||||
|
||||
|
||||
// Mark the user as verified and generate an account ID
|
||||
$account_id = 'ACC' . str_pad($user['id'], 8, '0', STR_PAD_LEFT); // Example: ACC00000001
|
||||
$this->userModel->update($user['id'], ['is_verified' => 1, 'token' => null, 'account_id' => $account_id]);
|
||||
|
||||
log_message('info', 'User verified and account ID generated: ' . $account_id);
|
||||
|
||||
// Redirect to the set password page
|
||||
return redirect()->to('/set_password/' . $user['id']);
|
||||
}
|
||||
|
||||
public function setPassword($token)
|
||||
{
|
||||
//echo "Reached setPassword with token: " . esc($token);
|
||||
//echo "Token received: " . $token;
|
||||
$user = $this->userModel->where('token', $token)->first();
|
||||
|
||||
if (!$user || $user['is_verified'] == 1) {
|
||||
return redirect()->to('/invalid_token');
|
||||
}
|
||||
|
||||
return view('user/set_password', [
|
||||
'userId' => $user['id'],
|
||||
'token' => $token
|
||||
]);
|
||||
}
|
||||
|
||||
public function savePassword()
|
||||
{
|
||||
$validation = \Config\Services::validation();
|
||||
$validation->setRules([
|
||||
'password' => [
|
||||
'label' => 'Password',
|
||||
'rules' => 'required|min_length[8]|regex_match[/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\-=\+*#$%&!?])[A-Za-z\d@\-=\+*#$%&!?]{8,}$/]',
|
||||
'errors' => [
|
||||
'required' => 'Password is required.',
|
||||
'min_length' => 'Password must be at least 8 characters long.',
|
||||
'regex_match' => 'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character (@, -, =, +, *, #, $, %, &, !, ?).'
|
||||
]
|
||||
],
|
||||
'password_confirm' => [
|
||||
'label' => 'Confirm Password',
|
||||
'rules' => 'required|matches[password]',
|
||||
'errors' => [
|
||||
'required' => 'Password confirmation is required.',
|
||||
'matches' => 'Passwords do not match.'
|
||||
]
|
||||
],
|
||||
'user_id' => 'required|integer',
|
||||
'token' => 'required'
|
||||
]);
|
||||
|
||||
if (!$this->validate($validation->getRules())) {
|
||||
return redirect()->back()->withInput()->with('errors', $validation->getErrors());
|
||||
}
|
||||
|
||||
$userId = $this->request->getPost('user_id');
|
||||
$token = $this->request->getPost('token');
|
||||
$password = $this->request->getPost('password');
|
||||
|
||||
$user = $this->userModel->where('id', $userId)->where('token', $token)->first();
|
||||
|
||||
log_message('debug', "Attempting to set password for user $userId with token $token");
|
||||
|
||||
if (!$user || $user['is_verified'] == 1) {
|
||||
return redirect()->to('/invalid_token');
|
||||
}
|
||||
|
||||
$hashedPassword = pbkdf2_hash($password); // Or use password_hash()
|
||||
|
||||
// Generate account_id only if it's not already set
|
||||
$account_id = $user['account_id'] ?? null;
|
||||
if (!$account_id) {
|
||||
$account_id = 'ACC' . str_pad($userId, 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$this->userModel->update($userId, [
|
||||
'password' => $hashedPassword,
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'token' => null,
|
||||
'account_id' => $account_id,
|
||||
]);
|
||||
|
||||
// Optional: trigger event
|
||||
$userData = $this->userModel->getUserInfoById($userId);
|
||||
if (!is_array($userData)) {
|
||||
$userData = (array) $userData;
|
||||
}
|
||||
$userData['id'] = $userId;
|
||||
\CodeIgniter\Events\Events::trigger('user_registered', $userData);
|
||||
|
||||
return redirect()->to('/user/password_set_success');
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function invalidToken()
|
||||
{
|
||||
return view('errors/invalid_token');
|
||||
}
|
||||
|
||||
|
||||
public function selectRole()
|
||||
{
|
||||
log_message('info', 'Processing setRole form submission.');
|
||||
|
||||
if (!session()->get('is_logged_in')) {
|
||||
log_message('error', 'User is not logged in.');
|
||||
return redirect()->to('/user/login');
|
||||
}
|
||||
|
||||
$roleKey = (string) $this->request->getPost('role');
|
||||
log_message('info', 'Role selected: ' . $roleKey);
|
||||
|
||||
$roleModel = new RoleModel();
|
||||
$route = $roleModel->getRouteByNameOrSlug($roleKey);
|
||||
|
||||
if ($route === null) {
|
||||
log_message('error', 'Invalid or inactive role selected: ' . $roleKey);
|
||||
return redirect()->back()->with('error', 'Invalid role selected.');
|
||||
}
|
||||
|
||||
$userId = (int) session()->get('user_id');
|
||||
log_message('info', 'User ID: ' . $userId);
|
||||
|
||||
// Persist the *exact* role name or slug—choose your convention.
|
||||
// If you want to store the canonical name, fetch the row and use $row['name'].
|
||||
$this->userModel->update($userId, ['role' => $roleKey]);
|
||||
log_message('info', 'Role updated in database.');
|
||||
|
||||
log_message('info', 'Redirecting to role dashboard: ' . $route);
|
||||
return redirect()->to($route);
|
||||
}
|
||||
|
||||
|
||||
public function welcomeBack()
|
||||
{
|
||||
if (!session()->get('is_logged_in')) {
|
||||
return redirect()->to('/user/login');
|
||||
}
|
||||
|
||||
return view('user/welcome_back');
|
||||
}
|
||||
|
||||
public function delete_role($roleId)
|
||||
{
|
||||
// Fetch the role to be deleted
|
||||
$role = $this->roleModel->find($roleId);
|
||||
if (!$role) {
|
||||
return redirect()->back()->with('error', 'Role not found.');
|
||||
}
|
||||
|
||||
// Fetch the "Guest" role
|
||||
$guestRole = $this->roleModel->where('name', 'Guest')->first();
|
||||
if (!$guestRole) {
|
||||
return redirect()->back()->with('error', 'Guest role not found.');
|
||||
}
|
||||
|
||||
// Update users with the deleted role to have the "Guest" role
|
||||
$this->userModel->where('role_id', $roleId)->set(['role_id' => $guestRole['id'], 'role' => 'Guest', 'status' => 'Inactive'])->update();
|
||||
|
||||
// Verify that users have been updated
|
||||
$updatedUsers = $this->userModel->where('role_id', $guestRole['id'])->findAll();
|
||||
log_message('debug', 'Updated users: ' . print_r($updatedUsers, true));
|
||||
|
||||
// Delete the role
|
||||
if ($this->roleModel->delete($roleId)) {
|
||||
return redirect()->to('/administrator/user_roles')->with('success', 'Role deleted successfully. Users with this role have been assigned the Guest role.');
|
||||
} else {
|
||||
return redirect()->back()->with('error', 'Failed to delete role.');
|
||||
}
|
||||
}
|
||||
|
||||
public function loginActivity()
|
||||
{
|
||||
helper('url');
|
||||
|
||||
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
|
||||
|
||||
return view('user/login_activity', [
|
||||
'loginActivityEndpoint' => site_url('api/login-activity'),
|
||||
'defaultPerPage' => $perPage > 0 ? $perPage : 25,
|
||||
]);
|
||||
}
|
||||
|
||||
public function loginActivityData()
|
||||
{
|
||||
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
|
||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
||||
|
||||
return $this->response->setJSON($this->buildLoginActivityPayload($perPage, $page));
|
||||
}
|
||||
|
||||
// Method to update an existing user
|
||||
public function updateUser()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||||
return redirect()->to(site_url('user/user_list'))->with('error', 'Invalid request.');
|
||||
}
|
||||
|
||||
$id = (int) $this->request->getPost('id');
|
||||
if (!$id) {
|
||||
return redirect()->to(site_url('user/user_list'))->with('error', 'User ID is missing.');
|
||||
}
|
||||
|
||||
$user = $this->userModel->find($id);
|
||||
if (!$user) {
|
||||
return redirect()->to(site_url('user/user_list'))->with('error', 'User not found.');
|
||||
}
|
||||
|
||||
// Helpers
|
||||
$toBool = fn($k) => $this->request->getPost($k) ? 1 : 0;
|
||||
$toDT = function ($key) {
|
||||
$v = trim((string)$this->request->getPost($key));
|
||||
if ($v === '') return null;
|
||||
// Expecting HTML datetime-local "YYYY-MM-DDTHH:MM"
|
||||
$ts = strtotime($v);
|
||||
return $ts ? date('Y-m-d H:i:s', $ts) : null;
|
||||
};
|
||||
|
||||
$data = [
|
||||
'id' => $id,
|
||||
'account_id' => trim((string)$this->request->getPost('account_id')),
|
||||
'lastname' => trim((string)$this->request->getPost('lastname')),
|
||||
'firstname' => trim((string)$this->request->getPost('firstname')),
|
||||
'gender' => trim((string)$this->request->getPost('gender')),
|
||||
'cellphone' => trim((string)$this->request->getPost('cellphone')),
|
||||
'email' => trim((string)$this->request->getPost('email')),
|
||||
'address_street' => trim((string)$this->request->getPost('address_street')),
|
||||
'apt' => trim((string)$this->request->getPost('apt')),
|
||||
'city' => trim((string)$this->request->getPost('city')),
|
||||
'state' => trim((string)$this->request->getPost('state')),
|
||||
'zip' => trim((string)$this->request->getPost('zip')),
|
||||
'accept_school_policy' => $toBool('accept_school_policy'),
|
||||
'user_type' => trim((string)$this->request->getPost('user_type')),
|
||||
'rfid_tag' => trim((string)$this->request->getPost('rfid_tag')),
|
||||
'school_id' => trim((string)$this->request->getPost('school_id')),
|
||||
'failed_attempts' => (string)$this->request->getPost('failed_attempts') === '' ? null : (int)$this->request->getPost('failed_attempts'),
|
||||
'last_failed_at' => $toDT('last_failed_at'),
|
||||
'semester' => trim((string)$this->request->getPost('semester')),
|
||||
'school_year' => trim((string)$this->request->getPost('school_year')),
|
||||
'status' => trim((string)$this->request->getPost('status')),
|
||||
'is_suspended' => $toBool('is_suspended'),
|
||||
'is_verified' => $toBool('is_verified'),
|
||||
'token' => trim((string)$this->request->getPost('token')),
|
||||
'updated_at' => $toDT('updated_at'),
|
||||
'created_at' => $toDT('created_at'),
|
||||
];
|
||||
|
||||
// Validation
|
||||
$rules = [
|
||||
'firstname' => 'required|min_length[2]|max_length[100]',
|
||||
'lastname' => 'required|min_length[2]|max_length[100]',
|
||||
'email' => "required|valid_email|is_unique[users.email,id,{$id}]",
|
||||
'state' => 'permit_empty|max_length[2]',
|
||||
'zip' => 'permit_empty|max_length[20]',
|
||||
'cellphone' => 'permit_empty|max_length[50]',
|
||||
'failed_attempts' => 'permit_empty|integer',
|
||||
'gender' => 'permit_empty|in_list[male,female,MALE,FEMALE]', // adjust as needed
|
||||
];
|
||||
|
||||
if (!$this->validate($rules)) {
|
||||
return redirect()->back()
|
||||
->with('error', implode(' ', $this->validator->getErrors()))
|
||||
->withInput();
|
||||
}
|
||||
|
||||
if (!$this->userModel->save($data)) {
|
||||
return redirect()->back()->with('error', 'Failed to update user.')->withInput();
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('user/user_list'))->with('success', 'User updated successfully.');
|
||||
}
|
||||
|
||||
private function buildLoginActivityPayload(int $perPage, int $page): array
|
||||
{
|
||||
$perPage = max(1, min($perPage, 200));
|
||||
$page = max(1, $page);
|
||||
|
||||
$totalActivities = (int) $this->loginActivityModel->countAll();
|
||||
$activities = $this->loginActivityModel
|
||||
->orderBy('login_time', 'DESC')
|
||||
->paginate($perPage, 'loginActivity', $page) ?? [];
|
||||
|
||||
$pager = $this->loginActivityModel->pager;
|
||||
$currentPage = $pager ? (int) $pager->getCurrentPage('loginActivity') : $page;
|
||||
$pageCount = $pager ? (int) $pager->getPageCount('loginActivity') : (int) max(1, ceil($totalActivities / $perPage));
|
||||
|
||||
$normalized = array_map(static function ($activity) {
|
||||
if (!is_array($activity)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $activity['user_id'] ?? null,
|
||||
'email' => $activity['email'] ?? null,
|
||||
'login_time' => $activity['login_time'] ?? null,
|
||||
'logout_time' => $activity['logout_time'] ?? null,
|
||||
'ip_address' => $activity['ip_address'] ?? null,
|
||||
'user_agent' => $activity['user_agent'] ?? null,
|
||||
];
|
||||
}, $activities);
|
||||
|
||||
return [
|
||||
'activities' => $normalized,
|
||||
'pagination' => [
|
||||
'total' => $totalActivities,
|
||||
'perPage' => $perPage,
|
||||
'currentPage' => $currentPage,
|
||||
'pageCount' => $pageCount,
|
||||
'hasNext' => $currentPage < $pageCount,
|
||||
'hasPrevious' => $currentPage > 1,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user