2542 lines
106 KiB
PHP
Executable File
2542 lines
106 KiB
PHP
Executable File
<?php
|
||
|
||
namespace App\Controllers\View;
|
||
use App\Controllers\BaseController;
|
||
use App\Models\AttendanceTrackingModel;
|
||
use App\Models\StudentClassModel;
|
||
use App\Models\StudentModel;
|
||
use App\Models\ConfigurationModel;
|
||
use App\Models\AttendanceDataModel;
|
||
use CodeIgniter\Events\Events;
|
||
use App\Models\ParentNotificationModel;
|
||
use App\Models\AttendanceEmailTemplateModel;
|
||
|
||
|
||
class AttendanceTrackingController extends BaseController
|
||
{
|
||
protected $attendanceEmailTemplateModel;
|
||
protected $attendanceDataModel;
|
||
protected $notificationModel;
|
||
|
||
protected $studentModel;
|
||
protected $semester;
|
||
protected $schoolYear;
|
||
protected $studentClassModel;
|
||
protected $attendanceTrackingModel;
|
||
protected $configModel;
|
||
protected $email;
|
||
protected $db;
|
||
protected array $debugCompute = [];
|
||
protected ?array $attendanceReportedColumns = null;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->studentModel = new StudentModel();
|
||
$this->studentClassModel = new StudentClassModel();
|
||
$this->attendanceDataModel = new AttendanceDataModel();
|
||
$this->attendanceTrackingModel = new AttendanceTrackingModel();
|
||
$this->configModel = new ConfigurationModel();
|
||
$this->email = \Config\Services::email();
|
||
$this->db = \Config\Database::connect();
|
||
$this->notificationModel = new ParentNotificationModel();
|
||
$this->attendanceEmailTemplateModel = new AttendanceEmailTemplateModel();
|
||
$this->semester = (string) $this->configModel->getConfig('semester');
|
||
$this->schoolYear = (string) $this->configModel->getConfig('school_year');
|
||
}
|
||
|
||
public function pendingViolationsView()
|
||
{
|
||
$syParam = $this->request->getGet('school_year');
|
||
$semParam = $this->request->getGet('semester');
|
||
$schoolYear = (is_string($syParam) && $syParam !== '') ? (string)$syParam : (string)$this->schoolYear;
|
||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : null; // semester filter disabled
|
||
|
||
$debugInfo = [
|
||
'school_year_param' => $schoolYear,
|
||
'semester_param' => $semester,
|
||
'class_students' => 0,
|
||
'student_ids' => 0,
|
||
'attendance_rows' => 0,
|
||
'filtered_rows' => 0,
|
||
'violations' => 0,
|
||
'active_weeks' => 0,
|
||
'abs_last5' => 0,
|
||
'late_last5' => 0,
|
||
];
|
||
|
||
$classStudents = $this->studentClassModel
|
||
->active()
|
||
->where('student_class.school_year', $schoolYear)
|
||
->findAll();
|
||
$debugInfo['class_students'] = count($classStudents);
|
||
|
||
// Fallback: if the configured school_year has no class assignments, look up the latest term
|
||
if (empty($classStudents)) {
|
||
$latestTerm = $this->db->table('student_class')
|
||
->select('school_year, semester')
|
||
->orderBy('id', 'DESC')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
if ($latestTerm && !empty($latestTerm['school_year'])) {
|
||
$schoolYear = (string)$latestTerm['school_year'];
|
||
if (!empty($latestTerm['semester'])) {
|
||
$semester = (string)$latestTerm['semester'];
|
||
}
|
||
|
||
$classStudents = $this->studentClassModel
|
||
->active()
|
||
->where('student_class.school_year', $schoolYear)
|
||
->findAll();
|
||
}
|
||
}
|
||
|
||
// Gather candidate student identifiers (numeric IDs and code-like IDs)
|
||
$studentIds = [];
|
||
$studentCodes = [];
|
||
foreach ($classStudents as $row) {
|
||
$raw = $row['student_id'] ?? null;
|
||
if (is_numeric($raw)) {
|
||
$studentIds[] = (int)$raw;
|
||
} elseif (is_string($raw) && $raw !== '') {
|
||
$studentCodes[] = trim($raw);
|
||
}
|
||
}
|
||
|
||
// Fallback #2: if still empty, derive student IDs directly from attendance_data (school year optional)
|
||
if (empty($studentIds) && empty($studentCodes)) {
|
||
$builder = $this->db->table('attendance_data')
|
||
->select('DISTINCT student_id', false)
|
||
->orderBy('student_id', 'ASC');
|
||
if (!empty($schoolYear)) {
|
||
$builder->where('school_year', $schoolYear);
|
||
}
|
||
$attendanceStudents = $builder->get()->getResultArray();
|
||
foreach ($attendanceStudents as $r) {
|
||
$raw = $r['student_id'] ?? null;
|
||
if (is_numeric($raw)) {
|
||
$studentIds[] = (int)$raw;
|
||
} elseif (is_string($raw) && $raw !== '') {
|
||
$studentCodes[] = trim($raw);
|
||
}
|
||
}
|
||
if (!empty($studentIds) || !empty($studentCodes)) {
|
||
$classStudents = array_map(fn($sid) => ['student_id' => $sid], array_merge($studentIds, $studentCodes));
|
||
}
|
||
}
|
||
|
||
$studentIds = array_values(array_unique(array_filter($studentIds, fn($v) => $v > 0)));
|
||
$studentCodes = array_values(array_unique(array_filter($studentCodes, fn($v) => $v !== '')));
|
||
|
||
// Remove inactive students from the candidate list (avoid listing them in tracking views)
|
||
$inactiveIdSet = [];
|
||
if (!empty($studentIds)) {
|
||
$inactiveRows = $this->studentModel
|
||
->select('id')
|
||
->whereIn('id', $studentIds)
|
||
->where('is_active', 0)
|
||
->findAll();
|
||
$inactiveIdSet = array_flip(array_map(
|
||
static fn($r) => (int)($r['id'] ?? 0),
|
||
$inactiveRows
|
||
));
|
||
$studentIds = array_values(array_filter(
|
||
$studentIds,
|
||
static fn($id) => !isset($inactiveIdSet[$id])
|
||
));
|
||
}
|
||
|
||
$inactiveCodeSet = [];
|
||
if (!empty($studentCodes)) {
|
||
$inactiveCodeRows = $this->studentModel
|
||
->select('school_id')
|
||
->whereIn('school_id', $studentCodes)
|
||
->where('is_active', 0)
|
||
->findAll();
|
||
$inactiveCodeSet = array_flip(array_map(
|
||
static fn($r) => (string)($r['school_id'] ?? ''),
|
||
$inactiveCodeRows
|
||
));
|
||
$studentCodes = array_values(array_filter(
|
||
$studentCodes,
|
||
static fn($code) => $code !== '' && !isset($inactiveCodeSet[$code])
|
||
));
|
||
}
|
||
|
||
$debugInfo['student_ids'] = count($studentIds) + count($studentCodes);
|
||
|
||
if (empty($studentIds) && empty($studentCodes)) {
|
||
return view('attendance/violations_pending', [
|
||
'title' => 'Pending Attendance Violations',
|
||
'students' => [],
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
'error' => 'No student identifiers found for the current school year.',
|
||
]);
|
||
}
|
||
|
||
$students = [];
|
||
if (!empty($studentIds)) {
|
||
$students = $this->studentModel
|
||
->whereIn('id', $studentIds)
|
||
->where('is_active', 1)
|
||
->findAll();
|
||
}
|
||
if (!empty($studentCodes)) {
|
||
$byCode = $this->studentModel
|
||
->whereIn('school_id', $studentCodes)
|
||
->where('is_active', 1)
|
||
->findAll();
|
||
// merge unique by id
|
||
$seen = array_flip(array_map(fn($s) => (int)($s['id'] ?? 0), $students));
|
||
foreach ($byCode as $s) {
|
||
$sid = (int)($s['id'] ?? 0);
|
||
if ($sid > 0 && !isset($seen[$sid])) {
|
||
$students[] = $s;
|
||
$seen[$sid] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// If some codes were not found in students table, create lightweight placeholder entries
|
||
$knownCodes = [];
|
||
foreach ($students as $s) {
|
||
$code = trim((string)($s['school_id'] ?? ''));
|
||
if ($code !== '') {
|
||
$knownCodes[$code] = true;
|
||
}
|
||
}
|
||
foreach ($studentCodes as $code) {
|
||
if (!isset($knownCodes[$code])) {
|
||
$virtualId = abs(crc32($code)) ?: mt_rand(1000000, 1999999);
|
||
$students[] = [
|
||
'id' => $virtualId,
|
||
'school_id' => $code,
|
||
'firstname' => $code,
|
||
'lastname' => '',
|
||
'parent_id' => 0,
|
||
'class_name' => '',
|
||
];
|
||
}
|
||
}
|
||
|
||
if (empty($students)) {
|
||
return view('attendance/violations_pending', [
|
||
'title' => 'Pending Attendance Violations',
|
||
'students' => [],
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
'error' => 'No students found matching attendance records.',
|
||
'debug' => $debugInfo,
|
||
]);
|
||
}
|
||
|
||
// Ensure every attendance student (numeric or code) has a student entry
|
||
$studentCodeToId = [];
|
||
foreach ($students as $stu) {
|
||
$code = trim((string)($stu['student_code'] ?? $stu['school_id'] ?? ''));
|
||
if ($code !== '') {
|
||
$studentCodeToId[$code] = (int)($stu['id'] ?? 0);
|
||
}
|
||
}
|
||
$existingIds = array_flip(array_map(fn($s) => (int)($s['id'] ?? 0), $students));
|
||
foreach (array_merge($studentIds, $studentCodes) as $sidOrCode) {
|
||
$sid = null;
|
||
$code = null;
|
||
if (is_numeric($sidOrCode)) {
|
||
$sid = (int)$sidOrCode;
|
||
} else {
|
||
$code = (string)$sidOrCode;
|
||
}
|
||
if ($sid !== null && $sid > 0) {
|
||
if (!isset($existingIds[$sid])) {
|
||
$students[] = [
|
||
'id' => $sid,
|
||
'school_id' => $code ?? (string)$sid,
|
||
'firstname' => (string)$sid,
|
||
'lastname' => '',
|
||
'parent_id' => 0,
|
||
'class_name' => '',
|
||
];
|
||
$existingIds[$sid] = true;
|
||
}
|
||
} elseif ($code !== null && $code !== '') {
|
||
if (!isset($studentCodeToId[$code])) {
|
||
$virtualId = abs(crc32($code)) ?: mt_rand(2000000, 2999999);
|
||
$students[] = [
|
||
'id' => $virtualId,
|
||
'school_id' => $code,
|
||
'firstname' => $code,
|
||
'lastname' => '',
|
||
'parent_id' => 0,
|
||
'class_name' => '',
|
||
];
|
||
$studentCodeToId[$code] = $virtualId;
|
||
$existingIds[$virtualId] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Map code-like identifiers to numeric IDs (attendance_data may store codes in student_id)
|
||
$studentCodeToId = [];
|
||
foreach ($students as $stu) {
|
||
$code = trim((string)($stu['student_code'] ?? $stu['school_id'] ?? ''));
|
||
if ($code !== '') {
|
||
$studentCodeToId[$code] = (int)($stu['id'] ?? 0);
|
||
}
|
||
}
|
||
$existingIds = array_flip(array_map(fn($s) => (int)($s['id'] ?? 0), $students));
|
||
|
||
$today = new \DateTime('today');
|
||
[$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear);
|
||
|
||
// Use the whole school year window (capped at today) so older absences are considered
|
||
$start = new \DateTime($syStart . ' 00:00:00');
|
||
$rangeEnd = new \DateTime(min($syEnd, $today->format('Y-m-d')) . ' 00:00:00');
|
||
$endEx = (clone $rangeEnd)->modify('+1 day');
|
||
|
||
// not notified yet (tolerant: != 'yes', 0, '0', null)
|
||
$qb = $this->attendanceDataModel->builder();
|
||
$qb->select('id, student_id, class_id, class_section_id, date, status, reason, school_year, semester, is_reported, is_notified');
|
||
if (!empty($schoolYear)) {
|
||
$qb->where('school_year', $schoolYear);
|
||
}
|
||
if (!empty($semester)) {
|
||
$qb->where('semester', $semester);
|
||
}
|
||
// Support attendance_data that stores student codes instead of numeric IDs
|
||
$qb->groupStart();
|
||
$studentCodes = array_keys($studentCodeToId);
|
||
$hasClause = false;
|
||
if (!empty($studentIds)) {
|
||
$qb->whereIn('student_id', $studentIds);
|
||
$hasClause = true;
|
||
}
|
||
if (!empty($studentCodes)) {
|
||
if ($hasClause) {
|
||
$qb->orWhereIn('student_id', $studentCodes);
|
||
} else {
|
||
$qb->whereIn('student_id', $studentCodes);
|
||
}
|
||
$hasClause = true;
|
||
}
|
||
$qb->groupEnd();
|
||
|
||
// ✅ unreported records only (honor legacy columns)
|
||
$this->applyUnreportedAttendanceFilter($qb);
|
||
|
||
// ✅ not yet notified records (treat both string 'yes' and numeric 1 as notified)
|
||
$qb->groupStart()
|
||
->where("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))", null, false)
|
||
->groupEnd();
|
||
|
||
// ✅ only absents/lates
|
||
// Normalize status with TRIM/LOWER to catch trailing spaces/case differences
|
||
$qb->where("LOWER(TRIM(status)) IN ('absent','late')", null, false);
|
||
|
||
// School-year date window (inclusive start, exclusive end)
|
||
$qb->where('date >=', $start->format('Y-m-d'))
|
||
->where('date <', $endEx->format('Y-m-d'));
|
||
|
||
$termRows = $qb->orderBy('date', 'DESC')->get()->getResultArray();
|
||
|
||
if (empty($termRows)) {
|
||
log_message('debug', 'pendingViolationsView: no attendance rows found', [
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
'student_ids' => $studentIds,
|
||
]);
|
||
// Second chance: detect the latest attendance_data term for these students
|
||
$latestAttendanceTerm = $this->db->table('attendance_data')
|
||
->select('school_year, semester, date')
|
||
->whereIn('student_id', $studentIds)
|
||
->orderBy('date', 'DESC')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
if ($latestAttendanceTerm) {
|
||
$schoolYear = !empty($latestAttendanceTerm['school_year'])
|
||
? (string)$latestAttendanceTerm['school_year']
|
||
: $schoolYear;
|
||
$semester = !empty($latestAttendanceTerm['semester'])
|
||
? (string)$latestAttendanceTerm['semester']
|
||
: $semester;
|
||
|
||
[$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear ?: $this->schoolYear);
|
||
$start = new \DateTime($syStart . ' 00:00:00');
|
||
$rangeEnd = new \DateTime(min($syEnd, $today->format('Y-m-d')) . ' 00:00:00');
|
||
$endEx = (clone $rangeEnd)->modify('+1 day');
|
||
|
||
$qb = $this->attendanceDataModel->builder();
|
||
$qb->select('id, student_id, class_id, class_section_id, date, status, reason, school_year, semester, is_reported, is_notified');
|
||
if (!empty($schoolYear)) {
|
||
$qb->where('school_year', $schoolYear);
|
||
}
|
||
if (!empty($semester)) {
|
||
$qb->where('semester', $semester);
|
||
}
|
||
|
||
$qb->groupStart();
|
||
$hasClause = false;
|
||
if (!empty($studentIds)) {
|
||
$qb->whereIn('student_id', $studentIds);
|
||
$hasClause = true;
|
||
}
|
||
if (!empty($studentCodes)) {
|
||
if ($hasClause) {
|
||
$qb->orWhereIn('student_id', $studentCodes);
|
||
} else {
|
||
$qb->whereIn('student_id', $studentCodes);
|
||
}
|
||
}
|
||
$qb->groupEnd();
|
||
$this->applyUnreportedAttendanceFilter($qb);
|
||
$qb->groupStart()
|
||
->where("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))", null, false)
|
||
->groupEnd();
|
||
$qb->where("LOWER(TRIM(status)) IN ('absent','late')", null, false);
|
||
$qb->where('date >=', $start->format('Y-m-d'))
|
||
->where('date <', $endEx->format('Y-m-d'));
|
||
|
||
$termRows = $qb->orderBy('date', 'DESC')->get()->getResultArray();
|
||
}
|
||
|
||
if (empty($termRows)) {
|
||
log_message('debug', 'pendingViolationsView: still no rows after fallback', [
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
'student_ids' => $studentIds,
|
||
]);
|
||
return view('attendance/violations_pending', [
|
||
'title' => 'Pending Attendance Violations',
|
||
'students' => [],
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
]);
|
||
}
|
||
}
|
||
|
||
$attendanceRows = [];
|
||
foreach ($termRows as $row) {
|
||
$dStr = $row['date'] ?? null;
|
||
if (!$dStr) continue;
|
||
|
||
try {
|
||
$d = new \DateTime($dStr);
|
||
} catch (\Throwable $e) {
|
||
continue;
|
||
}
|
||
if ($d < $start || $d >= $endEx) continue;
|
||
|
||
// Normalize student_id: some rows store student_code instead of numeric id
|
||
$rawSid = $row['student_id'] ?? null;
|
||
$sid = null;
|
||
if (is_numeric($rawSid)) {
|
||
$sid = (int)$rawSid;
|
||
} elseif (is_string($rawSid)) {
|
||
$code = trim($rawSid);
|
||
$sid = $studentCodeToId[$code] ?? null;
|
||
if ($sid === null) {
|
||
// Create a placeholder student for unseen code
|
||
$sid = abs(crc32($code)) ?: mt_rand(3000000, 3999999);
|
||
$studentCodeToId[$code] = $sid;
|
||
if (!isset($existingIds[$sid])) {
|
||
$students[] = [
|
||
'id' => $sid,
|
||
'school_id' => $code,
|
||
'firstname' => $code,
|
||
'lastname' => '',
|
||
'parent_id' => 0,
|
||
];
|
||
$existingIds[$sid] = true;
|
||
}
|
||
}
|
||
}
|
||
if ($sid === null || $sid <= 0) continue;
|
||
$row['student_id'] = $sid;
|
||
|
||
$row['status'] = strtolower(trim((string)($row['status'] ?? '')));
|
||
$row['date'] = substr($d->format('Y-m-d H:i:s'), 0, 10);
|
||
|
||
$attendanceRows[] = $row;
|
||
}
|
||
|
||
// Ensure every attendance student ID has a student entry
|
||
foreach ($attendanceRows as $arow) {
|
||
$sid = (int)($arow['student_id'] ?? 0);
|
||
if ($sid > 0 && !isset($existingIds[$sid])) {
|
||
$students[] = [
|
||
'id' => $sid,
|
||
'school_id' => (string)($arow['student_code'] ?? $arow['student_id']),
|
||
'firstname' => (string)($arow['student_code'] ?? $arow['student_id']),
|
||
'lastname' => '',
|
||
'parent_id' => 0,
|
||
];
|
||
$existingIds[$sid] = true;
|
||
}
|
||
}
|
||
|
||
if (empty($attendanceRows)) {
|
||
log_message('debug', 'pendingViolationsView: filtered attendance rows empty', [
|
||
'raw_rows' => count($termRows),
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
]);
|
||
return view('attendance/violations_pending', [
|
||
'title' => 'Pending Attendance Violations',
|
||
'students' => [],
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
'debug' => $debugInfo,
|
||
]);
|
||
}
|
||
|
||
$debugInfo['attendance_rows'] = count($termRows);
|
||
$debugInfo['filtered_rows'] = count($attendanceRows);
|
||
|
||
log_message('debug', 'pendingViolationsView: attendance rows ready', [
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
'rows' => count($attendanceRows),
|
||
'students' => count($students),
|
||
]);
|
||
|
||
$violations = $this->computeViolations($students, $attendanceRows, $schoolYear, null, $attendanceRows);
|
||
$debugInfo['violations'] = count($violations);
|
||
$debugInfo['active_weeks'] = $this->debugCompute['active_weeks'] ?? 0;
|
||
$debugInfo['by_student'] = $this->debugCompute['by_student'] ?? 0;
|
||
$debugInfo['abs_last5'] = $this->debugCompute['abs_last5'] ?? 0;
|
||
$debugInfo['late_last5'] = $this->debugCompute['late_last5'] ?? 0;
|
||
|
||
return view('attendance/violations_pending', [
|
||
'title' => 'Pending Attendance Violations',
|
||
'students' => $violations,
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
'debug' => $debugInfo,
|
||
]);
|
||
}
|
||
|
||
|
||
|
||
private function notificationAlreadySent(int $studentId, string $code, string $ymd, string $channel = 'email', ?string $to = null): bool
|
||
{
|
||
try {
|
||
return $this->notificationModel->hasSent($studentId, $code, $ymd, $channel, $to);
|
||
} catch (\Throwable $e) {
|
||
log_message('debug', 'notificationAlreadySent(): ' . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private function logNotification(int $studentId, string $code, string $ymd, string $channel, ?string $to, string $subject, string $status, string $response = ''): void
|
||
{
|
||
try {
|
||
// Upsert by unique key (student_id, code, incident_date, channel, to_address)
|
||
$where = [
|
||
'student_id' => $studentId,
|
||
'code' => $code,
|
||
'incident_date' => $ymd,
|
||
'channel' => $channel,
|
||
];
|
||
if (!empty($to)) {
|
||
$where['to_address'] = $to;
|
||
}
|
||
|
||
$existing = $this->notificationModel->where($where)->orderBy('id', 'DESC')->first();
|
||
|
||
if ($existing && !empty($existing['id'])) {
|
||
// Update status/response/subject to reflect latest attempt
|
||
$this->notificationModel->update((int) $existing['id'], [
|
||
'subject' => $subject,
|
||
'status' => $status,
|
||
'response' => $response,
|
||
'semester' => $this->semester,
|
||
'school_year' => $this->schoolYear,
|
||
'updated_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
} else {
|
||
// Insert a new log row
|
||
$this->notificationModel->insert([
|
||
'student_id' => $studentId,
|
||
'code' => $code,
|
||
'incident_date' => $ymd,
|
||
'channel' => $channel,
|
||
'to_address' => $to,
|
||
'subject' => $subject,
|
||
'status' => $status,
|
||
'response' => $response,
|
||
'semester' => $this->semester,
|
||
'school_year' => $this->schoolYear,
|
||
]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
log_message('debug', 'logNotification(): ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
public function record()
|
||
{
|
||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||
return redirect()->to(site_url('attendance/violations'));
|
||
}
|
||
|
||
$post = $this->request->getPost();
|
||
|
||
$rules = [
|
||
'student_id' => 'required|is_natural_no_zero',
|
||
'date' => 'required|valid_date[Y-m-d]',
|
||
'parent_email' => 'permit_empty|valid_email',
|
||
'parent_name' => 'permit_empty|string',
|
||
'subject_type' => 'permit_empty|string',
|
||
'semester' => 'permit_empty|string',
|
||
'school_year' => 'permit_empty|string',
|
||
'return_url' => 'permit_empty|string',
|
||
];
|
||
if (! $this->validate($rules)) {
|
||
return redirect()->back()->withInput()->with('error', implode(' ', $this->validator->getErrors()));
|
||
}
|
||
|
||
$sid = (int) $post['student_id'];
|
||
$ymd = substr((string) $post['date'], 0, 10);
|
||
$semester = $post['semester'] ?? $this->semester;
|
||
$schoolYear = $post['school_year'] ?? $this->schoolYear;
|
||
$returnUrl = $post['return_url'] ?? site_url('attendance/violations');
|
||
$subject = $post['subject_type'] ?? 'Absent';
|
||
|
||
$student = $this->studentModel->find($sid) ?? [];
|
||
$parentEmail = $post['parent_email'] ?? '';
|
||
$parentName = $post['parent_name'] ?? '';
|
||
|
||
if (!$parentEmail) {
|
||
$parent = $this->getPrimaryParentForStudent($sid) ?? [];
|
||
$parentEmail = $parent['email'] ?? '';
|
||
$parentName = $parent['parent_name'] ?? '';
|
||
}
|
||
if (!$parentEmail) {
|
||
return redirect()->to($returnUrl)->with('error', 'No parent email found for this student.');
|
||
}
|
||
|
||
$tracking = $this->attendanceTrackingModel;
|
||
[$start, $end] = $this->dayBounds($ymd);
|
||
$row = $tracking->builder()
|
||
->where('student_id', $sid)
|
||
->where('semester', $semester)
|
||
->where('school_year', $schoolYear)
|
||
->where('date >=', $start)
|
||
->where('date <', $end)
|
||
->orderBy('date', 'DESC')
|
||
->get()->getFirstRow('array');
|
||
|
||
$consequence = null;
|
||
$reasonTxt = (string) ($row['reason'] ?? '');
|
||
if (preg_match('/\bABS_4\b|dismissal/i', $reasonTxt)) $consequence = 'dismissal';
|
||
elseif (preg_match('/\bABS_3\b|final_warning/i', $reasonTxt)) $consequence = 'final_warning';
|
||
elseif (preg_match('/\bABS_2\b|follow_up/i', $reasonTxt)) $consequence = 'follow_up';
|
||
|
||
$class = $this->studentClassModel->getClassSectionsByStudentId($sid, $this->schoolYear);
|
||
|
||
$payload = [
|
||
'student_id' => $sid,
|
||
'student' => $student,
|
||
'parent' => ['email' => $parentEmail, 'name' => $parentName],
|
||
'semester' => $semester,
|
||
'school_year' => $schoolYear,
|
||
'class' => $class,
|
||
'now' => date('Y-m-d H:i:s'),
|
||
'date' => $ymd,
|
||
'subject' => $subject,
|
||
];
|
||
|
||
switch ($consequence) {
|
||
case 'dismissal':
|
||
Events::trigger('attendance.dismissal', $payload);
|
||
break;
|
||
case 'final_warning':
|
||
Events::trigger('attendance.final_warning', $payload);
|
||
break;
|
||
case 'follow_up':
|
||
default:
|
||
Events::trigger('attendance.follow_up', $payload);
|
||
break;
|
||
}
|
||
|
||
try {
|
||
if ($row && !empty($row['id'])) {
|
||
$tracking->markAsNotified((int) $row['id']);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
log_message('debug', 'Could not update notif_counter: ' . $e->getMessage());
|
||
}
|
||
|
||
return redirect()->to($returnUrl)->with('message', 'Notification queued.');
|
||
}
|
||
|
||
public function viewStudentCase($studentId)
|
||
{
|
||
$student = $this->studentModel->find($studentId);
|
||
if (!$student) {
|
||
return redirect()->to('/attendance')->with('error', 'Student not found');
|
||
}
|
||
$student['name'] = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''));
|
||
|
||
// Optional filters passed from violations list
|
||
$codeParam = strtoupper((string)($this->request->getGet('code') ?? ''));
|
||
$incidentDate = (string)($this->request->getGet('incident_date') ?? '');
|
||
$incidentDate = preg_match('/^\d{4}-\d{2}-\d{2}$/', $incidentDate) ? $incidentDate : '';
|
||
$includeNotified = filter_var($this->request->getGet('include_notified'), FILTER_VALIDATE_BOOL) === true;
|
||
$pendingOnly = filter_var($this->request->getGet('pending_only'), FILTER_VALIDATE_BOOL) === true;
|
||
|
||
// Map code -> statuses
|
||
$statusFilter = ['absent', 'late'];
|
||
if (str_starts_with($codeParam, 'ABS')) {
|
||
$statusFilter = ['absent'];
|
||
} elseif (str_starts_with($codeParam, 'LATE')) {
|
||
$statusFilter = ['late'];
|
||
} elseif (str_starts_with($codeParam, 'MIX')) {
|
||
$statusFilter = ['absent', 'late'];
|
||
}
|
||
|
||
$syParam = $this->request->getGet('school_year');
|
||
$semParam = $this->request->getGet('semester');
|
||
$schoolYearFilter = (is_string($syParam) && $syParam !== '') ? (string)$syParam : null;
|
||
$semesterFilter = (is_string($semParam) && $semParam !== '') ? (string)$semParam : null;
|
||
|
||
$schoolYear = $schoolYearFilter
|
||
?? ($incidentDate !== '' ? $this->schoolYearForDate($incidentDate) : (string)$this->schoolYear);
|
||
$semester = $semesterFilter;
|
||
// For non-violation detail pages, default to current semester when no filter provided.
|
||
if ($semester === null && $codeParam === '' && $incidentDate === '') {
|
||
$semester = (string)$this->semester;
|
||
}
|
||
[$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear ?: date('Y'));
|
||
$today = new \DateTime('today');
|
||
$start = new \DateTime($syStart . ' 00:00:00');
|
||
$rangeEnd = new \DateTime(min($syEnd, $today->format('Y-m-d')) . ' 00:00:00');
|
||
$endEx = (clone $rangeEnd)->modify('+1 day');
|
||
|
||
// Date window: use the last N active weeks ending at incidentDate (or today)
|
||
$windowWeeks = $this->windowWeeksForViolationCode($codeParam);
|
||
$endYmd = $incidentDate !== '' ? $incidentDate : date('Y-m-d');
|
||
$startYmd = $this->activeWeekWindowStartYmd($endYmd, $windowWeeks, $schoolYear, $semester);
|
||
|
||
$studentIdValues = [(int)$studentId];
|
||
$studentCodeRaw = trim((string)($student['student_code'] ?? $student['school_id'] ?? ''));
|
||
$studentCodeValues = [];
|
||
if ($studentCodeRaw !== '') {
|
||
if (is_numeric($studentCodeRaw)) {
|
||
$studentCodeNum = (int)$studentCodeRaw;
|
||
if ($studentCodeNum > 0 && $studentCodeNum !== (int)$studentId) {
|
||
$studentIdValues[] = $studentCodeNum;
|
||
}
|
||
} else {
|
||
$studentCodeValues[] = $studentCodeRaw;
|
||
}
|
||
}
|
||
$studentIdValues = array_values(array_unique(array_filter($studentIdValues, fn($v) => $v > 0)));
|
||
$studentCodeValues = array_values(array_unique(array_filter($studentCodeValues, fn($v) => $v !== '')));
|
||
|
||
$qb = $this->attendanceDataModel->builder();
|
||
$qb->select('id, student_id, class_id, class_section_id, date, status, reason, school_year, semester, is_reported, is_notified')
|
||
->groupStart()
|
||
->whereIn('student_id', $studentIdValues);
|
||
if (!empty($studentCodeValues)) {
|
||
$qb->orWhereIn('student_id', $studentCodeValues);
|
||
}
|
||
$qb->groupEnd()
|
||
->where('date >=', max($start->format('Y-m-d'), $startYmd))
|
||
->where('date <', $this->dayBounds($endYmd)[1]);
|
||
|
||
// Status filter (tolerate legacy codes like ABS/A/LATE/L)
|
||
if ($statusFilter === ['absent']) {
|
||
$qb->where("(LOWER(TRIM(status)) LIKE 'abs%' OR LOWER(TRIM(status)) = 'a')", null, false);
|
||
} elseif ($statusFilter === ['late']) {
|
||
$qb->where("(LOWER(TRIM(status)) LIKE 'late%' OR LOWER(TRIM(status)) = 'l')", null, false);
|
||
} else {
|
||
$qb->where("(LOWER(TRIM(status)) LIKE 'abs%' OR LOWER(TRIM(status)) LIKE 'late%' OR LOWER(TRIM(status)) IN ('a','l'))", null, false);
|
||
}
|
||
|
||
$qb
|
||
->orderBy('date', 'DESC');
|
||
|
||
// Pending-only filter (optional)
|
||
if ($pendingOnly) {
|
||
$this->applyUnreportedAttendanceFilter($qb);
|
||
if (!$includeNotified) {
|
||
$qb->groupStart()
|
||
->where("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))", null, false)
|
||
->groupEnd();
|
||
}
|
||
}
|
||
|
||
if ($schoolYearFilter !== null) {
|
||
$qb->where('school_year', $schoolYearFilter);
|
||
}
|
||
if ($semesterFilter !== null) {
|
||
$qb->where('semester', $semesterFilter);
|
||
}
|
||
|
||
$attendanceRows = $qb->get()->getResultArray();
|
||
|
||
$data = [
|
||
'title' => 'Attendance History - ' . $student['name'],
|
||
'student' => $student,
|
||
'attendance' => $attendanceRows,
|
||
'violation_code' => $codeParam !== '' ? $codeParam : null,
|
||
// Prefer the incident date passed from the violation list; otherwise fall back to
|
||
// the newest attendance row in the window so the details page always shows a date.
|
||
'violation_date' => $incidentDate,
|
||
'violation_notified' => null,
|
||
'show_violation_summary' => true,
|
||
];
|
||
|
||
if ($data['violation_date'] === '' && !empty($data['attendance'][0]['date'])) {
|
||
$data['violation_date'] = substr((string)$data['attendance'][0]['date'], 0, 10);
|
||
}
|
||
|
||
if (!empty($data['attendance'][0]['is_notified'])) {
|
||
$data['violation_notified'] = $data['attendance'][0]['is_notified'];
|
||
}
|
||
|
||
// Avoid duplicating the violation summary row when the attendance list already
|
||
// contains the same incident date.
|
||
if ($data['violation_date'] !== '') {
|
||
foreach ($attendanceRows as $row) {
|
||
$ymd = substr((string)($row['date'] ?? ''), 0, 10);
|
||
if ($ymd === $data['violation_date']) {
|
||
$data['show_violation_summary'] = false;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return view('attendance/view', $data);
|
||
}
|
||
|
||
private function dayBounds(string $ymd): array
|
||
{
|
||
$d = substr($ymd, 0, 10);
|
||
return [
|
||
$d . ' 00:00:00',
|
||
date('Y-m-d', strtotime($d . ' +1 day')) . ' 00:00:00'
|
||
];
|
||
}
|
||
|
||
private function schoolYearForDate(string $ymd): string
|
||
{
|
||
$ts = strtotime($ymd);
|
||
if ($ts === false) {
|
||
return (string) $this->schoolYear;
|
||
}
|
||
$y = (int) date('Y', $ts);
|
||
$m = (int) date('n', $ts);
|
||
$startY = ($m >= 8) ? $y : $y - 1;
|
||
return sprintf('%d-%d', $startY, $startY + 1);
|
||
}
|
||
|
||
protected function getPrimaryParentForStudent(int $studentId): ?array
|
||
{
|
||
$row = $this->db->table('students s')
|
||
->select('u.id AS user_id, u.email, u.cellphone, CONCAT(u.firstname, " ", u.lastname) AS parent_name', false)
|
||
->join('users u', 'u.id = s.parent_id', 'left')
|
||
->where('s.id', $studentId)
|
||
->get()->getRowArray();
|
||
|
||
if (!$row || empty($row['user_id'])) return null;
|
||
|
||
return [
|
||
'user_id' => (int) $row['user_id'],
|
||
'email' => (string) ($row['email'] ?? ''),
|
||
'parent_name' => (string) ($row['parent_name'] ?? ''),
|
||
'phone' => $row['cellphone'] ?? null,
|
||
];
|
||
}
|
||
|
||
protected function getSecondaryParentForStudent(int $studentId): ?array
|
||
{
|
||
try {
|
||
$stu = $this->db->table('students')
|
||
->select('id, parent_id, school_year, semester')
|
||
->where('id', $studentId)
|
||
->get()->getRowArray();
|
||
|
||
if (!$stu) return null;
|
||
$primaryId = (int)($stu['parent_id'] ?? 0);
|
||
$schoolYear = (string)($stu['school_year'] ?? '');
|
||
|
||
$pb = $this->db->table('parents')->where('firstparent_id', $primaryId);
|
||
if ($schoolYear !== '') {
|
||
$pb->where('school_year', $schoolYear);
|
||
}
|
||
$pRow = $pb->orderBy('updated_at', 'DESC')->limit(1)->get()->getRowArray();
|
||
if (!$pRow) return null;
|
||
|
||
$secondId = (int)($pRow['secondparent_id'] ?? 0);
|
||
if ($secondId > 0) {
|
||
$u2 = $this->db->table('users')
|
||
->select('id, firstname, lastname, email, cellphone')
|
||
->where('id', $secondId)
|
||
->get()->getRowArray();
|
||
if ($u2 && !empty($u2['email'])) {
|
||
return [
|
||
'user_id' => (int)$u2['id'],
|
||
'email' => (string)$u2['email'],
|
||
'parent_name' => trim(($u2['firstname'] ?? '') . ' ' . ($u2['lastname'] ?? '')),
|
||
'phone' => (string)($u2['cellphone'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
|
||
// Fallback to plain fields on parents row
|
||
$fallbackEmail = (string)($pRow['secondparent_email'] ?? '');
|
||
$fallbackPhone = (string)($pRow['secondparent_phone'] ?? '');
|
||
$fallbackFirst = (string)($pRow['secondparent_firstname'] ?? '');
|
||
$fallbackLast = (string)($pRow['secondparent_lastname'] ?? '');
|
||
|
||
if ($fallbackEmail || $fallbackPhone || $fallbackFirst || $fallbackLast) {
|
||
return [
|
||
'user_id' => $secondId ?: null,
|
||
'email' => $fallbackEmail,
|
||
'parent_name' => trim(($fallbackFirst . ' ' . $fallbackLast)) ?: null,
|
||
'phone' => $fallbackPhone,
|
||
];
|
||
}
|
||
} catch (\Throwable $e) {
|
||
log_message('error', 'getSecondaryParentForStudent() failed: ' . $e->getMessage());
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// RULES ENGINE (Absence/Late) — uses ONLY non-reported rows
|
||
// ------------------------------------------------------------
|
||
private function computeViolations(
|
||
array $students,
|
||
array $attendanceData,
|
||
?string $schoolYear = null,
|
||
?string $semester = null,
|
||
?array $weekRowsFallback = null
|
||
): array {
|
||
$this->debugCompute = ['active_weeks' => 0, 'by_student' => 0];
|
||
$schoolYear = $schoolYear ?: $this->schoolYear;
|
||
$semester = ($semester !== null && $semester !== '') ? (string)$semester : null;
|
||
|
||
$studentCodeToId = [];
|
||
foreach ($students as $stu) {
|
||
$code = trim((string)($stu['student_code'] ?? $stu['school_id'] ?? ''));
|
||
if ($code !== '') {
|
||
$studentCodeToId[$code] = (int)($stu['id'] ?? 0);
|
||
}
|
||
}
|
||
|
||
$byStudent = [];
|
||
foreach ($attendanceData as $r) {
|
||
$rawSid = $r['student_id'] ?? null;
|
||
if (is_numeric($rawSid)) {
|
||
$sid = (int)$rawSid;
|
||
} else {
|
||
$sid = $studentCodeToId[trim((string)$rawSid)] ?? 0;
|
||
}
|
||
if ($sid <= 0) continue;
|
||
$ymd = substr((string)($r['date'] ?? ''), 0, 10);
|
||
$stat = strtolower(trim((string)$r['status']));
|
||
|
||
if (!in_array($stat, ['absent', 'late'], true)) continue;
|
||
|
||
$byStudent[$sid][$stat][] = $ymd;
|
||
}
|
||
$this->debugCompute['by_student'] = count($byStudent);
|
||
|
||
[$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear);
|
||
// Only trust fallback rows for "active weeks" if they include non-violation statuses.
|
||
$weekRowsSource = null;
|
||
if (!empty($weekRowsFallback)) {
|
||
foreach ($weekRowsFallback as $r) {
|
||
$st = strtolower(trim((string)($r['status'] ?? '')));
|
||
if ($st !== '' && !in_array($st, ['absent', 'late'], true)) {
|
||
$weekRowsSource = $weekRowsFallback;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
$activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $syEnd, $schoolYear, $semester, $weekRowsSource);
|
||
$this->debugCompute['active_weeks'] = count($activeWeekKeys);
|
||
if (empty($activeWeekKeys)) {
|
||
log_message('debug', 'computeViolations: no active weeks', [
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
'rows' => count($attendanceData),
|
||
]);
|
||
return [];
|
||
}
|
||
|
||
$weekIndex = array_flip($activeWeekKeys);
|
||
$currentWeekIdx = count($activeWeekKeys) - 1;
|
||
// Use only the last 5 active weeks for violation calculations
|
||
$windowStartIdx = max(0, $currentWeekIdx - 4);
|
||
$requiredWeeksCount = $currentWeekIdx - $windowStartIdx + 1;
|
||
// Keep dates that fall within the last 5 active weeks
|
||
$keepLastWeeks = function (array $dates) use ($weekIndex, $windowStartIdx, $currentWeekIdx): array {
|
||
$out = [];
|
||
foreach ($dates as $d) {
|
||
$key = date('o-\WW', strtotime($d));
|
||
if (isset($weekIndex[$key])) {
|
||
$idx = $weekIndex[$key];
|
||
$ymd = substr((string)$d, 0, 10);
|
||
if ($idx >= $windowStartIdx && $idx <= $currentWeekIdx) {
|
||
$out[] = $ymd;
|
||
}
|
||
}
|
||
}
|
||
$out = array_values(array_unique($out));
|
||
rsort($out);
|
||
return $out;
|
||
};
|
||
|
||
$out = [];
|
||
|
||
// Prefetch class/grade (class section) labels for all students to avoid N+1 queries
|
||
$classByStudent = [];
|
||
try {
|
||
$studentIdsForClass = array_values(array_unique(array_map(
|
||
static fn($s) => (int)($s['id'] ?? 0),
|
||
$students
|
||
)));
|
||
|
||
if (!empty($studentIdsForClass)) {
|
||
$rows = $this->db->table('student_class sc')
|
||
->select('sc.student_id, sc.class_section_id, cs.class_section_name, cs.class_id, c.class_name')
|
||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||
->join('classes c', 'c.id = cs.class_id', 'left')
|
||
->whereIn('sc.student_id', $studentIdsForClass)
|
||
->where('sc.school_year', $schoolYear)
|
||
->orderBy('sc.id', 'DESC') // latest assignment first
|
||
->get()->getResultArray();
|
||
|
||
// Keep the latest row per student_id
|
||
foreach ($rows as $row) {
|
||
$sid = (int)($row['student_id'] ?? 0);
|
||
if ($sid <= 0) continue;
|
||
if (!isset($classByStudent[$sid])) {
|
||
$classByStudent[$sid] = [
|
||
'class_section_id' => $row['class_section_id'] ?? null,
|
||
'class_section_name' => (string)($row['class_section_name'] ?? ''),
|
||
'class_id' => $row['class_id'] ?? null,
|
||
'class_name' => (string)($row['class_name'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// If anything goes wrong, fail gracefully without class labels
|
||
log_message('debug', 'computeViolations(): class prefetch failed: ' . $e->getMessage());
|
||
}
|
||
|
||
$absCountLast5 = 0;
|
||
$lateCountLast5 = 0;
|
||
|
||
foreach ($students as $stu) {
|
||
$sid = (int) ($stu['id'] ?? 0);
|
||
$name = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? ''));
|
||
|
||
$absDatesAll = array_values(array_unique($byStudent[$sid]['absent'] ?? []));
|
||
$lateDatesAll = array_values(array_unique($byStudent[$sid]['late'] ?? []));
|
||
|
||
$absDates = $keepLastWeeks($absDatesAll);
|
||
$lateDates = $keepLastWeeks($lateDatesAll);
|
||
|
||
if (empty($absDates) && empty($lateDates)) continue;
|
||
|
||
$absWeekIdx = $this->datesToWeekIndices($absDates, $weekIndex);
|
||
$lateWeekIdx = $this->datesToWeekIndices($lateDates, $weekIndex);
|
||
$absCountLast5 += count($absDates);
|
||
$lateCountLast5 += count($lateDates);
|
||
|
||
$absenceViolation = null;
|
||
if (!empty($absDates)) {
|
||
$lastAbsDate = $absDates[0];
|
||
|
||
$A2row = $this->hasNConsecutiveItems($absDates, 2, 7);
|
||
$A3row = $this->hasNConsecutiveItems($absDates, 3, 7);
|
||
$A4row = $this->hasNConsecutiveItems($absDates, 4, 7);
|
||
|
||
$A_in3w2 = $this->hasNInWActiveWeeks($absWeekIdx, 2, 3);
|
||
$A_in4w3 = $this->hasNInWActiveWeeks($absWeekIdx, 3, 4);
|
||
$A_in5w4 = $this->hasNInWActiveWeeks($absWeekIdx, 4, 5);
|
||
|
||
if ($A4row || $A_in5w4) {
|
||
$absenceViolation = [
|
||
'type' => 'absence',
|
||
'code' => 'ABS_4',
|
||
'severity' => 4,
|
||
'action' => 'expel_notify',
|
||
'color' => '#ff4d4f',
|
||
'title' => '4 Absences (in a row or within last 5 active weeks)',
|
||
'description' => 'Notify team to inform parent about expulsion and send email.',
|
||
'last_date' => $lastAbsDate,
|
||
];
|
||
} elseif ($A3row || $A_in4w3) {
|
||
$absenceViolation = [
|
||
'type' => 'absence',
|
||
'code' => 'ABS_3',
|
||
'severity' => 3,
|
||
'action' => 'team_notify',
|
||
'color' => '#fa8c16',
|
||
'title' => '3 Absences (in a row or within last 4 active weeks)',
|
||
'description' => 'Team calls parent and sends email.',
|
||
'last_date' => $lastAbsDate,
|
||
];
|
||
} elseif ($A2row || $A_in3w2) {
|
||
$absenceViolation = [
|
||
'type' => 'absence',
|
||
'code' => 'ABS_2',
|
||
'severity' => 2,
|
||
'action' => 'team_notify',
|
||
'color' => '#fadb14',
|
||
'title' => '2 Absences (in a row or within last 3 active weeks)',
|
||
'description' => 'Team calls parent and sends email.',
|
||
'last_date' => $lastAbsDate,
|
||
];
|
||
}
|
||
}
|
||
|
||
$lateViolation = null;
|
||
if (!empty($lateDates)) {
|
||
$lastLateDate = $lateDates[0];
|
||
|
||
$L2row = $this->hasNConsecutiveItems($lateDates, 2, 7);
|
||
$L3row = $this->hasNConsecutiveItems($lateDates, 3, 7);
|
||
$L4row = $this->hasNConsecutiveItems($lateDates, 4, 7);
|
||
|
||
$L_in3w2 = $this->hasNInWActiveWeeks($lateWeekIdx, 2, 3);
|
||
$L_in4w3 = $this->hasNInWActiveWeeks($lateWeekIdx, 3, 4);
|
||
|
||
$lateCoversAllWindowWeeks = false;
|
||
if ($requiredWeeksCount >= 4) {
|
||
$lateWeeksSet = array_flip($lateWeekIdx);
|
||
$lateCoversAllWindowWeeks = true;
|
||
for ($i = $windowStartIdx; $i <= $currentWeekIdx; $i++) {
|
||
if (!isset($lateWeeksSet[$i])) {
|
||
$lateCoversAllWindowWeeks = false;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($L4row || $lateCoversAllWindowWeeks) {
|
||
$lateViolation = [
|
||
'type' => 'late',
|
||
'code' => 'LATE_4',
|
||
'severity' => 4,
|
||
'action' => 'team_notify',
|
||
'color' => '#ff4d4f',
|
||
'title' => '4 Lates in a row OR in each of the last 4 active weeks',
|
||
'description' => 'Team calls parent and sends email.',
|
||
'last_date' => $lastLateDate,
|
||
];
|
||
} else {
|
||
$twoLatesOneAbsWithin4 = $this->twoLatesOneAbsInWWeeks($lateWeekIdx, $absWeekIdx, 4);
|
||
|
||
if ($L3row || $L_in4w3 || $twoLatesOneAbsWithin4) {
|
||
$lateViolation = [
|
||
'type' => $twoLatesOneAbsWithin4 ? 'mix' : 'late',
|
||
'code' => $twoLatesOneAbsWithin4 ? 'MIX_L2A1' : 'LATE_3',
|
||
'severity' => 3,
|
||
'action' => 'team_notify',
|
||
'color' => '#002766',
|
||
'title' => $twoLatesOneAbsWithin4
|
||
? '2 Lates + 1 Absence (within last 4 active weeks)'
|
||
: '3 Lates (in a row or within last 4 active weeks)',
|
||
'description' => 'Team calls parent and sends email.',
|
||
'last_date' => $lastLateDate,
|
||
];
|
||
} elseif ($L2row || $L_in3w2) {
|
||
$lateViolation = [
|
||
'type' => 'late',
|
||
'code' => 'LATE_2',
|
||
'severity' => 1,
|
||
'action' => 'auto_email',
|
||
'color' => '#bae7ff',
|
||
'title' => '2 Lates (in a row or within last 3 active weeks)',
|
||
'description' => 'Send automated email to parent.',
|
||
'last_date' => $lastLateDate,
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
$chosen = $this->chooseHigherSeverity($absenceViolation, $lateViolation);
|
||
|
||
if (!$chosen && count($absDates) === 1) {
|
||
$chosen = [
|
||
'type' => 'absence',
|
||
'code' => 'ABS_1',
|
||
'severity' => 1,
|
||
'action' => 'auto_email',
|
||
'color' => '#e6f7ff',
|
||
'title' => '1 Unreported Absence',
|
||
'description' => 'Send automated email to parent.',
|
||
'last_date' => $absDates[0],
|
||
];
|
||
}
|
||
|
||
if (!$chosen) continue;
|
||
|
||
$parent = $this->getPrimaryParentForStudent($sid);
|
||
|
||
// Do not skip pending violations even if a previous tracking/notification exists;
|
||
// the view should surface all current infractions.
|
||
|
||
[$start, $end] = $this->dayBounds($chosen['last_date']);
|
||
$trackingQ = $this->attendanceTrackingModel
|
||
->where('student_id', $sid)
|
||
->where('school_year', $schoolYear)
|
||
->where('date >=', $start)
|
||
->where('date <', $end)
|
||
->like('reason', $chosen['code'], 'both');
|
||
if ($semester !== null) {
|
||
$trackingQ->where('semester', $semester);
|
||
}
|
||
$tracking = $trackingQ->orderBy('date', 'DESC')->first();
|
||
|
||
$isNotified = (bool)($tracking['is_notified'] ?? 0);
|
||
$notifCnt = (int)($tracking['notif_counter'] ?? 0);
|
||
|
||
// Resolve class/grade label: prefer class_section_name (e.g., "3-A"),
|
||
// fall back to classes.class_name if available.
|
||
$cls = $classByStudent[$sid] ?? [];
|
||
$classLabel = (string)($cls['class_section_name'] ?? '');
|
||
if ($classLabel === '' && !empty($cls['class_name'])) {
|
||
$classLabel = (string)$cls['class_name'];
|
||
}
|
||
|
||
$out[] = [
|
||
'id' => $sid,
|
||
'name' => $name,
|
||
// Provide multiple common keys so views can pick any
|
||
'class_name' => $classLabel,
|
||
'class_section_name' => (string)($cls['class_section_name'] ?? ''),
|
||
'className' => (string)($cls['class_name'] ?? ''),
|
||
'class_id' => $cls['class_id'] ?? null,
|
||
'class_section_id'=> $cls['class_section_id'] ?? null,
|
||
'parent_email' => $parent['email'] ?? '',
|
||
'parent_name' => $parent['parent_name'] ?? '',
|
||
'parent_phone' => $parent['phone'] ?? null,
|
||
|
||
'violation' => $chosen['title'],
|
||
'violation_code' => $chosen['code'],
|
||
'type' => $chosen['type'],
|
||
'severity' => $chosen['severity'],
|
||
'action' => $chosen['action'],
|
||
'color' => $chosen['color'],
|
||
'last_absence' => $absDates[0] ?? null,
|
||
'last_late' => $lateDates[0] ?? null,
|
||
'last_date' => $chosen['last_date'],
|
||
|
||
'is_notified' => $isNotified,
|
||
'notif_counter' => $notifCnt,
|
||
|
||
'absences' => $absDates,
|
||
'lates' => $lateDates,
|
||
];
|
||
}
|
||
|
||
$this->debugCompute['abs_last5'] = $absCountLast5;
|
||
$this->debugCompute['late_last5'] = $lateCountLast5;
|
||
|
||
return $out;
|
||
}
|
||
|
||
public function sendAutoEmails()
|
||
{
|
||
if (!$this->request->is('post')) {
|
||
return $this->response->setStatusCode(405)->setJSON([
|
||
'success' => false,
|
||
'message' => 'Method Not Allowed'
|
||
]);
|
||
}
|
||
|
||
$globalVariantOverride = (string)($this->request->getVar('variant') ?? '');
|
||
|
||
$classStudents = $this->studentClassModel
|
||
->active()
|
||
->where('school_year', $this->schoolYear)
|
||
->findAll();
|
||
|
||
if (empty($classStudents)) {
|
||
return $this->response->setJSON([
|
||
'success' => true,
|
||
'sent' => 0,
|
||
'skipped' => 0,
|
||
'errors' => 0,
|
||
'details' => []
|
||
]);
|
||
}
|
||
|
||
$studentIds = array_column($classStudents, 'student_id');
|
||
$students = $this->studentModel
|
||
->whereIn('id', $studentIds)
|
||
->where('is_active', 1)
|
||
->findAll();
|
||
|
||
$attendanceQuery = $this->attendanceDataModel
|
||
->whereIn('student_id', $studentIds)
|
||
->where('school_year', $this->schoolYear)
|
||
->where('date >=', (new \DateTime('today'))->modify('-5 weeks')->format('Y-m-d'));
|
||
$this->applyUnreportedAttendanceFilter($attendanceQuery);
|
||
$attendanceData = $attendanceQuery
|
||
->where("LOWER(TRIM(status)) IN ('absent','late')", null, false)
|
||
->orderBy('date', 'DESC')
|
||
->findAll();
|
||
|
||
$violations = $this->computeViolations($students, $attendanceData, $this->schoolYear, null, $attendanceData);
|
||
$toSend = array_values(array_filter($violations, fn($v) => ($v['action'] ?? '') === 'auto_email'));
|
||
|
||
$sent = 0;
|
||
$skipped = 0;
|
||
$errors = 0;
|
||
$details = [];
|
||
|
||
$getPrimaryParent = function (int $sid): array {
|
||
return $this->getPrimaryParentForStudent($sid) ?? [];
|
||
};
|
||
$buildContext = function (array $v, array $parent): array {
|
||
return [
|
||
'{{parent_name}}' => (string)($parent['parent_name'] ?? 'Parent/Guardian'),
|
||
'{{student_name}}' => (string)($v['name'] ?? ''),
|
||
'{{incident_date}}' => (string)($v['last_date'] ?? date('Y-m-d')),
|
||
'{{school_phone}}' => '978-364-0219',
|
||
'{{voicemail_phone}}' => (string)($parent['phone'] ?? '—'),
|
||
'{{class_time}}' => '10:00 AM',
|
||
];
|
||
};
|
||
$pickVariant = function (string $code) use ($globalVariantOverride): string {
|
||
if ($globalVariantOverride !== '') return $globalVariantOverride;
|
||
return match ($code) {
|
||
'ABS_2', 'ABS_2_IN3W' => 'no_answer',
|
||
default => 'default',
|
||
};
|
||
};
|
||
$renderFromTemplate = function (string $code, array $context, string $variant = 'default') {
|
||
$templateModel = $this->attendanceEmailTemplateModel ?? new \App\Models\AttendanceEmailTemplateModel();
|
||
$row = $templateModel->getTemplate($code, $variant) ?: $templateModel->getTemplate($code, 'default');
|
||
if (!$row) return null;
|
||
return [strtr($row['subject'], $context), strtr($row['body_html'], $context)];
|
||
};
|
||
|
||
foreach ($toSend as $v) {
|
||
$sid = (int)($v['id'] ?? 0);
|
||
$code = (string)($v['violation_code'] ?? $v['code'] ?? '');
|
||
$date = (string)($v['last_date'] ?? '');
|
||
$to = trim((string)($v['parent_email'] ?? ''));
|
||
$pname = (string)($v['parent_name'] ?? '');
|
||
|
||
if ($sid <= 0 || !$code || !$date || !$to) {
|
||
$errors++;
|
||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'missing data'];
|
||
continue;
|
||
}
|
||
|
||
if ($this->notificationAlreadySent($sid, $code, $date, 'email', $to)) {
|
||
$skipped++;
|
||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'duplicate'];
|
||
continue;
|
||
}
|
||
|
||
$parent = $getPrimaryParent($sid);
|
||
$context = $buildContext($v, $parent);
|
||
|
||
$variant = $pickVariant($code);
|
||
$tpl = $renderFromTemplate($code, $context, $variant);
|
||
|
||
if (!$tpl) {
|
||
$subject = match ($code) {
|
||
'ABS_1' => 'Attendance Notice: Unreported Absence',
|
||
'LATE_2' => 'Attendance Notice: Repeated Lateness',
|
||
default => 'Attendance Notice',
|
||
};
|
||
$studentName = (string)($v['name'] ?? '');
|
||
$body = "<p>Dear " . ($pname ?: 'Parent/Guardian') . "</p>"
|
||
. "<p>We'd like to inform you about your student's recent attendance:</p>"
|
||
. "<ul>"
|
||
. "<li><strong>Student:</strong> " . $studentName . "</li>"
|
||
. "<li><strong>Issue:</strong> " . (string)($v['violation'] ?? '') . "</li>"
|
||
. "<li><strong>As of:</strong> " . $date . "</li>"
|
||
. "</ul>"
|
||
. "<p>If you have any questions, please contact the school office.</p>";
|
||
} else {
|
||
[$subject, $body] = $tpl;
|
||
}
|
||
|
||
$wrapped = $this->renderWithEmailLayout($subject, $body);
|
||
|
||
$anyOk = false;
|
||
$mailer = new EmailController();
|
||
|
||
// Primary send
|
||
try {
|
||
$ok = $mailer->sendEmail($to, $subject, $wrapped);
|
||
if ($ok) {
|
||
$anyOk = true;
|
||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'sent'];
|
||
$this->logNotification($sid, $code, $date, 'email', $to, $subject, 'sent', 'OK');
|
||
} else {
|
||
$errors++;
|
||
$this->logNotification($sid, $code, $date, 'email', $to, $subject, 'failed', 'sendEmail returned false');
|
||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'failed'];
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$errors++;
|
||
$this->logNotification($sid, $code, $date, 'email', $to, $subject, 'failed', $e->getMessage());
|
||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'error: ' . $e->getMessage()];
|
||
}
|
||
|
||
// Secondary (CC-like) send: send separate email to second parent if available
|
||
try {
|
||
$sec = $this->getSecondaryParentForStudent($sid);
|
||
$to2 = trim((string)($sec['email'] ?? ''));
|
||
if ($to2 !== '' && strcasecmp($to2, $to) !== 0) {
|
||
if ($this->notificationAlreadySent($sid, $code, $date, 'email', $to2)) {
|
||
$skipped++;
|
||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'duplicate_cc'];
|
||
} else {
|
||
$ok2 = $mailer->sendEmail($to2, $subject, $wrapped);
|
||
if ($ok2) {
|
||
$anyOk = true;
|
||
$this->logNotification($sid, $code, $date, 'email', $to2, $subject, 'sent', 'OK');
|
||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_sent'];
|
||
} else {
|
||
$errors++;
|
||
$this->logNotification($sid, $code, $date, 'email', $to2, $subject, 'failed', 'sendEmail returned false');
|
||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_failed'];
|
||
}
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$errors++;
|
||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_error: ' . $e->getMessage()];
|
||
}
|
||
|
||
// Mark tracking notified once per student/code/date if any send succeeded
|
||
if ($anyOk) {
|
||
$sent++;
|
||
$this->attendanceTrackingModel->markRuleAsNotified(
|
||
$sid,
|
||
$code,
|
||
$date,
|
||
$this->semester,
|
||
$this->schoolYear
|
||
);
|
||
// Mark all dates involved in this violation as reported+notified
|
||
$allDates = [];
|
||
$absDates = array_map(fn($d) => substr((string)$d, 0, 10), (array)($v['absences'] ?? []));
|
||
$lateDates= array_map(fn($d) => substr((string)$d, 0, 10), (array)($v['lates'] ?? []));
|
||
if (!empty($absDates)) $allDates = array_merge($allDates, $absDates);
|
||
if (!empty($lateDates)) $allDates = array_merge($allDates, $lateDates);
|
||
if (empty($allDates)) {
|
||
$allDates[] = substr($date, 0, 10);
|
||
}
|
||
$this->attendanceDataModel->markReportedAndNotified($sid, $allDates, $this->semester, $this->schoolYear);
|
||
}
|
||
}
|
||
|
||
return $this->response->setJSON([
|
||
'success' => true,
|
||
'sent' => $sent,
|
||
'skipped' => $skipped,
|
||
'errors' => $errors,
|
||
'details' => $details,
|
||
]);
|
||
}
|
||
|
||
|
||
/**
|
||
* Wraps an email body with the app/Views/layout/email_layout.php view if present.
|
||
* Falls back to raw body if the view is missing to avoid crashing.
|
||
*/
|
||
private function renderWithEmailLayout(string $subject, string $bodyHtml): string
|
||
{
|
||
return view('emails/_wrap_layout', [
|
||
'title' => $subject,
|
||
'body_html' => $bodyHtml,
|
||
], ['saveData' => false]);
|
||
}
|
||
|
||
private function chooseHigherSeverity(?array $a, ?array $b): ?array
|
||
{
|
||
if ($a && $b) {
|
||
if ($a['severity'] === $b['severity']) {
|
||
$prio = ['expel_notify' => 3, 'team_notify' => 2, 'auto_email' => 1];
|
||
return ($prio[$a['action']] ?? 0) >= ($prio[$b['action']] ?? 0) ? $a : $b;
|
||
}
|
||
return $a['severity'] > $b['severity'] ? $a : $b;
|
||
}
|
||
return $a ?: $b;
|
||
}
|
||
|
||
// -------------- Helpers (dates & weeks) --------------
|
||
|
||
private function attendanceReportedColumns(): array
|
||
{
|
||
if ($this->attendanceReportedColumns !== null) {
|
||
return $this->attendanceReportedColumns;
|
||
}
|
||
$this->attendanceReportedColumns = [
|
||
'is_reported' => $this->db->fieldExists('is_reported', 'attendance_data'),
|
||
'reported' => $this->db->fieldExists('reported', 'attendance_data'),
|
||
];
|
||
return $this->attendanceReportedColumns;
|
||
}
|
||
|
||
private function applyUnreportedAttendanceFilter($qb): void
|
||
{
|
||
$cols = $this->attendanceReportedColumns();
|
||
if (!empty($cols['is_reported'])) {
|
||
$qb->where("(LOWER(TRIM(COALESCE(is_reported, ''))) NOT IN ('yes','1','true','y','on'))", null, false);
|
||
}
|
||
if (!empty($cols['reported'])) {
|
||
$qb->where("(LOWER(TRIM(COALESCE(reported, ''))) NOT IN ('yes','1','true','y','on'))", null, false);
|
||
}
|
||
// Treat parent-reported rows as reported even if flags weren't updated
|
||
$qb->where("(LOWER(COALESCE(reason, '')) NOT LIKE '%parent-reported%')", null, false)
|
||
->where("(LOWER(COALESCE(reason, '')) NOT LIKE '%parent reported%')", null, false);
|
||
if ($this->db->tableExists('parent_attendance_reports')) {
|
||
$qb->where("NOT EXISTS (
|
||
SELECT 1 FROM parent_attendance_reports par
|
||
WHERE par.student_id = attendance_data.student_id
|
||
AND DATE(par.report_date) = DATE(attendance_data.`date`)
|
||
AND par.type IN ('absent','late')
|
||
)", null, false);
|
||
}
|
||
}
|
||
|
||
private function getActiveWeeksFromAttendanceData(
|
||
string $startYmd,
|
||
string $endYmd,
|
||
?string $schoolYear = null,
|
||
?string $semester = null,
|
||
?array $weekRowsFallback = null
|
||
): array {
|
||
$schoolYear = $schoolYear ?: $this->schoolYear;
|
||
$semester = ($semester !== null && $semester !== '') ? (string)$semester : null;
|
||
$weeks = [];
|
||
|
||
// Prefer explicit rows passed in (all statuses) to define active weeks for the window
|
||
if (!empty($weekRowsFallback)) {
|
||
foreach ($weekRowsFallback as $r) {
|
||
$d = $r['date'] ?? null;
|
||
if (!$d) continue;
|
||
$ts = strtotime($d);
|
||
if ($ts === false) continue;
|
||
$ymd = date('Y-m-d', $ts);
|
||
if ($ymd < $startYmd || $ymd > $endYmd) continue;
|
||
$wk = date('o-\WW', $ts);
|
||
$weeks[$wk] = true;
|
||
}
|
||
}
|
||
|
||
// If still empty, fall back to open dates from attendance_data table
|
||
if (empty($weeks)) {
|
||
$builder = $this->db->table('attendance_data')
|
||
->select('DATE(`date`) AS ymd')
|
||
->where('school_year', $schoolYear)
|
||
->where('DATE(`date`) >=', $startYmd)
|
||
->where('DATE(`date`) <=', $endYmd)
|
||
->groupStart()
|
||
->where('reason', null)
|
||
->orGroupStart()
|
||
->notLike('reason', 'break')
|
||
->notLike('reason', 'cancel')
|
||
->groupEnd()
|
||
->groupEnd()
|
||
->groupBy('DATE(`date`)')
|
||
->orderBy('ymd', 'ASC');
|
||
if ($semester !== null) {
|
||
$builder->where('semester', $semester);
|
||
}
|
||
$rows = $builder->get()->getResultArray();
|
||
|
||
foreach ($rows as $r) {
|
||
$wk = date('o-\WW', strtotime($r['ymd']));
|
||
$weeks[$wk] = true;
|
||
}
|
||
}
|
||
|
||
$keys = array_keys($weeks);
|
||
sort($keys, SORT_NATURAL);
|
||
return $keys;
|
||
}
|
||
|
||
private function datesToWeekIndices(array $dates, array $weekIndex): array
|
||
{
|
||
$set = [];
|
||
foreach ($dates as $ymd) {
|
||
$wk = date('o-\WW', strtotime($ymd));
|
||
if (isset($weekIndex[$wk])) {
|
||
$set[$weekIndex[$wk]] = true;
|
||
}
|
||
}
|
||
$idx = array_keys($set);
|
||
sort($idx);
|
||
return $idx;
|
||
}
|
||
|
||
private function windowWeeksForViolationCode(string $code): int
|
||
{
|
||
$code = strtoupper(trim($code));
|
||
if ($code === '') {
|
||
return 5;
|
||
}
|
||
if (str_starts_with($code, 'ABS_2') || str_starts_with($code, 'LATE_2')) {
|
||
return 3;
|
||
}
|
||
if (str_starts_with($code, 'ABS_3') || str_starts_with($code, 'LATE_3') || str_starts_with($code, 'MIX')) {
|
||
return 4;
|
||
}
|
||
if (str_starts_with($code, 'LATE_4')) {
|
||
return 4;
|
||
}
|
||
if (str_starts_with($code, 'ABS_4')) {
|
||
return 5;
|
||
}
|
||
return 5;
|
||
}
|
||
|
||
private function isoWeekStartYmd(string $weekKey): string
|
||
{
|
||
if (preg_match('/^(\d{4})-W(\d{2})$/', $weekKey, $m)) {
|
||
return date('Y-m-d', strtotime($m[1] . '-W' . $m[2] . '-1'));
|
||
}
|
||
return date('Y-m-d');
|
||
}
|
||
|
||
private function activeWeekWindowStartYmd(
|
||
string $endYmd,
|
||
int $windowWeeks,
|
||
string $schoolYear,
|
||
?string $semester
|
||
): string {
|
||
$windowWeeks = max(1, $windowWeeks);
|
||
[$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear ?: $this->schoolYear);
|
||
$endBound = $endYmd;
|
||
if ($syEnd !== '' && $endBound > $syEnd) {
|
||
$endBound = $syEnd;
|
||
}
|
||
|
||
$activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $endBound, $schoolYear, $semester, null);
|
||
if (empty($activeWeekKeys)) {
|
||
$days = ($windowWeeks * 7) - 1;
|
||
return date('Y-m-d', strtotime($endBound . ' -' . $days . ' days'));
|
||
}
|
||
|
||
$activeWeekKeys = array_values($activeWeekKeys);
|
||
$weekIndex = array_flip($activeWeekKeys);
|
||
$endWeekKey = date('o-\WW', strtotime($endBound));
|
||
if (!isset($weekIndex[$endWeekKey])) {
|
||
$endWeekKey = null;
|
||
for ($i = count($activeWeekKeys) - 1; $i >= 0; $i--) {
|
||
$wkStart = $this->isoWeekStartYmd($activeWeekKeys[$i]);
|
||
if ($wkStart <= $endBound) {
|
||
$endWeekKey = $activeWeekKeys[$i];
|
||
break;
|
||
}
|
||
}
|
||
if ($endWeekKey === null) {
|
||
$endWeekKey = end($activeWeekKeys);
|
||
}
|
||
}
|
||
|
||
$currentIdx = $weekIndex[$endWeekKey] ?? (count($activeWeekKeys) - 1);
|
||
$windowStartIdx = max(0, $currentIdx - ($windowWeeks - 1));
|
||
$startWeekKey = $activeWeekKeys[$windowStartIdx] ?? $activeWeekKeys[0];
|
||
return $this->isoWeekStartYmd($startWeekKey);
|
||
}
|
||
|
||
private function hasNConsecutiveItems(array $datesDesc, int $n, int $daysApart): bool
|
||
{
|
||
$N = count($datesDesc);
|
||
if ($N < $n) return false;
|
||
|
||
$dates = $datesDesc;
|
||
sort($dates); // asc
|
||
$run = 1;
|
||
for ($i = 1; $i < $N; $i++) {
|
||
$prev = new \DateTime($dates[$i - 1]);
|
||
$curr = new \DateTime($dates[$i]);
|
||
$diff = (int)$prev->diff($curr)->format('%a');
|
||
if ($diff === $daysApart) {
|
||
$run++;
|
||
if ($run >= $n) return true;
|
||
} else {
|
||
$run = 1;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private function hasNInWActiveWeeks(array $weekIdx, int $n, int $w): bool
|
||
{
|
||
$N = count($weekIdx);
|
||
if ($N < $n) return false;
|
||
|
||
$l = 0;
|
||
for ($r = 0; $r < $N; $r++) {
|
||
while ($weekIdx[$r] - $weekIdx[$l] > ($w - 1)) {
|
||
$l++;
|
||
}
|
||
if (($r - $l + 1) >= $n) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private function twoLatesOneAbsInWWeeks(array $lateIdx, array $absIdx, int $w): bool
|
||
{
|
||
if (count($lateIdx) < 2 || count($absIdx) < 1) return false;
|
||
|
||
$L = $lateIdx;
|
||
$A = $absIdx;
|
||
$iL1 = 0;
|
||
$iL2 = 1;
|
||
$iA = 0;
|
||
|
||
while ($iL2 < count($L)) {
|
||
$windowStart = $L[$iL1];
|
||
$windowEnd = $windowStart + ($w - 1);
|
||
|
||
if ($L[$iL2] > $windowEnd) {
|
||
$iL1++;
|
||
$iL2 = $iL1 + 1;
|
||
continue;
|
||
}
|
||
|
||
while ($iA < count($A) && $A[$iA] < $windowStart) $iA++;
|
||
if ($iA < count($A) && $A[$iA] <= $windowEnd) return true;
|
||
|
||
$iL2++;
|
||
if ($iL2 >= count($L)) {
|
||
$iL1++;
|
||
$iL2 = $iL1 + 1;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* True only if the student has a late in EVERY active week from week 0 → currentWeekIdx.
|
||
* Also requires at least 4 active weeks to have passed (guards single-week false positives).
|
||
*/
|
||
private function latesCoverAllPassedWeeks(array $lateIdx, int $currentWeekIdx, int $minWeeks = 4): bool
|
||
{
|
||
if ($currentWeekIdx < 0) return false; // no active weeks yet
|
||
$totalWeeksPassed = $currentWeekIdx + 1; // weeks are 0-based
|
||
if ($totalWeeksPassed < $minWeeks) return false; // don’t escalate before 4 weeks total
|
||
if (count($lateIdx) < $totalWeeksPassed) return false; // not enough late weeks to cover all
|
||
|
||
$set = array_flip($lateIdx); // fast lookup
|
||
for ($i = 0; $i <= $currentWeekIdx; $i++) {
|
||
if (!isset($set[$i])) return false; // any gap breaks the condition
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private function alreadyReported(
|
||
int $studentId,
|
||
string $code,
|
||
string $lastDate,
|
||
?string $semester = null,
|
||
?string $schoolYear = null
|
||
): bool {
|
||
$semester = ($semester !== null && $semester !== '') ? (string)$semester : null;
|
||
$schoolYear = $schoolYear ?: $this->schoolYear;
|
||
try {
|
||
$bounds = $this->dayBounds($lastDate);
|
||
$start = $bounds[0];
|
||
$end = $bounds[1];
|
||
|
||
$q = $this->attendanceTrackingModel
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $schoolYear)
|
||
->where('date >=', $start)
|
||
->where('date <', $end)
|
||
->like('reason', $code, 'both');
|
||
if ($semester !== null) {
|
||
$q->where('semester', $semester);
|
||
}
|
||
|
||
$row = $q->orderBy('date', 'DESC')->first();
|
||
|
||
if (!$row) {
|
||
$q2 = $this->attendanceTrackingModel
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $schoolYear)
|
||
->where('date >=', $start)
|
||
->where('date <', $end);
|
||
if ($semester !== null) {
|
||
$q2->where('semester', $semester);
|
||
}
|
||
$row = $q2->orderBy('date', 'DESC')->first();
|
||
}
|
||
|
||
return $row ? (bool) ($row['is_notified'] ?? 0) : false;
|
||
} catch (\Throwable $e) {
|
||
log_message('debug', 'alreadyReported() error: ' . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public function notifiedViolationsView()
|
||
{
|
||
$syParam = $this->request->getGet('school_year');
|
||
$semParam = $this->request->getGet('semester');
|
||
$schoolYear = (is_string($syParam) && $syParam !== '') ? (string)$syParam : (string)$this->schoolYear;
|
||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
||
|
||
// 1️⃣ Get all notified records for this term from attendance_tracking
|
||
$trackingData = $this->attendanceTrackingModel
|
||
->where('school_year', $schoolYear)
|
||
->where('semester', $semester)
|
||
->groupStart()
|
||
->where('is_notified', 1)
|
||
->orWhere('is_notified', '1')
|
||
->orWhere("LOWER(TRIM(COALESCE(is_notified, ''))) =", 'yes')
|
||
->groupEnd()
|
||
->orderBy('date', 'DESC')
|
||
->findAll();
|
||
|
||
// 🔄 Fallback: if none found for the configured term, auto-switch to the latest notified term
|
||
if (empty($trackingData)) {
|
||
$latest = $this->attendanceTrackingModel
|
||
->select('school_year, semester')
|
||
->groupStart()
|
||
->where('is_notified', 1)
|
||
->orWhere('is_notified', '1')
|
||
->orWhere("LOWER(TRIM(COALESCE(is_notified, ''))) =", 'yes')
|
||
->groupEnd()
|
||
->orderBy('date', 'DESC')
|
||
->orderBy('id', 'DESC')
|
||
->first();
|
||
|
||
if ($latest) {
|
||
$schoolYear = (string)($latest['school_year'] ?? $schoolYear);
|
||
$semester = (string)($latest['semester'] ?? $semester);
|
||
|
||
$trackingData = $this->attendanceTrackingModel
|
||
->where('school_year', $schoolYear)
|
||
->where('semester', $semester)
|
||
->groupStart()
|
||
->where('is_notified', 1)
|
||
->orWhere('is_notified', '1')
|
||
->orWhere("LOWER(TRIM(COALESCE(is_notified, ''))) =", 'yes')
|
||
->groupEnd()
|
||
->orderBy('date', 'DESC')
|
||
->findAll();
|
||
}
|
||
}
|
||
|
||
// If no notified entries, show empty view
|
||
if (empty($trackingData)) {
|
||
return view('attendance/violations_notified', [
|
||
'title' => 'Notified Attendance Violations',
|
||
'students' => [],
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
'show_actions' => false,
|
||
]);
|
||
}
|
||
|
||
// 2️⃣ Collect unique student IDs from tracking table
|
||
$studentIds = array_unique(array_column($trackingData, 'student_id'));
|
||
|
||
// 3️⃣ Get student records for those IDs
|
||
$students = $this->studentModel
|
||
->whereIn('id', $studentIds)
|
||
->where('is_active', 1)
|
||
->findAll();
|
||
|
||
$activeIdSet = array_flip(array_map(
|
||
static fn($row) => (int)($row['id'] ?? 0),
|
||
$students
|
||
));
|
||
$trackingData = array_values(array_filter(
|
||
$trackingData,
|
||
static fn($row) => isset($activeIdSet[(int)($row['student_id'] ?? 0)])
|
||
));
|
||
$studentIds = array_keys($activeIdSet);
|
||
|
||
if (empty($trackingData)) {
|
||
return view('attendance/violations_notified', [
|
||
'title' => 'Notified Attendance Violations',
|
||
'students' => [],
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
'show_actions' => false,
|
||
]);
|
||
}
|
||
|
||
// 4️⃣ Build a map for faster merge
|
||
$studentMap = [];
|
||
foreach ($students as $s) {
|
||
$studentMap[$s['id']] = $s;
|
||
}
|
||
|
||
// 4.5️⃣ Prefetch latest class/grade label per student for the selected school year
|
||
$classByStudent = [];
|
||
try {
|
||
$rows = $this->db->table('student_class sc')
|
||
->select('sc.student_id, sc.class_section_id, cs.class_section_name, cs.class_id, c.class_name')
|
||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||
->join('classes c', 'c.id = cs.class_id', 'left')
|
||
->whereIn('sc.student_id', $studentIds)
|
||
->where('sc.school_year', $schoolYear)
|
||
->orderBy('sc.id', 'DESC') // latest assignment first
|
||
->get()->getResultArray();
|
||
|
||
foreach ($rows as $row) {
|
||
$sid = (int)($row['student_id'] ?? 0);
|
||
if ($sid <= 0) continue;
|
||
if (!isset($classByStudent[$sid])) {
|
||
$classByStudent[$sid] = [
|
||
'class_section_id' => $row['class_section_id'] ?? null,
|
||
'class_section_name' => (string)($row['class_section_name'] ?? ''),
|
||
'class_id' => $row['class_id'] ?? null,
|
||
'class_name' => (string)($row['class_name'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// ignore enrichment failure
|
||
}
|
||
|
||
// 5️⃣ Merge tracking info with student info (and class/grade when available)
|
||
$mergedData = [];
|
||
foreach ($trackingData as $row) {
|
||
$sid = (int)($row['student_id'] ?? 0);
|
||
if (isset($studentMap[$sid])) {
|
||
$merged = array_merge($studentMap[$sid], $row);
|
||
// attach grade/class labels for view consumption
|
||
if (isset($classByStudent[$sid])) {
|
||
$merged['class_section_name'] = (string)($classByStudent[$sid]['class_section_name'] ?? '');
|
||
$merged['class_name'] = (string)($classByStudent[$sid]['class_name'] ?? '');
|
||
} else {
|
||
// fallback to registration_grade if present
|
||
if (!empty($merged['registration_grade'])) {
|
||
$merged['grade'] = (string)$merged['registration_grade'];
|
||
}
|
||
}
|
||
$mergedData[] = $merged;
|
||
} else {
|
||
// Skip rows with no active student record
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// 6️⃣ Enrich with primary parent contact so the view can display inline info
|
||
foreach ($mergedData as &$row) {
|
||
$sid = (int)($row['student_id'] ?? $row['id'] ?? 0);
|
||
if ($sid > 0) {
|
||
try {
|
||
$p = $this->getPrimaryParentForStudent($sid);
|
||
if ($p) {
|
||
$row['parent_name'] = $p['parent_name'] ?? '';
|
||
$row['parent_email'] = $p['email'] ?? '';
|
||
$row['parent_phone'] = $p['phone'] ?? '';
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// ignore enrichment failure; view will fallback
|
||
}
|
||
}
|
||
}
|
||
unset($row);
|
||
|
||
// 7️⃣ Send data to view
|
||
return view('attendance/violations_notified', [
|
||
'title' => 'Notified Attendance Violations',
|
||
'students' => $mergedData, // tracking + students + parent info
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
]);
|
||
}
|
||
|
||
private function deriveSchoolYearBounds(string $schoolYear): array
|
||
{
|
||
if (!preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m)) {
|
||
$y = (int) date('Y');
|
||
$startY = (int) (date('n') >= 8 ? $y : $y - 1);
|
||
return [sprintf('%d-08-01', $startY), sprintf('%d-07-31', $startY + 1)];
|
||
}
|
||
$startY = (int) $m[1];
|
||
$endY = (int) $m[2];
|
||
return [sprintf('%d-08-01', $startY), sprintf('%d-07-31', $endY)];
|
||
}
|
||
|
||
private function buildTemplateContext(array $v, ?array $parent = null): array
|
||
{
|
||
$studentName = (string)($v['name'] ?? '');
|
||
$incident = (string)($v['last_date'] ?? date('Y-m-d'));
|
||
$p = $parent ?: $this->getPrimaryParentForStudent((int)($v['id'] ?? 0)) ?? [];
|
||
|
||
return [
|
||
'{{parent_name}}' => (string)($p['parent_name'] ?? 'Parent/Guardian'),
|
||
'{{student_name}}' => $studentName,
|
||
'{{incident_date}}' => $incident,
|
||
'{{school_phone}}' => '978-364-0219',
|
||
'{{voicemail_phone}}' => (string)($p['phone'] ?? '—'),
|
||
'{{class_time}}' => '10:00 AM',
|
||
];
|
||
}
|
||
|
||
private function renderTemplate(string $code, string $variant, array $context): ?array
|
||
{
|
||
$row = $this->attendanceEmailTemplateModel->getTemplate($code, $variant);
|
||
if (!$row) {
|
||
$row = $this->attendanceEmailTemplateModel->getTemplate($code, 'default');
|
||
}
|
||
if (!$row) return null;
|
||
|
||
// CI4 won't interpolate placeholders automatically
|
||
log_message('debug', 'Email template resolved: code=' . $code . ' variant=' . ($row['variant'] ?? 'unknown'));
|
||
|
||
$subject = strtr($row['subject'], $context);
|
||
$body = strtr($row['body_html'], $context);
|
||
return [$subject, $body];
|
||
}
|
||
|
||
private function buildFallbackTemplate(string $code, string $variant, array $context): array
|
||
{
|
||
$studentName = (string) ($context['{{student_name}}'] ?? 'the student');
|
||
$incident = (string) ($context['{{incident_date}}'] ?? date('Y-m-d'));
|
||
$parentName = (string) ($context['{{parent_name}}'] ?? 'Parent/Guardian');
|
||
$phone = (string) ($context['{{school_phone}}'] ?? 'the school office');
|
||
$voicemail = (string) ($context['{{voicemail_phone}}'] ?? 'your voicemail');
|
||
|
||
$subject = match (true) {
|
||
str_starts_with($code, 'ABS_1') => 'Attendance Notice: Unreported Absence',
|
||
str_starts_with($code, 'ABS_2') => 'Attendance Follow-Up: Two Consecutive Absences',
|
||
str_starts_with($code, 'ABS_3') => 'Attendance Follow-Up: Three Absences',
|
||
str_starts_with($code, 'LATE_2') => 'Attendance Notice: Repeated Lateness',
|
||
str_starts_with($code, 'LATE_3'),
|
||
str_starts_with($code, 'LATE_4'),
|
||
str_starts_with($code, 'MIX') => 'Attendance Follow-Up: Repeated Lateness and Absence',
|
||
default => 'Attendance Notice',
|
||
};
|
||
|
||
$intro = "<p>Insha Allah this email finds you well";
|
||
if ($variant === 'answered') {
|
||
$intro .= ", <strong>{$parentName}</strong>. Jazakum Allahu khayran for taking the time to speak with us today.</p>";
|
||
} elseif ($variant === 'no_answer') {
|
||
$intro .= ".</p><p>We tried to reach you but could not connect and left a voice message at <strong>{$voicemail}</strong>.</p>";
|
||
} else {
|
||
$intro .= ".</p>";
|
||
}
|
||
|
||
$details = match (true) {
|
||
str_starts_with($code, 'ABS_1')
|
||
=> "<p>This is to let you know that <strong>{$studentName}</strong> was absent on <strong>{$incident}</strong> without prior notice.</p>",
|
||
str_starts_with($code, 'ABS_2')
|
||
=> "<p>This is a reminder that <strong>{$studentName}</strong> has had repeated absences, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
|
||
str_starts_with($code, 'ABS_3')
|
||
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been absent three times, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
|
||
str_starts_with($code, 'LATE_2')
|
||
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been late multiple times, most recently on <strong>{$incident}</strong>.</p>",
|
||
str_starts_with($code, 'LATE_3'),
|
||
str_starts_with($code, 'LATE_4')
|
||
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had repeated lateness concerns, most recently on <strong>{$incident}</strong>.</p>",
|
||
str_starts_with($code, 'MIX')
|
||
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had a mix of repeated lateness and absence concerns, most recently on <strong>{$incident}</strong>.</p>",
|
||
default
|
||
=> "<p>We'd like to inform you about <strong>{$studentName}</strong>'s recent attendance concern dated <strong>{$incident}</strong>.</p>",
|
||
};
|
||
|
||
$closing = "<p>If your child will be absent or late due to illness or family commitments, please let us know ahead of time.</p>"
|
||
. "<p>You can email/call/text us at <strong>{$phone}</strong>.</p>";
|
||
|
||
return [$subject, $intro . $details . $closing];
|
||
}
|
||
|
||
|
||
public function compose()
|
||
{
|
||
$sid = (int)($this->request->getGet('student_id') ?? 0);
|
||
$code = (string)($this->request->getGet('code') ?? 'ABS_1');
|
||
$variant = (string)($this->request->getGet('variant') ?? 'default');
|
||
$incident = (string)($this->request->getGet('incident_date') ?? '');
|
||
|
||
if ($sid <= 0) {
|
||
return redirect()->to(site_url('attendance/violations'))
|
||
->with('error', 'Missing student_id.');
|
||
}
|
||
|
||
$student = $this->studentModel->find($sid) ?? [];
|
||
$parent = $this->getPrimaryParentForStudent($sid);
|
||
$secondary = $this->getSecondaryParentForStudent($sid);
|
||
|
||
// Prefer incident date from query if valid (YYYY-MM-DD), else today
|
||
$lastDate = (preg_match('/^\d{4}-\d{2}-\d{2}$/', $incident)) ? $incident : date('Y-m-d');
|
||
$violation = [
|
||
'id' => $sid,
|
||
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
||
'last_date' => $lastDate,
|
||
'violation' => $code,
|
||
'violation_code' => $code,
|
||
];
|
||
$ctx = $this->buildTemplateContext($violation, $parent);
|
||
$rendered = $this->renderTemplate($code, $variant, $ctx);
|
||
|
||
if (!$rendered) {
|
||
log_message('warning', 'Attendance email template missing; using fallback compose body. code=' . $code . ' variant=' . $variant);
|
||
$rendered = $this->buildFallbackTemplate($code, $variant, $ctx);
|
||
}
|
||
|
||
[$subject, $body] = $rendered;
|
||
|
||
return view('attendance/compose_email', [
|
||
'title' => 'Compose Attendance Email',
|
||
'student_id' => $sid,
|
||
'student_name' => $violation['name'],
|
||
'parent_email' => (string)($parent['email'] ?? ''),
|
||
'parent_name' => (string)($parent['parent_name'] ?? ''),
|
||
'secondary_email' => (string)($secondary['email'] ?? ''),
|
||
'secondary_name' => (string)($secondary['parent_name'] ?? ''),
|
||
'code' => $code,
|
||
'variant' => $variant,
|
||
'subject' => $subject,
|
||
'body_html' => $body,
|
||
'semester' => $this->semester,
|
||
'school_year' => $this->schoolYear,
|
||
'incident_date'=> $lastDate,
|
||
]);
|
||
}
|
||
|
||
public function sendEmailManual()
|
||
{
|
||
if (! $this->request->is('post')) {
|
||
return redirect()->to(site_url('attendance/violations'));
|
||
}
|
||
|
||
// Validate
|
||
$rules = [
|
||
'student_id' => 'required|is_natural_no_zero',
|
||
'to' => 'required|valid_email',
|
||
'subject' => 'required|string',
|
||
// We are accepting PLAIN TEXT in this field name (kept as body_html for compatibility)
|
||
'body_html' => 'required|string',
|
||
'code' => 'required|string',
|
||
'variant' => 'permit_empty|string',
|
||
'incident_date' => 'permit_empty|valid_date[Y-m-d]',
|
||
'return_url' => 'permit_empty|string',
|
||
];
|
||
if (! $this->validate($rules)) {
|
||
return redirect()->back()->withInput()
|
||
->with('error', implode(' ', $this->validator->getErrors()));
|
||
}
|
||
|
||
// Inputs
|
||
$sid = (int) $this->request->getPost('student_id');
|
||
$to = trim((string) $this->request->getPost('to'));
|
||
$subject = (string) $this->request->getPost('subject');
|
||
$plainBody = (string) $this->request->getPost('body_html'); // plain text by design
|
||
$code = (string) $this->request->getPost('code');
|
||
$variant = (string) ($this->request->getPost('variant') ?? 'default');
|
||
$returnUrl = (string) ($this->request->getPost('return_url') ?? site_url('attendance/violations'));
|
||
|
||
// Normalize incident date strictly to YYYY-MM-DD; do NOT invent "today"
|
||
$ymdRaw = (string) ($this->request->getPost('incident_date') ?? '');
|
||
$ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : ''; // '' means unknown
|
||
|
||
// Treat body_html as HTML when it contains tags; otherwise convert plain text to basic HTML
|
||
// This prevents recipients from seeing raw tags when composing with the rich-text editor.
|
||
if ($plainBody !== '' && preg_match('/<[^>]+>/', $plainBody)) {
|
||
$safeHtmlBody = $plainBody; // HTML already
|
||
} elseif ($plainBody !== '' && stripos($plainBody, '<') !== false) {
|
||
// Hidden field might contain escaped HTML if JS didn’t run; decode and re-check
|
||
$decoded = html_entity_decode($plainBody, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||
$safeHtmlBody = preg_match('/<[^>]+>/', $decoded) ? $decoded : nl2br(esc($plainBody), false);
|
||
} else {
|
||
// Plain text fallback: escape + line breaks
|
||
$safeHtmlBody = nl2br(esc($plainBody), false);
|
||
}
|
||
|
||
// Wrap with your email layout
|
||
$wrapped = $this->renderWithEmailLayout($subject, $safeHtmlBody);
|
||
|
||
// Resolve term (prefer controller properties if set)
|
||
$semester = $this->semester ?? (new \App\Models\ConfigurationModel())->getConfig('semester');
|
||
$schoolYear = $this->schoolYear ?? (new \App\Models\ConfigurationModel())->getConfig('school_year');
|
||
|
||
try {
|
||
// Send to primary and (if available) to second parent as separate email
|
||
$mailer = new EmailController();
|
||
$anyOk = false;
|
||
|
||
// Primary
|
||
$ok = $mailer->sendEmail($to, $subject, $wrapped);
|
||
$this->logNotification(
|
||
$sid,
|
||
$code,
|
||
$ymd,
|
||
'email',
|
||
$to,
|
||
$subject,
|
||
$ok ? 'sent' : 'failed',
|
||
$ok ? 'OK' : 'sendEmail returned false'
|
||
);
|
||
if ($ok) {
|
||
$anyOk = true;
|
||
}
|
||
|
||
// Secondary
|
||
$sec = $this->getSecondaryParentForStudent($sid);
|
||
$to2 = trim((string)($sec['email'] ?? ''));
|
||
if ($to2 !== '' && strcasecmp($to2, $to) !== 0) {
|
||
if (! $this->notificationAlreadySent($sid, $code, $ymd, 'email', $to2)) {
|
||
$ok2 = $mailer->sendEmail($to2, $subject, $wrapped);
|
||
$this->logNotification(
|
||
$sid,
|
||
$code,
|
||
$ymd,
|
||
'email',
|
||
$to2,
|
||
$subject,
|
||
$ok2 ? 'sent' : 'failed',
|
||
$ok2 ? 'OK' : 'sendEmail returned false'
|
||
);
|
||
if ($ok2) {
|
||
$anyOk = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (! $anyOk) {
|
||
return redirect()->back()->withInput()->with('error', 'Failed to send email.');
|
||
}
|
||
|
||
// === Post-send bookkeeping ===
|
||
|
||
// 1) TRACKING TABLE: mark notified + bump counter
|
||
$this->attendanceTrackingModel->markRuleAsNotified(
|
||
$sid,
|
||
$code,
|
||
$ymd !== '' ? $ymd : date('Y-m-d'), // if not supplied, use today as a fallback for the tracking row only
|
||
$semester,
|
||
$schoolYear
|
||
);
|
||
|
||
// 2) ATTENDANCE DATA: mark all dates in the violation as reported + notified
|
||
$violationDates = $this->getViolationDatesForStudent($sid, $code, $ymd);
|
||
$this->attendanceDataModel->markReportedAndNotified($sid, $violationDates, $semester, $schoolYear);
|
||
|
||
// Queue comment modal on the violations page
|
||
try {
|
||
$stu = $this->studentModel->find($sid) ?: [];
|
||
$stuName = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? ''));
|
||
session()->set('open_comment_modal', [
|
||
'student_id' => $sid,
|
||
'student_name' => $stuName,
|
||
'code' => $code,
|
||
'incident_date'=> $ymd !== '' ? $ymd : date('Y-m-d'),
|
||
'to' => $to,
|
||
'subject' => $subject,
|
||
'variant' => $variant,
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
log_message('debug', 'Could not set open_comment_modal: ' . $e->getMessage());
|
||
}
|
||
|
||
$msg = 'Email sent to ' . esc($to);
|
||
if (!empty($to2) && strcasecmp($to2, $to) !== 0) {
|
||
$msg .= ' (CC ' . esc($to2) . ')';
|
||
}
|
||
return redirect()->to($returnUrl)->with('message', $msg);
|
||
} catch (\Throwable $e) {
|
||
$this->logNotification($sid, $code, $ymd, 'email', $to, $subject, 'failed', $e->getMessage());
|
||
return redirect()->back()->withInput()->with('error', 'Error: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* Convert user-entered plain text to safe HTML.
|
||
* - Escapes all HTML
|
||
* - Splits on blank lines into <p> paragraphs
|
||
* - Converts single newlines to <br>
|
||
* - Auto-links http/https URLs
|
||
*/
|
||
private function plainTextToHtml(string $text): string
|
||
{
|
||
// Normalize line endings and trim
|
||
$text = preg_replace("/\r\n?/", "\n", trim($text));
|
||
|
||
if ($text === '') {
|
||
return '<p></p>';
|
||
}
|
||
|
||
// Escape everything first
|
||
$escaped = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||
|
||
// Auto-link URLs (http/https)
|
||
$escaped = preg_replace_callback(
|
||
'#\bhttps?://[^\s<]+#i',
|
||
static function ($m) {
|
||
$url = $m[0];
|
||
$urlAttr = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||
return '<a href="' . $urlAttr . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
|
||
},
|
||
$escaped
|
||
);
|
||
|
||
// Split into paragraphs by blank lines
|
||
$paragraphs = preg_split("/\n{2,}/", $escaped);
|
||
|
||
// Turn single newlines into <br> inside each paragraph
|
||
$paragraphs = array_map(static function ($p) {
|
||
$p = preg_replace("/\n/", "<br>", $p);
|
||
return '<p>' . $p . '</p>';
|
||
}, $paragraphs);
|
||
|
||
return implode("\n", $paragraphs);
|
||
}
|
||
|
||
private function renderAutoEmailFor(array $v): ?array
|
||
{
|
||
// $v is each element in $toSend (from your sendAutoEmails)
|
||
$code = (string)($v['violation_code'] ?? $v['code'] ?? '');
|
||
$sid = (int)($v['id'] ?? 0);
|
||
$p = $this->getPrimaryParentForStudent($sid);
|
||
|
||
$ctx = $this->buildTemplateContext($v, $p);
|
||
$tpl = $this->renderTemplate($code, 'default', $ctx);
|
||
|
||
if (!$tpl) {
|
||
// fallback generic
|
||
$subject = ($code === 'ABS_1') ? 'Attendance Notice: Unreported Absence'
|
||
: (($code === 'LATE_2') ? 'Attendance Notice: Repeated Lateness' : 'Attendance Notice');
|
||
$html = "<p>Dear " . esc($p['parent_name'] ?? 'Parent/Guardian') . ",</p>"
|
||
. "<p>We'd like to inform you about your student's recent attendance:</p>"
|
||
. "<ul>"
|
||
. "<li><strong>Student:</strong> " . esc((string)($v['name'] ?? '')) . "</li>"
|
||
. "<li><strong>Issue:</strong> " . esc((string)($v['violation'] ?? '')) . "</li>"
|
||
. "<li><strong>As of:</strong> " . esc((string)($v['last_date'] ?? '')) . "</li>"
|
||
. "</ul>"
|
||
. "<p>If you have any questions, please contact the school office.</p>";
|
||
return [$subject, $html];
|
||
}
|
||
return $tpl;
|
||
}
|
||
|
||
public function parentsInfo()
|
||
{
|
||
$studentId = (int)($this->request->getGet('student_id') ?? 0);
|
||
if ($studentId <= 0) {
|
||
return $this->response->setStatusCode(422)->setJSON(['error' => 'Missing student_id']);
|
||
}
|
||
|
||
try {
|
||
$stu = $this->db->table('students')
|
||
->select('id, parent_id, school_year, semester')
|
||
->where('id', $studentId)
|
||
->get()->getRowArray();
|
||
|
||
if (!$stu) {
|
||
return $this->response->setStatusCode(404)->setJSON(['error' => 'Student not found']);
|
||
}
|
||
|
||
$primaryId = (int)$stu['parent_id'];
|
||
$schoolYear = $stu['school_year'] ?: ($this->schoolYear ?? null);
|
||
|
||
$u1 = $this->db->table('users')
|
||
->select('id, firstname, lastname, email, cellphone')
|
||
->where('id', $primaryId)
|
||
->get()->getRowArray();
|
||
|
||
$primary = $u1 ? [
|
||
'id' => (int)$u1['id'],
|
||
'firstname' => (string)$u1['firstname'],
|
||
'lastname' => (string)$u1['lastname'],
|
||
'email' => (string)$u1['email'],
|
||
'cellphone' => (string)$u1['cellphone'],
|
||
] : null;
|
||
|
||
$pb = $this->db->table('parents')
|
||
->where('firstparent_id', $primaryId);
|
||
|
||
if (!empty($schoolYear)) {
|
||
$pb->where('school_year', $schoolYear);
|
||
}
|
||
|
||
$pRow = $pb->orderBy('updated_at', 'DESC')->limit(1)->get()->getRowArray();
|
||
|
||
$secondary = null;
|
||
if ($pRow) {
|
||
$secondId = (int)($pRow['secondparent_id'] ?? 0);
|
||
|
||
if ($secondId > 0) {
|
||
$u2 = $this->db->table('users')
|
||
->select('id, firstname, lastname, email, cellphone')
|
||
->where('id', $secondId)
|
||
->get()->getRowArray();
|
||
|
||
if ($u2) {
|
||
$secondary = [
|
||
'id' => (int)$u2['id'],
|
||
'firstname' => (string)$u2['firstname'],
|
||
'lastname' => (string)$u2['lastname'],
|
||
'email' => (string)$u2['email'],
|
||
'cellphone' => (string)$u2['cellphone'],
|
||
];
|
||
}
|
||
}
|
||
|
||
if (!$secondary) {
|
||
$fallbackEmail = (string)($pRow['secondparent_email'] ?? '');
|
||
$fallbackPhone = (string)($pRow['secondparent_phone'] ?? '');
|
||
$fallbackFirst = (string)($pRow['secondparent_firstname'] ?? '');
|
||
$fallbackLast = (string)($pRow['secondparent_lastname'] ?? '');
|
||
|
||
if ($fallbackEmail || $fallbackPhone || $fallbackFirst || $fallbackLast) {
|
||
$secondary = [
|
||
'id' => $secondId ?: null,
|
||
'firstname' => $fallbackFirst,
|
||
'lastname' => $fallbackLast,
|
||
'email' => $fallbackEmail,
|
||
'cellphone' => $fallbackPhone,
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $this->response->setJSON([
|
||
'primary' => $primary,
|
||
'secondary' => $secondary,
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
log_message('error', 'parentsInfo() failed: ' . $e->getMessage());
|
||
return $this->response->setStatusCode(500)->setJSON(['error' => 'Server error']);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Collect all dates involved in a violation for a student.
|
||
* Uses provided incident date and recent attendance rows to gather unreported absences/lates.
|
||
*/
|
||
private function getViolationDatesForStudent(int $studentId, string $code, ?string $incidentDate = null): array
|
||
{
|
||
$code = strtoupper(trim($code));
|
||
$statuses = ['absent', 'late'];
|
||
if (str_starts_with($code, 'ABS')) {
|
||
$statuses = ['absent'];
|
||
} elseif (str_starts_with($code, 'LATE')) {
|
||
$statuses = ['late'];
|
||
} elseif (str_starts_with($code, 'MIX')) {
|
||
$statuses = ['absent', 'late'];
|
||
}
|
||
|
||
return $this->attendanceDataModel->getRecentUnreportedDates(
|
||
$studentId,
|
||
$statuses,
|
||
$incidentDate,
|
||
35,
|
||
$this->semester,
|
||
$this->schoolYear
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Save a comment/note after notifying a parent about an attendance case.
|
||
* Posts from the modal in violations_pending view.
|
||
*/
|
||
public function saveNotificationNote()
|
||
{
|
||
if (! $this->request->is('post')) {
|
||
return redirect()->to(site_url('attendance/violations'));
|
||
}
|
||
|
||
$rules = [
|
||
'student_id' => 'required|is_natural_no_zero',
|
||
'code' => 'required|string',
|
||
'note' => 'required|string',
|
||
'incident_date' => 'permit_empty|valid_date[Y-m-d]',
|
||
'return_url' => 'permit_empty|string',
|
||
'to' => 'permit_empty|valid_email',
|
||
'subject' => 'permit_empty|string',
|
||
'variant' => 'permit_empty|string',
|
||
];
|
||
if (! $this->validate($rules)) {
|
||
return redirect()->back()->withInput()->with('error', implode(' ', $this->validator->getErrors()));
|
||
}
|
||
|
||
$sid = (int) $this->request->getPost('student_id');
|
||
$code = (string) $this->request->getPost('code');
|
||
$note = trim((string) $this->request->getPost('note'));
|
||
$ymdRaw = (string) ($this->request->getPost('incident_date') ?? '');
|
||
$ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : '';
|
||
$returnUrl = (string) ($this->request->getPost('return_url') ?? site_url('attendance/violations'));
|
||
|
||
// Find corresponding tracking row for that student/day (prefer matching reason/code)
|
||
try {
|
||
$day = $ymd !== '' ? $ymd : date('Y-m-d');
|
||
[$start, $end] = $this->dayBounds($day);
|
||
|
||
$qb = $this->attendanceTrackingModel
|
||
->where('student_id', $sid)
|
||
->where('semester', $this->semester)
|
||
->where('school_year', $this->schoolYear)
|
||
->where('date >=', $start)
|
||
->where('date <', $end)
|
||
->orderBy('date', 'DESC');
|
||
|
||
// Prefer a row whose reason contains the code
|
||
$row = $this->attendanceTrackingModel
|
||
->where('student_id', $sid)
|
||
->where('semester', $this->semester)
|
||
->where('school_year', $this->schoolYear)
|
||
->where('date >=', $start)
|
||
->where('date <', $end)
|
||
->like('reason', $code, 'both')
|
||
->orderBy('date', 'DESC')
|
||
->first();
|
||
|
||
if (! $row) {
|
||
$row = $qb->first();
|
||
}
|
||
|
||
if ($row && !empty($row['id'])) {
|
||
$updates = [
|
||
'note' => $note,
|
||
'is_notified' => 1,
|
||
'updated_at' => date('Y-m-d H:i:s'),
|
||
];
|
||
// Ensure counter exists
|
||
$this->attendanceTrackingModel->builder()
|
||
->where('id', (int)$row['id'])
|
||
->set('notif_counter', 'COALESCE(notif_counter,0)+1', false)
|
||
->update();
|
||
$this->attendanceTrackingModel->update((int)$row['id'], $updates);
|
||
} else {
|
||
// No row exists for that day — create a minimal one to hold the note
|
||
$this->attendanceTrackingModel->insert([
|
||
'student_id' => $sid,
|
||
'date' => $start,
|
||
'is_reported' => 0,
|
||
'reason' => $code,
|
||
'is_notified' => 1,
|
||
'notif_counter' => 1,
|
||
'semester' => $this->semester,
|
||
'school_year' => $this->schoolYear,
|
||
'note' => $note,
|
||
'created_at' => date('Y-m-d H:i:s'),
|
||
'updated_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
}
|
||
|
||
// One-time modal context — clear so it won't re-open on refresh
|
||
try { session()->remove('open_comment_modal'); } catch (\Throwable $e) {}
|
||
|
||
return redirect()->to($returnUrl)->with('message', 'Note saved.');
|
||
} catch (\Throwable $e) {
|
||
return redirect()->back()->withInput()->with('error', 'Failed to save note: ' . $e->getMessage());
|
||
}
|
||
}
|
||
}
|