diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..028ca0f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +/node_modules +/public/build +/public/hot +/storage/*.key +/storage/app/public +/storage/app/private +/storage/framework/cache +/storage/framework/sessions +/storage/framework/testing +/storage/framework/views +/storage/logs +/vendor +.env +.env.*.bak +.phpunit.result.cache +composer.phar +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log +/.idea +/.vscode +/.fleet +/.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..903671f5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +FROM composer:2 AS vendor + +WORKDIR /app +COPY composer.json composer.lock ./ +COPY app/Helpers/ci_helpers.php app/Helpers/ +RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts +COPY . . +RUN composer dump-autoload --optimize \ + && php artisan package:discover --ansi + +FROM node:20-alpine AS assets +WORKDIR /app +COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./ +RUN if [ -f package-lock.json ]; then npm ci; \ + elif [ -f yarn.lock ]; then yarn install --frozen-lockfile; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --frozen-lockfile; \ + else npm install; fi +COPY resources ./resources +COPY vite.config.js ./ +COPY public ./public +RUN if [ -f package.json ]; then npm run build; fi + +FROM php:8.2-fpm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + unzip \ + libpng-dev \ + libonig-dev \ + libxml2-dev \ + libzip-dev \ + && docker-php-ext-install pdo_mysql mbstring xml zip \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /var/www/html + +COPY --from=vendor /app /var/www/html +COPY --from=assets /app/public /var/www/html/public + +RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 00000000..72aff55f --- /dev/null +++ b/Dockerfile.ci @@ -0,0 +1,16 @@ +FROM php:8.2-cli + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + unzip \ + sqlite3 \ + libsqlite3-dev \ + libonig-dev \ + libxml2-dev \ + && docker-php-ext-install pdo_sqlite mbstring xml \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /var/www/html diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 00000000..b67f4e4b --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,47 @@ +pipeline { + agent any + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Build CI Image') { + steps { + sh 'docker compose -f docker-compose.ci.yml build --pull' + } + } + + stage('Install Dependencies') { + steps { + sh 'docker compose -f docker-compose.ci.yml run --rm app composer install --no-interaction --prefer-dist --no-progress' + } + } + + stage('Prepare Test DB') { + steps { + sh 'docker compose -f docker-compose.ci.yml run --rm app sh -lc \"mkdir -p database && touch database/database.sqlite\"' + } + } + + stage('Run Tests') { + steps { + script { + env.APP_KEY = sh( + returnStdout: true, + script: "php -r 'echo \"base64:\" . base64_encode(random_bytes(32));'" + ).trim() + } + sh 'APP_KEY=$APP_KEY docker compose -f docker-compose.ci.yml run --rm app php artisan test' + } + } + } + + post { + always { + sh 'docker compose -f docker-compose.ci.yml down -v --remove-orphans' + } + } +} diff --git a/app/Http/Controllers/Api/Admin/AdminNotificationController.php b/app/Http/Controllers/Api/Admin/AdminNotificationController.php deleted file mode 100644 index 4fee73c9..00000000 --- a/app/Http/Controllers/Api/Admin/AdminNotificationController.php +++ /dev/null @@ -1,41 +0,0 @@ -service->alertsPayload()); - } - - public function saveSubjects(SaveAdminNotificationSubjectsRequest $request): JsonResponse - { - $result = $this->service->saveSubjects($request->validated('subjects') ?? []); - return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); - } - - public function printRecipients(): PrintRecipientsResource - { - return new PrintRecipientsResource($this->service->printRecipientsPayload()); - } - - public function savePrintRecipients(SavePrintNotificationRecipientsRequest $request): JsonResponse - { - $result = $this->service->savePrintRecipients($request->validated('notify') ?? []); - return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); - } -} - diff --git a/app/Http/Controllers/Api/Admin/AdminProgressAttachmentController.php b/app/Http/Controllers/Api/Admin/AdminProgressAttachmentController.php deleted file mode 100644 index f40087d8..00000000 --- a/app/Http/Controllers/Api/Admin/AdminProgressAttachmentController.php +++ /dev/null @@ -1,53 +0,0 @@ -find($reportId); - - if (! $row || empty($row->attachment_path)) { - abort(404, 'Attachment not found.'); - } - - $file = $this->resolveAttachmentFile($row->toArray()); - if (! $file) { - abort(404, 'Attachment missing.'); - } - - return response()->download($file, basename($file)); - } - - /** - * Download file from normalized attachments table - */ - public function downloadAttachment(int $attachmentId): BinaryFileResponse - { - $attachment = ClassProgressAttachment::query()->find($attachmentId); - - if (! $attachment || empty($attachment->file_path)) { - abort(404, 'Attachment not found.'); - } - - $file = $this->resolveAttachmentPath((string) $attachment->file_path); - if (! $file) { - abort(404, 'Attachment missing.'); - } - - $downloadName = $attachment->original_name ?: basename($file); - - return response()->download($file, $downloadName); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/AdminProgressReportController.php b/app/Http/Controllers/Api/Admin/AdminProgressReportController.php deleted file mode 100644 index 2833b75a..00000000 --- a/app/Http/Controllers/Api/Admin/AdminProgressReportController.php +++ /dev/null @@ -1,209 +0,0 @@ -validated(); - - // Ensure all keys exist (same shape as CI) - $filters = array_merge([ - 'from' => '', - 'to' => '', - 'class_section_id' => '', - 'status' => '', - ], $filters); - - $query = ClassProgressReport::query() - ->from('class_progress_reports') - ->select([ - 'class_progress_reports.*', - 'cs.class_section_name', - ]) - ->selectRaw("CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS teacher_name") - ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id') - ->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id'); - - $this->applyFilters($query, $filters); - - $rows = $query - ->orderByDesc('week_start') - ->get() - ->map(function ($row) { - $arr = $row->toArray(); - $arr['status_label'] = config('progress.status_options.' . ($arr['status'] ?? ''), 'Unknown'); - return $arr; - }) - ->all(); - - $reportGroups = $this->groupReportsByWeekAndSection($rows); - - $classSections = $this->getClassSections(); - $studentCounts = $this->getStudentCountsBySection(); - - $filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts) { - $sectionId = (int) ($section['class_section_id'] ?? 0); - return isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0; - })); - - return response()->json([ - 'data' => ProgressGroupResource::collection(collect(array_values($reportGroups))), - 'meta' => [ - 'filters' => $filters, - 'class_sections' => $filteredSections, - 'status_options' => config('progress.status_options', []), - 'subject_sections' => config('progress.subject_sections', []), - ], - ]); - } - - public function show(int $id): JsonResponse - { - $row = ClassProgressReport::query() - ->from('class_progress_reports') - ->select([ - 'class_progress_reports.*', - 'cs.class_section_name', - ]) - ->selectRaw("CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS teacher_name") - ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id') - ->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id') - ->where('class_progress_reports.id', $id) - ->first(); - - if (! $row) { - return response()->json(['message' => 'Progress report not found.'], 404); - } - - $rowData = $row->toArray(); - $rowData['status_label'] = config('progress.status_options.' . ($rowData['status'] ?? ''), 'Unknown'); - $rowData['flags'] = $this->decodeFlags($rowData['flags_json'] ?? null); - - $weeklyReports = ClassProgressReport::query() - ->where('class_section_id', $rowData['class_section_id']) - ->where('week_start', $rowData['week_start']) - ->orderBy('subject', 'asc') - ->get() - ->map(fn ($r) => $r->toArray()) - ->all(); - - $attachmentMap = $this->loadAttachmentsForReports(array_column($weeklyReports, 'id')); - - foreach ($weeklyReports as &$report) { - $reportId = (int) ($report['id'] ?? 0); - $report['attachments'] = $attachmentMap[$reportId] ?? []; - - // Legacy fallback attachment (same behavior as CI) - if (empty($report['attachments']) && ! empty($report['attachment_path'])) { - $report['attachments'][] = [ - 'id' => $reportId, // pseudo-id for legacy - 'name' => basename((string) $report['attachment_path']), - 'legacy' => true, - ]; - } - } - unset($report); - - return response()->json([ - 'data' => new ProgressReportDetailResource([ - 'report' => $rowData, - 'weekly_reports' => $weeklyReports, - 'subject_sections' => config('progress.subject_sections', []), - ]), - ]); - } - - private function applyFilters(Builder $query, array $filters): void - { - if (! empty($filters['from'])) { - $query->where('week_start', '>=', $filters['from']); - } - - if (! empty($filters['to'])) { - $query->where('week_end', '<=', $filters['to']); - } - - if (! empty($filters['class_section_id'])) { - $query->where('class_progress_reports.class_section_id', (int) $filters['class_section_id']); - } - - if (! empty($filters['status'])) { - $query->where('class_progress_reports.status', $filters['status']); - } - } - - private function groupReportsByWeekAndSection(array $rows): array - { - $groups = []; - - foreach ($rows as $row) { - $key = ($row['week_start'] ?? '') . '_' . ($row['class_section_id'] ?? ''); - if ($key === '_') { - continue; - } - - if (! isset($groups[$key])) { - $groups[$key] = [ - 'week_start' => $row['week_start'] ?? null, - 'week_end' => $row['week_end'] ?? null, - 'class_section_id' => $row['class_section_id'] ?? null, - 'class_section_name' => $row['class_section_name'] ?? '', - 'reports' => [], - ]; - } - - $subject = (string) ($row['subject'] ?? 'unknown'); - $groups[$key]['reports'][$subject] = $row; - } - - return $groups; - } - - /** - * Replace with relation/scopes/service if you already have them. - */ - private function getClassSections(): array - { - if (! class_exists(ClassSection::class)) { - return []; - } - - return ClassSection::query() - ->orderBy('class_section_name') - ->get() - ->map(fn ($r) => $r->toArray()) - ->all(); - } - - /** - * Expected shape: [class_section_id => count] - */ - private function getStudentCountsBySection(): array - { - if (! class_exists(StudentClass::class)) { - return []; - } - - return StudentClass::query() - ->selectRaw('class_section_id, COUNT(*) as total') - ->groupBy('class_section_id') - ->pluck('total', 'class_section_id') - ->map(fn ($v) => (int) $v) - ->all(); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/AdminTeacherSubmissionController.php b/app/Http/Controllers/Api/Admin/AdminTeacherSubmissionController.php deleted file mode 100644 index 21c129e8..00000000 --- a/app/Http/Controllers/Api/Admin/AdminTeacherSubmissionController.php +++ /dev/null @@ -1,31 +0,0 @@ -service->report()); - } - - public function sendNotifications(SendTeacherSubmissionNotificationsRequest $request): JsonResponse - { - $result = $this->service->sendNotifications( - (int) $request->user()->id, - $request->validated() - ); - - return response()->json($result); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/HandlesProgressAttachments.php b/app/Http/Controllers/Api/Admin/HandlesProgressAttachments.php deleted file mode 100644 index aa35b175..00000000 --- a/app/Http/Controllers/Api/Admin/HandlesProgressAttachments.php +++ /dev/null @@ -1,71 +0,0 @@ -resolveAttachmentPath($path); - } - - protected function resolveAttachmentPath(string $path): ?string - { - // CI stored paths may look like: writable/uploads/... - $relative = preg_replace('#^writable/uploads/#', '', $path); - - // Laravel equivalent target - $absolute = storage_path('app/uploads/' . ltrim((string) $relative, '/')); - - return is_file($absolute) ? $absolute : null; - } - - /** - * Returns map: [report_id => [ ['id'=>.., 'name'=>..], ... ]] - */ - protected function loadAttachmentsForReports(array $reportIds): array - { - $reportIds = array_values(array_filter(array_map('intval', $reportIds))); - if (empty($reportIds)) { - return []; - } - - $rows = ClassProgressAttachment::query() - ->whereIn('report_id', $reportIds) - ->orderBy('id', 'asc') - ->get(); - - $map = []; - - foreach ($rows as $row) { - $reportId = (int) ($row->report_id ?? 0); - if ($reportId === 0) { - continue; - } - - $map[$reportId][] = [ - 'id' => (int) $row->id, - 'name' => $row->original_name ?: basename((string) $row->file_path), - ]; - } - - return $map; - } - - protected function decodeFlags(?string $json): array - { - if (! $json) { - return []; - } - - $flags = json_decode($json, true); - return is_array($flags) ? $flags : []; - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Administrator/AdministratorAbsenceController.php b/app/Http/Controllers/Api/Administrator/AdministratorAbsenceController.php new file mode 100644 index 00000000..547b31e1 --- /dev/null +++ b/app/Http/Controllers/Api/Administrator/AdministratorAbsenceController.php @@ -0,0 +1,43 @@ +json(['message' => 'Please log in first.'], 401); + } + + return response()->json($this->service->getAbsenceFormData($userId)); + } + + public function store(SubmitAdministratorAbsenceRequest $request): JsonResponse + { + $userId = (int) Auth::id(); + if ($userId <= 0) { + return response()->json(['message' => 'Please log in first.'], 401); + } + + $result = $this->service->submit($request, $userId); + + return response()->json([ + 'message' => $result['message'], + 'saved' => $result['saved'] ?? 0, + 'dates' => $result['dates'] ?? [], + ], $result['status']); + } +} diff --git a/app/Http/Controllers/Api/Administrator/AdministratorDashboardController.php b/app/Http/Controllers/Api/Administrator/AdministratorDashboardController.php new file mode 100644 index 00000000..67a82364 --- /dev/null +++ b/app/Http/Controllers/Api/Administrator/AdministratorDashboardController.php @@ -0,0 +1,28 @@ +json($this->service->metrics()); + } + + public function userSearch(Request $request): JsonResponse + { + return response()->json( + $this->service->userSearch((string) $request->query('query', '')) + ); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Administrator/AdministratorEnrollmentController.php b/app/Http/Controllers/Api/Administrator/AdministratorEnrollmentController.php new file mode 100644 index 00000000..6c961343 --- /dev/null +++ b/app/Http/Controllers/Api/Administrator/AdministratorEnrollmentController.php @@ -0,0 +1,48 @@ +json( + $this->service->enrollmentWithdrawalData( + (string) $request->query('schoolYear', '') + ) + ); + } + + public function newStudents(): JsonResponse + { + return response()->json($this->service->showNewStudentsData()); + } + + public function updateStatuses(UpdateEnrollmentStatusesRequest $request): JsonResponse + { + $editorUserId = (int) Auth::id(); + if ($editorUserId <= 0) { + return response()->json(['message' => 'Please log in first.'], 401); + } + + $data = $request->validated(); + $result = $this->service->updateStatuses( + (array) ($data['enrollment_status'] ?? []), + $editorUserId + ); + + return response()->json(['message' => $result['message']], $result['status']); + } +} diff --git a/app/Http/Controllers/Api/Administrator/AdministratorNotificationController.php b/app/Http/Controllers/Api/Administrator/AdministratorNotificationController.php new file mode 100644 index 00000000..354b63a0 --- /dev/null +++ b/app/Http/Controllers/Api/Administrator/AdministratorNotificationController.php @@ -0,0 +1,47 @@ +json($this->service->notificationsAlertsData()); + } + + public function saveAlerts(SaveAdminNotificationSubjectsRequest $request): JsonResponse + { + $data = $request->validated(); + $result = $this->service->saveNotificationSubjects( + (array) ($data['subjects'] ?? []) + ); + + return response()->json(['message' => $result['message']], $result['status']); + } + + public function printRecipients(): JsonResponse + { + return response()->json($this->service->printNotificationRecipientsData()); + } + + public function savePrintRecipients(SavePrintRecipientsRequest $request): JsonResponse + { + $data = $request->validated(); + $result = $this->service->savePrintNotificationRecipients( + (array) ($data['notify'] ?? []) + ); + + return response()->json(['message' => $result['message']], $result['status']); + } +} diff --git a/app/Http/Controllers/Api/Administrator/AdministratorTeacherSubmissionController.php b/app/Http/Controllers/Api/Administrator/AdministratorTeacherSubmissionController.php new file mode 100644 index 00000000..c4930ca1 --- /dev/null +++ b/app/Http/Controllers/Api/Administrator/AdministratorTeacherSubmissionController.php @@ -0,0 +1,38 @@ +json($this->service->report()); + } + + public function notify(SendTeacherSubmissionNotificationsRequest $request): JsonResponse + { + $adminId = (int) Auth::id(); + if ($adminId <= 0) { + return response()->json(['message' => 'Please log in first.'], 401); + } + + $result = $this->service->sendNotifications($request, $adminId); + + return response()->json([ + 'message' => $result['message'], + 'sent' => $result['sent'] ?? 0, + 'failed' => $result['failed'] ?? 0, + ], $result['status']); + } +} diff --git a/app/Http/Controllers/Api/AdministratorController.php b/app/Http/Controllers/Api/AdministratorController.php deleted file mode 100755 index 5e04fe85..00000000 --- a/app/Http/Controllers/Api/AdministratorController.php +++ /dev/null @@ -1,844 +0,0 @@ -user = model(User::class); - $this->student = model(Student::class); - $this->config = model(Configuration::class); - $this->loginActivity = model(LoginActivity::class); - $this->enrollment = model(Enrollment::class); - $this->refund = model(Refund::class); - $this->invoice = model(Invoice::class); - $this->classSection = model(ClassSection::class); - $this->userRole = model(UserRole::class); - $this->studentClass = model(StudentClass::class); - $this->staffAttendance= model(StaffAttendance::class); - $this->db = \Config\Database::connect(); - $this->schoolYear = $this->config->getConfig('school_year'); - $this->semester = $this->config->getConfig('semester'); - } - - /** - * Get dashboard metrics - * GET /api/v1/administrator/dashboard - */ - public function dashboard() - { - try { - $recentActivities = $this->loginActivity->getLastActivities(4); - if (!is_array($recentActivities)) { - $recentActivities = []; - } - - $totalAdmins = (int) ($this->user->countAdminsBySchoolYear($this->schoolYear) ?? 0); - - $teachers = $this->user->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear); - $totalTeachers = count(array_unique(array_column($teachers, 'id'))); - - $teacherAssistants = $this->user->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear); - $totalTeacherAssistants = count(array_unique(array_column($teacherAssistants, 'id'))); - - $parents = $this->user->getUsersByRoleAndSchoolYear('parent', $this->schoolYear); - $totalParents = count(array_unique(array_column($parents, 'id'))); - - $totalStudents = (int) ( - $this->db->table('student_class') - ->select('COUNT(DISTINCT student_class.student_id) AS cnt') - ->where('student_class.school_year', $this->schoolYear) - ->where('student_class.class_section_id IS NOT NULL', null, false) - ->get() - ->getRow('cnt') - ?? 0 - ); - - // Align shape with View\AdministratorController::buildDashboardMetrics() - $data = [ - 'counts' => [ - 'students' => $totalStudents, - 'teachers' => $totalTeachers, - 'teacherAssistants' => $totalTeacherAssistants, - 'admins' => $totalAdmins, - 'parents' => $totalParents, - ], - 'recentActivities' => array_map(static function ($activity) { - if (!is_array($activity)) { return []; } - return [ - 'login_time' => $activity['login_time'] ?? null, - 'email' => $activity['email'] ?? null, - ]; - }, $recentActivities), - 'meta' => [ - 'schoolYear' => $this->schoolYear, - 'semester' => $this->semester, - ], - ]; - - return $this->success($data, 'Dashboard metrics retrieved successfully'); - } catch (\Exception $e) { - log_message('error', 'Dashboard metrics error: ' . $e->getMessage()); - return $this->error('Failed to retrieve dashboard metrics', 500); - } - } - - /** - * Compute allowed absence dates (future Sundays within Sep..May for configured school year) - */ - private function allowedAbsenceDates(): array - { - $todayStr = local_date(utc_now(), 'Y-m-d'); - try { $today = new \DateTimeImmutable($todayStr); } catch (\Throwable $e) { $today = new \DateTimeImmutable(); } - - $sy = (string)($this->schoolYear ?? ''); - $startYear = null; $endYear = null; - if (preg_match('/^(\d{4})\D+(\d{4})$/', $sy, $m)) { - $startYear = (int)$m[1]; - $endYear = (int)$m[2]; - } else { - $cy = (int)date('Y'); - $cm = (int)date('n'); - if ($cm >= 9) { // Sep-Dec - $startYear = $cy; - $endYear = $cy + 1; - } else { - $startYear = $cy - 1; - $endYear = $cy; - } - } - - try { - $start = new \DateTimeImmutable(sprintf('%04d-09-01', $startYear)); - $end = new \DateTimeImmutable(sprintf('%04d-05-31', $endYear)); - } catch (\Throwable $e) { - return []; - } - - if ($start < $today) { - $start = $today; - } - - $dates = []; - for ($cursor = $start; $cursor <= $end; $cursor = $cursor->modify('+1 day')) { - if ((int)$cursor->format('w') === 0) { // Sunday - $dates[] = $cursor->format('Y-m-d'); - } - } - return $dates; - } - - /** - * GET /api/v1/administrator/absence - * Return current admin absence context and allowed dates - */ - public function absenceInfo() - { - $userId = $this->getCurrentUserId(); - if (!$userId) { - return $this->error('Unauthorized', 401); - } - - $admin = $this->user->find($userId); - $displayName = $admin ? trim(($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? '')) : 'Administrator'; - - $semester = (string)($this->semester ?? ''); - $schoolYear = (string)($this->schoolYear ?? ''); - - $existing = $this->staffAttendance - ->where('user_id', $userId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->orderBy('date', 'DESC') - ->findAll(); - - return $this->success([ - 'admin_name' => $displayName, - 'semester' => $semester, - 'school_year' => $schoolYear, - 'existing' => $existing, - 'available_dates'=> $this->allowedAbsenceDates(), - ], 'Absence info retrieved'); - } - - /** - * POST /api/v1/administrator/absence - * Submit absence entries for current admin - * Payload: { dates: ["YYYY-MM-DD",...], reason_type?: string, reason: string } - */ - public function submitAbsence() - { - $userId = $this->getCurrentUserId(); - if (!$userId) { - return $this->error('Unauthorized', 401); - } - - $payload = $this->request->getJSON(true) ?? []; - $dates = (array)($payload['dates'] ?? []); - $reasonType = trim((string)($payload['reason_type'] ?? '')); - $reasonText = trim((string)($payload['reason'] ?? '')); - - $semester = (string)($this->semester ?? ''); - $schoolYear = (string)($this->schoolYear ?? ''); - - if ($semester === '' || $schoolYear === '') { - return $this->error('Semester or school year not configured', 500); - } - if ($reasonText === '') { - return $this->error('Reason is required', 422); - } - - $reasonBase = ($reasonType !== '') ? strtolower($reasonType) : ''; - $reason = $reasonBase !== '' ? ($reasonBase . ': ' . $reasonText) : $reasonText; - - $allowedDates = $this->allowedAbsenceDates(); - $allowedSet = array_fill_keys($allowedDates, true); - - $roleName = $this->user->getUserRole($userId) ?: null; - - $saved = 0; $invalid = []; $savedDates = []; - $dates = array_values(array_unique(array_map('strval', $dates))); - foreach ($dates as $d) { - $d = trim((string)$d); - if ($d === '') continue; - $dt = date_create_from_format('Y-m-d', $d); - if (!$dt || $dt->format('Y-m-d') !== $d || empty($allowedSet[$d])) { - $invalid[] = $d; - continue; - } - - $ok = $this->staffAttendance->upsertOne( - userId: $userId, - roleName: $roleName, - date: $d, - semester: $semester, - schoolYear: $schoolYear, - status: StaffAttendance::STATUS_ABSENT, - reason: $reason, - editorId: $userId - ); - if ($ok) { $saved++; $savedDates[] = $d; } - } - - // Best-effort principal email - try { - $user = $this->user->find($userId) ?: []; - $fullName = trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')) ?: 'Administrator'; - $userEmail = $user['email'] ?? ''; - $role = $roleName ?: 'admin'; - $dateList = !empty($savedDates) ? implode(', ', $savedDates) : implode(', ', $dates); - - $subject = sprintf('TimeOff Request: %s (%s) — %s', $fullName, ucfirst((string)$role), $dateList ?: 'No dates'); - - $body = '
A staff time-off request was submitted from the administrator portal.
' - . '| Name | ' . esc($fullName) . ' |
| ' . esc($userEmail) . ' | |
| Role | ' . esc((string)$role) . ' |
| Semester | ' . esc($semester) . ' |
| School Year | ' . esc($schoolYear) . ' |
| Reason Type | ' . esc($reasonType ?: '-') . ' |
| Reason | ' . esc($reasonText) . ' |
| Dates | ' . esc($dateList ?: '-') . ' |
| Submitted At | ' . esc(utc_now()) . ' |
Dear ' . esc($parentName) . ',
' - . 'We would like to inform you about a recent attendance concern for your student.
' - . 'Absence dates:
' . $datesList) : '') - . 'Please contact the school office if you have any questions.
'; - - return [$subject, $body]; - } - - private function normalizeBodyHtml(string $body): string - { - if ($body === '') { - return ''; - } - - if (preg_match('/<[^>]+>/', $body)) { - return $body; - } - - $decoded = html_entity_decode($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - if (preg_match('/<[^>]+>/', $decoded)) { - return $decoded; - } - - return nl2br(esc($body), false); - } - - private function fetchClassInfoByStudent(array $studentIds, string $schoolYear): array - { - if (empty($studentIds)) { - return []; - } - - $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') - ->get()->getResultArray(); - - $map = []; - foreach ($rows as $row) { - $sid = (int) ($row['student_id'] ?? 0); - if ($sid > 0 && !isset($map[$sid])) { - $map[$sid] = $row; - } - } - - return $map; - } - - 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', - ]; - } - - // The remainder of this class consists of helper methods copied (with minor adjustments) - // from the legacy AttendanceTrackingController to compute violations and build contexts. - // --- BEGIN legacy helpers --- - - private function computeViolations(array $students, array $attendanceData): array - { - $byStudent = []; - foreach ($attendanceData as $r) { - $sid = (int) $r['student_id']; - $ymd = substr((string) ($r['date'] ?? ''), 0, 10); - $stat = strtolower((string) $r['status']); - - if (!in_array($stat, ['absent', 'late'], true)) { - continue; - } - - $byStudent[$sid][$stat][] = $ymd; - } - - [$syStart, $syEnd] = $this->deriveSchoolYearBounds($this->schoolYear); - $activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $syEnd); - if (empty($activeWeekKeys)) { - return []; - } - - $weekIndex = array_flip($activeWeekKeys); - $currentWeekIdx = count($activeWeekKeys) - 1; - $windowStartIdx = max(0, $currentWeekIdx - 3); - $requiredWeeksCount = $currentWeekIdx - $windowStartIdx + 1; - - $keepLastWeeks = function (array $dates) use ($weekIndex, $windowStartIdx, $currentWeekIdx) { - $out = []; - foreach ($dates as $d) { - $key = date('o-\WW', strtotime($d)); - if (isset($weekIndex[$key])) { - $idx = $weekIndex[$key]; - if ($idx >= $windowStartIdx && $idx <= $currentWeekIdx) { - $out[] = substr((string) $d, 0, 10); - } - } - } - $out = array_values(array_unique($out)); - rsort($out); - return $out; - }; - - $classByStudent = $this->fetchClassInfoByStudent(array_column($students, 'id'), $this->schoolYear); - - $out = []; - 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); - - $absenceViolation = $this->buildAbsenceViolation($absDates, $absWeekIdx, $currentWeekIdx); - $lateViolation = $this->buildLateViolation($lateDates, $lateWeekIdx, $absWeekIdx, $currentWeekIdx, $requiredWeeksCount); - $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); - - if ($this->alreadyReported($sid, $chosen['code'], $chosen['last_date'])) { - continue; - } - - if ($this->notificationAlreadySent($sid, $chosen['code'], $chosen['last_date'], 'email', $parent['email'] ?? null)) { - continue; - } - - $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, - 'class_name' => $classLabel, - 'class_section_name' => (string) ($cls['class_section_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'], - 'absences' => $absDates, - 'lates' => $lateDates, - ]; - } - - return $out; - } - - private function buildAbsenceViolation(array $absDates, array $absWeekIdx, int $currentWeekIdx): ?array - { - if (empty($absDates)) { - return null; - } - - $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_in4w4 = $this->hasNInWActiveWeeks($absWeekIdx, 4, 4); - - if ($A4row || $A_in4w4) { - return [ - 'type' => 'absence', - 'code' => 'ABS_4', - 'severity' => 4, - 'action' => 'expel_notify', - 'color' => '#ff4d4f', - 'title' => '4 Absences (in a row or within last 4 active weeks)', - 'description' => 'Notify team to inform parent about expulsion and send email.', - 'last_date' => $lastAbsDate, - ]; - } - - if ($A3row || $A_in4w3) { - return [ - '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, - ]; - } - - if ($A2row || $A_in3w2) { - return [ - '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, - ]; - } - - return null; - } - - private function buildLateViolation(array $lateDates, array $lateWeekIdx, array $absWeekIdx, int $currentWeekIdx, int $requiredWeeksCount): ?array - { - if (empty($lateDates)) { - return null; - } - - $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); - - $lateWeeksSet = array_flip($lateWeekIdx); - $lateCoversAllWindowWeeks = false; - if ($requiredWeeksCount >= 4) { - $lateCoversAllWindowWeeks = true; - for ($i = $currentWeekIdx - 3; $i <= $currentWeekIdx; $i++) { - if ($i < 0) { - continue; - } - if (!isset($lateWeeksSet[$i])) { - $lateCoversAllWindowWeeks = false; - break; - } - } - } - - if ($L4row || $lateCoversAllWindowWeeks) { - return [ - '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, - ]; - } - - $twoLatesOneAbsWithin4 = $this->twoLatesOneAbsInWWeeks($lateWeekIdx, $absWeekIdx, 4); - - if ($L3row || $L_in4w3 || $twoLatesOneAbsWithin4) { - return [ - '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, - ]; - } - - if ($L2row || $L_in3w2) { - return [ - '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, - ]; - } - - return null; - } - - private function deriveSchoolYearBounds(string $schoolYear): array - { - if (!preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m)) { - $y = (int) local_now('Y'); - $startY = (int) (local_now('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 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, - ]; - } - - private 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'] ?? ''), - ]; - } - } - - $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::error('getSecondaryParentForStudent() failed: ' . $e->getMessage()); - } - - return null; - } - - private function alreadyReported(int $studentId, string $code, string $lastDate): bool - { - try { - [$start, $end] = $this->dayBounds($lastDate); - - $row = $this->attendanceTracking - ->where('student_id', $studentId) - ->where('semester', $this->semester) - ->where('school_year', $this->schoolYear) - ->where('date', '>=', $start) - ->where('date', '<', $end) - ->where('reason', 'like', "%{$code}%") - ->orderBy('date', 'DESC') - ->first(); - - if (!$row) { - $row = $this->attendanceTracking - ->where('student_id', $studentId) - ->where('semester', $this->semester) - ->where('school_year', $this->schoolYear) - ->where('date', '>=', $start) - ->where('date', '<', $end) - ->orderBy('date', 'DESC') - ->first(); - } - - return $row ? (bool) ($row['is_notified'] ?? 0) : false; - } catch (\Throwable $e) { - Log::debug('alreadyReported() error: ' . $e->getMessage()); - return false; - } - } - - private function getRecentAbsenceDatesForStudent(int $studentId, string $lastDateYmd, int $weeks = 5): array - { - if ($studentId <= 0) { - return []; - } - $day = substr($lastDateYmd, 0, 10); - $start = date('Y-m-d', strtotime($day . " -{$weeks} weeks")) . ' 00:00:00'; - $end = date('Y-m-d', strtotime($day . ' +1 day')) . ' 00:00:00'; - - $qb = $this->db->table('attendance_data'); - $qb->select('date') - ->where('student_id', $studentId) - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) - ->groupStart() - ->where('is_reported', 'no') - ->orWhere('is_reported', 0) - ->orWhere('is_reported', '0') - ->orWhere('is_reported', null) - ->groupEnd() - ->groupStart() - ->where('is_notified', 'no') - ->orWhere('is_notified', 0) - ->orWhere('is_notified', '0') - ->orWhere('is_notified', null) - ->groupEnd() - ->groupStart() - ->where("UPPER(status) IN ('A','ABS','ABSENT')", null, false) - ->orWhere("UPPER(status) LIKE 'ABS%'", null, false) - ->groupEnd() - ->where('date >=', $start) - ->where('date <', $end) - ->orderBy('date', 'DESC'); - - $rows = $qb->get()->getResultArray(); - $ymds = []; - foreach ($rows as $r) { - $d = substr((string) ($r['date'] ?? ''), 0, 10); - if ($d !== '') { - $ymds[$d] = true; - } - } - $out = array_keys($ymds); - sort($out); - return $out; - } - - private function renderTemplate(string $code, string $variant, array $context): ?array - { - if (!class_exists(\App\Models\AttendanceEmailTemplate::class)) { - return null; - } - - $template = model(\App\Models\AttendanceEmailTemplate::class); - $row = $template->getTemplate($code, $variant); - if (!$row) { - $row = $template->getTemplate($code, 'default'); - } - if (!$row) { - return null; - } - - $subject = strtr($row['subject'], $context); - $body = strtr($row['body_html'], $context); - return [$subject, $body]; - } - - private function buildTemplateContext(array $v, ?array $parent = null): array - { - $studentName = (string) ($v['name'] ?? ''); - $incident = (string) ($v['last_date'] ?? local_date(utc_now(), 'Y-m-d')); - $p = $parent ?: $this->getPrimaryParentForStudent((int) ($v['id'] ?? 0)) ?? []; - - $absences = []; - if (!empty($v['absences']) && is_array($v['absences'])) { - $absences = array_values(array_unique(array_map(static fn($d) => substr((string) $d, 0, 10), $v['absences']))); - } - - $absencesAsc = $absences; - sort($absencesAsc); - $absenceCount = count($absencesAsc); - $absenceDatesCsv = $absenceCount ? implode(', ', $absencesAsc) : ''; - $absenceDatesLines = $absenceCount ? implode('You have been added as an authorized user for another account.
' - . '' - . 'If you did not request this, please ignore this email.
'; - } - - $mailer = new EmailController(); - $subject = 'Authorized User Confirmation'; - - if ($mailer->sendEmail($email, $subject, $message)) { - log_message('info', 'Authorized user confirmation email sent to ' . $email); - } else { - log_message('error', 'Failed to send authorized user confirmation email to ' . $email); - } - } -} diff --git a/app/Http/Controllers/Api/Badges/BadgeController.php b/app/Http/Controllers/Api/Badges/BadgeController.php new file mode 100644 index 00000000..6637a79a --- /dev/null +++ b/app/Http/Controllers/Api/Badges/BadgeController.php @@ -0,0 +1,87 @@ +badgePdfService->generate( + userIds: $request->input('user_ids', []), + schoolYear: $request->input('school_year'), + rolesMap: $request->input('roles', []), + classesMap: $request->input('classes', []), + actorId: optional($request->user())->id + ); + } + + public function printStatus(BadgePrintStatusRequest $request): JsonResponse + { + try { + return response()->json([ + 'ok' => true, + 'data' => $this->badgePrintLogService->getStatus( + $request->normalizedUserIds(), + $request->input('school_year') + ), + ]); + } catch (Throwable) { + return response()->json([ + 'ok' => false, + 'error' => 'Failed to query status', + ], 500); + } + } + + public function logPrint(LogBadgePrintRequest $request): JsonResponse + { + try { + $inserted = $this->badgePrintLogService->log( + userIds: $request->input('user_ids', []), + actorId: optional($request->user())->id, + schoolYear: $request->input('school_year'), + rolesMap: $request->input('roles', []), + classesMap: $request->input('classes', []) + ); + + return response()->json([ + 'ok' => true, + 'inserted' => $inserted, + ]); + } catch (Throwable $e) { + return response()->json([ + 'ok' => false, + 'error' => $e->getMessage(), + ], 500); + } + } + + public function formData(BadgePrintStatusRequest $request): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => $this->badgeFormDataService->build( + schoolYear: $request->input('school_year'), + selectedUserIds: $request->normalizedUserIds(), + activeRole: $request->input('active_role') + ), + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/BroadcastEmailController.php b/app/Http/Controllers/Api/BroadcastEmailController.php deleted file mode 100755 index aaa67d42..00000000 --- a/app/Http/Controllers/Api/BroadcastEmailController.php +++ /dev/null @@ -1,244 +0,0 @@ -user = model(User::class); - $this->mailer = app(EmailService::class); - } - - public function metadata(): JsonResponse - { - $parents = array_values(array_filter( - $this->user->getParents(), - static fn ($parent) => !empty($parent['email']) - )); - - return $this->success([ - 'parents' => $parents, - 'fromOptions' => $this->senderOptionsFromEnv(), - ], 'Broadcast email metadata retrieved'); - } - - public function send(): JsonResponse - { - $data = $this->payloadData(); - $rules = [ - 'subject' => 'required|string', - 'body_html' => 'required|string', - 'mode' => 'permit_empty|string', - 'from_key' => 'permit_empty|string', - 'wrap_layout'=> 'permit_empty', - 'preheader' => 'permit_empty|string', - 'cta_text' => 'permit_empty|string', - 'cta_url' => 'permit_empty|string', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $isTest = !empty($data['send_test_only']); - $mode = (string) ($data['mode'] ?? 'standard'); - $subject = trim((string) $data['subject']); - $fromKey = trim((string) ($data['from_key'] ?? 'general')); - $wrapLayout = !empty($data['wrap_layout']); - $preheader = (string) ($data['preheader'] ?? ''); - $ctaText = (string) ($data['cta_text'] ?? ''); - $ctaUrl = (string) ($data['cta_url'] ?? ''); - $body = $this->sanitizeEmailHtml((string) $data['body_html']); - - if ($subject === '' || $body === '') { - return $this->respondError('Subject and body are required.', Response::HTTP_BAD_REQUEST); - } - - if ($isTest) { - $testEmail = trim((string) ($data['test_email'] ?? '')); - if ($testEmail === '' || !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) { - return $this->respondError('Provide a valid test email address.', Response::HTTP_BAD_REQUEST); - } - - $html = $this->composeEmailHtml( - $wrapLayout, - $subject, - $body, - 'Parent', - $preheader, - $ctaText, - $ctaUrl, - $mode === 'personalized' - ); - - $ok = $this->mailer->send($testEmail, '[TEST] ' . $subject, $html, $fromKey); - return $ok - ? $this->success(['message' => "Test email sent to {$testEmail}."], 'Test email sent') - : $this->respondError('Test email failed to send. Check logs.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - $parentIds = $this->parseParentIds($data['parent_ids'] ?? []); - if (empty($parentIds)) { - return $this->respondError('Please select at least one parent.', Response::HTTP_BAD_REQUEST); - } - - $rows = $this->user->newQuery() - ->select('users.id', 'users.email', 'users.firstname', 'users.lastname') - ->whereIn('users.id', $parentIds) - ->whereNotNull('users.email') - ->get(); - - if ($rows->isEmpty()) { - return $this->respondError('No valid parent emails found.', Response::HTTP_BAD_REQUEST); - } - - $stats = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'mode' => $mode]; - $personalized = ($mode === 'personalized'); - - foreach ($rows as $row) { - $stats['attempted']++; - $recipientName = trim(($row->firstname ?? '') . ' ' . ($row->lastname ?? '')) ?: 'Parent'; - $html = $this->composeEmailHtml( - $wrapLayout, - $subject, - $body, - $recipientName, - $preheader, - $ctaText, - $ctaUrl, - $personalized - ); - - $ok = $this->mailer->send($row->email, $subject, $html, $fromKey); - $ok ? $stats['sent']++ : $stats['failed']++; - } - - $message = "Broadcast finished. Mode: {$stats['mode']}. Sent: {$stats['sent']}/{$stats['attempted']}. Failures: {$stats['failed']}."; - $status = $stats['failed'] > 0 ? Response::HTTP_OK : Response::HTTP_OK; - - return $this->success(array_merge($stats, ['message' => $message]), 'Broadcast completed', $status); - } - - public function uploadImage(): JsonResponse - { - if (!$this->request->is('post')) { - return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED); - } - - $file = request()->file('image'); - if (!$file || !$file->isValid()) { - return $this->respondError('No image uploaded.', Response::HTTP_BAD_REQUEST); - } - - $mime = strtolower((string) $file->getMimeType()); - $allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp']; - if (!isset($allowed[$mime])) { - return $this->respondError('Unsupported image type.', Response::HTTP_UNSUPPORTED_MEDIA_TYPE); - } - if ($file->getSize() > 5 * 1024 * 1024) { - return $this->respondError('Image too large (max 5MB).', Response::HTTP_REQUEST_ENTITY_TOO_LARGE); - } - - $targetDir = public_path('uploads/email'); - if (!is_dir($targetDir)) { - @mkdir($targetDir, 0755, true); - } - - $newName = uniqid('em_', true) . '.' . $allowed[$mime]; - $file->move($targetDir, $newName); - - $url = url('uploads/email/' . $newName); - - return $this->success([ - 'success' => true, - 'url' => $url, - ], 'Image uploaded successfully'); - } - - private function composeEmailHtml( - bool $wrap, - string $subject, - string $body, - string $recipientName, - string $preheader, - string $ctaText, - string $ctaUrl, - bool $doPersonalize - ): string { - $content = $doPersonalize ? str_replace('{{name}}', $recipientName, $body) : $body; - - if (!$wrap) { - return $content; - } - - try { - return view('emails/broadcast_wrapper', [ - 'subject' => $subject, - 'content' => $content, - 'preheader' => $preheader, - 'cta_text' => $ctaText, - 'cta_url' => $ctaUrl, - ], ['saveData' => false]); - } catch (\Throwable $e) { - Log::warning('broadcast_wrapper view missing: ' . $e->getMessage()); - return $content; - } - } - - private function sanitizeEmailHtml(string $html): string - { - $html = preg_replace('#<(script|iframe)[^>]*>.*?\\1>#is', '', $html); - $html = preg_replace('/\son\w+="[^"]*"/i', '', $html); - $html = preg_replace("/\son\w+='[^']*'/i", '', $html); - return $html; - } - - private function senderOptionsFromEnv(): array - { - $json = env('MAIL_SENDERS', '{}'); - $arr = json_decode($json, true); - if (!is_array($arr) || empty($arr)) { - $smtpUser = env('SMTP_USER', ''); - $name = 'Al Rahma Sunday School'; - return [[ - 'key' => 'general', - 'label' => trim($name . ($smtpUser ? " <{$smtpUser}>" : '')), - ]]; - } - - $out = []; - foreach ($arr as $key => $info) { - $nm = $info['name'] ?? 'Sender'; - $em = $info['email'] ?? ''; - $out[] = ['key' => (string) $key, 'label' => trim($nm . ($em ? " <{$em}>" : ''))]; - } - return $out; - } - - private function parseParentIds($raw): array - { - $raw = is_array($raw) ? $raw : [$raw]; - $ids = []; - foreach ($raw as $value) { - if (is_string($value) && str_contains($value, ',')) { - $ids = array_merge($ids, array_map('intval', explode(',', $value))); - } else { - $ids[] = (int) $value; - } - } - - return array_values(array_unique(array_filter($ids))); - } -} diff --git a/app/Http/Controllers/Api/CalendarController.php b/app/Http/Controllers/Api/CalendarController.php deleted file mode 100755 index 24d1d0a3..00000000 --- a/app/Http/Controllers/Api/CalendarController.php +++ /dev/null @@ -1,111 +0,0 @@ -calendar = model(Calendar::class); - } - - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $month = $this->request->getGet('month'); - $year = $this->request->getGet('year'); - - $query = $this->calendar->newQuery(); - - if ($month && $year) { - $query->whereMonth('start_date', $month)->whereYear('start_date', $year); - } elseif ($year) { - $query->whereYear('start_date', $year); - } - - $query->orderBy('start_date', 'ASC'); - - $result = $this->paginate($query, $page, $perPage); - - return $this->success($result, 'Calendar events retrieved successfully'); - } - - public function show($id = null) - { - $event = $this->calendar->find($id); - if (!$event) { - return $this->respondError('Event not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($event, 'Calendar event retrieved successfully'); - } - - public function store() - { - $data = $this->payloadData(); - $rules = [ - 'title' => 'required|string|max_length[255]', - 'description' => 'permit_empty|string', - 'start_date' => 'required|valid_date[Y-m-d]', - 'end_date' => 'permit_empty|valid_date[Y-m-d]', - 'location' => 'permit_empty|string|max_length[255]', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $event = $this->calendar->create($data); - - return $this->respondCreated([ - 'id' => $event->id, - 'data' => $event, - ], 'Calendar event created successfully'); - } - - public function update($id = null) - { - $event = $this->calendar->find($id); - if (!$event) { - return $this->respondError('Event not found', Response::HTTP_NOT_FOUND); - } - - $data = $this->payloadData(); - $rules = [ - 'title' => 'permit_empty|string|max_length[255]', - 'description' => 'permit_empty|string', - 'start_date' => 'permit_empty|valid_date[Y-m-d]', - 'end_date' => 'permit_empty|valid_date[Y-m-d]', - 'location' => 'permit_empty|string|max_length[255]', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $event->fill($data); - $event->save(); - - return $this->success($event->fresh(), 'Calendar event updated successfully'); - } - - public function delete($id = null) - { - $event = $this->calendar->find($id); - if (!$event) { - return $this->respondError('Event not found', Response::HTTP_NOT_FOUND); - } - - $event->delete(); - return $this->respondDeleted(null, 'Calendar event deleted successfully'); - } -} diff --git a/app/Http/Controllers/Api/ClassController.php b/app/Http/Controllers/Api/ClassController.php deleted file mode 100755 index 0d6086c2..00000000 --- a/app/Http/Controllers/Api/ClassController.php +++ /dev/null @@ -1,63 +0,0 @@ -class = model(ClassModel::class); - $this->studentClass = model(StudentClass::class); - } - - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $grade = $this->request->getGet('grade'); - $teacher = $this->request->getGet('teacher_id'); - - $query = $this->class->newQuery(); - if ($grade) { - $query->where('grade', $grade); - } - if ($teacher) { - $query->where('teacher_id', $teacher); - } - - $query->orderBy('grade', 'ASC'); - - $result = $this->paginate($query, $page, $perPage); - return $this->success($result, 'Classes retrieved successfully'); - } - - public function show($id = null) - { - $class = $this->class->find($id); - if (!$class) { - return $this->respondError('Class not found', Response::HTTP_NOT_FOUND); - } - return $this->success($class, 'Class details retrieved successfully'); - } - - public function students($id = null) - { - $students = $this->studentClass->newQuery() - ->select('students.id', 'students.firstname', 'students.lastname', 'students.school_id') - ->join('students', 'students.id', '=', 'student_class.student_id') - ->where('student_class.class_id', $id) - ->orderBy('students.lastname', 'ASC') - ->get() - ->toArray(); - - return $this->success($students, 'Students retrieved successfully for class'); - } -} diff --git a/app/Http/Controllers/Api/ClassPreparationController.php b/app/Http/Controllers/Api/ClassPreparationController.php deleted file mode 100755 index 241cce69..00000000 --- a/app/Http/Controllers/Api/ClassPreparationController.php +++ /dev/null @@ -1,411 +0,0 @@ -studentClass = $studentClass; - $this->prepLog = $prepLog; - $this->classSection = $classSection; - $this->config = $config; - $this->adjustment = $adjustment; - - $this->schoolYear = $this->config->getConfig('school_year'); - $this->semester = $this->config->getConfig('semester'); - } - - public function index() - { - $schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? ''); - $semester = (string) ($this->request->getGet('semester') ?? $this->semester ?? ''); - $allowed = $this->allowedPrepCategories; - - $classSections = $this->studentClass->newQuery() - ->selectRaw('class_section_id, COUNT(DISTINCT student_id) AS student_count') - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->groupBy('class_section_id') - ->get() - ->map(fn ($row) => (array) $row) - ->toArray(); - - $inventoryMap = $this->buildInventoryMap($schoolYear, $semester); - $requiredTotals = array_fill_keys($allowed, 0); - $results = []; - - foreach ($classSections as $section) { - $classSectionId = (string) ($section['class_section_id'] ?? ''); - $studentCount = (int) ($section['student_count'] ?? 0); - $classLevel = $this->getClassLevelBySection($classSectionId); - $className = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId; - - $baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId); - - $rawAdjustments = $this->adjustment->newQuery() - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->where('adjustable', 1) - ->get() - ->map(fn ($row) => (array) $row) - ->toArray(); - - $adjMap = ['Large Table' => 0, 'Small Table' => 0]; - - foreach ($rawAdjustments as $adjustment) { - $item = $adjustment['item_name'] ?? ''; - $delta = (int) ($adjustment['adjustment'] ?? 0); - - if (array_key_exists($item, $adjMap)) { - $adjMap[$item] = $delta; - } - - if (isset($baseItems[$item])) { - $baseItems[$item] = max(0, (int) $baseItems[$item] + $delta); - } elseif (in_array($item, $allowed, true)) { - $baseItems[$item] = max(0, $delta); - } - } - - foreach ($allowed as $category) { - $requiredTotals[$category] += (int) ($baseItems[$category] ?? 0); - } - - $oldSnap = $this->prepLog->newQuery() - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->orderBy('created_at', 'DESC') - ->first(); - - $oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'] ?? '[]', true) : []; - $hasChanged = $this->hasPrepChanged($baseItems, $oldPrep); - - $results[] = [ - 'class_section' => $className, - 'class_section_id' => $classSectionId, - 'student_count' => $studentCount, - 'class_level' => $classLevel, - 'prep_items' => $baseItems, - 'needs_print' => $hasChanged, - 'last_printed_at' => $oldSnap['created_at'] ?? null, - 'adjustments' => $adjMap, - ]; - } - - $shortages = $this->calculateShortages($requiredTotals, $inventoryMap); - - return $this->success([ - 'schoolYear' => $schoolYear, - 'semester' => $semester, - 'results' => $results, - 'totals' => $requiredTotals, - 'shortages' => $shortages, - 'available' => $inventoryMap, - ], 'Class preparation data retrieved'); - } - - public function saveAdjustment() - { - $data = $this->request->all(); - $sectionId = (string) ($data['class_section_id'] ?? ''); - $schoolYear = (string) ($data['school_year'] ?? $this->schoolYear ?? ''); - $adjustments = is_array($data['adjustments'] ?? null) ? $data['adjustments'] : []; - - $updated = 0; - - foreach ($adjustments as $itemName => $adjustment) { - $row = $this->adjustment->newQuery() - ->where('class_section_id', $sectionId) - ->where('school_year', $schoolYear) - ->where('item_name', $itemName) - ->first(); - - $payload = [ - 'class_section_id' => $sectionId, - 'item_name' => $itemName, - 'adjustment' => (int) $adjustment, - 'school_year' => $schoolYear, - 'adjustable' => 1, - ]; - - if ($row) { - $this->adjustment->newQuery() - ->where('id', $row['id']) - ->update(['adjustment' => $payload['adjustment'], 'adjustable' => 1]); - } else { - $payload['created_at'] = utc_now(); - $this->adjustment->insert($payload); - } - - $updated++; - } - - return $this->success(['updated' => $updated], 'Adjustments saved successfully'); - } - - public function markPrinted() - { - $schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear ?? ''); - $ids = $this->request->getPost('class_section_ids') ?? []; - if (!is_array($ids)) { - $ids = $ids ? [$ids] : []; - } - $ids = array_values(array_unique(array_filter(array_map('strval', $ids)))); - - $now = utc_now(); - $count = 0; - - foreach ($ids as $classSectionId) { - $studentRow = $this->studentClass->newQuery() - ->selectRaw('COUNT(DISTINCT student_id) AS cnt') - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->where('semester', $this->semester) - ->first(); - - $studentCount = (int) ($studentRow['cnt'] ?? 0); - $classLevel = $this->getClassLevelBySection((string) $classSectionId); - $className = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId; - - $items = $this->calculatePrepItems($studentCount, $classLevel, (string) $classSectionId); - - $rawAdjustments = $this->adjustment->newQuery() - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->where('adjustable', 1) - ->get() - ->map(fn ($row) => (array) $row) - ->toArray(); - - foreach ($rawAdjustments as $adjustment) { - $item = $adjustment['item_name'] ?? ''; - $delta = (int) ($adjustment['adjustment'] ?? 0); - if (isset($items[$item])) { - $items[$item] = max(0, (int) $items[$item] + $delta); - } - } - - try { - $this->prepLog->insert([ - 'class_section_id' => (string) $classSectionId, - 'class_section' => $className, - 'school_year' => $schoolYear, - 'prep_data' => json_encode($items), - 'created_at' => $now, - ]); - $count++; - } catch (\Throwable $e) { - // ignore and continue - } - } - - return $this->success(['updated' => $count], 'Marked sections as printed'); - } - - private function calculateShortages(array $requiredTotals, array $inventoryMap): array - { - $shortages = []; - foreach ($requiredTotals as $item => $reqQty) { - $have = (int) ($inventoryMap[$item] ?? 0); - if ($have < (int) $reqQty) { - $shortages[$item] = (int) $reqQty - $have; - } - } - return $shortages; - } - - private function getTeacherCountForSection(string $classSectionId, ?string $schoolYear = null): int - { - $schoolYear = $schoolYear ?? $this->schoolYear; - - $row = DB::table('teacher_class') - ->selectRaw('COUNT(*) AS cnt') - ->where('class_section_id', $classSectionId) - ->whereIn('position', ['main', 'ta']) - ->where('school_year', $schoolYear) - ->first(); - - return (int) ($row->cnt ?? 0); - } - - private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array - { - $allowed = $this->allowedPrepCategories; - $items = array_fill_keys($allowed, 0); - - $categories = DB::table('inventory_categories') - ->select('name', 'grade_min', 'grade_max') - ->where('type', 'classroom') - ->whereIn('name', $allowed) - ->orderBy('name') - ->get() - ->map(fn ($row) => (array) $row) - ->toArray(); - - $isLowerGrades = in_array($classLevel, [1, 2], true); - $teacherCount = $this->getTeacherCountForSection($classSectionId); - - foreach ($categories as $category) { - $name = $category['name'] ?? ''; - $gradeMin = $category['grade_min']; - $gradeMax = $category['grade_max']; - - if ($gradeMin !== null && $classLevel < (int) $gradeMin) { - $items[$name] = 0; - continue; - } - if ($gradeMax !== null && $classLevel > (int) $gradeMax) { - $items[$name] = 0; - continue; - } - - $qty = 0; - switch (strtolower($name)) { - case 'small table': - $qty = $isLowerGrades ? (int) ceil($students / 3) : 0; - break; - case 'large table': - $qty = $isLowerGrades ? 0 : (int) ceil($students / 4); - break; - case 'small chair': - $qty = $isLowerGrades ? $students : 0; - break; - case 'regular chair': - $qty = $isLowerGrades ? 0 : $students; - break; - case 'teacher chair': - $qty = $teacherCount; - break; - case 'trash bin': - case 'white board': - case 'grade box': - $qty = 1; - break; - default: - $qty = 0; - } - - if (array_key_exists($name, $items)) { - $items[$name] = $qty; - } - } - - return $items; - } - - private function hasPrepChanged(array $new, array $old): bool - { - ksort($new); - ksort($old); - - return json_encode($new) !== json_encode($old); - } - - private function buildInventoryMap(string $schoolYear, string $semester): array - { - $allowed = $this->allowedPrepCategories; - $inventoryMap = array_fill_keys($allowed, 0); - - $queries = [ - DB::table('inventory_items as ii') - ->selectRaw('ic.name AS item_name, COALESCE(SUM(ii.good_qty),0) AS available') - ->join('inventory_categories as ic', 'ic.id', '=', 'ii.category_id') - ->where('ii.type', 'classroom') - ->where('ii.school_year', $schoolYear) - ->where('ii.semester', $semester) - ->whereIn('ic.name', $allowed) - ->groupBy('ic.name'), - DB::table('inventory_items') - ->selectRaw('name AS item_name, COALESCE(SUM(good_qty),0) AS available') - ->where('type', 'classroom') - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->whereIn('name', $allowed) - ->groupBy('name'), - DB::table('inventory_items as ii') - ->selectRaw('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available') - ->join('inventory_categories as ic', 'ic.id', '=', 'ii.category_id') - ->where('ii.type', 'classroom') - ->where('ii.school_year', $schoolYear) - ->where('ii.semester', $semester) - ->whereIn('ic.name', $allowed) - ->groupBy('ic.name'), - DB::table('inventory_items') - ->selectRaw('name AS item_name, COALESCE(SUM(CASE WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available') - ->where('type', 'classroom') - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->whereIn('name', $allowed) - ->groupBy('name'), - ]; - - foreach ($queries as $query) { - foreach ($query->get() as $row) { - $row = (array) $row; - $name = (string) ($row['item_name'] ?? $row['name'] ?? ''); - $val = (int) ($row['available'] ?? 0); - - if ($name !== '' && isset($inventoryMap[$name])) { - $inventoryMap[$name] = max($inventoryMap[$name], $val); - } - } - } - - return $inventoryMap; - } - - private function getClassLevelBySection(string $classSectionId): int - { - if (preg_match('/^(kg|k)(\b|[^a-z0-9])/i', $classSectionId)) { - return 1; - } - - $numeric = (int) preg_replace('/\D/', '', $classSectionId); - if ($numeric > 0 && $numeric < 30) { - $firstDigit = (int) substr((string) $numeric, 0, 1); - return match ($firstDigit) { - 1 => 1, - 2 => 2, - default => 3, - }; - } - - return 3; - } - -} diff --git a/app/Http/Controllers/Api/CommunicationController.php b/app/Http/Controllers/Api/CommunicationController.php deleted file mode 100755 index bb46ac57..00000000 --- a/app/Http/Controllers/Api/CommunicationController.php +++ /dev/null @@ -1,351 +0,0 @@ -communication = model(Communication::class); - $this->student = model(Student::class); - $this->familyStudent = model(FamilyStudent::class); - $this->template = model(EmailTemplate::class); - $this->log = model(CommunicationLog::class); - } - - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $senderId = $this->request->getGet('sender_id'); - $receiverId = $this->request->getGet('receiver_id'); - - $query = $this->communication->newQuery(); - if ($senderId) { - $query->where('sender_id', $senderId); - } - if ($receiverId) { - $query->where('receiver_id', $receiverId); - } - $query->orderBy('created_at', 'DESC'); - - $result = $this->paginate($query, $page, $perPage); - - return $this->success($result, 'Communications retrieved successfully'); - } - - public function send() - { - $data = $this->payloadData(); - $rules = [ - 'sender_id' => 'required|integer', - 'receiver_id' => 'required|integer', - 'subject' => 'required|string|max_length[255]', - 'message' => 'required|string', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $communication = $this->communication->create([ - 'sender_id' => $data['sender_id'], - 'receiver_id' => $data['receiver_id'], - 'subject' => $data['subject'], - 'message' => $data['message'], - ]); - - return $this->respondCreated([ - 'id' => $communication->id, - 'data' => $communication, - ], 'Message sent successfully'); - } - - public function conversation() - { - $senderId = $this->request->getGet('sender_id'); - $receiverId = $this->request->getGet('receiver_id'); - - if (!$senderId || !$receiverId) { - return $this->respondValidationError([ - 'sender_id' => 'sender_id is required', - 'receiver_id' => 'receiver_id is required', - ]); - } - - $conversation = $this->communication->newQuery() - ->where(function ($query) use ($senderId, $receiverId) { - $query->where('sender_id', $senderId) - ->where('receiver_id', $receiverId); - }) - ->orWhere(function ($query) use ($senderId, $receiverId) { - $query->where('sender_id', $receiverId) - ->where('receiver_id', $senderId); - }) - ->orderBy('created_at', 'ASC') - ->get() - ->toArray(); - - return $this->success($conversation, 'Conversation retrieved successfully'); - } - - public function delete($id = null) - { - $message = $this->communication->find($id); - if (!$message) { - return $this->respondError('Message not found', Response::HTTP_NOT_FOUND); - } - - $message->delete(); - return $this->respondDeleted(null, 'Message deleted successfully'); - } - - public function metadata() - { - $students = $this->student->newQuery() - ->selectRaw('id, firstname, lastname, COALESCE(registration_grade, \'\') AS grade') - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->get() - ->toArray(); - - $templates = $this->template->getActiveTemplates(); - - return $this->success([ - 'students' => $students, - 'templates' => $templates, - ], 'Communication metadata retrieved'); - } - - public function families(int $studentId) - { - $families = $this->familyStudent->getFamiliesForStudent($studentId); - return $this->success(['data' => $families], 'Families retrieved'); - } - - public function guardians(int $familyId) - { - $rows = DB::table('family_guardians as fg') - ->select('u.id AS user_id', 'u.firstname', 'u.lastname', 'u.email', 'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms') - ->join('users as u', 'u.id', '=', 'fg.user_id') - ->where('fg.family_id', $familyId) - ->get() - ->map(fn ($row) => (array) $row) - ->toArray(); - - return $this->success(['data' => $rows], 'Guardians retrieved'); - } - - public function preview() - { - $data = $this->payloadData(); - $rules = [ - 'template_key' => 'required|string', - 'student_id' => 'required|integer', - 'family_id' => 'required|integer', - 'vars' => 'permit_empty', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $template = $this->template->findByKey((string) ($data['template_key'] ?? '')); - if (!$template) { - return $this->respondError('Template not found', Response::HTTP_NOT_FOUND); - } - - $student = $this->student->find($data['student_id']); - if (!$student) { - return $this->respondError('Student not found', Response::HTTP_NOT_FOUND); - } - - $guardianRows = DB::table('family_guardians as fg') - ->select('u.firstname', 'u.lastname') - ->join('users as u', 'u.id', '=', 'fg.user_id') - ->where('fg.family_id', $data['family_id']) - ->where('fg.receive_emails', 1) - ->orderByDesc('fg.is_primary') - ->orderBy('u.lastname') - ->orderBy('u.firstname') - ->get() - ->map(fn ($row) => (array) $row) - ->toArray(); - - $salutation = 'Parent/Guardian'; - if (!empty($guardianRows)) { - $names = array_map(fn ($row) => trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')), $guardianRows); - $names = array_filter($names, fn ($name) => $name !== ''); - if (!empty($names)) { - $salutation = implode(' & ', $names); - } - } - - $varsPost = $data['vars'] ?? null; - $vars = $this->normalizeVars($varsPost); - - $autoVars = [ - 'student_fullname' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), - 'student_grade' => $student['grade'] ?? $student['registration_grade'] ?? '', - 'parent_salutation'=> $salutation, - 'date' => local_date(utc_now(), 'Y-m-d'), - 'school_name' => 'Al Rahma Sunday School', - 'teacher_name' => (string) (session('display_name') ?? 'Teacher'), - ]; - - $context = array_merge($autoVars, $vars); - $subject = $this->renderTwig($template['subject'] ?? '', $context); - $body = nl2br($this->renderTwig($template['body'] ?? '', $context)); - - return $this->success([ - 'subject' => $subject, - 'html' => $body, - ], 'Template preview generated'); - } - - public function sendEmail() - { - $data = $this->payloadData(); - $rules = [ - 'student_id' => 'required|integer', - 'family_id' => 'required|integer', - 'template_key' => 'required|string', - 'subject' => 'required|string', - 'body' => 'required|string', - 'recipients' => 'required', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $student = $this->student->find($data['student_id']); - if (!$student) { - return $this->respondError('Student not found', Response::HTTP_NOT_FOUND); - } - - $recipients = $this->normalizeRecipients($data['recipients']); - $cc = $this->normalizeRecipients($data['cc'] ?? null); - $bcc = $this->normalizeRecipients($data['bcc'] ?? null); - - if (empty($recipients)) { - return $this->respondError('At least one recipient is required'); - } - - $bodyHtml = (string) ($data['body'] ?? ''); - $subject = (string) ($data['subject'] ?? ''); - $fromAddress = config('mail.from.address', 'no-reply@alrahmaisgl.org'); - $fromName = config('mail.from.name', config('app.name', 'Al Rahma')); - - $status = 'failed'; - $error = null; - - try { - Mail::send([], [], function (Message $message) use ($fromAddress, $fromName, $subject, $bodyHtml, $recipients, $cc, $bcc) { - $message->from($fromAddress, $fromName); - $message->to($recipients); - - if (!empty($cc)) { - $message->cc($cc); - } - if (!empty($bcc)) { - $message->bcc($bcc); - } - - $message->subject($subject); - $message->setBody($bodyHtml, 'text/html'); - $message->addPart(strip_tags($bodyHtml), 'text/plain'); - }); - $status = 'sent'; - } catch (\Throwable $t) { - $error = $t->getMessage(); - } - - $studentName = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')); - - $this->log->insert([ - 'student_id' => $data['student_id'], - 'family_id' => $data['family_id'], - 'student_name' => $studentName, - 'template_key' => $data['template_key'], - 'subject' => $subject, - 'body' => $bodyHtml, - 'recipients' => json_encode(array_values($recipients)), - 'cc' => json_encode(array_values($cc)), - 'bcc' => json_encode(array_values($bcc)), - 'status' => $status, - 'error_message' => $error, - 'sent_by' => (int) (session('user_id') ?? 0), - 'metadata' => null, - ]); - - if ($status === 'sent') { - return $this->success(['status' => 'sent'], 'Email sent successfully'); - } - - return $this->error('Failed to send email: ' . ($error ?? 'Unknown error'), Response::HTTP_INTERNAL_SERVER_ERROR); - } - - private function normalizeVars($value): array - { - if (is_string($value)) { - $decoded = json_decode($value, true); - if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { - return $decoded; - } - return []; - } - - if (is_array($value)) { - return $value; - } - - return []; - } - - private function normalizeRecipients($input): array - { - $values = []; - if (is_string($input)) { - $decoded = json_decode($input, true); - if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { - $values = $decoded; - } elseif ($input !== '') { - $values = [$input]; - } - } elseif (is_array($input)) { - $values = $input; - } - - $sanitized = array_values(array_filter(array_map(fn ($item) => trim((string) $item), $values))); - return $sanitized; - } - - private function renderTwig(string $template, array $vars): string - { - return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}/', function ($match) use ($vars) { - $key = $match[1]; - return htmlspecialchars((string) ($vars[$key] ?? ''), ENT_QUOTES, 'UTF-8'); - }, $template); - } -} diff --git a/app/Http/Controllers/Api/ConfigurationController.php b/app/Http/Controllers/Api/ConfigurationController.php deleted file mode 100755 index 1af62f12..00000000 --- a/app/Http/Controllers/Api/ConfigurationController.php +++ /dev/null @@ -1,148 +0,0 @@ -config = model(Configuration::class); - } - - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $key = $this->request->getGet('key'); - - $query = $this->config->newQuery()->orderBy('id', 'ASC'); - if ($key) { - $query->where('config_key', 'LIKE', '%' . $key . '%'); - } - - $result = $this->paginate($query, $page, $perPage); - return $this->success($result, 'Configurations retrieved successfully'); - } - - public function show($id = null) - { - $config = $this->config->find($id); - if (!$config) { - return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($config, 'Configuration retrieved successfully'); - } - - public function getByKey($key = null) - { - $config = $this->config->where('config_key', $key)->first(); - if (!$config) { - return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($config, 'Configuration retrieved successfully'); - } - - public function create() - { - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'config_key' => 'required|string|max_length[255]|is_unique[configuration.config_key]', - 'config_value' => 'required', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - try { - $config = $this->config->create([ - 'config_key' => $data['config_key'], - 'config_value' => $data['config_value'], - ]); - } catch (\Throwable $e) { - log_message('error', 'Configuration creation error: ' . $e->getMessage()); - return $this->respondError('Failed to create configuration', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - return $this->respondCreated([ - 'id' => $config->id, - 'data' => $config, - ], 'Configuration created successfully'); - } - - public function update($id = null) - { - $config = $this->config->find($id); - if (!$config) { - return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $updateData = array_intersect_key($data, array_flip(['config_key', 'config_value'])); - if (empty($updateData)) { - return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST); - } - - try { - $config->fill($updateData); - $config->save(); - } catch (\Throwable $e) { - log_message('error', 'Configuration update error: ' . $e->getMessage()); - return $this->respondError('Failed to update configuration', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - return $this->success($config->fresh(), 'Configuration updated successfully'); - } - - public function delete($id = null) - { - $config = $this->config->find($id); - if (!$config) { - return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND); - } - - try { - $config->delete(); - } catch (\Throwable $e) { - log_message('error', 'Configuration deletion error: ' . $e->getMessage()); - return $this->respondError('Failed to delete configuration', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - return $this->success(null, 'Configuration deleted successfully'); - } - - public function listData() - { - $configs = $this->config->newQuery() - ->orderBy('id', 'ASC') - ->get() - ->map(function ($row) { - return [ - 'id' => (int) ($row->id ?? 0), - 'config_key' => (string) ($row->config_key ?? ''), - 'config_value' => (string) ($row->config_value ?? ''), - ]; - }) - ->values() - ->toArray(); - - return $this->success(['configs' => $configs], 'Configuration list retrieved'); - } -} diff --git a/app/Http/Controllers/Api/ContactController.php b/app/Http/Controllers/Api/ContactController.php deleted file mode 100755 index d65cddca..00000000 --- a/app/Http/Controllers/Api/ContactController.php +++ /dev/null @@ -1,59 +0,0 @@ -payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'name' => 'required|min_length[3]', - 'email' => 'required|valid_email', - 'subject' => 'required|min_length[3]', - 'message' => 'required|min_length[10]', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $name = trim((string) ($data['name'] ?? '')); - $email = strtolower(trim((string) ($data['email'] ?? ''))); - $subject = trim((string) ($data['subject'] ?? '')); - $message = trim((string) ($data['message'] ?? '')); - - $body = "From: {$name} ({$email})
" - . "Subject: {$subject}
" - . "{$message}
"; - - $supportEmail = env('SUPPORT_EMAIL', 'support@alrahmaisgl.org'); - - try { - /** @var EmailService $mailer */ - $mailer = app(EmailService::class); - $sent = $mailer->setFrom($email, $name) - ->setTo($supportEmail) - ->setSubject($subject) - ->setMessage($body) - ->send(); - - if (!$sent) { - return $this->respondError('There was an error sending your message. Please try again later.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - return $this->success(null, 'Thank you for contacting us! We will get back to you soon.'); - } catch (\Throwable $e) { - log_message('error', 'Contact form submission error: ' . $e->getMessage()); - return $this->respondError('Failed to submit contact form', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/DashboardRedirectController.php b/app/Http/Controllers/Api/DashboardRedirectController.php deleted file mode 100755 index a05e138c..00000000 --- a/app/Http/Controllers/Api/DashboardRedirectController.php +++ /dev/null @@ -1,45 +0,0 @@ -get('roles') ?? []; - - if (empty($roles) && $session->has('role')) { - $roles = [$session->get('role')]; - } - - return $this->redirectToDashboard($roles); - } - - private function redirectToDashboard(array $roles): RedirectResponse - { - if (empty($roles)) { - log_message('error', 'Empty roles array passed to redirectToDashboard.'); - return redirect()->to('/landing_page/guest_dashboard'); - } - - $rows = $this->role->findByNamesOrSlugs($roles); - - if (!empty($rows)) { - $route = $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard'; - log_message('debug', 'Redirecting user to: ' . $route); - return redirect()->to($route); - } - - log_message('warning', 'No matching role found. Redirecting to guest dashboard.'); - return redirect()->to('/landing_page/guest_dashboard'); - } -} diff --git a/app/Http/Controllers/Api/DiscountController.php b/app/Http/Controllers/Api/DiscountController.php deleted file mode 100755 index 5ac41a98..00000000 --- a/app/Http/Controllers/Api/DiscountController.php +++ /dev/null @@ -1,647 +0,0 @@ -discount = model(DiscountVoucher::class); - $this->discountUsage = model(DiscountUsage::class); - $this->invoice = model(Invoice::class); - $this->user = model(User::class); - $this->payment = model(Payment::class); - $this->config = model(Configuration::class); - - $this->schoolYear = $this->config->getConfig('school_year') ?? ''; - $sem = $this->config->getConfig('semester'); - $this->semester = is_string($sem) && $sem !== '' ? $sem : null; - } - - public function index() - { - $activeOnly = $this->request->getGet('active') === 'true'; - $query = $this->discount->newQuery(); - - if ($activeOnly) { - $today = now()->toDateString(); - $query->where('is_active', 1) - ->where(function ($q) use ($today) { - $q->whereNull('valid_from')->orWhere('valid_from', '<=', $today); - }) - ->where(function ($q) use ($today) { - $q->whereNull('valid_until')->orWhere('valid_until', '>=', $today); - }); - } - - $discounts = $query->orderBy('created_at', 'DESC')->get()->toArray(); - return $this->success($discounts, 'Discount vouchers retrieved successfully'); - } - - public function getByCode($code = null) - { - $discount = $this->findActiveDiscount($code); - - if (!$discount) { - return $this->respondError('Discount voucher not found or expired', Response::HTTP_NOT_FOUND); - } - - if ($this->isUsageLimitReached($discount)) { - return $this->respondError('Discount voucher has reached maximum usage limit', Response::HTTP_BAD_REQUEST); - } - - return $this->success($discount->toArray(), 'Discount voucher retrieved successfully'); - } - - public function apply() - { - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'code' => 'required', - 'invoice_id' => 'required|integer', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $code = strtoupper(trim((string) ($data['code'] ?? ''))); - $discount = $this->findActiveDiscount($code); - if (!$discount) { - return $this->respondError('Invalid or expired discount voucher', Response::HTTP_BAD_REQUEST); - } - - if ($this->isUsageLimitReached($discount)) { - return $this->respondError('Discount voucher has reached maximum usage limit', Response::HTTP_BAD_REQUEST); - } - - $exists = $this->discountUsage->newQuery() - ->where('voucher_id', $discount->id) - ->where('invoice_id', $data['invoice_id']) - ->exists(); - - if ($exists) { - return $this->respondError('Discount voucher already applied to this invoice', Response::HTTP_BAD_REQUEST); - } - - try { - $usage = $this->discountUsage->create([ - 'voucher_id' => $discount->id, - 'invoice_id' => $data['invoice_id'], - 'used_by' => $user->id, - 'used_at' => now()->toDateTimeString(), - ]); - - $discount->increment('times_used'); - - return $this->success([ - 'discount' => $discount->fresh()->toArray(), - 'applied' => true, - ], 'Discount voucher applied successfully'); - } catch (\Throwable $e) { - log_message('error', 'Discount apply error: ' . $e->getMessage()); - return $this->respondError('Failed to apply discount voucher', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function parents() - { - $parents = $this->user->getParents(); - $result = []; - foreach ($parents as $parent) { - $discount = DB::table('discount_usages as du') - ->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_discount') - ->join('invoices as i', 'i.id', '=', 'du.invoice_id') - ->where('i.parent_id', $parent['id']) - ->where('i.school_year', $this->schoolYear) - ->first(); - - $totalDiscount = (float) ($discount->total_discount ?? 0); - $result[] = [ - 'id' => $parent['id'], - 'firstname' => $parent['firstname'], - 'lastname' => $parent['lastname'], - 'email' => $parent['email'], - 'school_id' => $parent['school_id'] ?? null, - 'total_discount' => $totalDiscount, - 'has_discount' => $totalDiscount > 0 ? 1 : 0, - ]; - } - - return $this->success($result, 'Parent discounts retrieved'); - } - - public function applyVoucher() - { - $data = $this->payloadData(); - $rules = [ - 'voucher_id' => 'required|integer', - 'parent_ids' => 'required', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $voucherId = (int) $data['voucher_id']; - $parentIds = $this->normalizeParentIds($data['parent_ids']); - if (empty($parentIds)) { - return $this->respondError('At least one parent is required', Response::HTTP_BAD_REQUEST); - } - - $voucher = $this->discount->find($voucherId); - if (!$voucher) { - return $this->respondError('Voucher not found', Response::HTTP_NOT_FOUND); - } - - $remainingUses = max(0, (int) ($voucher['max_uses'] ?? 1) - (int) ($voucher['times_used'] ?? 0)); - if ($remainingUses <= 0) { - return $this->respondError('Voucher has reached its maximum allowed uses', Response::HTTP_BAD_REQUEST); - } - - $description = $this->normalizeDescription($voucher['description'] ?? ''); - $now = utc_now(); - $fullyCoveredInvoiceIds = []; - $appliedInvoices = []; - $errors = []; - $usedCount = 0; - - DB::beginTransaction(); - try { - foreach ($parentIds as $parentId) { - $invoices = $this->invoice->getInvoicesByUserId($parentId, $this->schoolYear); - if (empty($invoices)) { - $errors[] = "Parent {$parentId} has no invoices for {$this->schoolYear}."; - continue; - } - - foreach ($invoices as $invoice) { - if ($invoice['balance'] <= 0) { - continue; - } - if ($remainingUses <= 0) { - break 2; - } - - $initialPreBalance = $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear); - - $already = $this->discountUsage->newQuery() - ->where('voucher_id', $voucherId) - ->where('invoice_id', $invoice['id']) - ->exists(); - if ($already) { - continue; - } - - $rawDiscount = ($voucher['discount_type'] === 'percent') - ? round(((float) $invoice['total_amount'] * (float) $voucher['discount_value']) / 100, 2) - : (float) $voucher['discount_value']; - - $discountAmount = min($rawDiscount, $initialPreBalance); - if ($discountAmount <= 0) { - continue; - } - - $this->db->table('discount_usages')->insert([ - 'voucher_id' => $voucherId, - 'invoice_id' => $invoice['id'], - 'parent_id' => $parentId, - 'discount_amount' => $discountAmount, - 'description' => $description, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'updated_by' => $this->getCurrentUserId() ?? 0, - 'used_at' => $now, - 'created_at' => $now, - 'updated_at' => $now, - ]); - - $newBalance = max(0.0, round(((float) $invoice['balance']) - $discountAmount, 2)); - $this->invoice->update($invoice['id'], [ - 'balance' => $newBalance, - 'has_discount' => 1, - 'updated_at' => $now, - ]); - - $postBalance = max(0.0, round($initialPreBalance - $discountAmount, 2)); - $currentBalance = $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear); - - $this->discount->newQuery() - ->where('id', $voucherId) - ->set('times_used', 'times_used + 1', false) - ->update(); - - [$eventData, $studentData] = $this->buildPaymentEventData( - $invoice['id'], - (string) $voucherId, - $discountAmount, - 'discount', - $now, - '', - 0, - $initialPreBalance, - $postBalance - ); - event('paymentReceived', [$eventData, $studentData]); - - $epsilon = 0.01; - $fullyCovered = (abs($discountAmount - $initialPreBalance) <= $epsilon) - || ($postBalance <= $epsilon) - || ($currentBalance <= $epsilon); - - if ($fullyCovered) { - $fullyCoveredInvoiceIds[] = (int) $invoice['id']; - } - - $appliedInvoices[] = (int) $invoice['id']; - $remainingUses--; - $usedCount++; - - if ($remainingUses <= 0) { - $this->discount->update($voucherId, [ - 'is_active' => 0, - 'updated_at' => $now, - ]); - break 2; - } - } - } - - DB::commit(); - } catch (\Throwable $e) { - DB::rollBack(); - log_message('error', 'Voucher application failed: ' . $e->getMessage()); - return $this->respondError('Voucher application failed. Transaction rolled back.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - $enrollmentUpdates = 0; - foreach (array_unique($fullyCoveredInvoiceIds) as $invoiceId) { - try { - $enrollmentUpdates += $this->updateEnrollmentStatusIfPaid($invoiceId); - } catch (\Throwable $e) { - log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [ - 'iid' => $invoiceId, - 'err' => $e->getMessage(), - ]); - } - } - - return $this->success([ - 'applied_invoices' => array_values(array_unique($appliedInvoices)), - 'errors' => $errors, - 'enrollments' => $enrollmentUpdates, - 'used_count' => $usedCount, - ], 'Voucher applied successfully'); - } - - public function createVoucher() - { - $data = $this->payloadData(); - $rules = [ - 'code' => 'required|string', - 'discount_type' => 'required|in:percent,fixed', - 'discount_value' => 'required|numeric', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim((string) $data['code']))); - if ($code === '') { - return $this->respondError('Voucher code is required', Response::HTTP_BAD_REQUEST); - } - - $payload = [ - 'code' => $code, - 'discount_type' => strtolower((string) $data['discount_type']), - 'discount_value' => (float) $data['discount_value'], - 'max_uses' => isset($data['max_uses']) && $data['max_uses'] !== '' ? (int) $data['max_uses'] : null, - 'valid_from' => $this->normalizeDate($data['valid_from'] ?? null), - 'valid_until' => $this->normalizeDate($data['valid_until'] ?? null), - 'is_active' => isset($data['is_active']) ? (bool) $data['is_active'] : true, - 'description' => trim((string) ($data['description'] ?? '')), - ]; - - if ($payload['valid_from'] && $payload['valid_until'] && $payload['valid_from'] > $payload['valid_until']) { - return $this->respondError('Valid until must be the same as or after valid from', Response::HTTP_BAD_REQUEST); - } - - try { - $voucher = $this->discount->create($payload); - } catch (\Throwable $e) { - log_message('error', 'Voucher creation failed: ' . $e->getMessage()); - return $this->respondError('Failed to save voucher: ' . $e->getMessage()); - } - - return $this->respondCreated($voucher->toArray(), 'Voucher created successfully'); - } - - public function editVoucher($id) - { - $voucher = $this->discount->find($id); - if (!$voucher) { - return $this->respondError('Voucher not found', Response::HTTP_NOT_FOUND); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $update = []; - foreach (['code', 'discount_type', 'discount_value', 'max_uses', 'valid_from', 'valid_until', 'is_active', 'description'] as $field) { - if (array_key_exists($field, $data)) { - $value = $data[$field]; - if (in_array($field, ['valid_from', 'valid_until'], true)) { - $value = $this->normalizeDate($value); - } - $update[$field] = $value; - } - } - - if (!empty($update)) { - $this->discount->update($id, $update); - } - - return $this->success($this->discount->find($id), 'Voucher updated successfully'); - } - - protected function findActiveDiscount(?string $code) - { - if (empty($code)) { - return null; - } - - $today = now()->toDateString(); - return $this->discount->newQuery() - ->where('code', $code) - ->where('is_active', 1) - ->where(function ($q) use ($today) { - $q->whereNull('valid_from')->orWhere('valid_from', '<=', $today); - }) - ->where(function ($q) use ($today) { - $q->whereNull('valid_until')->orWhere('valid_until', '>=', $today); - }) - ->first(); - } - - protected function isUsageLimitReached($discount): bool - { - if (!$discount->max_uses) { - return false; - } - return isset($discount->times_used) && $discount->times_used >= $discount->max_uses; - } - - private function normalizeParentIds($input): array - { - $values = []; - if (is_string($input)) { - $decoded = json_decode($input, true); - if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { - $values = $decoded; - } else { - $values = [$input]; - } - } elseif (is_array($input)) { - $values = $input; - } - - return array_values(array_filter(array_map('intval', $values), fn($id) => $id > 0)); - } - - private function normalizeDescription(?string $description): string - { - if ($description === null) { - return ''; - } - $description = trim($description); - return preg_replace( - '/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i', - '', - $description - ); - } - - private function normalizeDate($value): ?string - { - if ($value === null || $value === '') { - return null; - } - - $date = preg_replace('/[^0-9\-]/', '', (string) $value); - return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) ? $date : null; - } - - private function getCurrentInvoiceBalance($invoiceId, $schoolYear): float - { - $invoice = $this->invoice->find($invoiceId); - if (!$invoice) { - return 0.0; - } - - $qb = $this->payment - ->select('COALESCE(SUM(paid_amount),0) AS total_paid') - ->where('invoice_id', $invoiceId) - ->where('school_year', $schoolYear); - - $table = $this->payment->table; - if ($this->db->getSchemaBuilder()->hasColumn($table, 'status')) { - $qb->where(function ($q) { - $q->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']) - ->orWhereNull('status'); - }); - } - if ($this->db->getSchemaBuilder()->hasColumn($table, 'is_void')) { - $qb->where(function ($q) { - $q->where('is_void', 0) - ->orWhereNull('is_void'); - }); - } - - $rowPaid = $qb->first(); - $totalPaid = (float) ($rowPaid['total_paid'] ?? 0); - - $rowDisc = DB::table('discount_usages as du') - ->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_disc') - ->join('invoices as i', 'i.id', '=', 'du.invoice_id') - ->where('du.invoice_id', $invoiceId) - ->where('i.school_year', $schoolYear) - ->first(); - $totalDisc = (float) ($rowDisc->total_disc ?? 0); - - $rowRefund = DB::table('refunds') - ->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid') - ->where('invoice_id', $invoiceId) - ->where('school_year', $schoolYear) - ->whereIn('status', ['Partial', 'Paid']) - ->first(); - $totalRefundPaid = (float) ($rowRefund->total_refund_paid ?? 0); - - $total = (float) ($invoice['total_amount'] ?? 0); - return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2)); - } - - private function updateEnrollmentStatusIfPaid(int $invoiceId): int - { - $invoice = $this->invoice->find($invoiceId); - if (!$invoice) { - log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]); - return 0; - } - - $total = (float) ($invoice['total_amount'] ?? 0); - $balance = (float) ($invoice['balance'] ?? 0); - if (!($total > 0 && $balance < $total)) { - log_message('info', 'No payment yet. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]); - return 0; - } - - $parentId = (int) ($invoice['parent_id'] ?? 0); - if ($parentId <= 0 || $this->schoolYear === '') { - log_message('warning', 'Missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]); - return 0; - } - - $paidEnrollmentIds = []; - try { - $rows = DB::table('invoice_items') - ->select('enrollment_id') - ->where('invoice_id', $invoiceId) - ->whereNotNull('enrollment_id') - ->get() - ->pluck('enrollment_id') - ->map('intval') - ->filter(fn($eid) => $eid > 0) - ->unique() - ->values() - ->toArray(); - $paidEnrollmentIds = $rows; - } catch (\Throwable $e) { - // ignore - } - - $builder = DB::table('enrollments') - ->where('parent_id', $parentId) - ->where('school_year', $this->schoolYear) - ->where(function ($query) { - $query->where('enrollment_status', 'payment pending') - ->orWhere('enrollment_status', 'Payment pending') - ->orWhere('enrollment_status', 'Payment Pending') - ->orWhere('enrollment_status', 'PAYMENT PENDING') - ->orWhereRaw("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'"); - }); - - if ($this->semester !== null) { - $builder->where('semester', $this->semester); - } - if (!empty($paidEnrollmentIds)) { - $builder->whereIn('id', $paidEnrollmentIds); - } - - $toUpdateIds = $builder->pluck('id')->map('intval')->toArray(); - if (empty($toUpdateIds)) { - return 0; - } - - $updated = DB::table('enrollments') - ->whereIn('id', $toUpdateIds) - ->update([ - 'enrollment_status' => 'enrolled', - 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), - ]); - - return $updated; - } - - private function buildPaymentEventData( - int $invoiceId, - string $transactionId, - float $amount, - string $paymentMethod, - string $paymentDate, - ?string $checkNumber, - int $installmentSeq, - float $preBalance, - float $postBalance - ): array { - $invoice = $this->invoice->find($invoiceId); - if (!$invoice) { - throw new \RuntimeException("Invoice {$invoiceId} not found"); - } - - $parentRow = DB::table('users') - ->select('id', 'firstname', 'lastname', 'email') - ->where('id', $invoice['parent_id']) - ->first(); - - if (!$parentRow) { - throw new \RuntimeException("Parent not found for invoice {$invoiceId}"); - } - - $studentRows = DB::table('students as s') - ->select('s.id', 's.firstname', 's.lastname', 'sc.class_section_id') - ->join('student_class as sc', 'sc.student_id', '=', 's.id') - ->where('s.parent_id', $invoice['parent_id']) - ->where('sc.school_year', $this->schoolYear) - ->when($this->semester !== null, fn($q) => $q->where('sc.semester', $this->semester)) - ->get() - ->toArray(); - - $data = [ - 'user_id' => (int) $parentRow->id, - 'email' => $parentRow->email, - 'firstname' => $parentRow->firstname, - 'lastname' => $parentRow->lastname, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'invoice_id' => $invoiceId, - 'transaction_id' => $transactionId, - 'payment_date' => $paymentDate, - 'amount' => $amount, - 'method' => $paymentMethod, - 'check_number' => $checkNumber, - 'installment_seq' => $installmentSeq, - 'invoice_total' => (float) $invoice['total_amount'], - 'pre_balance' => $preBalance, - 'post_balance' => $postBalance, - 'portalLink' => URL::to('/parent/invoices/' . $invoiceId), - ]; - - return [$data, $studentRows]; - } -} diff --git a/app/Http/Controllers/Api/EmailExtractorController.php b/app/Http/Controllers/Api/EmailExtractorController.php deleted file mode 100755 index 2b2922ae..00000000 --- a/app/Http/Controllers/Api/EmailExtractorController.php +++ /dev/null @@ -1,170 +0,0 @@ -select('email')->get(); - foreach ($userRows as $row) { - $email = strtolower(trim((string)($row->email ?? ''))); - if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { - $users[] = $email; - } - } - // De-duplicate - $users = array_values(array_unique($users)); - - // Fetch parents.secondparent_email (non-null, non-empty) - $parentRows = DB::table('parents')->select('secondparent_email')->get(); - foreach ($parentRows as $row) { - $email = strtolower(trim((string)($row->secondparent_email ?? ''))); - if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { - $parents[] = $email; - } - } - // De-duplicate - $parents = array_values(array_unique($parents)); - - return $this->success([ - 'users' => $users, - 'parents' => $parents, - ], 'Emails retrieved successfully'); - } catch (\Throwable $e) { - Log::error('Email extraction error: ' . $e->getMessage()); - return $this->respondError('Database error: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * POST /api/compare - * Accepts multipart/form-data with a CSV file named 'file' (optional), - * or JSON body with { csvEmails: string[] } (optional). - * Compares against DB and returns: - * { - * existed: string[], // in CSV AND in DB - * needToAdd: string[], // in DB BUT NOT in CSV - * counts: { csv: number, db: number, users: number, parents: number } - * } - */ - public function compare() - { - $csvEmails = []; - - // 1) Try read from uploaded file - if ($this->laravelRequest->hasFile('file')) { - $file = $this->laravelRequest->file('file'); - if ($file->isValid()) { - $contents = file_get_contents($file->getRealPath()); - $csvEmails = $this->extractEmailsFromText($contents); - } - } - - // 2) Or from JSON body - if (empty($csvEmails)) { - $data = $this->payloadData(); - if (isset($data['csvEmails']) && is_array($data['csvEmails'])) { - $csvEmails = $this->normalizeEmailArray($data['csvEmails']); - } - } - - // 3) Compare with DB - try { - // Fetch DB emails - $users = []; - $parents = []; - - $userRows = DB::table('users')->select('email')->get(); - foreach ($userRows as $row) { - $e = strtolower(trim((string)($row->email ?? ''))); - if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) { - $users[] = $e; - } - } - $users = array_values(array_unique($users)); - - $parentRows = DB::table('parents')->select('secondparent_email')->get(); - foreach ($parentRows as $row) { - $e = strtolower(trim((string)($row->secondparent_email ?? ''))); - if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) { - $parents[] = $e; - } - } - $parents = array_values(array_unique($parents)); - - // Sets for comparison - $csvSet = array_flip(array_values(array_unique($csvEmails))); - $dbUnion = array_values(array_unique(array_merge($users, $parents))); - $dbSet = array_flip($dbUnion); - - // existed: in CSV and in DB - $existed = []; - foreach ($csvSet as $email => $_) { - if (isset($dbSet[$email])) { - $existed[] = $email; - } - } - sort($existed); - - // needToAdd: in DB but NOT in CSV - $needToAdd = []; - foreach ($dbSet as $email => $_) { - if (!isset($csvSet[$email])) { - $needToAdd[] = $email; - } - } - sort($needToAdd); - - return $this->success([ - 'existed' => $existed, - 'needToAdd' => $needToAdd, - 'counts' => [ - 'csv' => count($csvEmails), - 'db' => count($dbUnion), - 'users' => count($users), - 'parents' => count($parents), - ], - ], 'Comparison completed successfully'); - } catch (\Throwable $e) { - Log::error('Email comparison error: ' . $e->getMessage()); - return $this->respondError('Database error: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - // Helpers - - private function normalizeEmailArray(array $arr): array - { - $out = []; - foreach ($arr as $e) { - $email = strtolower(trim((string)$e)); - if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { - $out[] = $email; - } - } - return array_values(array_unique($out)); - } - - private function extractEmailsFromText(string $text): array - { - $pattern = '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i'; - preg_match_all($pattern, $text, $matches); - $emails = $matches[0] ?? []; - return $this->normalizeEmailArray($emails); - } -} diff --git a/app/Http/Controllers/Api/EmergencyContactController.php b/app/Http/Controllers/Api/EmergencyContactController.php deleted file mode 100755 index b17abb60..00000000 --- a/app/Http/Controllers/Api/EmergencyContactController.php +++ /dev/null @@ -1,251 +0,0 @@ -emergencyContact = model(EmergencyContact::class); - $this->config = model(Configuration::class); - $this->student = model(Student::class); - $this->user = model(User::class); - } - - public function index() - { - $parentId = $this->request->getGet('parent_id'); - $schoolYear = $this->config->getConfig('school_year'); - $semester = $this->config->getConfig('semester'); - - $query = $this->emergencyContact->newQuery(); - if ($parentId) { - $query->where('parent_id', $parentId); - } - if ($schoolYear) { - $query->where('school_year', $schoolYear); - } - if ($semester) { - $query->where('semester', $semester); - } - - $contacts = $query->get()->toArray(); - - return $this->success($contacts, 'Emergency contacts retrieved successfully'); - } - - public function show($id = null) - { - $contact = $this->emergencyContact->find($id); - if (!$contact) { - return $this->respondError('Emergency contact not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($contact, 'Emergency contact retrieved successfully'); - } - - public function getByParent($id = null) - { - $schoolYear = $this->config->getConfig('school_year'); - $semester = $this->config->getConfig('semester'); - - $contacts = $this->emergencyContact->newQuery() - ->where('parent_id', $id); - if ($schoolYear) { - $contacts->where('school_year', $schoolYear); - } - if ($semester) { - $contacts->where('semester', $semester); - } - - return $this->success($contacts->get()->toArray(), 'Emergency contacts retrieved successfully'); - } - - public function create() - { - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'parent_id' => 'required|integer', - 'emergency_contact_name' => 'required|max_length[255]', - 'cellphone' => 'required|max_length[20]', - 'relation' => 'required|max_length[50]', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $payload = [ - 'parent_id' => $data['parent_id'], - 'emergency_contact_name'=> $data['emergency_contact_name'], - 'cellphone' => $data['cellphone'], - 'email' => $data['email'] ?? null, - 'relation' => $data['relation'], - 'school_year' => $this->config->getConfig('school_year'), - 'semester' => $this->config->getConfig('semester'), - ]; - - try { - $contact = $this->emergencyContact->create($payload); - return $this->respondCreated([ - 'id' => $contact->id, - 'data' => $contact, - ], 'Emergency contact created successfully'); - } catch (\Throwable $e) { - Log::error('Emergency contact creation error: ' . $e->getMessage()); - return $this->respondError('Failed to create emergency contact', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function update($id = null) - { - $contact = $this->emergencyContact->find($id); - if (!$contact) { - return $this->respondError('Emergency contact not found', Response::HTTP_NOT_FOUND); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $allowed = ['emergency_contact_name', 'cellphone', 'email', 'relation']; - $updateData = []; - foreach ($allowed as $field) { - if (array_key_exists($field, $data)) { - $updateData[$field] = $data[$field]; - } - } - - if (empty($updateData)) { - return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST); - } - - try { - $contact->fill($updateData); - $contact->save(); - return $this->success($contact->fresh(), 'Emergency contact updated successfully'); - } catch (\Throwable $e) { - Log::error('Emergency contact update error: ' . $e->getMessage()); - return $this->respondError('Failed to update emergency contact', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function delete($id = null) - { - $contact = $this->emergencyContact->find($id); - if (!$contact) { - return $this->respondError('Emergency contact not found', Response::HTTP_NOT_FOUND); - } - - try { - $contact->delete(); - return $this->success(null, 'Emergency contact deleted successfully'); - } catch (\Throwable $e) { - Log::error('Emergency contact deletion error: ' . $e->getMessage()); - return $this->respondError('Failed to delete emergency contact', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/emergency-contacts/data - * Returns JSON payload for emergency contacts grouped by parent with parent info, students, and contacts. - */ - public function data() - { - try { - // Get distinct parent IDs from emergency contacts - $parentRows = $this->emergencyContact->newQuery() - ->distinct() - ->select('parent_id') - ->get() - ->toArray(); - - $groups = []; - - foreach ($parentRows as $row) { - $parentId = (int)($row['parent_id'] ?? 0); - if ($parentId <= 0) { - continue; - } - - // Get parent info - $parent = $this->user->find($parentId); - $parentName = 'Unknown Parent'; - $parentPhone = ''; - - if ($parent) { - $parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')) ?: 'Unknown Parent'; - $parentPhone = (string)($parent['cellphone'] ?? ''); - } - - // Try to load second parent phone from parents table - $secondPhone = ''; - try { - $secondParentRow = DB::table('parents') - ->select('secondparent_phone') - ->where('firstparent_id', $parentId) - ->orderBy('updated_at', 'DESC') - ->first(); - - if ($secondParentRow && !empty($secondParentRow->secondparent_phone)) { - $secondPhone = (string)$secondParentRow->secondparent_phone; - } - } catch (\Throwable $e) { - Log::debug('EmergencyContactController: could not load second parent phone: ' . $e->getMessage()); - } - - // Get students for this parent - $students = $this->student->newQuery() - ->select('id', 'firstname', 'lastname', 'school_id') - ->where('parent_id', $parentId) - ->get() - ->toArray(); - - // Get emergency contacts for this parent - $contacts = $this->emergencyContact->newQuery() - ->where('parent_id', $parentId) - ->orderBy('updated_at', 'DESC') - ->get() - ->toArray(); - - // Filter out empty phone numbers - $parentPhones = array_values(array_filter([$parentPhone, $secondPhone], static fn($v) => (string)$v !== '')); - - $groups[] = [ - 'parent_id' => $parentId, - 'parent_name' => $parentName, - 'students' => $students, - 'contacts' => $contacts, - 'parent_phones' => $parentPhones, - ]; - } - - return $this->success([ - 'groups' => $groups, - ], 'Emergency contacts grouped by parent retrieved successfully'); - } catch (\Throwable $e) { - Log::error('Emergency contact data error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve emergency contact data', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/EventController.php b/app/Http/Controllers/Api/EventController.php deleted file mode 100755 index df8281da..00000000 --- a/app/Http/Controllers/Api/EventController.php +++ /dev/null @@ -1,382 +0,0 @@ -event = model(Event::class); - $this->config = model(Configuration::class); - $this->eventCharges = model(EventCharges::class); - $this->student = model(Student::class); - $this->user = model(User::class); - $this->invoiceController = app(InvoiceController::class); - - $this->schoolYear = $this->config->getConfig('school_year'); - $this->semester = $this->config->getConfig('semester'); - } - - public function index() - { - $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; - $upcomingOnly = $this->request->getGet('upcoming') === 'true'; - - $query = $this->event->newQuery(); - if ($schoolYear) { - $query->where('school_year', $schoolYear); - } - if ($upcomingOnly) { - $today = Carbon::now()->toDateString(); - $query->where('expiration_date', '>=', $today); - } - - $events = $query->orderBy('created_at', 'DESC')->get()->toArray(); - - // Count active events - $today = Carbon::now()->toDateString(); - $activeEventCount = $this->event->newQuery() - ->where('expiration_date', '>=', $today) - ->count(); - - return $this->success([ - 'events' => $events, - 'active_event_count' => $activeEventCount, - ], 'Events retrieved successfully'); - } - - public function show($id = null) - { - $event = $this->event->find($id); - if (!$event) { - return $this->respondError('Event not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($event, 'Event retrieved successfully'); - } - - public function create() - { - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'event_name' => 'required|max_length[255]', - 'description' => 'nullable', - 'amount' => 'required|numeric', - 'expiration_date' => 'required|date', - 'semester' => 'nullable', - 'school_year' => 'nullable', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $flyerPath = null; - - // Handle file upload if present - if ($this->laravelRequest->hasFile('flyer')) { - $file = $this->laravelRequest->file('flyer'); - if ($file->isValid()) { - $path = $file->store('event_flyers', 'public'); - $flyerPath = $path; - } - } - - $payload = [ - 'event_name' => $data['event_name'], - 'description' => $data['description'] ?? null, - 'amount' => $data['amount'], - 'flyer' => $flyerPath, - 'expiration_date' => $data['expiration_date'], - 'semester' => $data['semester'] ?? $this->semester, - 'school_year' => $data['school_year'] ?? $this->schoolYear, - 'created_by' => $this->getCurrentUserId(), - ]; - - try { - $event = $this->event->create($payload); - return $this->respondCreated($event->toArray(), 'Event created successfully'); - } catch (\Throwable $e) { - Log::error('Event creation error: ' . $e->getMessage()); - return $this->respondError('Failed to create event', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function update($id = null) - { - $event = $this->event->find($id); - if (!$event) { - return $this->respondError('Event not found', Response::HTTP_NOT_FOUND); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $flyerPath = $event['flyer']; // Keep existing flyer by default - - // Handle file upload if present - if ($this->laravelRequest->hasFile('flyer')) { - $file = $this->laravelRequest->file('flyer'); - if ($file->isValid()) { - // Delete old flyer if exists - if ($flyerPath && Storage::disk('public')->exists($flyerPath)) { - Storage::disk('public')->delete($flyerPath); - } - $path = $file->store('event_flyers', 'public'); - $flyerPath = $path; - } - } - - $updateData = []; - $allowed = ['event_name', 'description', 'amount', 'expiration_date', 'semester', 'school_year']; - foreach ($allowed as $field) { - if (array_key_exists($field, $data)) { - $updateData[$field] = $data[$field]; - } - } - $updateData['flyer'] = $flyerPath; - - try { - $this->event->update($id, $updateData); - $updated = $this->event->find($id); - return $this->success($updated->toArray(), 'Event updated successfully'); - } catch (\Throwable $e) { - Log::error('Event update error: ' . $e->getMessage()); - return $this->respondError('Failed to update event', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function delete($id = null) - { - $event = $this->event->find($id); - if (!$event) { - return $this->respondError('Event not found', Response::HTTP_NOT_FOUND); - } - - try { - // Delete related charges and collect parent IDs - $charges = $this->eventCharges->newQuery() - ->where('event_id', $id) - ->get() - ->toArray(); - - $parentIds = []; - foreach ($charges as $charge) { - $this->eventCharges->delete($charge['id']); - $parentIds[] = $charge['parent_id']; - } - - // Delete event - $this->event->delete($id); - - $parentIds = array_unique($parentIds); - - // Regenerate invoices for affected parents - // Note: generateInvoice method may need to be implemented in InvoiceController - foreach ($parentIds as $parentId) { - // This would need to be implemented or called differently - // $this->invoiceController->generateInvoice($parentId); - } - - return $this->success(null, 'Event, charges, and invoices updated successfully'); - } catch (\Throwable $e) { - Log::error('Event deletion error: ' . $e->getMessage()); - return $this->respondError('Failed to delete event', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/events/charges - * Returns event charges with parent and student information - */ - public function charges() - { - $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; - $semester = $this->request->getGet('semester') ?? $this->semester; - - try { - $parents = $this->user->getParents(); - $events = $this->event->getActiveEvents($this->schoolYear); - - $charges = $this->eventCharges->newQuery() - ->select([ - 'event_charges.*', - 'users.firstname AS parent_firstname', - 'users.lastname AS parent_lastname', - 'students.firstname AS student_firstname', - 'students.lastname AS student_lastname', - 'events.event_name' - ]) - ->leftJoin('users', 'users.id', '=', 'event_charges.parent_id') - ->leftJoin('students', 'students.id', '=', 'event_charges.student_id') - ->leftJoin('events', 'events.id', '=', 'event_charges.event_id') - ->where('event_charges.school_year', $schoolYear) - ->where('event_charges.semester', $semester) - ->orderBy('event_charges.created_at', 'DESC') - ->get() - ->toArray(); - - return $this->success([ - 'charges' => $charges, - 'parents' => $parents, - 'events' => $events, - 'school_year' => $schoolYear, - 'semester' => $semester, - ], 'Event charges retrieved successfully'); - } catch (\Throwable $e) { - Log::error('Event charges retrieval error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve event charges', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * POST /api/v1/events/charges/update - * Updates event charges for a parent and students - */ - public function updateCharges() - { - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $schoolYear = $data['school_year'] ?? $this->schoolYear; - $semester = $data['semester'] ?? $this->semester; - $parentId = $data['parent_id'] ?? null; - $eventId = $data['event_id'] ?? null; - $participations = $data['participation'] ?? []; - - if (!$parentId || !$eventId || empty($participations)) { - return $this->respondError('Missing required information: parent_id, event_id, and participation are required', Response::HTTP_BAD_REQUEST); - } - - try { - $userId = $this->getCurrentUserId(); - $event = $this->event->getEvent($eventId, $schoolYear); - - if (!$event) { - return $this->respondError('Event not found', Response::HTTP_NOT_FOUND); - } - - foreach ($participations as $studentId => $value) { - $existing = $this->eventCharges->newQuery() - ->where('parent_id', $parentId) - ->where('student_id', $studentId) - ->where('event_id', $eventId) - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->first(); - - if ($value === 'no') { - if ($existing) { - $this->eventCharges->delete($existing['id']); - } - continue; - } - - // value is 'yes' - $chargeData = [ - 'parent_id' => $parentId, - 'student_id' => $studentId, - 'event_id' => $eventId, - 'participation' => 'yes', - 'charged' => $event['amount'], - 'school_year' => $schoolYear, - 'semester' => $semester, - ]; - - if ($existing) { - $chargeData['updated_by'] = $userId; - $this->eventCharges->update($existing['id'], $chargeData); - } else { - $chargeData['updated_by'] = $userId; - $this->eventCharges->insert($chargeData); - } - } - - // Regenerate invoice for parent - // Note: generateInvoice method may need to be implemented - // $this->invoiceController->generateInvoice($parentId); - - return $this->success(null, 'Event charges updated successfully'); - } catch (\Throwable $e) { - Log::error('Event charges update error: ' . $e->getMessage()); - return $this->respondError('Failed to update event charges', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/events/students-with-charges - * Returns students for a parent with their charge status - */ - public function getStudentsWithCharges() - { - $parentId = $this->request->getGet('parent_id'); - $semester = $this->request->getGet('semester') ?? $this->semester; - $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; - - if (!$parentId) { - return $this->respondError('parent_id is required', Response::HTTP_BAD_REQUEST); - } - - try { - // Get students for parent - $students = $this->student->newQuery() - ->where('parent_id', $parentId) - ->get() - ->toArray(); - - // Get student_ids that already have charges - $chargedStudentIds = $this->eventCharges->newQuery() - ->where('parent_id', $parentId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->groupBy('student_id') - ->pluck('student_id') - ->toArray(); - - $data = []; - foreach ($students as $student) { - $data[] = [ - 'id' => $student['id'], - 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), - 'charged' => in_array($student['id'], $chargedStudentIds), - ]; - } - - return $this->success($data, 'Students with charges retrieved successfully'); - } catch (\Throwable $e) { - Log::error('Get students with charges error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve students with charges', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/ExpenseController.php b/app/Http/Controllers/Api/ExpenseController.php deleted file mode 100755 index 8ea01d14..00000000 --- a/app/Http/Controllers/Api/ExpenseController.php +++ /dev/null @@ -1,429 +0,0 @@ -expense = model(Expense::class); - $this->user = model(User::class); - $this->config = model(Configuration::class); - - $this->schoolYear = $this->config->getConfig('school_year'); - $this->semester = $this->config->getConfig('semester'); - - // Default list of common retailors; adjust as needed - $this->retailors = [ - 'Amazon', - 'Walmart', - 'Costco', - 'BJ\'s', - 'Market Basket', - 'Aldi', - 'Hannaford', - 'Sam\'s Club', - 'HomeGoods', - 'Hostinger', - 'Wicked Cheesy', - 'Shatila', - 'Brothers Pizzeria', - 'Paradise Biryani Pointe', - 'Emad Leiman', - 'Nova Trampoline Park', - 'Lubin\'s Awards', - 'Dollar Tree', - 'Stop & Shop', - 'Dunkin\' Donuts', - 'Giovanni\'s Pizza' - ]; - } - - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $status = $this->request->getGet('status'); - $category = $this->request->getGet('category'); - - $query = $this->expense - ->select(" - expenses.*, - u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname, - approver.firstname AS approver_firstname, approver.lastname AS approver_lastname - ") - ->join('users u', 'u.id = expenses.purchased_by', 'left') - ->join('users approver', 'approver.id = expenses.approved_by', 'left'); - - if ($this->schoolYear) { - $query->where('expenses.school_year', $this->schoolYear); - } - if ($this->semester) { - $query->where('expenses.semester', $this->semester); - } - if ($status) { - $query->where('expenses.status', $status); - } - if ($category) { - $query->where('expenses.category', $category); - } - - $expenses = $query->orderBy('expenses.created_at', 'DESC')->findAll(); - - // Enrich each row with a URL that goes through Files::receipt($name) - $expenses = array_map(function ($row) { - $name = $row['receipt_path'] ?? null; - $row['receipt_url'] = $this->receiptUrl($name); - return $row; - }, $expenses); - - // Apply pagination manually for array results - $total = count($expenses); - $offset = ($page - 1) * $perPage; - $paginatedExpenses = array_slice($expenses, $offset, $perPage); - - $result = [ - 'data' => $paginatedExpenses, - 'pagination' => [ - 'current_page' => $page, - 'per_page' => $perPage, - 'total' => $total, - 'total_pages' => (int) ceil($total / $perPage), - ], - ]; - - return $this->success($result, 'Expenses retrieved successfully'); - } - - public function show($id = null) - { - $expense = $this->expense - ->select(" - expenses.*, - u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname, - approver.firstname AS approver_firstname, approver.lastname AS approver_lastname - ") - ->join('users u', 'u.id = expenses.purchased_by', 'left') - ->join('users approver', 'approver.id = expenses.approved_by', 'left') - ->where('expenses.id', $id) - ->first(); - - if (!$expense) { - return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND); - } - - // Add receipt URL - $expense['receipt_url'] = $this->receiptUrl($expense['receipt_path'] ?? null); - - return $this->success($expense, 'Expense retrieved successfully'); - } - - /** - * Get metadata for creating an expense (users list and retailors) - */ - public function createMetadata() - { - // Limit users to non-teacher/non-parent - $users = $this->user - ->select('users.id, users.firstname, users.lastname') - ->join('user_roles', 'user_roles.user_id = users.id') - ->join('roles', 'roles.id = user_roles.role_id') - ->whereNotIn('roles.name', ['teacher', 'parent']) - ->distinct() - ->findAll(); - - return $this->success([ - 'users' => $users, - 'retailors' => $this->retailors, - ], 'Metadata retrieved successfully'); - } - - public function create() - { - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'category' => 'required|in:Expense,Purchase,Reimbursement', - 'amount' => 'required|numeric|gt:0', - 'purchased_by' => 'required', - 'retailor' => 'nullable|max:255', - 'date_of_purchase'=> 'nullable|date', - ]; - - // Handle file upload validation - if ($this->laravelRequest->hasFile('receipt')) { - $rules['receipt'] = 'file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf'; - } else { - $rules['receipt'] = 'required|file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf'; - } - - // Use Laravel Validator directly for file validation - $validator = Validator::make($this->laravelRequest->all(), $rules); - if ($validator->fails()) { - return $this->respondValidationError($validator->errors()->toArray()); - } - - // Parse "purchased_by" as "7|John Doe" if provided in that format - $purchasedById = $user->id; - if (isset($data['purchased_by'])) { - $purchasedInfo = (string) $data['purchased_by']; - if (strpos($purchasedInfo, '|') !== false) { - [$purchasedById, $purchasedByName] = array_pad(explode('|', $purchasedInfo, 2), 2, null); - $purchasedById = (int) $purchasedById; - } else { - $purchasedById = (int) $purchasedInfo; - } - } - - // Handle file upload - $receiptName = null; - if ($this->laravelRequest->hasFile('receipt')) { - $file = $this->laravelRequest->file('receipt'); - if ($file->isValid()) { - $targetDir = storage_path('app/uploads/receipts'); - if (!is_dir($targetDir)) { - @mkdir($targetDir, 0755, true); - } - $extension = $file->getClientOriginalExtension(); - $newName = uniqid('', true) . '.' . $extension; - $file->move($targetDir, $newName); - $receiptName = $newName; - } - } - - $schoolYear = $this->schoolYear ?: date('Y'); - $semester = $this->semester ?: 'Fall'; - - $expenseData = [ - 'category' => $data['category'], - 'amount' => $data['amount'], - 'receipt_path' => $receiptName, - 'description' => $data['description'] ?? null, - 'retailor' => !empty($data['retailor']) ? trim($data['retailor']) : null, - 'date_of_purchase' => $data['date_of_purchase'] ?? null, - 'purchased_by' => $purchasedById, - 'added_by' => $user->id, - 'school_year' => $schoolYear, - 'semester' => $semester, - 'status' => 'pending', - ]; - - try { - $expense = $this->expense->create($expenseData); - $expense['receipt_url'] = $this->receiptUrl($receiptName); - return $this->respondCreated($expense, 'Expense created successfully'); - } catch (\Throwable $e) { - log_message('error', 'Expense creation error: ' . $e->getMessage()); - return $this->respondError('Failed to create expense', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Get metadata for editing an expense - */ - public function editMetadata($id = null) - { - $expense = $this->expense->find($id); - if (!$expense) { - return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND); - } - - // Get users list - $users = $this->user - ->select('users.id, users.firstname, users.lastname') - ->join('user_roles', 'user_roles.user_id = users.id') - ->join('roles', 'roles.id = user_roles.role_id') - ->whereNotIn('roles.name', ['teacher', 'parent']) - ->distinct() - ->findAll(); - - $expense['receipt_url'] = $expense['receipt_path'] ? $this->receiptUrl(basename($expense['receipt_path'])) : null; - - return $this->success([ - 'expense' => $expense, - 'users' => $users, - 'retailors' => $this->retailors, - ], 'Metadata retrieved successfully'); - } - - public function update($id = null) - { - $expense = $this->expense->find($id); - if (!$expense) { - return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND); - } - - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'category' => 'required|in:Expense,Purchase,Reimbursement', - 'amount' => 'required|numeric|gt:0', - 'purchased_by' => 'required', - 'retailor' => 'nullable|max:255', - 'date_of_purchase'=> 'nullable|date', - ]; - - // Optional new receipt validation (only if provided) - if ($this->laravelRequest->hasFile('receipt')) { - $rules['receipt'] = 'file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf'; - } - - // Use Laravel Validator directly for file validation - $validator = Validator::make($this->laravelRequest->all(), $rules); - if ($validator->fails()) { - return $this->respondValidationError($validator->errors()->toArray()); - } - - // Parse "purchased_by" as "id|Name" if provided in that format - $purchasedById = $expense['purchased_by']; - if (isset($data['purchased_by'])) { - $purchasedInfo = (string) $data['purchased_by']; - if (strpos($purchasedInfo, '|') !== false) { - [$purchasedById] = array_pad(explode('|', $purchasedInfo, 2), 2, null); - $purchasedById = (int) $purchasedById; - } else { - $purchasedById = (int) $purchasedInfo; - } - } - - // Keep old receipt unless replaced or removed - $receiptName = $expense['receipt_path']; - if ($this->laravelRequest->hasFile('receipt')) { - $file = $this->laravelRequest->file('receipt'); - if ($file->isValid()) { - $targetDir = storage_path('app/uploads/receipts'); - if (!is_dir($targetDir)) { - @mkdir($targetDir, 0755, true); - } - $extension = $file->getClientOriginalExtension(); - $newName = uniqid('', true) . '.' . $extension; - $file->move($targetDir, $newName); - $receiptName = $newName; - } - } - if (isset($data['remove_receipt']) && $data['remove_receipt'] === '1') { - $receiptName = null; - } - - $updateData = [ - 'category' => $data['category'], - 'amount' => $data['amount'], - 'description' => $data['description'] ?? $expense['description'], - 'retailor' => isset($data['retailor']) ? (trim($data['retailor']) ?: null) : $expense['retailor'], - 'date_of_purchase'=> $data['date_of_purchase'] ?? $expense['date_of_purchase'], - 'purchased_by' => $purchasedById, - 'receipt_path' => $receiptName, - 'updated_by' => $user->id, - ]; - - try { - $this->expense->update($id, $updateData); - $updatedExpense = $this->expense->find($id); - $updatedExpense['receipt_url'] = $this->receiptUrl($receiptName); - return $this->success($updatedExpense, 'Expense updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Expense update error: ' . $e->getMessage()); - return $this->respondError('Failed to update expense', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function updateStatus() - { - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - $id = isset($data['id']) ? (int)$data['id'] : null; - $status = $data['status'] ?? null; - $reason = $data['reason'] ?? ''; - - if (!$id || !in_array($status, ['approved', 'denied'], true)) { - log_message('error', 'Invalid status or ID'); - return $this->respondError('Invalid data', Response::HTTP_BAD_REQUEST); - } - - $expense = $this->expense->find($id); - if (!$expense) { - log_message('error', 'Expense not found for ID ' . $id); - return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND); - } - - try { - $this->expense->update($id, [ - 'status' => $status, - 'status_reason' => $reason, - 'approved_by' => $user->id, - 'updated_by' => $user->id - ]); - - $updatedExpense = $this->expense->find($id); - return $this->success($updatedExpense, 'Expense status updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Expense update failed for ID ' . $id . ': ' . $e->getMessage()); - return $this->respondError('Update failed', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function delete($id = null) - { - $expense = $this->expense->find($id); - if (!$expense) { - return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND); - } - - try { - $expense->delete(); - return $this->success(null, 'Expense deleted successfully'); - } catch (\Throwable $e) { - log_message('error', 'Expense delete error: ' . $e->getMessage()); - return $this->respondError('Failed to delete expense', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Build a public URL for a receipt filename through Files::receipt($name). - * Expects just the filename (e.g., "1759113425_1c443e607e1900f92f62.png"). - */ - private function receiptUrl(?string $filename): ?string - { - if (!$filename) { - return null; - } - // Using API route pattern - ensure route exists in routes/api.php - // Example: Route::get('files/receipt/{name}', [FilesController::class, 'receipt']); - return url('/api/v1/files/receipt/' . $filename); - } -} diff --git a/app/Http/Controllers/Api/ExtraChargesController.php b/app/Http/Controllers/Api/ExtraChargesController.php deleted file mode 100755 index 45bcccbb..00000000 --- a/app/Http/Controllers/Api/ExtraChargesController.php +++ /dev/null @@ -1,446 +0,0 @@ -additionalCharge = model(AdditionalCharge::class); - $this->user = model(User::class); - $this->invoice = model(Invoice::class); - $this->config = model(Configuration::class); - $this->schoolYear = $this->config->getConfig('school_year'); - $this->semester = $this->config->getConfig('semester'); - } - - public function index() - { - $year = (string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? ''); - $sem = (string) ($this->request->getGet('semester') ?? $this->semester ?? ''); - $status = $this->request->getGet('status') ?: null; - $q = trim((string) ($this->request->getGet('q') ?? '')) ?: null; - $per = (int) ($this->request->getGet('per_page') ?? 50); - - $paginator = $this->additionalCharge->listAllForTerm($year, $sem, $status, $q, $per); - - $meta = [ - 'perPage' => $paginator->perPage(), - 'page' => $paginator->currentPage(), - 'total' => $paginator->total(), - 'pageCount' => $paginator->lastPage(), - ]; - - return $this->success([ - 'school_year' => $year, - 'semester' => $sem, - 'rows' => $paginator->items(), - 'pager' => $meta, - ], 'Extra charges retrieved successfully'); - } - - public function show($id = null) - { - $charge = $this->additionalCharge->find($id); - if (!$charge) { - return $this->respondError('Extra charge not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($charge->toArray(), 'Extra charge retrieved successfully'); - } - - /** - * Get parent options for Select2 (with search) - */ - public function parentOptions() - { - $q = trim((string) ($this->request->getGet('q') ?? '')); - $limit = 20; - - $query = $this->user - ->select('users.id, users.firstname, users.lastname, users.email') - ->join('user_roles', 'user_roles.user_id = users.id') - ->join('roles', 'roles.id = user_roles.role_id') - ->whereRaw('LOWER(roles.name) = ?', ['parent']) - ->whereNull('user_roles.deleted_at'); - - if ($q !== '') { - $query->where(function ($builder) use ($q) { - $builder->where('users.firstname', 'like', "%{$q}%") - ->orWhere('users.lastname', 'like', "%{$q}%") - ->orWhere('users.email', 'like', "%{$q}%"); - }); - } - - $rows = $query - ->orderBy('users.lastname', 'ASC') - ->orderBy('users.firstname', 'ASC') - ->limit($limit) - ->findAll(); - - $results = array_map(function ($r) { - $name = trim(($r['lastname'] ?? '') . ', ' . ($r['firstname'] ?? '')); - $email = $r['email'] ?? ''; - $label = $name !== ',' ? $name : ('User #' . $r['id']); - if ($email) $label .= ' — ' . $email; - $label .= ' (ID: ' . $r['id'] . ')'; - return ['id' => (int) $r['id'], 'text' => $label]; - }, $rows); - - return $this->success(['results' => $results], 'Parent options retrieved successfully'); - } - - /** - * Get invoices for a parent (for Select2) - */ - public function invoicesForParent() - { - $parentId = (int) ($this->request->getGet('parent_id') ?? 0); - $schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? ''); - - $rows = ($parentId > 0) - ? ($this->invoice->getInvoicesByUserId($parentId, $schoolYear) ?? []) - : []; - - $results = array_map(function ($inv) { - $id = (int) ($inv['id'] ?? 0); - $num = (string) ($inv['invoice_number'] ?? ('INV-' . $id)); - $status = strtolower((string) ($inv['status'] ?? '')); - $balance = (float) ($inv['balance'] ?? 0); - $issue = $inv['issue_date'] ?? null; - - return [ - 'id' => $id, - 'invoice_number' => $num, - 'status' => $status, - 'balance' => $balance, - 'issue_date' => $issue, - 'text' => $num - . ($status ? ' — ' . ucfirst($status) : '') - . ' (Bal: $' . number_format($balance, 2) . ')', - ]; - }, $rows); - - return $this->success(['results' => $results], 'Invoices retrieved successfully'); - } - - public function store() - { - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'parent_id' => 'required|integer', - 'title' => 'required|string|min:2', - 'amount' => 'required|numeric', - 'charge_type' => 'required|in:add,deduct', - 'due_date' => 'nullable|date', - 'invoice_id' => 'nullable|integer', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $invoiceId = !empty($data['invoice_id']) ? (int) $data['invoice_id'] : null; - $chargeType = (string) $data['charge_type']; - - $amountAbs = round(abs((float) $data['amount']), 2); - $signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs; - - $payload = [ - 'parent_id' => (int) $data['parent_id'], - 'invoice_id' => $invoiceId, - 'school_year' => $data['school_year'] ?? $this->schoolYear, - 'semester' => $data['semester'] ?? $this->semester, - 'charge_type' => $chargeType, - 'title' => trim($data['title']), - 'description' => trim($data['description'] ?? ''), - 'amount' => $signedAmount, - 'due_date' => !empty($data['due_date']) ? $data['due_date'] : null, - 'status' => $invoiceId ? 'applied' : 'pending', - 'created_by' => $user->id, - 'created_at' => Carbon::now('UTC')->toDateTimeString(), - ]; - - try { - DB::beginTransaction(); - - // Get invoice before - $invoiceBefore = $this->normalizeRow( - $this->invoice->getInvoicesByParentId($data['parent_id'], $this->schoolYear) - ); - - // Insert charge - $chargeId = $this->additionalCharge->insert($payload); - if (!$chargeId) { - throw new \Exception('Failed to insert charge'); - } - - // Apply to invoice if present - if ($invoiceId) { - try { - if ($chargeType === 'add') { - $this->invoice->applyAdditionalCharge($invoiceId, $amountAbs); - } else { - $this->invoice->deductAdditionalCharge($invoiceId, $amountAbs); - } - } catch (\Throwable $e) { - log_message('error', 'apply/deductAdditionalCharge failed: ' . $e->getMessage()); - throw $e; - } - } - - // Get invoice after - $invoiceAfter = $this->normalizeRow( - $this->invoice->getInvoicesByParentId($data['parent_id'], $this->schoolYear) - ); - - // Get parent user info - $parentUser = $this->user->getUserInfoById($data['parent_id']); - - DB::commit(); - - // Build event payload - $first = $parentUser['first_name'] ?? $parentUser['firstname'] ?? ''; - $last = $parentUser['last_name'] ?? $parentUser['lastname'] ?? ''; - $parentName = trim($first . ' ' . $last); - - $invoiceTotal = $invoiceAfter ? (float) ($invoiceAfter['total_amount'] ?? 0) : 0.0; - $preBalance = $invoiceBefore ? (float) ($invoiceBefore['balance'] ?? 0) : 0.0; - $postBalance = $invoiceAfter ? (float) ($invoiceAfter['balance'] ?? 0) : 0.0; - - $eventData = [ - 'user_id' => (int) ($parentUser['id'] ?? 0), - 'email' => $parentUser['email'] ?? null, - 'firstname' => $first, - 'lastname' => $last, - 'parentName' => $parentName, - 'school_year' => $payload['school_year'], - 'semester' => $payload['semester'], - 'invoice_id' => $invoiceId, - 'invoice_number' => $invoiceAfter['invoice_number'] ?? ($invoiceId ? 'INV-' . $invoiceId : null), - 'charge_id' => $chargeId, - 'charge_title' => $payload['title'], - 'charge_desc' => $payload['description'], - 'charge_type' => $payload['charge_type'], - 'amount_signed' => $signedAmount, - 'amount_abs' => $amountAbs, - 'due_date' => $payload['due_date'], - 'created_at' => $payload['created_at'], - 'pre_balance' => $preBalance, - 'post_balance' => $postBalance, - 'invoice_total' => $invoiceTotal, - 'portal_link' => url('/login'), - 'invoice_link' => $invoiceId ? url('parent/invoices/view/' . $invoiceId) : url('parent/invoices'), - ]; - - // Dispatch event - Event::dispatch('extraCharge', [$eventData]); - - return $this->respondCreated([ - 'id' => $chargeId, - 'invoice_id' => $invoiceId, - 'parent_id' => (int) $data['parent_id'], - ], 'Charge recorded successfully'); - } catch (\Throwable $e) { - DB::rollBack(); - log_message('error', 'Transaction failed in ExtraChargesController::store: ' . $e->getMessage()); - return $this->respondError('Failed to save charge: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function update($id = null) - { - $row = $this->additionalCharge->find($id); - if (!$row) { - return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $newAmount = isset($data['amount']) ? (float) $data['amount'] : (float) $row['amount']; - $delta = $newAmount - (float) $row['amount']; - - try { - DB::beginTransaction(); - - // Update the charge - $this->additionalCharge->update($id, [ - 'title' => trim($data['title'] ?? $row['title']), - 'description' => trim($data['description'] ?? $row['description']), - 'amount' => $newAmount, - 'due_date' => $data['due_date'] ?? $row['due_date'], - 'charge_type' => $data['charge_type'] ?? $row['charge_type'], - ]); - - // If it's already applied on an invoice and the amount changed, reflect the delta - if ($row['status'] === 'applied' && !empty($row['invoice_id']) && abs($delta) > 0.00001) { - if ($delta > 0) { - $this->invoice->applyAdditionalCharge((int) $row['invoice_id'], $delta); - } else { - $this->invoice->reverseAdditionalCharge((int) $row['invoice_id'], -$delta); - } - } - - DB::commit(); - - $updated = $this->additionalCharge->find($id); - return $this->success($updated->toArray(), 'Charge updated successfully'); - } catch (\Throwable $e) { - DB::rollBack(); - log_message('error', 'Failed to update charge: ' . $e->getMessage()); - return $this->respondError('Failed to update charge', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Mark a charge as void and roll back its impact on the invoice if applied. - */ - public function void($id = null) - { - $row = $this->additionalCharge->find((int) $id); - if (!$row) { - return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND); - } - - $invoiceId = (int) ($row['invoice_id'] ?? 0); - $amountAbs = round(abs((float) ($row['amount'] ?? 0)), 2); - $chargeType = (string) ($row['charge_type'] ?? 'add'); - $status = (string) ($row['status'] ?? 'pending'); - - try { - DB::beginTransaction(); - - if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) { - try { - if ($chargeType === 'add') { - $this->invoice->reverseAdditionalCharge($invoiceId, $amountAbs); - } else { - // voiding a deduction -> add back - $this->invoice->applyAdditionalCharge($invoiceId, $amountAbs); - } - } catch (\Throwable $e) { - log_message('error', 'void(): invoice adjust failed: ' . $e->getMessage()); - throw $e; - } - } - - $this->additionalCharge->update((int) $id, [ - 'status' => 'void', - ]); - - DB::commit(); - - return $this->success(null, 'Charge voided successfully'); - } catch (\Throwable $e) { - DB::rollBack(); - log_message('error', 'Failed to void charge: ' . $e->getMessage()); - return $this->respondError('Failed to void charge', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Reverse a previously applied charge: undo invoice impact and return to pending state. - */ - public function reverse($id = null) - { - $row = $this->additionalCharge->find((int) $id); - if (!$row) { - return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND); - } - - $invoiceId = (int) ($row['invoice_id'] ?? 0); - $amountAbs = round(abs((float) ($row['amount'] ?? 0)), 2); - $chargeType = (string) ($row['charge_type'] ?? 'add'); - $status = (string) ($row['status'] ?? 'pending'); - - if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) { - return $this->respondError('Nothing to reverse', Response::HTTP_BAD_REQUEST); - } - - try { - DB::beginTransaction(); - - try { - if ($chargeType === 'add') { - $this->invoice->reverseAdditionalCharge($invoiceId, $amountAbs); - } else { - $this->invoice->applyAdditionalCharge($invoiceId, $amountAbs); - } - } catch (\Throwable $e) { - log_message('error', 'reverse(): invoice adjust failed: ' . $e->getMessage()); - throw $e; - } - - $this->additionalCharge->update((int) $id, [ - 'status' => 'pending', - 'invoice_id' => null, - ]); - - DB::commit(); - - return $this->success(null, 'Charge reversed to pending successfully'); - } catch (\Throwable $e) { - DB::rollBack(); - log_message('error', 'Failed to reverse charge: ' . $e->getMessage()); - return $this->respondError('Failed to reverse charge', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function delete($id = null) - { - $charge = $this->additionalCharge->find($id); - if (!$charge) { - return $this->respondError('Extra charge not found', Response::HTTP_NOT_FOUND); - } - - try { - $charge->delete(); - return $this->success(null, 'Extra charge deleted successfully'); - } catch (\Throwable $e) { - log_message('error', 'Failed to delete charge: ' . $e->getMessage()); - return $this->respondError('Failed to delete charge', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Normalize query result into a single associative row - */ - private function normalizeRow($row): ?array - { - if (!$row) return null; - if (is_array($row) && isset($row[0]) && is_array($row[0])) { - return $row[0]; // unwrap first row from a result set - } - return is_array($row) ? $row : null; - } -} diff --git a/app/Http/Controllers/Api/FamilyAdminController.php b/app/Http/Controllers/Api/FamilyAdminController.php deleted file mode 100755 index 3fad56b7..00000000 --- a/app/Http/Controllers/Api/FamilyAdminController.php +++ /dev/null @@ -1,525 +0,0 @@ -config = model(Configuration::class); - $this->invoice = model(Invoice::class); - $this->payment = model(Payment::class); - $this->studentClass = model(StudentClass::class); - $this->schoolYear = $this->config->getConfig('school_year'); - } - - public function index() - { - $studentId = (int) ($this->request->getGet('student_id') ?? 0); - $guardianId = (int) ($this->request->getGet('guardian_id') ?? 0); - - try { - $data = [ - 'student' => null, - 'families' => [], - 'guardians' => [], - 'students' => [], - 'search_students' => [], - 'search_guardians' => [], - ]; - - // Simple student list for select - $data['students'] = DB::table('students') - ->select('id', DB::raw("CONCAT(lastname, ', ', firstname) AS name")) - ->orderBy('lastname') - ->orderBy('firstname') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - // Preload search datasets - $data['search_students'] = DB::table('students') - ->select('id', 'firstname', 'lastname') - ->orderBy('lastname') - ->orderBy('firstname') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - $data['search_guardians'] = DB::table('family_guardians as fg') - ->distinct() - ->select('u.id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone') - ->join('users as u', 'u.id', '=', 'fg.user_id') - ->orderBy('u.lastname') - ->orderBy('u.firstname') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - // If a guardian is provided, resolve one of their students - if (!$studentId && $guardianId) { - $row = DB::table('family_guardians as fg') - ->select('s.id as student_id') - ->join('family_students as fs', 'fs.family_id', '=', 'fg.family_id') - ->join('students as s', 's.id', '=', 'fs.student_id') - ->where('fg.user_id', $guardianId) - ->orderBy('s.lastname') - ->orderBy('s.firstname') - ->first(); - - if (!$row) { - $row = DB::table('students as s') - ->select('s.id as student_id') - ->where('s.parent_id', $guardianId) - ->orderBy('s.lastname') - ->orderBy('s.firstname') - ->first(); - } - - if ($row && !empty($row->student_id)) { - $studentId = (int) $row->student_id; - } - } - - if ($studentId > 0) { - $student = DB::table('students') - ->select('id', 'firstname', 'lastname') - ->where('id', $studentId) - ->first(); - - if ($student) { - $data['student'] = (array) $student; - - // Fetch all families for this student - $families = DB::table('family_students as fs') - ->select( - 'f.id', 'f.family_code', 'f.household_name', 'f.address_line1', 'f.address_line2', - 'f.city', 'f.state', 'f.postal_code', 'f.country', 'f.primary_phone', - 'f.preferred_lang', 'f.preferred_contact_method', 'f.is_active', - 'fs.is_primary_home' - ) - ->join('families as f', 'f.id', '=', 'fs.family_id') - ->where('fs.student_id', $studentId) - ->orderByDesc('fs.is_primary_home') - ->orderBy('f.household_name') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - // Enrich each family with guardians, students, and financials - foreach ($families as &$fam) { - $fid = (int) $fam['id']; - - // Guardians - $guardians = DB::table('family_guardians as fg') - ->select( - 'u.id AS user_id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone', - 'u.address_street', 'u.city', 'u.state', 'u.zip', - 'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms' - ) - ->join('users as u', 'u.id', '=', 'fg.user_id') - ->where('fg.family_id', $fid) - ->orderByDesc('fg.is_primary') - ->orderBy('u.lastname') - ->orderBy('u.firstname') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - $fam['guardians'] = $guardians; - - // Students in this family - $studentsRows = DB::table('family_students as fs') - ->select('s.id', 's.firstname', 's.lastname') - ->join('students as s', 's.id', '=', 'fs.student_id') - ->where('fs.family_id', $fid) - ->orderBy('s.lastname') - ->orderBy('s.firstname') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - // Enrich with grade label - if (!empty($studentsRows)) { - foreach ($studentsRows as &$sr) { - $sid = (int) ($sr['id'] ?? 0); - $sr['grade'] = $sid && $this->schoolYear - ? (string) ($this->studentClass->getClassSectionsByStudentId($sid, $this->schoolYear) ?? '') - : ''; - } - unset($sr); - } - - $fam['students'] = $studentsRows; - - // Financials: invoices and payments for all guardians - $parentIds = array_map(static fn($g) => (int) ($g['user_id'] ?? 0), $guardians); - $parentIds = array_values(array_filter($parentIds)); - - $fam['invoices'] = []; - $fam['payments'] = []; - $fam['finance_summary'] = [ - 'invoices_count' => 0, - 'total_amount' => 0.0, - 'paid_amount' => 0.0, - 'balance' => 0.0, - ]; - - if (!empty($parentIds)) { - // Invoices - $invRows = DB::table('invoices') - ->select( - 'id', 'parent_id', 'invoice_number', 'status', 'total_amount', - 'paid_amount', 'balance', 'issue_date', 'due_date' - ) - ->whereIn('parent_id', $parentIds) - ->orderByDesc('issue_date') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - $fam['invoices'] = $invRows; - - // Map invoice id -> number - $invoiceMap = []; - foreach ($invRows as $ir) { - $invoiceMap[(int) $ir['id']] = (string) ($ir['invoice_number'] ?? ''); - } - $fam['invoice_map'] = $invoiceMap; - - // Aggregate summary - foreach ($invRows as $ir) { - $fam['finance_summary']['invoices_count']++; - $fam['finance_summary']['total_amount'] += (float) ($ir['total_amount'] ?? 0); - $fam['finance_summary']['paid_amount'] += (float) ($ir['paid_amount'] ?? 0); - $fam['finance_summary']['balance'] += (float) ($ir['balance'] ?? 0); - } - - // Recent payments (limit 10) - $payRows = DB::table('payments') - ->select( - 'id', 'parent_id', 'invoice_id', 'paid_amount', 'balance', - 'payment_method', 'payment_date', 'status' - ) - ->whereIn('parent_id', $parentIds) - ->orderByDesc('payment_date') - ->limit(10) - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - $fam['payments'] = $payRows; - } - } - unset($fam); - - $data['families'] = $families; - - // Back-compat: also expose guardians of first family - if (!empty($families)) { - $familyId = (int) $families[0]['id']; - $data['guardians'] = DB::table('family_guardians as fg') - ->select( - 'u.id', 'u.firstname', 'u.lastname', 'u.email', - 'fg.relation', 'fg.is_primary', 'fg.receive_emails' - ) - ->join('users as u', 'u.id', '=', 'fg.user_id') - ->where('fg.family_id', $familyId) - ->orderByDesc('fg.is_primary') - ->orderBy('u.lastname') - ->orderBy('u.firstname') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - } - } - } - - return $this->success($data, 'Family admin data retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Family admin error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve family admin data', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function search() - { - $q = trim((string) ($this->request->getGet('q') ?? '')); - if ($q === '') { - return $this->success(['items' => []], 'Search completed'); - } - - try { - $qs = '%' . $q . '%'; - - // Students suggestions - $students = DB::table('students') - ->select('id', 'firstname', 'lastname') - ->whereRaw("CONCAT_WS(' ', firstname, lastname) LIKE ?", [$qs]) - ->orderBy('lastname') - ->orderBy('firstname') - ->limit(8) - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - // Parents/Guardians suggestions - $guardians = DB::table('users') - ->select('id', 'firstname', 'lastname', 'email', 'cellphone') - ->where(function ($query) use ($qs) { - $query->whereRaw("CONCAT_WS(' ', firstname, lastname) LIKE ?", [$qs]) - ->orWhere('email', 'like', $qs) - ->orWhere('cellphone', 'like', $qs); - }) - ->orderBy('lastname') - ->orderBy('firstname') - ->limit(8) - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - $items = []; - foreach ($students as $s) { - $items[] = [ - 'type' => 'student', - 'id' => (int) $s['id'], - 'label' => trim(($s['firstname'] ?? '') . ' ' . ($s['lastname'] ?? '')), - 'sub' => 'Student', - ]; - } - - foreach ($guardians as $g) { - $items[] = [ - 'type' => 'guardian', - 'id' => (int) $g['id'], - 'label' => trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? '')), - 'sub' => trim(($g['email'] ?? '') . ' ' . ($g['cellphone'] ?? '')), - ]; - } - - return $this->success(['items' => $items], 'Search completed successfully'); - } catch (\Throwable $e) { - log_message('error', 'Family admin search error: ' . $e->getMessage()); - return $this->respondError('Failed to perform search', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function card() - { - $studentId = (int) ($this->request->getGet('student_id') ?? 0); - $guardianId = (int) ($this->request->getGet('guardian_id') ?? 0); - $familyId = (int) ($this->request->getGet('family_id') ?? 0); - - try { - if (!$familyId) { - if ($studentId) { - $row = DB::table('family_students as fs') - ->select('f.id') - ->join('families as f', 'f.id', '=', 'fs.family_id') - ->where('fs.student_id', $studentId) - ->orderByDesc('fs.is_primary_home') - ->orderBy('f.household_name') - ->first(); - - if ($row && !empty($row->id)) { - $familyId = (int) $row->id; - } - } elseif ($guardianId) { - // Try via guardians link - $row = DB::table('family_guardians as fg') - ->select('f.id') - ->join('families as f', 'f.id', '=', 'fg.family_id') - ->where('fg.user_id', $guardianId) - ->orderBy('f.household_name') - ->first(); - - if ($row && !empty($row->id)) { - $familyId = (int) $row->id; - } else { - // Fallback via students.parent_id → family_students - $row = DB::table('students as s') - ->select('f.id') - ->join('family_students as fs', 'fs.student_id', '=', 's.id') - ->join('families as f', 'f.id', '=', 'fs.family_id') - ->where('s.parent_id', $guardianId) - ->orderByDesc('fs.is_primary_home') - ->orderBy('f.household_name') - ->first(); - - if ($row && !empty($row->id)) { - $familyId = (int) $row->id; - } - } - } - } - - if (!$familyId) { - return $this->respondError('Family not found', Response::HTTP_NOT_FOUND); - } - - $family = DB::table('families as f') - ->select( - 'f.id', 'f.family_code', 'f.household_name', 'f.address_line1', 'f.address_line2', - 'f.city', 'f.state', 'f.postal_code', 'f.country', 'f.primary_phone', - 'f.preferred_lang', 'f.preferred_contact_method', 'f.is_active' - ) - ->where('f.id', $familyId) - ->first(); - - if (!$family) { - return $this->respondError('Family not found', Response::HTTP_NOT_FOUND); - } - - $family = (array) $family; - - // Guardians - $guardians = DB::table('family_guardians as fg') - ->select( - 'u.id AS user_id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone', - 'u.address_street', 'u.city', 'u.state', 'u.zip', - 'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms' - ) - ->join('users as u', 'u.id', '=', 'fg.user_id') - ->where('fg.family_id', $familyId) - ->orderByDesc('fg.is_primary') - ->orderBy('u.lastname') - ->orderBy('u.firstname') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - $family['guardians'] = $guardians; - - // Students - $studentsRows = DB::table('family_students as fs') - ->select('s.id', 's.firstname', 's.lastname') - ->join('students as s', 's.id', '=', 'fs.student_id') - ->where('fs.family_id', $familyId) - ->orderBy('s.lastname') - ->orderBy('s.firstname') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - if (!empty($studentsRows)) { - foreach ($studentsRows as &$sr) { - $sid = (int) ($sr['id'] ?? 0); - $sr['grade'] = $sid && $this->schoolYear - ? (string) ($this->studentClass->getClassSectionsByStudentId($sid, $this->schoolYear) ?? '') - : ''; - } - unset($sr); - } - - $family['students'] = $studentsRows; - - // Financials - $parentIds = array_map(static fn($g) => (int) ($g['user_id'] ?? 0), $guardians); - $parentIds = array_values(array_filter($parentIds)); - - $family['invoices'] = []; - $family['payments'] = []; - $family['finance_summary'] = [ - 'invoices_count' => 0, - 'total_amount' => 0.0, - 'paid_amount' => 0.0, - 'balance' => 0.0, - ]; - - // Emergency contacts - $family['emergency_contacts'] = []; - - if (!empty($parentIds)) { - // Map guardian name by user_id - $gmap = []; - foreach ($guardians as $g) { - $gid = (int) ($g['user_id'] ?? 0); - if ($gid > 0) { - $gmap[$gid] = trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? '')); - } - } - - $ecRows = DB::table('emergency_contacts') - ->select( - 'id', 'parent_id', 'emergency_contact_name', 'relation', 'cellphone', 'email', - 'school_year', 'semester', 'created_at', 'updated_at' - ) - ->whereIn('parent_id', $parentIds) - ->orderByDesc('updated_at') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - if (!empty($ecRows)) { - foreach ($ecRows as &$ec) { - $pid = (int) ($ec['parent_id'] ?? 0); - $ec['parent_label'] = $gmap[$pid] ?? ('Parent #' . $pid); - } - unset($ec); - $family['emergency_contacts'] = $ecRows; - } - - // Invoices - $invRows = DB::table('invoices') - ->select( - 'id', 'parent_id', 'invoice_number', 'status', 'total_amount', - 'paid_amount', 'balance', 'issue_date', 'due_date' - ) - ->whereIn('parent_id', $parentIds) - ->orderByDesc('issue_date') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - $family['invoices'] = $invRows; - - $invoiceMap = []; - foreach ($invRows as $ir) { - $invoiceMap[(int) $ir['id']] = (string) ($ir['invoice_number'] ?? ''); - } - $family['invoice_map'] = $invoiceMap; - - foreach ($invRows as $ir) { - $family['finance_summary']['invoices_count']++; - $family['finance_summary']['total_amount'] += (float) ($ir['total_amount'] ?? 0); - $family['finance_summary']['paid_amount'] += (float) ($ir['paid_amount'] ?? 0); - $family['finance_summary']['balance'] += (float) ($ir['balance'] ?? 0); - } - - // Payments - $payRows = DB::table('payments') - ->select( - 'id', 'parent_id', 'invoice_id', 'paid_amount', 'balance', - 'payment_method', 'payment_date', 'status' - ) - ->whereIn('parent_id', $parentIds) - ->orderByDesc('payment_date') - ->limit(10) - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - $family['payments'] = $payRows; - } - - return $this->success(['family' => $family], 'Family card retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Family card error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve family card', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/FamilyController.php b/app/Http/Controllers/Api/FamilyController.php deleted file mode 100755 index 848bd3f0..00000000 --- a/app/Http/Controllers/Api/FamilyController.php +++ /dev/null @@ -1,651 +0,0 @@ -family = model(Family::class); - $this->familyStudent = model(FamilyStudent::class); - $this->familyGuardian = model(FamilyGuardian::class); - $this->student = model(Student::class); - $this->user = model(User::class); - } - - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - - $result = $this->paginate($this->family->newQuery()->orderBy('id'), $page, $perPage); - - foreach ($result['data'] as &$family) { - $family['students'] = $this->familyStudent - ->where('family_id', $family['id']) - ->findAll(); - $family['guardians'] = $this->familyGuardian - ->where('family_id', $family['id']) - ->findAll(); - } - - return $this->success($result, 'Families retrieved successfully'); - } - - public function show($id = null) - { - $family = $this->family->find($id); - if (!$family) { - return $this->respondError('Family not found', Response::HTTP_NOT_FOUND); - } - - $family['students'] = $this->familyStudent->where('family_id', $id)->findAll(); - $family['guardians'] = $this->familyGuardian->where('family_id', $id)->findAll(); - - return $this->success($family, 'Family retrieved successfully'); - } - - public function getByStudent($id = null) - { - $familyIds = $this->familyStudent->where('student_id', $id)->findColumn('family_id'); - if (empty($familyIds)) { - return $this->success([], 'No families found for this student'); - } - - $families = $this->family->whereIn('id', $familyIds)->get(); - $payload = []; - foreach ($families as $family) { - $family->students = $this->familyStudent->where('family_id', $family->id)->findAll(); - $family->guardians = $this->familyGuardian->where('family_id', $family->id)->findAll(); - $payload[] = $family; - } - - return $this->success($payload, 'Families retrieved successfully'); - } - - public function getGuardians($id = null) - { - $family = $this->family->find($id); - if (!$family) { - return $this->respondError('Family not found', Response::HTTP_NOT_FOUND); - } - - $guardians = $this->familyGuardian->where('family_id', $id)->findAll(); - return $this->success($guardians, 'Guardians retrieved successfully'); - } - - /** - * Get families for a student - * GET /api/v1/families/by-student/{student_id} - */ - public function familiesByStudent(int $studentId) - { - $rows = DB::table('family_students as fs') - ->select('f.*', 'fs.is_primary_home') - ->join('families as f', 'f.id', '=', 'fs.family_id') - ->where('fs.student_id', $studentId) - ->where('f.is_active', 1) - ->orderByDesc('fs.is_primary_home') - ->orderBy('f.household_name') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - return $this->success(['data' => $rows], 'Families retrieved successfully'); - } - - /** - * Get guardians for a family - * GET /api/v1/families/{family_id}/guardians - */ - public function guardiansByFamily(int $familyId) - { - $rows = DB::table('family_guardians as fg') - ->select( - 'u.id AS user_id', 'u.firstname', 'u.lastname', 'u.email', - 'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms' - ) - ->join('users as u', 'u.id', '=', 'fg.user_id') - ->where('fg.family_id', $familyId) - ->orderByDesc('fg.is_primary') - ->orderBy('u.lastname') - ->orderBy('u.firstname') - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - return $this->success(['data' => $rows], 'Guardians retrieved successfully'); - } - - /** - * Bootstrap families from existing students.parent_id - * POST /api/v1/families/bootstrap - */ - public function bootstrap() - { - // Guard: ensure required tables exist - foreach (['families', 'family_students', 'family_guardians'] as $t) { - if (!DB::getSchemaBuilder()->hasTable($t)) { - return $this->respondError( - "Missing required table '{$t}'. Run migrations.", - Response::HTTP_INTERNAL_SERVER_ERROR - ); - } - } - - try { - DB::beginTransaction(); - - // Get all distinct primary parents from students.parent_id - $primaryParents = DB::table('students') - ->select('parent_id') - ->whereNotNull('parent_id') - ->distinct() - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - $created = 0; - $linkedStudents = 0; - $linkedGuardians = 0; - - foreach ($primaryParents as $row) { - $primaryUserId = (int) $row['parent_id']; - if ($primaryUserId <= 0) continue; - - $familyId = $this->ensureFamilyForPrimaryParent($primaryUserId, $created); - - // Link all students of this primary parent - $students = DB::table('students') - ->select('id') - ->where('parent_id', $primaryUserId) - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - foreach ($students as $s) { - $linkedStudents += $this->attachStudentToFamily((int) $s['id'], $familyId); - } - - // Ensure primary parent is guardian on that family - $linkedGuardians += $this->attachGuardianUser($familyId, $primaryUserId, 'primary', true, 1, 0); - } - - DB::commit(); - - return $this->success([ - 'families_created' => $created, - 'students_linked' => $linkedStudents, - 'guardians_linked' => $linkedGuardians, - ], 'Bootstrap completed successfully'); - } catch (\Throwable $e) { - DB::rollBack(); - log_message('error', 'Family bootstrap error: ' . $e->getMessage()); - return $this->respondError('Failed to bootstrap families: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Attach second parent by user ID - * POST /api/v1/families/attach-second-by-user - */ - public function attachSecondByUser() - { - $data = $this->payloadData(); - $studentId = (int) ($data['student_id'] ?? 0); - $userId = (int) ($data['user_id'] ?? 0); - $relation = (string) ($data['relation'] ?? 'secondary'); - - if (!$studentId || !$userId) { - return $this->respondError('student_id and user_id required', Response::HTTP_BAD_REQUEST); - } - - $familyId = $this->familyIdFromStudentPrimary($studentId); - if (!$familyId) { - return $this->respondError('No primary family found for this student', Response::HTTP_NOT_FOUND); - } - - $rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0); - - return $this->success([ - 'attached' => $rows, - 'family_id' => $familyId, - ], 'Guardian attached successfully'); - } - - /** - * Attach second parent by email (creates user if needed) - * POST /api/v1/families/attach-second-by-email - */ - public function attachSecondByEmail() - { - $data = $this->payloadData(); - $studentId = (int) ($data['student_id'] ?? 0); - $email = trim((string) ($data['email'] ?? '')); - $first = trim((string) ($data['firstname'] ?? '')); - $last = trim((string) ($data['lastname'] ?? '')); - $relation = (string) ($data['relation'] ?? 'secondary'); - - if (!$studentId || !$email) { - return $this->respondError('student_id and email required', Response::HTTP_BAD_REQUEST); - } - - $familyId = $this->familyIdFromStudentPrimary($studentId); - if (!$familyId) { - return $this->respondError('No primary family found for this student', Response::HTTP_NOT_FOUND); - } - - $user = $this->user->where('email', $email)->first(); - if (!$user) { - // Create a minimal/stub user - $newUser = $this->user->create([ - 'firstname' => $first ?: '', - 'lastname' => $last ?: '', - 'email' => $email, - 'status' => 'Active', - 'user_type' => 'secondary', - ]); - if (!$newUser || !isset($newUser['id'])) { - return $this->respondError('Failed to create user', Response::HTTP_INTERNAL_SERVER_ERROR); - } - $userId = (int) $newUser['id']; - } else { - $userId = (int) $user['id']; - } - - $rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0); - - return $this->success([ - 'attached' => $rows, - 'family_id' => $familyId, - 'user_id' => $userId, - ], 'Guardian attached successfully'); - } - - /** - * Set primary home flag for a student-family relationship - * POST /api/v1/families/set-primary-home - */ - public function setPrimaryHome() - { - $data = $this->payloadData(); - $familyId = (int) ($data['family_id'] ?? 0); - $studentId = (int) ($data['student_id'] ?? 0); - $flag = (int) ($data['is_primary_home'] ?? 0); - - if (!$familyId || !$studentId) { - return $this->respondError('family_id and student_id required', Response::HTTP_BAD_REQUEST); - } - - try { - $this->familyStudent - ->where(['family_id' => $familyId, 'student_id' => $studentId]) - ->update(['is_primary_home' => $flag ? 1 : 0]); - - return $this->success(null, 'Primary home flag updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Set primary home error: ' . $e->getMessage()); - return $this->respondError('Failed to update primary home flag', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Set guardian flags (receive_emails, is_primary, receive_sms, relation) - * POST /api/v1/families/set-guardian-flags - */ - public function setGuardianFlags() - { - $data = $this->payloadData(); - $familyId = (int) ($data['family_id'] ?? 0); - $userId = (int) ($data['user_id'] ?? 0); - - if (!$familyId || !$userId) { - return $this->respondError('family_id and user_id required', Response::HTTP_BAD_REQUEST); - } - - $updateData = []; - foreach (['receive_emails', 'is_primary', 'receive_sms', 'relation'] as $k) { - if (isset($data[$k])) { - $val = $data[$k]; - $updateData[$k] = in_array($k, ['receive_emails', 'is_primary', 'receive_sms']) - ? (int) $val - : (string) $val; - } - } - - if (empty($updateData)) { - return $this->respondError('No flags provided', Response::HTTP_BAD_REQUEST); - } - - try { - $this->familyGuardian - ->where(['family_id' => $familyId, 'user_id' => $userId]) - ->update($updateData); - - return $this->success(null, 'Guardian flags updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Set guardian flags error: ' . $e->getMessage()); - return $this->respondError('Failed to update guardian flags', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Unlink a guardian from a family - * POST /api/v1/families/unlink-guardian - */ - public function unlinkGuardian() - { - $data = $this->payloadData(); - $familyId = (int) ($data['family_id'] ?? 0); - $userId = (int) ($data['user_id'] ?? 0); - - if (!$familyId || !$userId) { - return $this->respondError('family_id and user_id required', Response::HTTP_BAD_REQUEST); - } - - try { - $this->familyGuardian - ->where(['family_id' => $familyId, 'user_id' => $userId]) - ->delete(); - - return $this->success(null, 'Guardian unlinked successfully'); - } catch (\Throwable $e) { - log_message('error', 'Unlink guardian error: ' . $e->getMessage()); - return $this->respondError('Failed to unlink guardian', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Unlink a student from a family - * POST /api/v1/families/unlink-student - */ - public function unlinkStudent() - { - $data = $this->payloadData(); - $familyId = (int) ($data['family_id'] ?? 0); - $studentId = (int) ($data['student_id'] ?? 0); - - if (!$familyId || !$studentId) { - return $this->respondError('family_id and student_id required', Response::HTTP_BAD_REQUEST); - } - - try { - $this->familyStudent - ->where(['family_id' => $familyId, 'student_id' => $studentId]) - ->delete(); - - return $this->success(null, 'Student unlinked successfully'); - } catch (\Throwable $e) { - log_message('error', 'Unlink student error: ' . $e->getMessage()); - return $this->respondError('Failed to unlink student', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Import second parents from legacy 'parents' table - * POST /api/v1/families/import-second-parents - */ - public function importSecondParentsFromLegacy() - { - $L = [ - 'table' => 'parents', - 'firstparent_id' => 'firstparent_id', - 'second_user_id' => 'secondparent_id', - 'second_email' => 'secondparent_email', - 'second_firstname' => 'secondparent_firstname', - 'second_lastname' => 'secondparent_lastname', - ]; - - if (!DB::getSchemaBuilder()->hasTable($L['table'])) { - return $this->respondError("Legacy table '{$L['table']}' not found", Response::HTTP_NOT_FOUND); - } - - foreach (['families', 'family_students', 'family_guardians'] as $t) { - if (!DB::getSchemaBuilder()->hasTable($t)) { - return $this->respondError( - "Missing required table '{$t}'. Run migrations.", - Response::HTTP_INTERNAL_SERVER_ERROR - ); - } - } - - try { - $rows = DB::table($L['table']) - ->select( - DB::raw("{$L['firstparent_id']} AS first_id"), - DB::raw("{$L['second_user_id']} AS second_id"), - DB::raw("{$L['second_email']} AS email"), - DB::raw("{$L['second_firstname']} AS firstname"), - DB::raw("{$L['second_lastname']} AS lastname") - ) - ->where(function ($query) use ($L) { - $query->where($L['second_user_id'], '!=', 0) - ->orWhere($L['second_email'], '!=', ''); - }) - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - $createdUsers = 0; - $linked = 0; - $skipped = 0; - - foreach ($rows as $r) { - $firstId = (int) ($r['first_id'] ?? 0); - $secondId = (int) ($r['second_id'] ?? 0); - $email = trim((string) ($r['email'] ?? '')); - - if (!$firstId || (!$secondId && $email === '')) { - $skipped++; - continue; - } - - // Ensure/find family by primary parent - $code = 'FAM-' . $firstId; - $fam = $this->family->where('family_code', $code)->first(); - if ($fam) { - $familyId = (int) $fam['id']; - } else { - $tmp = 0; // counter sink - $familyId = $this->ensureFamilyForPrimaryParent($firstId, $tmp); - } - - // Resolve/create user - $userId = 0; - if ($secondId > 0) { - $userId = $secondId; - } else { - $user = $this->user->where('email', $email)->first(); - if (!$user) { - $newUser = $this->user->create([ - 'firstname' => $r['firstname'] ?? '', - 'lastname' => $r['lastname'] ?? '', - 'email' => $email, - 'status' => 'Active', - 'user_type' => 'secondary', - ]); - if ($newUser && isset($newUser['id'])) { - $userId = (int) $newUser['id']; - $createdUsers++; - } else { - $userId = 0; - } - } else { - $userId = (int) $user['id']; - } - } - - if (!$userId) { - $skipped++; - continue; - } - - // Ensure all students for this primary parent are linked to this family - $stuRows = DB::table('students') - ->select('id') - ->where('parent_id', $firstId) - ->get() - ->map(fn($row) => (array) $row) - ->toArray(); - - foreach ($stuRows as $sr) { - $sid = (int) ($sr['id'] ?? 0); - if ($sid > 0) { - $this->attachStudentToFamily($sid, $familyId); - } - } - - // Link as guardian (idempotent) - $linked += $this->attachGuardianUser($familyId, $userId, 'guardian', false, 1, 0); - } - - return $this->success([ - 'created_users' => $createdUsers, - 'guardians_linked' => $linked, - 'skipped' => $skipped, - 'total_source' => count($rows), - ], 'Legacy import completed successfully'); - } catch (\Throwable $e) { - log_message('error', 'Import second parents error: ' . $e->getMessage()); - return $this->respondError('Failed to import second parents: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /* ========================================================= - * Private helpers - * =======================================================*/ - - /** - * Ensure there is exactly one family per primary parent (users.id) - * Returns $familyId and increments $createdCounter by reference if newly created. - */ - protected function ensureFamilyForPrimaryParent(int $primaryUserId, int &$createdCounter): int - { - $code = 'FAM-' . $primaryUserId; - $row = $this->family->where('family_code', $code)->first(); - if ($row) { - return (int) $row['id']; - } - - $family = $this->family->create([ - 'family_code' => $code, - 'household_name' => 'Family of User ' . $primaryUserId, - 'is_active' => 1, - ]); - - if ($family && isset($family['id'])) { - $createdCounter++; - return (int) $family['id']; - } - - // If create failed, try to find it again (race condition) - $row = $this->family->where('family_code', $code)->first(); - if ($row) { - return (int) $row['id']; - } - - throw new \Exception('Failed to create family for primary parent ' . $primaryUserId); - } - - /** - * Attach a student to a family (idempotent; returns rows affected >0 if inserted) - */ - protected function attachStudentToFamily(int $studentId, int $familyId): int - { - // Check if already exists - $exists = $this->familyStudent - ->where('family_id', $familyId) - ->where('student_id', $studentId) - ->first(); - - if ($exists) { - return 0; - } - - // Insert - $result = $this->familyStudent->create([ - 'family_id' => $familyId, - 'student_id' => $studentId, - 'is_primary_home' => 1, - ]); - - return ($result && isset($result['id'])) ? 1 : 0; - } - - /** - * Attach a guardian user to family (idempotent) - */ - protected function attachGuardianUser( - int $familyId, - int $userId, - string $relation = 'primary', - bool $isPrimary = false, - int $receiveEmails = 1, - int $receiveSms = 0 - ): int { - // Check if already exists - $exists = $this->familyGuardian - ->where('family_id', $familyId) - ->where('user_id', $userId) - ->first(); - - if ($exists) { - return 0; - } - - // Insert - $result = $this->familyGuardian->create([ - 'family_id' => $familyId, - 'user_id' => $userId, - 'relation' => $relation, - 'is_primary' => $isPrimary ? 1 : 0, - 'receive_emails' => $receiveEmails, - 'receive_sms' => $receiveSms, - ]); - - return ($result && isset($result['id'])) ? 1 : 0; - } - - /** - * Find the "primary" family for a student = family created from parent_id - */ - protected function familyIdFromStudentPrimary(int $studentId): ?int - { - // Get primary parent id for the student - $st = $this->student->select('parent_id')->find($studentId); - if (!$st || empty($st['parent_id'])) { - return null; - } - - // The canonical family uses code FAM-{parent_id} - $code = 'FAM-' . (int) $st['parent_id']; - $row = $this->family->select('id')->where('family_code', $code)->first(); - if ($row) { - return (int) $row['id']; - } - - // If not found, fall back to any family linked already - $row = DB::table('family_students') - ->select('family_id') - ->where('student_id', $studentId) - ->orderByDesc('is_primary_home') - ->first(); - - return $row ? (int) $row->family_id : null; - } -} diff --git a/app/Http/Controllers/Api/FilesController.php b/app/Http/Controllers/Api/FilesController.php deleted file mode 100755 index fc1910c7..00000000 --- a/app/Http/Controllers/Api/FilesController.php +++ /dev/null @@ -1,184 +0,0 @@ -respondError('Invalid filename', Response::HTTP_BAD_REQUEST); - } - - // 2) Allow-list extensions (optional but safer) - $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); - if (!in_array($ext, $this->allowedExtensions, true)) { - throw new NotFoundHttpException(); - } - - // 3) Build path under storage/app/uploads/receipts - $path = $this->storagePath("uploads/receipts/{$name}"); - - if (!is_file($path)) { - throw new NotFoundHttpException(); - } - - return $this->streamFile($path, $name); - } - - public function reimb(string $name) - { - // 1) Path traversal guard - if ($name !== basename($name)) { - return $this->respondError('Invalid filename', Response::HTTP_BAD_REQUEST); - } - - // 2) Allow-list extensions - $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); - if (!in_array($ext, $this->allowedExtensions, true)) { - throw new NotFoundHttpException(); - } - - // 3) Build path under storage/app/uploads/reimbursements - $path = $this->storagePath("uploads/reimbursements/{$name}"); - - if (!is_file($path)) { - throw new NotFoundHttpException(); - } - - return $this->streamFile($path, $name); - } - - /** - * Alias for reimb() for backward compatibility - */ - public function reimbursement(string $name) - { - return $this->reimb($name); - } - - public function index() - { - $type = $this->request->getGet('type') ?? 'receipts'; - $allowedTypes = ['receipts', 'reimbursements', 'checks']; - - if (!in_array($type, $allowedTypes, true)) { - return $this->respondError('Invalid file type', Response::HTTP_BAD_REQUEST); - } - - try { - $path = $this->storagePath("uploads/{$type}/"); - $files = []; - - if (is_dir($path)) { - foreach (scandir($path) as $file) { - if ($file === '.' || $file === '..') { - continue; - } - $filePath = $path . $file; - if (!is_file($filePath)) { - continue; - } - $files[] = [ - 'name' => $file, - 'size' => filesize($filePath), - 'modified' => date('Y-m-d H:i:s', filemtime($filePath)), - ]; - } - } - - return $this->success([ - 'type' => $type, - 'files' => $files, - 'count' => count($files), - ], 'Files retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Files list error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve files', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - protected function streamFile(string $path, string $name): HttpResponse - { - // 4) MIME detection (finfo is more reliable than mime_content_type) - $mime = 'application/octet-stream'; - if (function_exists('finfo_open')) { - $fi = finfo_open(FILEINFO_MIME_TYPE); - if ($fi) { - $detected = finfo_file($fi, $path); - if ($detected) { - $mime = $detected; - } - finfo_close($fi); - } - } elseif (function_exists('mime_content_type')) { - $mime = mime_content_type($path) ?: $mime; - } - - // 5) Caching (ETag + Last-Modified) and 304 support - $mtime = filemtime($path) ?: time(); - $size = filesize($path) ?: 0; - $etag = md5($name . '|' . $mtime . '|' . $size); - - $ifNoneMatch = trim($this->laravelRequest->header('If-None-Match', ''), '"'); - $ifModifiedSince = $this->laravelRequest->header('If-Modified-Since'); - $imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false; - - if (($ifNoneMatch && $ifNoneMatch === $etag) || - ($imsTime !== false && $imsTime >= $mtime) - ) { - return response('', Response::HTTP_NOT_MODIFIED) - ->header('ETag', $etag) - ->header('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT'); - } - - // 6) Stream the file inline - return response()->make(file_get_contents($path), Response::HTTP_OK, [ - 'Content-Type' => $mime, - 'X-Content-Type-Options' => 'nosniff', - 'Content-Disposition' => 'inline; filename="' . $name . '"', - 'Content-Length' => (string) $size, - 'ETag' => $etag, - 'Last-Modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT', - 'Cache-Control' => 'public, max-age=86400', - ]); - } - - protected function storagePath(string $relative): string - { - return storage_path('app/' . $relative); - } - - protected function isAllowedExtension(string $name): bool - { - $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); - return in_array($ext, $this->allowedExtensions, true); - } - - protected function detectMime(string $path): string - { - if (function_exists('finfo_open')) { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - if ($finfo) { - $mime = finfo_file($finfo, $path); - finfo_close($finfo); - if ($mime) { - return $mime; - } - } - } elseif (function_exists('mime_content_type')) { - $mime = mime_content_type($path); - if ($mime) { - return $mime; - } - } - return 'application/octet-stream'; - } -} diff --git a/app/Http/Controllers/Api/FinalController.php b/app/Http/Controllers/Api/FinalController.php deleted file mode 100755 index f929e21b..00000000 --- a/app/Http/Controllers/Api/FinalController.php +++ /dev/null @@ -1,230 +0,0 @@ -final = model(FinalExam::class); - $this->config = model(Configuration::class); - $this->student = model(Student::class); - } - - public function getByStudent($id = null) - { - $schoolYear = $this->config->getConfig('school_year'); - $semester = $this->config->getConfig('semester'); - - $final = $this->final->newQuery() - ->when($id, fn($q) => $q->where('student_id', $id)) - ->when($schoolYear, fn($q) => $q->where('school_year', $schoolYear)) - ->when($semester, fn($q) => $q->where('semester', $semester)) - ->first(); - - return $this->success($final ?: null, 'Final exam retrieved successfully'); - } - - public function create() - { - $user = $this->getCurrentUser(); - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'student_id' => 'required|integer', - 'class_section_id' => 'required|integer', - ]; - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $schoolYear = $this->config->getConfig('school_year'); - $semester = $this->config->getConfig('semester'); - - $query = $this->final->newQuery() - ->where('student_id', $data['student_id']) - ->where('class_section_id', $data['class_section_id']); - if ($schoolYear) { - $query->where('school_year', $schoolYear); - } - if ($semester) { - $query->where('semester', $semester); - } - $existing = $query->first(); - - $finalData = [ - 'student_id' => $data['student_id'], - 'class_section_id' => $data['class_section_id'], - 'score' => $data['score'] ?? null, - 'comment' => $data['comment'] ?? null, - 'school_year' => $schoolYear, - 'semester' => $semester, - 'updated_by' => $user->id ?? null, - ]; - - try { - if ($existing) { - $existing->fill($finalData); - $existing->save(); - $final = $existing->fresh(); - } else { - $final = $this->final->create($finalData); - } - return $this->success($final, 'Final exam saved successfully', Response::HTTP_CREATED); - } catch (\Throwable $e) { - log_message('error', 'Final exam save error: ' . $e->getMessage()); - return $this->respondError('Failed to save final exam', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function update($id = null) - { - $final = $this->final->find($id); - if (!$final) { - return $this->respondError('Final exam not found', Response::HTTP_NOT_FOUND); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $allowed = ['score', 'comment']; - $updateData = []; - foreach ($allowed as $field) { - if (array_key_exists($field, $data)) { - $updateData[$field] = $data[$field]; - } - } - - if (empty($updateData)) { - return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST); - } - - $updateData['updated_by'] = $this->getCurrentUser()->id ?? null; - - try { - $final->fill($updateData); - $final->save(); - return $this->success($final->fresh(), 'Final exam updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Final exam update error: ' . $e->getMessage()); - return $this->respondError('Failed to update final exam', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Get students with their final exam scores for a class section - * - * @param int|null $classSectionId - * @return \Illuminate\Http\JsonResponse - */ - public function getByClassSection($classSectionId = null) - { - // Get class_section_id from route parameter, query param, or request body - $incoming = $classSectionId - ?? $this->request->getGet('class_section_id') - ?? $this->request->getPost('class_section_id') - ?? null; - - $classSectionId = (int) ($incoming ?: 0); - - if ($classSectionId <= 0) { - return $this->respondError('Missing or invalid class section ID', Response::HTTP_BAD_REQUEST); - } - - try { - $result = $this->getSavedScores($classSectionId); - return $this->success($result, 'Students with final exam scores retrieved successfully'); - } catch (\InvalidArgumentException $e) { - return $this->respondError($e->getMessage(), Response::HTTP_BAD_REQUEST); - } catch (\Throwable $e) { - log_message('error', 'Final exam getByClassSection error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve students with final exam scores', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Get students with their final exam scores for a class section - * - * @param int $classSectionId - * @return array - * @throws \InvalidArgumentException - */ - protected function getSavedScores(int $classSectionId): array - { - if ($classSectionId <= 0) { - throw new \InvalidArgumentException('Invalid class section id.'); - } - - $schoolYear = $this->config->getConfig('school_year'); - $semester = $this->config->getConfig('semester'); - - $db = \Config\Database::connect(); - - // Subquery: pick ONE final exam row per student (latest by id) - $latestFinalSub = $db->table('final_exam') - ->select('student_id, MAX(id) AS max_id') - ->where('class_section_id', $classSectionId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->groupBy('student_id') - ->getCompiledSelect(); - - // Main query: roster + LEFT JOIN the single final exam row - $builder = $this->student->builder(); - - $builder - ->select(' - s.id AS student_id, - s.school_id, - s.firstname, - s.lastname, - fe.score - ') - ->from('students s') - ->join( - 'student_class sc', - 'sc.student_id = s.id - AND sc.class_section_id = ' . (int) $classSectionId . ' - AND sc.school_year = ' . $db->escape($schoolYear), - 'inner', - false - ) - ->join('(' . $latestFinalSub . ') fl', 'fl.student_id = s.id', 'left', false) - ->join('final_exam fe', 'fe.id = fl.max_id', 'left') - ->orderBy('s.lastname', 'ASC') - ->orderBy('s.firstname', 'ASC'); - - $builder->distinct(); - $rows = $builder->get()->getResultArray(); - - // Ensure one row per student - $unique = []; - foreach ($rows as $r) { - $unique[(int)$r['student_id']] = $r; - } - $students = array_values($unique); - - return [ - 'students' => $students, - 'semester' => $semester, - 'schoolYear' => $schoolYear, - 'class_section_id' => $classSectionId, - ]; - } -} diff --git a/app/Http/Controllers/Api/FinancialController.php b/app/Http/Controllers/Api/FinancialController.php deleted file mode 100755 index c1300afe..00000000 --- a/app/Http/Controllers/Api/FinancialController.php +++ /dev/null @@ -1,724 +0,0 @@ - 'Masjid', - // 2 => 'Donation', - ]; - - public function __construct() - { - parent::__construct(); - $this->invoice = model(Invoice::class); - $this->payment = model(Payment::class); - $this->refund = model(Refund::class); - $this->expense = model(Expense::class); - $this->reimbursement = model(Reimbursement::class); - $this->discount = model(DiscountUsage::class); - $this->config = model(Configuration::class); - $this->additionalCharge = model(AdditionalCharge::class); - } - - /** - * Detailed financial report with invoices, payments, payment breakdown, refunds, expenses, reimbursements, discounts - */ - public function report() - { - $dateFrom = $this->request->getGet('date_from'); - $dateTo = $this->request->getGet('date_to'); - $schoolYear = $this->request->getGet('school_year'); - - try { - $db = \Config\Database::connect(); - - // Build filtered models - $invoice = $this->invoice; - $refund = $this->refund; - $discount = $this->discount; - $expense = $this->expense; - $reimbursement = $this->reimbursement; - - // Apply filters - if ($schoolYear) { - $invoice->where('invoices.school_year', $schoolYear); - $refund->where('school_year', $schoolYear); - $discount->where('school_year', $schoolYear); - $expense->where('school_year', $schoolYear); - $reimbursement->where('school_year', $schoolYear); - } - - $hasFrom = !empty($dateFrom); - $hasTo = !empty($dateTo); - - if ($hasFrom) { - $invoice->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom); - $refund->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom); - $discount->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom); - $expense->where('DATE(created_at) >=', $dateFrom); - $reimbursement->where('DATE(created_at) >=', $dateFrom); - } - - if ($hasTo) { - $invoice->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo); - $refund->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo); - $discount->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo); - $expense->where('DATE(created_at) <=', $dateTo); - $reimbursement->where('DATE(created_at) <=', $dateTo); - } - - // Get invoices - $invoices = $invoice - ->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name") - ->join('users', 'users.id = invoices.parent_id') - ->findAll(); - - // Helper to build filtered Payment - $buildPayment = function () use ($schoolYear, $dateFrom, $dateTo) { - $pm = $this->payment; - if ($schoolYear) { - $pm->where('school_year', $schoolYear); - } - if (!empty($dateFrom)) { - $pm->where('DATE(payment_date) >=', $dateFrom); - } - if (!empty($dateTo)) { - $pm->where('DATE(payment_date) <=', $dateTo); - } - return $pm; - }; - - // Payments aggregated by invoice_id - $payments = $buildPayment() - ->select('invoice_id, SUM(paid_amount) AS paid_amount') - ->where('invoice_id IS NOT NULL') - ->groupBy('invoice_id') - ->findAll(); - - // Payment breakdown by method (cash/credit/check) - $paymentBreakdownRows = $buildPayment() - ->select(" - invoice_id, - CASE - WHEN LOWER(TRIM(payment_method)) = 'cash' THEN 'cash' - WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN 'check' - WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN 'credit' - ELSE 'other' - END AS method, - SUM(paid_amount) AS amount - ") - ->where('invoice_id IS NOT NULL') - ->groupBy('invoice_id, method') - ->findAll(); - - $paymentBreakdown = []; - foreach ($paymentBreakdownRows as $row) { - $iid = (int)($row['invoice_id'] ?? 0); - if ($iid <= 0) continue; - $method = (string)$row['method']; - $amount = (float)$row['amount']; - if (!isset($paymentBreakdown[$iid])) $paymentBreakdown[$iid] = []; - $paymentBreakdown[$iid][$method] = $amount; - } - - // Overall payment totals by method - $paymentTotalsRow = $buildPayment() - ->select(" - SUM(paid_amount) AS total_all, - SUM(CASE WHEN LOWER(TRIM(payment_method)) = 'cash' THEN paid_amount ELSE 0 END) AS total_cash, - SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN paid_amount ELSE 0 END) AS total_check, - SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit - ") - ->where('invoice_id IS NOT NULL') - ->first() ?? []; - - $paymentTotals = [ - 'total_all' => (float)($paymentTotalsRow['total_all'] ?? 0), - 'total_cash' => (float)($paymentTotalsRow['total_cash'] ?? 0), - 'total_check' => (float)($paymentTotalsRow['total_check'] ?? 0), - 'total_credit' => (float)($paymentTotalsRow['total_credit'] ?? 0), - ]; - - // Refunds (grouped) - $refunds = $refund - ->select('invoice_id, school_year, SUM(refund_paid_amount) AS total_refunded') - ->where('invoice_id IS NOT NULL') - ->groupBy(['invoice_id', 'school_year']) - ->findAll(); - - // Discounts (grouped) - $discounts = $discount - ->select('invoice_id, school_year, SUM(discount_amount) AS discount_amount') - ->where('invoice_id IS NOT NULL') - ->groupBy(['invoice_id', 'school_year']) - ->get() - ->getResultArray(); - - // Expenses - $expenses = $expense - ->select('category, SUM(amount) AS total_amount') - ->groupBy('category') - ->findAll(); - - // Reimbursements - $reimbursements = $reimbursement - ->select('status, SUM(amount) AS total_amount') - ->groupBy('status') - ->findAll(); - - // School year options - $schoolYears = []; - try { - $rows = $db->table('invoices') - ->select('DISTINCT school_year', false) - ->where('school_year IS NOT NULL', null, false) - ->orderBy('school_year', 'DESC') - ->get()->getResultArray(); - foreach ($rows as $r) { - $val = (string)($r['school_year'] ?? ''); - if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val; - } - } catch (\Throwable $e) {} - - if (empty($schoolYears) && !empty($schoolYear)) { - $schoolYears[] = (string)$schoolYear; - } - - return $this->success([ - 'selectedYear' => $schoolYear, - 'dateFrom' => $dateFrom, - 'dateTo' => $dateTo, - 'schoolYears' => $schoolYears, - 'invoices' => $invoices, - 'payments' => $payments, - 'paymentBreakdown' => $paymentBreakdown, - 'paymentTotals' => $paymentTotals, - 'refunds' => $refunds, - 'expenses' => $expenses, - 'reimbursements' => $reimbursements, - 'discounts' => $discounts, - ], 'Financial report retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Financial report error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve financial report', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Financial report summary with aggregated totals - */ - public function reportSummary() - { - $dateFrom = $this->request->getGet('date_from'); - $dateTo = $this->request->getGet('date_to'); - $schoolYear = $this->request->getGet('school_year'); - - try { - $data = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear); - - // Build school year options - $schoolYears = []; - try { - $db = \Config\Database::connect(); - $rows = $db->table('invoices') - ->select('DISTINCT school_year', false) - ->where('school_year IS NOT NULL', null, false) - ->orderBy('school_year', 'DESC') - ->get()->getResultArray(); - foreach ($rows as $r) { - $val = (string)($r['school_year'] ?? ''); - if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val; - } - } catch (\Throwable $e) {} - - if (empty($schoolYears) && !empty($data['schoolYear'])) { - $schoolYears[] = (string)$data['schoolYear']; - } - - $data['schoolYears'] = $schoolYears; - - return $this->success($data, 'Financial report summary retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Financial report summary error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve financial report summary', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Download financial report as CSV - */ - public function downloadCsv() - { - $dateFrom = $this->request->getGet('date_from'); - $dateTo = $this->request->getGet('date_to'); - $schoolYear = $this->request->getGet('school_year'); - - try { - $invoice = $this->invoice; - $expense = $this->expense; - $reimbursement = $this->reimbursement; - - // Invoices with filters - $invBuilder = $invoice - ->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name") - ->join('users', 'users.id = invoices.parent_id'); - - if (!empty($schoolYear)) { - $invBuilder->where('invoices.school_year', $schoolYear); - } - if (!empty($dateFrom)) { - $invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom); - } - if (!empty($dateTo)) { - $invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo); - } - $invoices = $invBuilder->findAll(); - - // Expenses summary - $expBuilder = $expense->select('category, SUM(amount) AS total_amount'); - if (!empty($schoolYear)) { - $expBuilder->where('school_year', $schoolYear); - } - if (!empty($dateFrom)) { - $expBuilder->where('DATE(created_at) >=', $dateFrom); - } - if (!empty($dateTo)) { - $expBuilder->where('DATE(created_at) <=', $dateTo); - } - $expenses = $expBuilder->groupBy('category')->findAll(); - - // Reimbursements summary - $reimbBuilder = $reimbursement->select('status, SUM(amount) AS total_amount'); - if (!empty($schoolYear)) { - $reimbBuilder->where('school_year', $schoolYear); - } - if (!empty($dateFrom)) { - $reimbBuilder->where('DATE(created_at) >=', $dateFrom); - } - if (!empty($dateTo)) { - $reimbBuilder->where('DATE(created_at) <=', $dateTo); - } - $reimbursements = $reimbBuilder->groupBy('status')->findAll(); - - $filename = 'financial_report_' . date('Ymd_His') . '.csv'; - - $headers = [ - 'Content-Type' => 'text/csv', - 'Content-Disposition' => 'attachment; filename="' . $filename . '"', - ]; - - return new StreamedResponse(function () use ($invoices, $expenses, $reimbursements) { - $out = fopen('php://output', 'w'); - - // Invoices section - fputcsv($out, ['Invoice #', 'Parent', 'School Year', 'Total', 'Paid', 'Balance', 'Refund']); - foreach ($invoices as $inv) { - fputcsv($out, [ - $inv['invoice_number'] ?? '', - $inv['parent_name'] ?? '', - $inv['school_year'] ?? '', - $inv['total_amount'] ?? 0, - $inv['paid_amount'] ?? 0, - $inv['balance'] ?? 0, - $inv['refund_amount'] ?? 0, - ]); - } - - // Expenses section - fputcsv($out, []); - fputcsv($out, ['Expenses Summary']); - fputcsv($out, ['Category', 'Total Amount']); - foreach ($expenses as $exp) { - fputcsv($out, [ - $exp['category'] ?? '', - $exp['total_amount'] ?? 0, - ]); - } - - // Reimbursements section - fputcsv($out, []); - fputcsv($out, ['Reimbursements Summary']); - fputcsv($out, ['Status', 'Total Amount']); - foreach ($reimbursements as $r) { - fputcsv($out, [ - $r['status'] ?? '', - $r['total_amount'] ?? 0, - ]); - } - - fclose($out); - }, Response::HTTP_OK, $headers); - } catch (\Throwable $e) { - log_message('error', 'CSV download error: ' . $e->getMessage()); - return $this->respondError('Failed to generate CSV', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Get financial summary data - */ - protected function getFinancialSummary(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array - { - $schoolYear = $schoolYear ?: $this->config->getConfig('school_year'); - - $invoice = $this->invoice; - $payment = $this->payment; - $refund = $this->refund; - $expense = $this->expense; - $reimbursement = $this->reimbursement; - $discount = $this->discount; - - // Invoices: Total Charges - $invoiceBuilder = $invoice->where('school_year', $schoolYear); - if (!empty($dateFrom)) { - $invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom); - } - if (!empty($dateTo)) { - $invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo); - } - $invoices = $invoiceBuilder->findAll(); - $totalCharges = array_sum(array_column($invoices, 'total_amount')); - - // Additional Charges - $acBuilder = $this->additionalCharge->where('school_year', $schoolYear) - ->where('status !=', 'void'); - if ($dateFrom && $dateTo) { - $acBuilder->where('due_date >=', $dateFrom) - ->where('due_date <=', $dateTo); - } - $acResult = $acBuilder->selectSum('amount')->first(); - $totalExtraCharges = isset($acResult['amount']) ? (float) $acResult['amount'] : 0.00; - $totalCharges = $totalCharges + $totalExtraCharges; - - // Payments: Total Paid - $paymentBuilder = $payment->where('school_year', $schoolYear); - if (!empty($dateFrom)) { - $paymentBuilder->where('DATE(payment_date) >=', $dateFrom); - } - if (!empty($dateTo)) { - $paymentBuilder->where('DATE(payment_date) <=', $dateTo); - } - $paymentResult = $paymentBuilder->selectSum('paid_amount')->first(); - $totalPaid = isset($paymentResult['paid_amount']) ? (float) $paymentResult['paid_amount'] : 0.00; - - // Expenses - $expenseBuilder = $expense->where('school_year', $schoolYear); - if (!empty($dateFrom)) { - $expenseBuilder->where('DATE(created_at) >=', $dateFrom); - } - if (!empty($dateTo)) { - $expenseBuilder->where('DATE(created_at) <=', $dateTo); - } - $expenseResult = $expenseBuilder->selectSum('amount')->first(); - $totalExpenses = isset($expenseResult['amount']) ? (float) $expenseResult['amount'] : 0.00; - - // Reimbursements - $reimbursementBuilder = $reimbursement->where('school_year', $schoolYear); - if (!empty($dateFrom)) { - $reimbursementBuilder->where('DATE(created_at) >=', $dateFrom); - } - if (!empty($dateTo)) { - $reimbursementBuilder->where('DATE(created_at) <=', $dateTo); - } - $reimbursementResult = $reimbursementBuilder->selectSum('amount')->first(); - $totalReimbursements = isset($reimbursementResult['amount']) ? (float) $reimbursementResult['amount'] : 0.00; - - // Donations line: reimbursements recorded against Masjid/Donation recipients - $donationToSchool = 0.0; - $specialRecipientIds = array_map('intval', array_keys(self::SPECIAL_RECIPIENTS)); - if (!empty($specialRecipientIds)) { - $donationBuilder = $reimbursement - ->where('school_year', $schoolYear) - ->whereIn('reimbursed_to', $specialRecipientIds); - if (!empty($dateFrom)) { - $donationBuilder->where('DATE(created_at) >=', $dateFrom); - } - if (!empty($dateTo)) { - $donationBuilder->where('DATE(created_at) <=', $dateTo); - } - $donationRow = $donationBuilder->selectSum('amount')->first(); - $donationToSchool = isset($donationRow['amount']) ? (float) $donationRow['amount'] : 0.00; - } - $totalReimbursements = max(0.0, $totalReimbursements - $donationToSchool); - - // Refunds - $refundBuilder = $refund - ->where('school_year', $schoolYear) - ->where('status', 'Paid') - ->where('refund_paid_amount IS NOT NULL'); - if (!empty($dateFrom)) { - $refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom); - } - if (!empty($dateTo)) { - $refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo); - } - $refundResult = $refundBuilder->selectSum('refund_paid_amount')->first(); - $totalRefunds = isset($refundResult['refund_paid_amount']) ? (float) $refundResult['refund_paid_amount'] : 0.00; - - // Discounts - $discountBuilder = $discount - ->join('invoices', 'invoices.id = discount_usages.invoice_id') - ->where('invoices.school_year', $schoolYear); - if (!empty($dateFrom)) { - $discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) >=', $dateFrom); - } - if (!empty($dateTo)) { - $discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) <=', $dateTo); - } - $discountResult = $discountBuilder->selectSum('discount_amount')->first(); - $totalDiscounts = isset($discountResult['discount_amount']) ? (float) $discountResult['discount_amount'] : 0.00; - - // Net & Unpaid - $totalUnpaid = ($totalCharges - $totalDiscounts) - $totalPaid; - $amountCollected = $totalPaid; - $netAmount = ($totalCharges - $totalDiscounts - $totalRefunds); - - return [ - 'schoolYear' => $schoolYear, - 'dateFrom' => $dateFrom, - 'dateTo' => $dateTo, - 'totalCharges' => $totalCharges, - 'totalExtraCharges' => $totalExtraCharges, - 'totalDiscounts' => $totalDiscounts, - 'totalRefunds' => $totalRefunds, - 'totalExpenses' => $totalExpenses, - 'totalReimbursements' => $totalReimbursements, - 'donationToSchool' => $donationToSchool, - 'totalPaid' => $totalPaid, - 'amountCollected' => $amountCollected, - 'totalUnpaid' => $totalUnpaid, - 'netAmount' => $netAmount, - ]; - } - - /** - * List parents with outstanding balances (> 0) for a school year - */ - public function unpaidParents() - { - $schoolYear = trim((string)($this->request->getGet('school_year') ?? '')); - if ($schoolYear === '') { - $schoolYear = (string) ($this->config->getConfig('school_year') ?? date('Y')); - } - - try { - $db = \Config\Database::connect(); - - // Build school year options - $schoolYears = []; - try { - $rows = $db->table('invoices') - ->select('DISTINCT school_year', false) - ->where('school_year IS NOT NULL', null, false) - ->orderBy('school_year', 'DESC') - ->get()->getResultArray(); - foreach ($rows as $r) { - $val = (string)($r['school_year'] ?? ''); - if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val; - } - } catch (\Throwable $e) {} - - if (empty($schoolYears) && !empty($schoolYear)) { - $schoolYears[] = (string)$schoolYear; - } - - // Aggregate balances by parent - $invRows = $db->table('invoices i') - ->select('i.id, i.parent_id, i.total_amount, u.firstname, u.lastname, u.email') - ->join('users u', 'u.id = i.parent_id', 'inner') - ->where('i.school_year', $schoolYear) - ->orderBy('i.parent_id', 'ASC') - ->orderBy('i.id', 'ASC') - ->get()->getResultArray(); - - $byParent = []; - $paymentsTbl = 'payments'; - $hasStatus = $db->fieldExists('status', $paymentsTbl); - $hasVoid = $db->fieldExists('is_void', $paymentsTbl); - - foreach ($invRows as $r) { - $iid = (int)($r['id'] ?? 0); - $pid = (int)($r['parent_id'] ?? 0); - if ($iid <= 0 || $pid <= 0) continue; - - // Sum payments for this invoice - $qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid); - if ($hasStatus) { - $qb->groupStart() - ->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled']) - ->orWhere('status IS NULL', null, false) - ->groupEnd(); - } - if ($hasVoid) { - $qb->groupStart() - ->where('is_void', 0) - ->orWhere('is_void IS NULL', null, false) - ->groupEnd(); - } - $paidRow = $qb->get()->getRowArray(); - $paidSum = (float)($paidRow['tot'] ?? 0); - - // Sum discounts - $discRow = $db->table('discount_usages') - ->select('COALESCE(SUM(discount_amount),0) AS tot') - ->where('invoice_id', $iid) - ->get()->getRowArray(); - $discSum = (float)($discRow['tot'] ?? 0); - - // Sum refunds PAID - $refRow = $db->table('refunds') - ->select('COALESCE(SUM(refund_paid_amount),0) AS tot') - ->where('invoice_id', $iid) - ->whereIn('status', ['Partial','Paid']) - ->get()->getRowArray(); - $refSum = (float)($refRow['tot'] ?? 0); - - $total = (float)($r['total_amount'] ?? 0); - $balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2)); - - if (!isset($byParent[$pid])) { - $byParent[$pid] = [ - 'parent_id' => $pid, - 'firstname' => (string)($r['firstname'] ?? ''), - 'lastname' => (string)($r['lastname'] ?? ''), - 'email' => (string)($r['email'] ?? ''), - 'total_invoice' => 0.0, - 'total_balance' => 0.0, - 'total_paid' => 0.0, - ]; - } - - $byParent[$pid]['total_invoice'] += $total; - $byParent[$pid]['total_balance'] += $balance; - $byParent[$pid]['total_paid'] += $paidSum; - } - - // Build rows list (only parents with positive balance) - $rows = []; - $installmentEndRaw = (string) ($this->config->getConfig('installment_date') ?? ''); - $tzName = (string) (config('School')->attendance['timezone'] ?? 'America/New_York'); - $tzNY = new \DateTimeZone($tzName); - $todayNY = new \DateTimeImmutable('today', $tzNY); - $endDate = null; - if ($installmentEndRaw) { - try { $endDate = new \DateTimeImmutable($installmentEndRaw, $tzNY); } catch (\Throwable $e) { $endDate = null; } - } - - $monthsUntil = function (?\DateTimeImmutable $end) use ($todayNY): int { - if (!$end) return 0; - $y = (int)$end->format('Y') - (int)$todayNY->format('Y'); - $m = (int)$end->format('n') - (int)$todayNY->format('n'); - $months = $y * 12 + $m; - if ((int)$end->format('j') > (int)$todayNY->format('j')) $months += 1; - return max(0, $months); - }; - - foreach ($byParent as $pid => $agg) { - $balance = (float)($agg['total_balance'] ?? 0); - if ($balance > 0.00001) { - $remMonths = $monthsUntil($endDate); - $remainingInstallments = max(1, $remMonths); - $instAmount = round($balance / $remainingInstallments, 2); - - $rows[] = [ - 'parent_id' => $pid, - 'firstname' => $agg['firstname'], - 'lastname' => $agg['lastname'], - 'email' => $agg['email'], - 'total_invoice' => (float)$agg['total_invoice'], - 'total_balance' => $balance, - 'remaining_installments' => $remainingInstallments, - 'installment_amount' => $instAmount, - ]; - } - } - - // Order by total_balance desc - usort($rows, static function ($a, $b) { - return ($b['total_balance'] <=> $a['total_balance']); - }); - - // Compute next installment date - $tz = new \DateTimeZone($tzName); - $now = new \DateTime('now', $tz); - $nextInstallmentDt = (clone $now)->modify('first day of next month'); - $nextInstallmentYmd = $nextInstallmentDt->format('Y-m-d'); - - // Determine payment types - $parentIds = array_values(array_unique(array_map(static fn($r) => (int)($r['parent_id'] ?? 0), $rows))); - $hasPayments = []; - $paidTotals = []; - - if (!empty($parentIds)) { - try { - $pRows = $db->table('payments') - ->select('parent_id, SUM(paid_amount) AS total_paid') - ->whereIn('parent_id', $parentIds) - ->where('school_year', $schoolYear) - ->groupBy('parent_id') - ->get()->getResultArray(); - foreach ($pRows as $pr) { - $pid = (int)($pr['parent_id'] ?? 0); - if ($pid > 0) { - $hasPayments[$pid] = true; - $paidTotals[$pid] = (float)($pr['total_paid'] ?? 0); - } - } - } catch (\Throwable $e) {} - } - - $dataRows = array_map(function(array $r) use ($hasPayments, $paidTotals, $nextInstallmentYmd) { - $pid = (int)($r['parent_id'] ?? 0); - $name = trim((string)($r['firstname'] ?? '') . ' ' . (string)($r['lastname'] ?? '')); - - return [ - 'parent_id' => $pid, - 'parent_name' => $name, - 'email' => (string)($r['email'] ?? ''), - 'total_invoice' => (float)($r['total_invoice'] ?? 0), - 'total_balance' => (float)($r['total_balance'] ?? 0), - 'remaining_installments' => (int)($r['remaining_installments'] ?? 0), - 'installment_amount' => (float)($r['installment_amount'] ?? 0), - 'type' => isset($hasPayments[$pid]) ? 'installment' : 'no_payment', - 'total_paid' => isset($paidTotals[$pid]) ? (float)$paidTotals[$pid] : 0.0, - 'has_installment'=> isset($hasPayments[$pid]) ? 1 : 0, - 'next_installment' => $nextInstallmentYmd, - ]; - }, $rows); - - return $this->success([ - 'school_year' => $schoolYear, - 'schoolYears' => $schoolYears, - 'results' => $dataRows, - ], 'Unpaid parents retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Unpaid parents error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve unpaid parents', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/FlagController.php b/app/Http/Controllers/Api/FlagController.php deleted file mode 100755 index cb096656..00000000 --- a/app/Http/Controllers/Api/FlagController.php +++ /dev/null @@ -1,399 +0,0 @@ -currentFlag = model(CurrentFlag::class); - $this->flag = model(Flag::class); - $this->student = model(Student::class); - $this->classSection = model(ClassSection::class); - $this->studentClass = model(StudentClass::class); - $this->config = model(Configuration::class); - } - - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $studentId = $this->request->getGet('student_id'); - $flagType = $this->request->getGet('flag_type') ?? $this->request->getGet('flag'); - $state = $this->request->getGet('state') ?? $this->request->getGet('flag_state'); - - $query = $this->currentFlag->newQuery()->orderBy('created_at', 'DESC'); - if ($studentId) { - $query->where('student_id', $studentId); - } - if ($flagType) { - $query->where('flag', $flagType); - } - if ($state) { - $query->where('flag_state', $state); - } - - $result = $this->paginate($query, $page, $perPage); - - // Also return class sections (grades) for UI - $classSections = $this->classSection->findAll(); - $grades = []; - foreach ($classSections as $section) { - $grades[] = [ - 'id' => $section['class_section_id'] ?? $section['id'] ?? null, - 'name' => $section['class_section_name'] ?? $section['name'] ?? '', - ]; - } - - $result['grades'] = $grades; - - return $this->success($result, 'Flags retrieved successfully'); - } - - public function show($id = null) - { - $flag = $this->currentFlag->find($id); - if (!$flag) { - return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND); - } - return $this->success($flag, 'Flag retrieved successfully'); - } - - public function create() - { - return $this->addFlag(); - } - - /** - * Add or update a flag for a student - * If a flag of the same type exists, it updates it; otherwise creates a new one - */ - public function addFlag() - { - $user = $this->getCurrentUser(); - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $studentId = $data['student_id'] ?? $data['student'] ?? null; - $flagType = $data['flag'] ?? $data['flag_type'] ?? null; - $description = $data['description'] ?? $data['open_description'] ?? null; - $flagState = $data['flag_state'] ?? 'Open'; - $stateDescription = $data['state_description'] ?? null; - $grade = $data['grade'] ?? null; - - if (!$studentId || !$flagType || !$description) { - return $this->respondError('Missing required fields: student_id, flag, description', Response::HTTP_BAD_REQUEST); - } - - try { - // Retrieve student information - $student = $this->student->find($studentId); - if (!$student) { - return $this->respondError('Student not found', Response::HTTP_NOT_FOUND); - } - - $userId = $user->id ?? null; - $semester = $this->config->getConfig('semester'); - $schoolYear = $this->config->getConfig('school_year'); - $currentDateTime = date('Y-m-d H:i:s'); - - // Check if a flag of the same type already exists for this student - $existingFlag = $this->currentFlag - ->where('student_id', $studentId) - ->where('flag', $flagType) - ->first(); - - if ($existingFlag) { - // Update existing flag - $updateData = [ - 'flag_datetime' => $currentDateTime, - 'updated_by_open' => $userId, - ]; - - // Append description based on flag state - if ($flagState === 'Closed') { - $updateData['flag_state'] = 'Closed'; - $existingCloseDesc = $existingFlag['close_description'] ?? ''; - $updateData['close_description'] = $existingCloseDesc - ? $existingCloseDesc . PHP_EOL . $stateDescription - : $stateDescription; - } elseif ($flagState === 'Canceled') { - $updateData['flag_state'] = 'Canceled'; - $existingCancelDesc = $existingFlag['cancel_description'] ?? ''; - $updateData['cancel_description'] = $existingCancelDesc - ? $existingCancelDesc . PHP_EOL . $stateDescription - : $stateDescription; - } else { - $existingOpenDesc = $existingFlag['open_description'] ?? ''; - $updateData['open_description'] = $existingOpenDesc - ? $existingOpenDesc . PHP_EOL . $description - : $description; - } - - $this->currentFlag->update($existingFlag['id'], $updateData); - $flag = $this->currentFlag->find($existingFlag['id']); - - return $this->success($flag, 'Flag updated successfully'); - } else { - // Create new flag - $flagData = [ - 'student_id' => $student['id'], - 'student_name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), - 'grade' => $grade, - 'flag' => $flagType, - 'flag_datetime' => $currentDateTime, - 'flag_state' => 'Open', - 'updated_by_open' => $userId, - 'open_description' => $description, - 'semester' => $semester, - 'school_year' => $schoolYear, - ]; - - $flag = $this->currentFlag->create($flagData); - return $this->success($flag, 'New flag added successfully', Response::HTTP_CREATED); - } - } catch (\Throwable $e) { - log_message('error', 'Add flag error: ' . $e->getMessage()); - return $this->respondError('Failed to add flag', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function updateState($id = null) - { - $flag = $this->currentFlag->find($id); - if (!$flag) { - return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $newState = $data['flag_state'] ?? null; - if (!$newState) { - return $this->respondError('Flag state is required', Response::HTTP_BAD_REQUEST); - } - - try { - $updateData = ['flag_state' => $newState]; - - // Update appropriate description field based on state - if (isset($data['state_description'])) { - if ($newState === 'Closed') { - $updateData['close_description'] = $data['state_description']; - } elseif ($newState === 'Canceled') { - $updateData['cancel_description'] = $data['state_description']; - } else { - $updateData['open_description'] = $data['state_description']; - } - } - - $this->currentFlag->update($id, $updateData); - $flag = $this->currentFlag->find($id); - - return $this->success($flag, 'Flag state updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Update flag state error: ' . $e->getMessage()); - return $this->respondError('Failed to update flag state', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function close($id = null) - { - return $this->closeFlag($id); - } - - public function cancel($id = null) - { - return $this->cancelFlag($id); - } - - /** - * Close a flag and move it to history - */ - public function closeFlag($flagId = null) - { - $flagId = $flagId ?? $this->request->getPost('id') ?? $this->request->getGet('id'); - if (!$flagId) { - return $this->respondError('Flag ID is required', Response::HTTP_BAD_REQUEST); - } - - try { - $user = $this->getCurrentUser(); - $userId = $user->id ?? null; - - // Get the current flag data - $flagData = $this->currentFlag->find($flagId); - if (!$flagData) { - return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND); - } - - // Check if flag is already closed - if ($flagData['flag_state'] === 'Closed') { - return $this->respondError('Flag is already closed', Response::HTTP_BAD_REQUEST); - } - - // Get close description from request - $data = $this->payloadData(); - $closeDescription = $data['state_description'] ?? $data['close_description'] ?? null; - - if (empty($closeDescription)) { - return $this->respondError('Close description is required', Response::HTTP_BAD_REQUEST); - } - - // Update the flag state to Closed - $this->currentFlag->update($flagId, [ - 'flag_state' => 'Closed', - 'updated_by_closed' => $userId, - 'close_description' => $closeDescription, - ]); - - // Retrieve updated flag data - $flagData = $this->currentFlag->find($flagId); - - // Move to history - return $this->moveToHistory($flagData); - } catch (\Throwable $e) { - log_message('error', 'Close flag error: ' . $e->getMessage()); - return $this->respondError('Failed to close flag', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Cancel a flag and move it to history - */ - public function cancelFlag($flagId = null) - { - $flagId = $flagId ?? $this->request->getPost('id') ?? $this->request->getGet('id'); - if (!$flagId) { - return $this->respondError('Flag ID is required', Response::HTTP_BAD_REQUEST); - } - - try { - $user = $this->getCurrentUser(); - $userId = $user->id ?? null; - - // Get the current flag data - $flagData = $this->currentFlag->find($flagId); - if (!$flagData) { - return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND); - } - - // Check if flag is already canceled - if ($flagData['flag_state'] === 'Canceled') { - return $this->respondError('Flag is already canceled', Response::HTTP_BAD_REQUEST); - } - - // Get cancel description from request - $data = $this->payloadData(); - $cancelDescription = $data['state_description'] ?? $data['cancel_description'] ?? null; - - if (empty($cancelDescription)) { - return $this->respondError('Cancel description is required', Response::HTTP_BAD_REQUEST); - } - - // Update the flag state to Canceled - $this->currentFlag->update($flagId, [ - 'flag_state' => 'Canceled', - 'updated_by_canceled' => $userId, - 'cancel_description' => $cancelDescription, - ]); - - // Retrieve updated flag data - $flagData = $this->currentFlag->find($flagId); - - // Move to history - return $this->moveToHistory($flagData); - } catch (\Throwable $e) { - log_message('error', 'Cancel flag error: ' . $e->getMessage()); - return $this->respondError('Failed to cancel flag', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Move flag to history table and delete from current_flag table - */ - protected function moveToHistory($flagData) - { - try { - // Prepare data for the flag table - $dataToInsert = [ - 'student_id' => $flagData['student_id'] ?? null, - 'student_name' => $flagData['student_name'] ?? null, - 'grade' => $flagData['grade'] ?? null, - 'flag' => $flagData['flag'] ?? null, - 'flag_datetime' => $flagData['flag_datetime'] ?? null, - 'flag_state' => $flagData['flag_state'] ?? null, - 'updated_by_open' => $flagData['updated_by_open'] ?? null, - 'open_description' => $flagData['open_description'] ?? null, - 'updated_by_closed' => $flagData['updated_by_closed'] ?? null, - 'close_description' => $flagData['close_description'] ?? null, - 'updated_by_canceled' => $flagData['updated_by_canceled'] ?? null, - 'cancel_description' => $flagData['cancel_description'] ?? null, - 'semester' => $flagData['semester'] ?? null, - 'school_year' => $flagData['school_year'] ?? null, - 'created_at' => $flagData['created_at'] ?? date('Y-m-d H:i:s'), - 'updated_at' => $flagData['updated_at'] ?? date('Y-m-d H:i:s'), - ]; - - // Insert data into the flag table - $this->flag->insert($dataToInsert); - - // Delete the entry from the current_flag table - $this->currentFlag->delete($flagData['id']); - - return $this->success(['moved_to_history' => true], 'Flag has been moved to history'); - } catch (\Throwable $e) { - log_message('error', 'Move to history error: ' . $e->getMessage()); - return $this->respondError('Failed to move flag to history', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function getStudentsByGrade($gradeId = null) - { - try { - $studentIds = $this->studentClass->newQuery() - ->where('class_section_id', $gradeId) - ->pluck('student_id') - ->toArray(); - - if (empty($studentIds)) { - return $this->success([], 'No students found for this grade'); - } - - $students = $this->student->newQuery() - ->whereIn('id', $studentIds) - ->get() - ->map(fn($student) => [ - 'id' => $student->id, - 'name' => trim(($student->firstname ?? '') . ' ' . ($student->lastname ?? '')), - ]) - ->values() - ->toArray(); - - return $this->success($students, 'Students retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Get students by grade error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve students', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/FrontendController.php b/app/Http/Controllers/Api/FrontendController.php deleted file mode 100755 index ec55d1eb..00000000 --- a/app/Http/Controllers/Api/FrontendController.php +++ /dev/null @@ -1,56 +0,0 @@ -getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - try { - $user = model(User::class); - $userInfo = $user->getUserInfoById($user->id); - - if (!$userInfo) { - return $this->respondError('User not found', Response::HTTP_NOT_FOUND); - } - - $firstname = (string) ($userInfo['firstname'] ?? ''); - $lastname = (string) ($userInfo['lastname'] ?? ''); - - return $this->success([ - 'firstname' => $firstname, - 'lastname' => $lastname, - 'fullname' => trim($firstname . ' ' . $lastname), - 'initials' => strtoupper(($firstname[0] ?? '') . ($lastname[0] ?? '')), - ], 'User info retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Frontend user error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve user info', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function getPages() - { - try { - return $this->success([ - 'pages' => [ - 'facility' => '/facility', - 'team' => '/team', - 'call_to_action' => '/call_to_action', - 'testimonial' => '/testimonial', - ], - ], 'Frontend pages retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Frontend pages error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve frontend pages', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/GradingController.php b/app/Http/Controllers/Api/GradingController.php deleted file mode 100755 index 6da46b1f..00000000 --- a/app/Http/Controllers/Api/GradingController.php +++ /dev/null @@ -1,482 +0,0 @@ -config = model(Configuration::class); - $this->homework = model(Homework::class); - $this->user = model(User::class); - $this->studentClass = model(StudentClass::class); - $this->student = model(Student::class); - $this->teacherClass = model(TeacherClass::class); - $this->classSection = model(ClassSection::class); - $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); - $this->semester = (string) ($this->config->getConfig('semester') ?? ''); - $this->semesterScoreService = \Config\Services::semesterScoreService(); - } - - /** - * Show scores for a specific type, class section, and student - */ - public function show($type, $classSectionId = null, $studentId = null) - { - $model = $this->getModelByType($type); - if (!$model) { - return $this->respondError('Invalid type', Response::HTTP_BAD_REQUEST); - } - - $student = model(Student::class); - $student = null; - if ($studentId) { - $student = $student->find($studentId); - if (!$student) { - return $this->respondError('Student not found', Response::HTTP_NOT_FOUND); - } - } - - try { - $scores = $model->where([ - 'student_id' => $studentId, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear - ])->findAll(); - - return $this->success([ - 'student' => $student, - 'scores' => $scores, - 'type' => $type, - 'class_section_id' => $classSectionId, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - ], 'Scores retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Grading show error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve scores', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Update scores for various types - */ - public function update() - { - $data = $this->payloadData(); - $type = $data['type'] ?? null; - $studentId = (int) ($data['student_id'] ?? 0); - $classSectionId = (int) ($data['class_section_id'] ?? 0); - - if (!$type) { - return $this->respondError('Type is required', Response::HTTP_BAD_REQUEST); - } - - $model = $this->getModelByType($type); - if (!$model) { - return $this->respondError('Invalid type', Response::HTTP_BAD_REQUEST); - } - - try { - if (in_array($type, ['homework', 'quiz', 'project'])) { - $scoreIds = $data['score_ids'] ?? []; - $scores = $data['scores'] ?? []; - $comments = $data['comments'] ?? []; - - foreach ($scoreIds as $i => $id) { - $updateData = [ - 'score' => $scores[$i] ?? null, - 'updated_at' => utc_now() - ]; - if (isset($comments[$i])) { - $updateData['comment'] = $comments[$i]; - } - $model->update($id, $updateData); - } - } elseif (in_array($type, ['midterm', 'final', 'test'])) { - $score = $data['score'] ?? null; - $updateData = [ - 'score' => $score, - 'updated_at' => utc_now() - ]; - - $existing = $model->where([ - 'student_id' => $studentId, - 'class_section_id' => $classSectionId, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear - ])->first(); - - if ($existing) { - $model->update($existing['id'], $updateData); - } else { - $insertData = array_merge($updateData, [ - 'student_id' => $studentId, - 'class_section_id' => $classSectionId, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'created_at' => utc_now() - ]); - $model->insert($insertData); - } - } elseif ($type === 'comments') { - $comment = $data['comment'] ?? null; - - // Remove existing comments for this student/semester/year - $model->where([ - 'student_id' => $studentId, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear - ])->delete(); - - // Insert new comment - $model->insert([ - 'student_id' => $studentId, - 'score_type' => 'general', - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'comment' => $comment, - 'commented_by' => $this->getCurrentUserId(), - 'created_at' => utc_now() - ]); - } - - // Update semester scores for students in the class section - if ($classSectionId > 0) { - $studentTeacherInfo = $this->student->getStudentInfoByClassSectionId( - $classSectionId, - $this->semester, - $this->schoolYear - ); - - try { - foreach ($studentTeacherInfo as $studentInfo) { - $this->semesterScoreService->updateStudentScores([ - 'student_id' => $studentInfo['student_id'], - 'school_id' => $studentInfo['school_id'] ?? null, - 'class_section_id' => $studentInfo['class_section_id'], - 'updated_by' => $studentInfo['updated_by'] ?? $this->getCurrentUserId(), - ], $this->semester, $this->schoolYear); - } - } catch (RuntimeException $e) { - log_message('error', 'Failed to update semester scores: ' . $e->getMessage()); - } - } - - return $this->success(null, 'Scores updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Grading update error: ' . $e->getMessage()); - return $this->respondError('Failed to update scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Get grading main view data - all students grouped by class sections - */ - public function grading() - { - $schoolYear = (string) $this->schoolYear; - $semester = (string) $this->semester; - - // Roster + dual-join to semester_scores: - // ss_b: match when semester_scores.class_section_id = BUSINESS ID (student_class.class_section_id) - // ss_p: match when semester_scores.class_section_id = PK (classSection.id) - $rows = DB::table('student_class as sc') - ->select([ - 'cs.id as section_pk', - 'cs.class_section_id as section_id', - 'cs.class_id', - 'cs.class_section_name', - 's.id as student_id', - 's.school_id', - 's.firstname', - 's.lastname', - DB::raw('COALESCE(ss_b.ptap_score, ss_p.ptap_score) as ss_ptap_score'), - DB::raw('COALESCE(ss_b.semester_score, ss_p.semester_score) as ss_semester_score'), - 'ss_b.class_section_id as matched_biz_csid', - 'ss_p.class_section_id as matched_pk_csid' - ]) - ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') - ->join('students as s', 's.id', '=', 'sc.student_id') - ->leftJoin('semester_scores as ss_b', function($join) use ($semester, $schoolYear) { - $join->on('ss_b.student_id', '=', 's.id') - ->on('ss_b.class_section_id', '=', 'sc.class_section_id') - ->whereRaw('LOWER(ss_b.semester) = LOWER(?)', [$semester]) - ->where('ss_b.school_year', '=', $schoolYear); - }) - ->leftJoin('semester_scores as ss_p', function($join) use ($semester, $schoolYear) { - $join->on('ss_p.student_id', '=', 's.id') - ->on('ss_p.class_section_id', '=', 'cs.id') - ->whereRaw('LOWER(ss_p.semester) = LOWER(?)', [$semester]) - ->where('ss_p.school_year', '=', $schoolYear); - }) - ->where('sc.school_year', $schoolYear) - ->orderBy('cs.class_id', 'ASC') - ->orderBy('cs.class_section_name', 'ASC') - ->orderBy('s.lastname', 'ASC') - ->orderBy('s.firstname', 'ASC') - ->get() - ->toArray(); - - // Build structures keyed by BUSINESS section id - $grades = []; // class_id => [ ['class_section_id','class_section_name'], ... ] - $studentsBySection = []; // section_id => [ students... ] - - foreach ($rows as $r) { - $sectionId = (int) ($r->section_id ?? 0); - $classId = (int) ($r->class_id ?? 0); - $sectionName = (string) ($r->class_section_name ?? ''); - - if ($sectionId <= 0 || $classId <= 0) continue; - - if (!isset($grades[$classId])) { - $grades[$classId] = []; - } - - $exists = false; - foreach ($grades[$classId] as $s) { - if ((int)$s['class_section_id'] === $sectionId) { - $exists = true; - break; - } - } - - if (!$exists) { - $grades[$classId][] = [ - 'class_section_id' => $sectionId, - 'class_section_name' => $sectionName, - ]; - } - - $sid = (int) ($r->student_id ?? 0); - if ($sid <= 0) continue; - - $ptapScore = $r->ss_ptap_score ?? null; - $semesterScore = $r->ss_semester_score ?? null; - - if (!isset($studentsBySection[$sectionId])) { - $studentsBySection[$sectionId] = []; - } - - $studentsBySection[$sectionId][] = [ - 'id' => $sid, - 'school_id' => $r->school_id ?? null, - 'firstname' => $r->firstname ?? null, - 'lastname' => $r->lastname ?? null, - 'ptap' => is_null($ptapScore) ? null : (float) $ptapScore, - 'semester_score' => is_null($semesterScore) ? null : (float) $semesterScore, - ]; - } - - return $this->success([ - 'grades' => $grades, - 'students_by_section' => $studentsBySection, - 'semester' => $semester, - 'school_year' => $schoolYear, - ], 'Grading data retrieved successfully'); - } - - /** - * Get all scores and comments for students - */ - public function getScoreComment() - { - // Get all students for the current semester and school year - $studentClassEntries = $this->studentClass - ->where('semester', $this->semester) - ->where('school_year', $this->schoolYear) - ->findAll(); - - // Group student IDs - $studentIds = array_map(fn($entry) => $entry['student_id'], $studentClassEntries); - - if (empty($studentIds)) { - return $this->success([], 'No students found'); - } - - // Fetch all scores and comments for the students - $scoresAndComments = $this->getAllScoresAndComments($studentIds, $this->semester, $this->schoolYear); - - return $this->success($scoresAndComments, 'Scores and comments retrieved successfully'); - } - - /** - * Fetch all scores and comments for a list of students based on semester and school year. - * - * @param array $studentIds List of student IDs. - * @param string $semester Current semester (e.g., 'fall', 'spring'). - * @param string $schoolYear Current school year (e.g., '2025-2026'). - * @return array - */ - private function getAllScoresAndComments($studentIds, $semester, $schoolYear) - { - // Validate input parameters - if (empty($studentIds) || !is_array($studentIds)) { - return []; - } - - if (empty($semester) || empty($schoolYear)) { - throw new \InvalidArgumentException('Semester and school year must be provided'); - } - - // Initialize models - $models = [ - 'final_exam' => model(FinalExam::class), - 'homework' => model(Homework::class), - 'midterm' => model(MidtermExam::class), - 'project' => model(Project::class), - 'quiz' => model(Quiz::class), - 'comments' => model(ScoreComment::class), - 'semester_scores' => model(SemesterScore::class), - 'student' => model(Student::class), - 'student_class' => model(StudentClass::class), - 'teacher_class' => model(TeacherClass::class), - 'config' => model(Configuration::class), - 'attendance' => model(AttendanceRecord::class) - ]; - - // Get semester days configuration - $semesterKey = strtolower($semester) === 'fall' ? 'total_semester1_days' : 'total_semester2_days'; - $totalSemesterDays = $models['config']->getConfig($semesterKey) ?? 0; - - // Common query conditions - $conditions = [ - 'semester' => $semester, - 'school_year' => $schoolYear - ]; - - // Fetch all student data first - $students = $models['student']->whereIn('id', $studentIds) - ->where('school_year', $schoolYear) - ->findAll(); - - if (empty($students)) { - return []; - } - - // Initialize result array with student data - $allScores = []; - - foreach ($students as $student) { - $className = $models['student_class']->getClassSectionsByStudentId($student['id'], $schoolYear); - $updatedBy = $models['teacher_class']->getTeacherIdByClassSection($className, $semester, $schoolYear); - - $allScores[$student['id']] = [ - 'school_id' => $student['school_id'], - 'firstname' => $student['firstname'], - 'lastname' => $student['lastname'], - 'class_name' => $className, - 'comments' => [] - ]; - } - - // Fetch and process attendance data - foreach ($studentIds as $studentId) { - if (!isset($allScores[$studentId])) continue; - - $absences = $models['attendance']->getTotalAbsences($studentId, $semester, $schoolYear); - $attendance = min((($totalSemesterDays - $absences + 1) / $totalSemesterDays) * 100, 100); - - $allScores[$studentId]['attendance'] = [ - 'score' => round($attendance, 2), - 'absences' => $absences, - 'total_days' => $totalSemesterDays - ]; - } - - // Fetch and process all score types - $scoreTypes = [ - 'final_exam' => $models['final_exam'], - 'homework' => $models['homework'], - 'midterm' => $models['midterm'], - 'project' => $models['project'], - 'quiz' => $models['quiz'], - 'semester_score' => $models['semester_scores'] - ]; - - foreach ($scoreTypes as $type => $model) { - $scores = $model->whereIn('student_id', $studentIds) - ->where($conditions) - ->findAll(); - - foreach ($scores as $score) { - if ($type === 'semester_score') { - $allScores[$score['student_id']][$type] = [ - 'homework_avg' => $score['homework_avg'], - 'quiz_avg' => $score['quiz_avg'], - 'project_avg' => $score['project_avg'], - 'midterm_exam_score' => $score['midterm_exam_score'], - 'final_exam_score' => $score['final_exam_score'], - 'attendance_score' => $score['attendance_score'], - 'participation_score' => $score['participation_score'], - 'ptap_score' => $score['ptap_score'], - 'test_avg' => $score['test_avg'], - 'semester_score' => $score['semester_score'], - 'semester' => $score['semester'], - 'school_year' => $score['school_year'] - ]; - } else { - $allScores[$score['student_id']][$type] = $score; - } - } - } - - // Fetch and process comments - $comments = $models['comments']->whereIn('student_id', $studentIds) - ->where($conditions) - ->findAll(); - - foreach ($comments as $comment) { - $allScores[$comment['student_id']]['comments'][] = $comment; - } - - return $allScores; - } - - /** - * Get model instance by type - */ - private function getModelByType(?string $type) - { - return match ($type) { - 'homework' => model(Homework::class), - 'quiz' => model(Quiz::class), - 'project' => model(Project::class), - 'midterm' => model(MidtermExam::class), - 'final' => model(FinalExam::class), - 'test' => model(SemesterScore::class), - 'comments' => model(ScoreComment::class), - default => null, - }; - } -} diff --git a/app/Http/Controllers/Api/HealthController.php b/app/Http/Controllers/Api/HealthController.php deleted file mode 100755 index 1ecef65b..00000000 --- a/app/Http/Controllers/Api/HealthController.php +++ /dev/null @@ -1,94 +0,0 @@ - $label, - 'path' => $path, - 'exists' => is_dir($path), - 'writable' => is_writable($path), - ]; - } - - /** - * Health check endpoint - checks filesystem paths and database schema - */ - public function index() - { - try { - // Use storage_path('app') as the base (equivalent to WRITEPATH in CodeIgniter) - $uploadsBase = storage_path('app') . DIRECTORY_SEPARATOR . 'uploads'; - - $paths = [ - 'uploads' => $uploadsBase, - 'uploads/reimbursements' => $uploadsBase . DIRECTORY_SEPARATOR . 'reimbursements', - 'uploads/receipts' => $uploadsBase . DIRECTORY_SEPARATOR . 'receipts', - 'uploads/checks' => $uploadsBase . DIRECTORY_SEPARATOR . 'checks', - ]; - - $pathsStatus = []; - foreach ($paths as $label => $p) { - $pathsStatus[] = $this->checkPath($label, $p); - } - - // Use Laravel's schema builder to check tables and columns - $schema = DB::getSchemaBuilder(); - - $dbChecks = [ - 'user_preferences_exists' => $schema->hasTable('user_preferences'), - 'settings_exists' => $schema->hasTable('settings'), - 'migrations_exists' => $schema->hasTable('migrations'), - ]; - - $dbChecks['user_preferences_has_timezone'] = $dbChecks['user_preferences_exists'] - ? $schema->hasColumn('user_preferences', 'timezone') - : false; - - $dbChecks['settings_has_timezone'] = $dbChecks['settings_exists'] - ? $schema->hasColumn('settings', 'timezone') - : false; - - // Check if all paths are OK - $okPaths = array_reduce($pathsStatus, function ($carry, $row) { - return $carry && $row['exists'] && $row['writable']; - }, true); - - // Check if database schema is OK - $okDb = true; - if ($dbChecks['user_preferences_exists'] && !$dbChecks['user_preferences_has_timezone']) { - $okDb = false; - } - if ($dbChecks['settings_exists'] && !$dbChecks['settings_has_timezone']) { - $okDb = false; - } - - $ok = $okPaths && $okDb; - - $payload = [ - 'ok' => $ok, - 'paths' => $pathsStatus, - 'database' => $dbChecks, - 'write_path' => storage_path('app'), - 'timestamp' => date('c'), - ]; - - $status = $ok ? Response::HTTP_OK : Response::HTTP_SERVICE_UNAVAILABLE; - - // Return JSON response with appropriate status code - return response()->json($payload, $status); - } catch (\Throwable $e) { - log_message('error', 'Health check error: ' . $e->getMessage()); - return $this->error('Health check failed', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/HomeworkController.php b/app/Http/Controllers/Api/HomeworkController.php deleted file mode 100755 index 4e121aa7..00000000 --- a/app/Http/Controllers/Api/HomeworkController.php +++ /dev/null @@ -1,516 +0,0 @@ -homework = model(Homework::class); - $this->config = model(Configuration::class); - $this->studentClass = model(StudentClass::class); - $this->student = model(Student::class); - $this->teacherClass = model(TeacherClass::class); - $this->user = model(User::class); - $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); - $this->semester = (string) ($this->config->getConfig('semester') ?? ''); - $this->semesterScoreService = \Config\Services::semesterScoreService(); - } - - /** - * Get paginated homework list - */ - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $classSectionId = $this->request->getGet('class_section_id'); - - $query = $this->homework->newQuery() - ->when($this->schoolYear !== '', fn($q) => $q->where('school_year', $this->schoolYear)) - ->when($this->semester !== '', fn($q) => $q->where('semester', $this->semester)) - ->when($classSectionId, fn($q) => $q->where('class_section_id', $classSectionId)) - ->orderBy('created_at', 'DESC'); - - $result = $this->paginate($query, $page, $perPage); - return $this->success($result, 'Homework retrieved successfully'); - } - - /** - * Get homework by student ID - */ - public function getByStudent($id = null) - { - $query = $this->homework->newQuery() - ->when($id, fn($q) => $q->where('student_id', $id)) - ->when($this->schoolYear !== '', fn($q) => $q->where('school_year', $this->schoolYear)) - ->when($this->semester !== '', fn($q) => $q->where('semester', $this->semester)); - - $homework = $query->get()->toArray(); - return $this->success($homework, 'Homework retrieved successfully'); - } - - /** - * Create a single homework entry - */ - public function create() - { - $user = $this->getCurrentUser(); - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $errors = $this->validateRequest($data, [ - 'student_id' => 'required|integer', - 'class_section_id' => 'required|integer', - 'homework_index' => 'required|integer', - ]); - - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $payload = [ - 'student_id' => $data['student_id'], - 'class_section_id' => $data['class_section_id'], - 'homework_index' => $data['homework_index'], - 'score' => $data['score'] ?? null, - 'comment' => $data['comment'] ?? null, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'updated_by' => $user->id ?? null, - ]; - - $homework = $this->homework->create($payload); - return $this->success($homework, 'Homework created successfully', Response::HTTP_CREATED); - } - - /** - * Update a single homework entry - */ - public function update($id = null) - { - $homework = $this->homework->find($id); - if (!$homework) { - return $this->respondError('Homework not found', Response::HTTP_NOT_FOUND); - } - - $user = $this->getCurrentUser(); - $data = $this->payloadData(); - if (empty($data)) { - return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $allowed = ['score', 'comment']; - $update = []; - foreach ($allowed as $field) { - if (array_key_exists($field, $data)) { - $update[$field] = $data[$field]; - } - } - - if (empty($update)) { - return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST); - } - - $update['updated_by'] = $user->id ?? null; - $update['updated_at'] = utc_now(); - $this->homework->update($id, $update); - - return $this->success($this->homework->find($id), 'Homework updated successfully'); - } - - /** - * Bulk update homework scores for multiple students - */ - public function updateScores() - { - $data = $this->payloadData(); - $scores = $data['scores'] ?? null; - $classSectionId = (int) ($data['class_section_id'] ?? 0); - $updatedBy = $this->getCurrentUserId(); - - if (!$scores || !is_array($scores)) { - return $this->respondError('Scores data is required', Response::HTTP_BAD_REQUEST); - } - - if ($classSectionId <= 0) { - return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST); - } - - try { - foreach ($scores as $studentId => $hwData) { - if (!is_numeric($studentId)) continue; - - $student = $this->student->find($studentId); - if (!$student) continue; - - foreach ($hwData as $index => $score) { - // STEP 1: Always fetch the existing record - $existing = $this->homework->where([ - 'student_id' => $studentId, - 'homework_index' => $index, - 'class_section_id' => $classSectionId, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - ])->first(); - - // STEP 2: If blank, delete if exists - if ($score === '' || $score === null) { - if ($existing) { - $this->homework->delete($existing['id']); - log_message('debug', "Deleted blank score for student $studentId index $index"); - } - continue; - } - - // STEP 3: Save numeric score - if (is_numeric($score)) { - $updateData = [ - 'student_id' => $studentId, - 'school_id' => $student['school_id'] ?? '', - 'class_section_id' => $classSectionId, - 'updated_by' => $updatedBy, - 'homework_index' => $index, - 'score' => (float)$score, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'updated_at' => utc_now(), - ]; - - if ($existing) { - $this->homework->update($existing['id'], $updateData); - log_message('debug', "Updated homework ID {$existing['id']} with score $score"); - } else { - $updateData['created_at'] = utc_now(); - $this->homework->insert($updateData); - log_message('debug', "Inserted score for student $studentId index $index"); - } - } - } - } - - // Update semester scores for students in the class section - $studentUserInfo = $this->student->getStudentInfoByClassSectionId( - $classSectionId, - $this->semester, - $this->schoolYear - ); - - try { - foreach ($studentUserInfo as $studentInfo) { - $this->semesterScoreService->updateStudentScores([ - 'student_id' => $studentInfo['student_id'], - 'school_id' => $studentInfo['school_id'] ?? null, - 'class_section_id' => $studentInfo['class_section_id'], - 'updated_by' => $studentInfo['updated_by'] ?? $updatedBy, - ], $this->semester, $this->schoolYear); - } - } catch (RuntimeException $e) { - log_message('error', 'Failed to update semester scores: ' . $e->getMessage()); - } - - return $this->success(null, 'Homework scores updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Update homework scores error: ' . $e->getMessage()); - return $this->respondError('Failed to update homework scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Add a new homework column/index for all students in a class section - */ - public function addNextColumn() - { - $data = $this->payloadData(); - $classSectionId = (int) ($data['class_section_id'] ?? 0); - $updatedBy = $this->getCurrentUserId(); - - if ($classSectionId <= 0) { - return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST); - } - - try { - // Step 1: Get the highest existing homework_index - $existingIndexes = $this->homework - ->select('homework_index') - ->where('class_section_id', $classSectionId) - ->where('semester', $this->semester) - ->where('school_year', $this->schoolYear) - ->groupBy('homework_index') - ->orderBy('homework_index', 'DESC') - ->findAll(); - - $maxIndex = 0; - foreach ($existingIndexes as $row) { - if (isset($row['homework_index']) && is_numeric($row['homework_index'])) { - $maxIndex = max($maxIndex, (int)$row['homework_index']); - } - } - - $nextIndex = $maxIndex + 1; - - // Step 2: Get all students in the class - $students = $this->studentClass - ->where('class_section_id', $classSectionId) - ->where('school_year', $this->schoolYear) - ->findAll(); - - // Step 3: Insert a new homework row for each student if not already exists - $firstInsertedId = null; - $insertedCount = 0; - - foreach ($students as $i => $student) { - $studentId = $student['student_id']; - - // Check if record already exists - $existing = $this->homework - ->where('student_id', $studentId) - ->where('homework_index', $nextIndex) - ->where('class_section_id', $classSectionId) - ->where('semester', $this->semester) - ->where('school_year', $this->schoolYear) - ->first(); - - if ($existing) continue; - - $studentRecord = $this->student->find($studentId); - $insertData = [ - 'student_id' => $studentId, - 'school_id' => $studentRecord['school_id'] ?? '', - 'class_section_id' => $classSectionId, - 'updated_by' => $updatedBy, - 'homework_index' => $nextIndex, - 'score' => null, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'created_at' => utc_now(), - 'updated_at' => utc_now() - ]; - - $id = $this->homework->insert($insertData); - if ($i === 0) { - $firstInsertedId = $id; - } - $insertedCount++; - } - - return $this->success([ - 'homework_index' => $nextIndex, - 'new_homework_id' => $firstInsertedId, - 'students_processed' => $insertedCount - ], 'Homework column added successfully'); - } catch (\Throwable $e) { - log_message('error', 'Add homework column error: ' . $e->getMessage()); - return $this->respondError('Failed to add homework column: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Get homework data for a class section (for management view) - */ - public function getByClassSection() - { - $classSectionId = (int) ($this->request->getGet('class_section_id') ?? 0); - - if ($classSectionId <= 0) { - return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST); - } - - try { - $homeworkScores = $this->getHomeworkScoresByStudent($classSectionId, $this->semester, $this->schoolYear); - $students = $this->getStudentsByClassSectionAndYear($classSectionId, $this->schoolYear); - $homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $this->semester, $this->schoolYear); - - return $this->success([ - 'homework_scores' => $homeworkScores, - 'students' => $students, - 'homework_headers' => $homeworkHeaders, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'class_section_id' => $classSectionId, - 'has_homework_cols' => !empty($homeworkHeaders), - ], 'Homework data retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Get homework by class section error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve homework data: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Get students with their homework scores - */ - public function getStudentsWithScores() - { - $classSectionId = (int) ($this->request->getGet('class_section_id') ?? 0); - - if ($classSectionId <= 0) { - return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST); - } - - try { - $homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $this->semester, $this->schoolYear); - $students = $this->getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders); - - return $this->success([ - 'students' => $students, - 'homework_headers' => $homeworkHeaders, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'class_section_id' => $classSectionId, - ], 'Students with homework scores retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Get students with scores error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve students with scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - // ──────────────── Helper Methods ──────────────── - - /** - * Get students by class section and year - */ - private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear) - { - // Step 1: Get student IDs from student_class table - $studentClassRows = $this->studentClass - ->select('student_id') - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->findAll(); - - $studentIds = array_column($studentClassRows, 'student_id'); - - if (empty($studentIds)) { - return []; - } - - // Step 2: Get student data from students table - $students = $this->student - ->whereIn('id', $studentIds) - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->findAll(); - - return $students; - } - - /** - * Get homework scores grouped by student - */ - private function getHomeworkScoresByStudent($classSectionId, $semester, $schoolYear) - { - $rows = $this->homework - ->select('student_id, homework_index, score') - ->where('class_section_id', $classSectionId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->findAll(); - - $studentScores = []; - - foreach ($rows as $row) { - $studentId = $row['student_id']; - $index = $row['homework_index']; - $score = $row['score']; - - if (!isset($studentScores[$studentId])) { - $studentScores[$studentId] = ['scores' => []]; - } - - $studentScores[$studentId]['scores'][$index] = $score; - } - - return $studentScores; - } - - /** - * Get students with their homework scores - */ - private function getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders) - { - $studentClasses = $this->studentClass - ->where('class_section_id', $classSectionId) - ->where('school_year', $this->schoolYear) - ->findAll(); - - $students = []; - - foreach ($studentClasses as $sc) { - $student = $this->student->find($sc['student_id']); - if (!$student) continue; - - $scores = []; - - foreach ($homeworkHeaders as $index) { - $entry = $this->homework - ->where('homework_index', $index) - ->where('student_id', $sc['student_id']) - ->where('class_section_id', $classSectionId) - ->where('semester', $this->semester) - ->where('school_year', $this->schoolYear) - ->first(); - - $scores[$index] = $entry['score'] ?? ''; - } - - $students[] = [ - 'student_id' => $sc['student_id'], - 'firstname' => $student['firstname'], - 'lastname' => $student['lastname'], - 'scores' => $scores - ]; - } - - usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname'])); - - return $students; - } - - /** - * Get homework headers (unique homework_index values) - */ - private function getHomeworkHeaders($classSectionId, $semester, $schoolYear) - { - $rows = $this->homework - ->select('homework_index') - ->where('class_section_id', $classSectionId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->orderBy('homework_index', 'ASC') - ->findAll(); - - $headers = []; - - foreach ($rows as $row) { - $index = $row['homework_index']; - if (!is_array($index)) { - $headers[$index] = true; - } - } - - ksort($headers); - return array_keys($headers); - } -} diff --git a/app/Http/Controllers/Api/HomeworkTrackingController.php b/app/Http/Controllers/Api/HomeworkTrackingController.php deleted file mode 100755 index f5b308af..00000000 --- a/app/Http/Controllers/Api/HomeworkTrackingController.php +++ /dev/null @@ -1,275 +0,0 @@ -calendar = model(Calendar::class); - $this->homework = model(Homework::class); - $this->config = model(Configuration::class); - $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); - $this->semester = (string) ($this->config->getConfig('semester') ?? ''); - } - - /** - * Get homework tracking data with Sunday-based schedule - */ - public function index() - { - try { - // Get date range from request or use defaults - $startDate = $this->request->getGet('start_date') ?? '2025-09-21'; - $endDate = $this->request->getGet('end_date') ?? '2026-01-18'; - - // Parse dates - try { - $start = new \DateTime($startDate); - } catch (\Throwable $e) { - $start = new \DateTime('today'); - } - - try { - $end = new \DateTime($endDate); - } catch (\Throwable $e) { - $end = new \DateTime('today'); - } - - // Move start to the first Sunday on/after start - if ((int)$start->format('w') !== 0) { - $start = (clone $start)->modify('next sunday'); - } - - // Collect all Sundays within range - $sundays = []; - $d = clone $start; - while ($d <= $end) { - $sundays[] = $d->format('Y-m-d'); - $d->modify('+7 days'); - } - - // Map "no school" events by date (Y-m-d) using explicit DB flag - $events = $this->calendar->getEventsBySchoolYear($this->schoolYear) ?? []; - $eventDays = []; - - foreach ($events as $ev) { - $ymd = substr((string)($ev['date'] ?? $ev['start_date'] ?? ''), 0, 10); - $no = (int)($ev['no_school'] ?? 0); - if ($ymd && $no === 1) { - $eventDays[$ymd] = true; // yellow highlight for no-school day - } - } - - // Map Sundays to ordinal homework_index, skipping event Sundays - $dateToIndex = []; - $idx = 0; - foreach ($sundays as $ymd) { - if (!isset($eventDays[$ymd])) { - $idx++; - $dateToIndex[$ymd] = $idx; - } else { - $dateToIndex[$ymd] = null; // event day (no homework expected) - } - } - - // Aggregate homework presence + first entered date per class_section_id + homework_index - $rows = DB::table('homework') - ->select('class_section_id', 'homework_index', DB::raw('MIN(created_at) AS first_created'), DB::raw('COUNT(*) AS cnt')) - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) - ->groupBy('class_section_id', 'homework_index') - ->get() - ->toArray(); - - $hasHomework = []; - $hwEnteredAt = []; - - foreach ($rows as $r) { - $csid = (int)($r->class_section_id ?? 0); - $hi = (int)($r->homework_index ?? 0); - $cnt = (int)($r->cnt ?? 0); - - if ($csid > 0 && $hi > 0 && $cnt > 0) { - $hasHomework[$csid][$hi] = true; - $dateStr = substr((string)($r->first_created ?? ''), 0, 10); - $hwEnteredAt[$csid][$hi] = $dateStr ?: null; - } - } - - // Build date-based presence mapped to the nearest prior non-NoSchool Sunday - $rowsByDate = DB::table('homework') - ->select(DB::raw('class_section_id, DATE(created_at) AS hw_date, MIN(created_at) AS first_created, COUNT(*) AS cnt')) - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) - ->groupBy(DB::raw('class_section_id, DATE(created_at)')) - ->orderBy('hw_date', 'ASC') - ->get() - ->toArray(); - - $hasHomeworkByDate = []; - $hwEnteredAtByDate = []; - - foreach ($rowsByDate as $r) { - $csid = (int)($r->class_section_id ?? 0); - $d = substr((string)($r->hw_date ?? ''), 0, 10); - $cnt = (int)($r->cnt ?? 0); - $firstCreated = substr((string)($r->first_created ?? ''), 0, 10); - - if ($csid <= 0 || !$d || $cnt <= 0) { - continue; - } - - // Find the index of the last Sunday on or before $d - $baseIndex = -1; - for ($i = count($sundays) - 1; $i >= 0; $i--) { - if ($sundays[$i] <= $d) { - $baseIndex = $i; - break; - } - } - - if ($baseIndex < 0) { - continue; - } - - // Map this homework day to only the nearest prior non–No School Sunday - $j = $baseIndex; - while ($j >= 0 && !empty($eventDays[$sundays[$j]])) { - $j--; - } - - if ($j < 0) { - continue; - } - - $sd = $sundays[$j]; - - // Mark homework on that Sunday for this class_section (single mapping) - $hasHomeworkByDate[$csid][$sd] = true; - - if (empty($hwEnteredAtByDate[$csid][$sd])) { - $hwEnteredAtByDate[$csid][$sd] = $firstCreated ?: $d; - } else { - // Keep the earliest date for display - $existing = (string)$hwEnteredAtByDate[$csid][$sd]; - $candidate = $firstCreated ?: $d; - if ($candidate && (!$existing || $candidate < $existing)) { - $hwEnteredAtByDate[$csid][$sd] = $candidate; - } - } - } - - // Fetch teachers and TAs per section for this term - $teacherRows = DB::table('teacher_class as tc') - ->select('tc.class_section_id', 'tc.position', 'cs.class_section_name', 'cs.class_id', 'u.firstname', 'u.lastname') - ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id') - ->leftJoin('users as u', 'u.id', '=', 'tc.teacher_id') - ->where('tc.school_year', $this->schoolYear) - ->where('tc.semester', $this->semester) - ->whereNotNull('tc.class_section_id') - ->whereIn('tc.position', ['main', 'ta']) - ->orderBy('cs.class_id', 'ASC') - ->orderBy('cs.class_section_name', 'ASC') - ->orderByRaw("FIELD(tc.position, 'main','ta')") - ->orderBy('u.firstname', 'ASC') - ->get() - ->toArray(); - - $bySection = []; - - foreach ($teacherRows as $r) { - $sid = (int)($r->class_section_id ?? 0); - if ($sid <= 0) continue; - - if (!isset($bySection[$sid])) { - $bySection[$sid] = [ - 'class_section_id' => $sid, - 'class_id' => (int)($r->class_id ?? 0), - 'class_section_name' => (string)($r->class_section_name ?? ''), - 'teachers' => [], - 'tas' => [], - ]; - } - - $name = trim(($r->firstname ?? '') . ' ' . ($r->lastname ?? '')); - $pos = strtolower((string)($r->position ?? '')); - - if ($name !== '') { - if ($pos === 'main') { - $bySection[$sid]['teachers'][] = $name; - } elseif ($pos === 'ta') { - $bySection[$sid]['tas'][] = $name; - } - } - } - - $teachers = array_values($bySection); - - // Sort by grade order: KG(13) → Grade 1..11 → Youth(12) → others - usort($teachers, function ($a, $b) { - $ai = (int)($a['class_id'] ?? 0); - $bi = (int)($b['class_id'] ?? 0); - - $ord = function ($cid) { - if ($cid === 13) return 0; // KG first - if ($cid >= 1 && $cid <= 11) return $cid; // Grades 1..11 - if ($cid === 12) return 99; // Youth last of known - return 200 + $cid; // any others after - }; - - $cmp = $ord($ai) <=> $ord($bi); - if ($cmp !== 0) return $cmp; - - return strnatcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? ''); - }); - - // Simple server-side pagination for rows (8 lines per page) - $perPage = 8; - $totalRows = count($teachers); - $totalPages = max(1, (int)ceil($totalRows / $perPage)); - $page = (int) ($this->request->getGet('page') ?? 1); - - if ($page < 1) $page = 1; - if ($page > $totalPages) $page = $totalPages; - - $offset = ($page - 1) * $perPage; - $teachersPage = array_slice($teachers, $offset, $perPage); - - return $this->success([ - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'sundays' => $sundays, - 'event_days' => $eventDays, - 'date_to_index' => $dateToIndex, - 'teachers' => $teachersPage, - 'has_homework' => $hasHomework, - 'hw_entered_at' => $hwEnteredAt, - 'has_homework_by_date' => $hasHomeworkByDate, - 'hw_entered_at_by_date' => $hwEnteredAtByDate, - 'pagination' => [ - 'page' => $page, - 'total_pages' => $totalPages, - 'per_page' => $perPage, - 'total_rows' => $totalRows, - ], - ], 'Homework tracking retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Homework tracking error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve homework tracking: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/InfoIconController.php b/app/Http/Controllers/Api/InfoIconController.php deleted file mode 100755 index df14b47d..00000000 --- a/app/Http/Controllers/Api/InfoIconController.php +++ /dev/null @@ -1,62 +0,0 @@ -getCurrentUserId(); - - if (!$userId) { - return $this->respondError('User not logged in', Response::HTTP_UNAUTHORIZED); - } - - try { - $user = model(User::class); - $user = $user->getUserInfoById($userId); - - if (!$user) { - return $this->success([ - 'user_name' => 'User not found', - 'user_initials' => '??' - ], 'User info retrieved'); - } - - $firstname = (string) ($user['firstname'] ?? ''); - $lastname = (string) ($user['lastname'] ?? ''); - - $userName = trim($firstname . ' ' . $lastname); - if ($userName === '') { - $userName = $user['email'] ?? 'User #' . $userId; - } - - $userInitials = '??'; - if (!empty($firstname) && !empty($lastname)) { - $userInitials = strtoupper(($firstname[0] ?? '') . ($lastname[0] ?? '')); - } elseif (!empty($firstname)) { - $userInitials = strtoupper(substr($firstname, 0, 2)); - } elseif (!empty($lastname)) { - $userInitials = strtoupper(substr($lastname, 0, 2)); - } elseif (!empty($user['email'])) { - $email = $user['email']; - $userInitials = strtoupper(substr($email, 0, 2)); - } - - return $this->success([ - 'user_name' => $userName, - 'user_initials' => $userInitials - ], 'Profile icon info retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Profile icon error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve profile icon info', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} - diff --git a/app/Http/Controllers/Api/InventoryController.php b/app/Http/Controllers/Api/InventoryController.php deleted file mode 100755 index e1b574c9..00000000 --- a/app/Http/Controllers/Api/InventoryController.php +++ /dev/null @@ -1,1488 +0,0 @@ -item = model(InventoryItem::class); - $this->cat = model(InventoryCategory::class); - $this->mov = model(InventoryMovement::class); - $this->user = model(User::class); - $this->config = model(Configuration::class); - $this->teacherClass = model(TeacherClass::class); - $this->studentClass = model(StudentClass::class); - $this->classSection = model(ClassSection::class); - $this->teacher = model(Teacher::class); - $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); - $this->semester = (string) ($this->config->getConfig('semester') ?? ''); - } - - /** - * GET /api/v1/inventory - * List inventory items filtered by type, school year, and semester - */ - public function index() - { - $type = $this->normalizeType($this->request->getGet('type') ?? 'classroom'); - $schoolYears = $this->getSchoolYearsForType($type); - $requested = trim((string) ($this->request->getGet('school_year') ?? '')); - $selectedYear = $requested !== '' ? $requested : $this->schoolYear; - $selectedSem = trim((string) ($this->request->getGet('semester') ?? '')); - - $builder = $this->item->where('type', $type); - if (strtolower($selectedYear) !== 'all') { - $builder->where('school_year', $selectedYear); - } - if ($selectedSem !== '') { - $builder->where('semester', $selectedSem); - } - - $items = $builder->orderBy('name', 'ASC')->findAll(); - $categories = $this->cat->optionsForType($type); - - // Build UpdatedBy name map - $userIds = array_values(array_unique(array_map(fn($i) => (int)($i['updated_by'] ?? 0), $items))); - $userIds = array_values(array_filter($userIds)); - $userNames = $this->userNamesByIds($userIds); - - return $this->success([ - 'type' => $type, - 'items' => $items, - 'categories' => $categories, - 'conditionOptions' => [ - 'good' => 'Good condition', - 'needs_repair' => 'Needs repair', - 'need_replace' => 'Need replace', - 'cannot_find' => 'Cannot find' - ], - 'schoolYears' => $schoolYears, - 'selectedYear' => $selectedYear, - 'currentYear' => $this->schoolYear, - 'semester' => $selectedSem, - 'userNames' => $userNames, - ], 'Inventory items retrieved successfully'); - } - - /** - * GET /api/v1/inventory/{id} - * Get a single inventory item - */ - public function show($id = null) - { - $item = $this->item->find($id); - if (!$item) { - return $this->error('Item not found.', Response::HTTP_NOT_FOUND); - } - - $categories = $this->cat->optionsForType($item['type']); - - return $this->success([ - 'type' => $item['type'], - 'item' => $item, - 'categories' => $categories, - 'conditionOptions' => [ - 'good' => 'Good condition', - 'needs_repair' => 'Needs repair', - 'need_replace' => 'Need replace', - 'cannot_find' => 'Cannot find' - ], - 'isEdit' => true, - ], 'Inventory item retrieved successfully'); - } - - /** - * GET /api/v1/inventory/create - * Get form data for creating a new item - */ - public function create() - { - $type = $this->normalizeType($this->request->getGet('type') ?? 'classroom'); - $categories = $this->cat->optionsForType($type); - - return $this->success([ - 'type' => $type, - 'item' => [], - 'categories' => $categories, - 'conditionOptions' => [ - 'good' => 'Good condition', - 'needs_repair' => 'Needs repair', - 'need_replace' => 'Need replace', - 'cannot_find' => 'Cannot find', - ], - ], 'Form data retrieved successfully'); - } - - /** - * POST /api/v1/inventory - * Create a new inventory item - */ - public function store() - { - $data = $this->payloadData(); - $itemData = $this->filterItemData($data); - - try { - $itemId = $this->item->insert($itemData, true); - if ($itemId) { - $initialQty = (int) ($itemData['quantity'] ?? 0); - if ($initialQty !== 0) { - $this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock'); - } else { - $this->recalcQuantity($itemId); - } - $item = $this->item->find($itemId); - return $this->respondCreated($item, 'Item added.'); - } - return $this->error('Failed to create item', Response::HTTP_INTERNAL_SERVER_ERROR); - } catch (\Throwable $e) { - log_message('error', 'Inventory item creation error: ' . $e->getMessage()); - return $this->error('Failed to create item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * PATCH /api/v1/inventory/{id} - * Update an inventory item - */ - public function update($id = null) - { - $item = $this->item->find($id); - if (!$item) { - return $this->error('Item not found.', Response::HTTP_NOT_FOUND); - } - - $data = $this->filterItemData($this->payloadData(), $item['type']); - unset($data['id']); - - try { - if (!$this->item->update($id, $data)) { - $errors = $this->item->errors() ?? []; - return $this->respondValidationError($errors, 'Validation failed'); - } - $updatedItem = $this->item->find($id); - return $this->success($updatedItem, 'Item updated.'); - } catch (\Throwable $e) { - log_message('error', 'Inventory item update error: ' . $e->getMessage()); - return $this->error('Failed to update item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * DELETE /api/v1/inventory/{id} - * Delete an inventory item - */ - public function delete($id = null) - { - $item = $this->item->find($id); - if (!$item) { - return $this->error('Item not found.', Response::HTTP_NOT_FOUND); - } - - try { - $type = $item['type']; - $this->item->delete($id); - return $this->success(['type' => $type], 'Item deleted.'); - } catch (\Throwable $e) { - log_message('error', 'Inventory item deletion error: ' . $e->getMessage()); - return $this->error('Failed to delete item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * POST /api/v1/inventory/categories - * Save a category (create or update) - */ - public function saveCategory() - { - $rawType = (string)($this->request->getPost('type') ?? ''); - $type = strtolower(trim($rawType)); - $id = $this->request->getPost('id'); - $name = preg_replace('/\s+/', ' ', trim((string)$this->request->getPost('name'))); - - $data = [ - 'type' => in_array($type, ['classroom', 'book', 'office', 'kitchen'], true) ? $type : 'office', - 'name' => $name, - 'description' => (string)$this->request->getPost('description'), - ]; - - // Only books have grade range - if ($data['type'] === 'book') { - $gmin = $this->request->getPost('grade_min'); - $gmax = $this->request->getPost('grade_max'); - - $gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int)$gmin); - $gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int)$gmax); - - if ($gmin !== null && $gmax !== null && $gmin > $gmax) { - return $this->error('Grade Min cannot be greater than Grade Max.', Response::HTTP_UNPROCESSABLE_ENTITY); - } - if ($gmin !== null && $gmin > 13) $gmin = 13; - if ($gmax !== null && $gmax > 13) $gmax = 13; - - $data['grade_min'] = $gmin; - $data['grade_max'] = $gmax; - } else { - $data['grade_min'] = null; - $data['grade_max'] = null; - } - - // Duplicate guard for (type, name) pair - $existing = $this->cat - ->where('type', $data['type']) - ->where('name', $data['name']) - ->first(); - - try { - if ($id) { - $pk = $this->cat->primaryKey ?? 'id'; - if ($existing && (int)$existing[$pk] !== (int)$id) { - return $this->error( - 'A category with this Type & Name already exists. Please choose a different name or edit the existing one.', - Response::HTTP_CONFLICT - ); - } - $ok = $this->cat->update((int)$id, $data); - } else { - if ($existing) { - return $this->error( - 'This category already exists: ' . $data['type'] . ' — ' . $data['name'] . '.', - Response::HTTP_CONFLICT - ); - } - $ok = (bool) $this->cat->insert($data); - } - } catch (\Throwable $e) { - $msg = $e->getMessage(); - if (strpos($msg, 'Duplicate entry') !== false && strpos($msg, 'type_name') !== false) { - return $this->error( - 'Duplicate Type/Name. A category with this combination already exists.', - Response::HTTP_CONFLICT - ); - } - log_message('critical', 'Failed to save category: {msg}', ['msg' => $msg]); - return $this->error('Failed to save category.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - if (!$ok) { - return $this->error('Failed to save category.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - return $this->success(null, 'Category saved.'); - } - - /** - * GET /api/v1/inventory/classroom/{id}/audit - * Get audit form data for a classroom item - */ - public function auditClassroomForm($itemId) - { - $item = $this->item->find($itemId); - if (!$item || $item['type'] !== 'classroom') { - return $this->error('Invalid classroom item.', Response::HTTP_NOT_FOUND); - } - - $item['good_qty'] = (int)($item['good_qty'] ?? 0); - $item['needs_repair_qty'] = (int)($item['needs_repair_qty'] ?? 0); - $item['need_replace_qty'] = (int)($item['need_replace_qty'] ?? 0); - $item['cannot_find_qty'] = (int)($item['cannot_find_qty'] ?? 0); - - return $this->success(['item' => $item], 'Audit form data retrieved successfully'); - } - - /** - * POST /api/v1/inventory/classroom/{id}/audit - * Save audit data for a classroom item - */ - public function auditClassroomStore($itemId) - { - $item = $this->item->find($itemId); - if (!$item || $item['type'] !== 'classroom') { - return $this->error('Invalid classroom item.', Response::HTTP_NOT_FOUND); - } - - $prevGood = (int)($item['good_qty'] ?? 0); - $prevRepair = (int)($item['needs_repair_qty'] ?? 0); - $prevLoss = (int)($item['need_replace_qty'] ?? 0); - $prevMiss = (int)($item['cannot_find_qty'] ?? 0); - - $newRepair = max(0, (int)$this->request->getPost('needs_repair_qty')); - $newLoss = max(0, (int)$this->request->getPost('need_replace_qty')); - $newMiss = max(0, (int)$this->request->getPost('cannot_find_qty')); - - $onHandNow = (int)$item['quantity']; - $newGood = max(0, $onHandNow - $newRepair); - - $deltaLoss = $newLoss - $prevLoss; - $deltaMiss = $newMiss - $prevMiss; - - $totalOut = 0; - if ($deltaLoss > 0) $totalOut += $deltaLoss; - if ($deltaMiss > 0) $totalOut += $deltaMiss; - - $totalIn = 0; - if ($deltaLoss < 0) $totalIn += -$deltaLoss; - if ($deltaMiss < 0) $totalIn += -$deltaMiss; - - if ($totalOut > 0) { - if (!$this->recordMovement($itemId, -$totalOut, 'out', 'Audit: loss/missing increased')) { - return $this->error('Failed to record movement', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - if ($totalIn > 0) { - $this->recordMovement($itemId, +$totalIn, 'in', 'Audit: loss/missing decreased'); - } - - $this->recalcQuantity($itemId); - - try { - $this->item->update($itemId, [ - 'good_qty' => $newGood, - 'needs_repair_qty' => $newRepair, - 'need_replace_qty' => $newLoss, - 'cannot_find_qty' => $newMiss, - 'updated_by' => $this->getCurrentUserId(), - 'updated_at' => utc_now(), - ]); - return $this->success(null, 'Audit saved.'); - } catch (\Throwable $e) { - log_message('error', 'Audit save error: ' . $e->getMessage()); - return $this->error('Failed to save audit: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/inventory/summary/{type?} - * Get inventory summary for a type - */ - public function summary($type = 'classroom') - { - $type = $this->normalizeType($type); - $currentSem = $this->semester; - $currentYear = $this->schoolYear; - $selectedYear = trim((string) ($this->request->getGet('school_year') ?? '')) ?: $currentYear; - - $items = $this->item->where('type', $type)->orderBy('name', 'ASC')->findAll(); - $itemIds = array_map(fn($i) => (int)$i['id'], $items); - - $agg = []; - if ($itemIds) { - $qb = $this->mov->select('item_id, - SUM(CASE WHEN qty_change>0 THEN qty_change ELSE 0 END) AS received, - SUM(CASE WHEN qty_change<0 THEN -qty_change ELSE 0 END) AS issued') - ->whereIn('item_id', $itemIds) - ->groupBy('item_id'); - - if (strtolower($selectedYear) !== 'all') { - $qb->where('school_year', $selectedYear); - } - - foreach ($qb->findAll() as $row) { - $agg[(int)$row['item_id']] = [ - 'received' => (int)$row['received'], - 'issued' => (int)$row['issued'], - ]; - } - } - - $schoolYears = $this->getSchoolYearsForType($type); - - return $this->success([ - 'type' => $type, - 'items' => $items, - 'agg' => $agg, - 'selectedYear' => $selectedYear, - 'currentYear' => $currentYear, - 'schoolYears' => $schoolYears, - ], 'Summary retrieved successfully'); - } - - /** - * GET /api/v1/inventory/summary-all - * Get comprehensive inventory summary across all types - */ - public function summaryAll() - { - $selectedYear = trim((string) ($this->request->getGet('school_year') ?? '')) ?: (string) $this->schoolYear; - $selectedType = strtolower((string) ($this->request->getGet('type') ?? 'all')); - $isAllYears = (strtolower($selectedYear) === 'all'); - - $qbItems = DB::table('inventory_items i') - ->select('i.*, c.name AS category_name, CONCAT(u.firstname, " ", u.lastname) AS updated_by_name') - ->leftJoin('inventory_categories c', 'c.id', '=', 'i.category_id') - ->leftJoin('users u', 'u.id', '=', 'i.updated_by') - ->orderBy('i.name', 'ASC'); - - if (!$isAllYears) { - $qbItems->where('i.school_year', $selectedYear); - } - - if (in_array($selectedType, ['book', 'classroom', 'office', 'kitchen'], true)) { - $qbItems->where('i.type', $selectedType); - } - - $items = $qbItems->get()->toArray(); - - $rows = []; - $totals = [ - 'opening' => 0, - 'received' => 0, - 'distributed' => 0, - 'other_out' => 0, - 'adjust_net' => 0, - 'ending' => 0, - 'onhand' => 0, - 'variance' => 0, - ]; - - if (empty($items)) { - return $this->success([ - 'selectedYear' => $selectedYear, - 'currentYear' => $this->schoolYear, - 'schoolYears' => $this->getSchoolYearsForType('book') ?: [$this->schoolYear, 'All'], - 'rows' => $rows, - 'totals' => $totals, - 'selectedType' => $selectedType, - ], 'Summary retrieved successfully'); - } - - $itemIds = array_map('intval', array_column($items, 'id')); - - $aggById = []; - $qb = DB::table('inventory_movements m') - ->select(DB::raw(" - m.item_id, - SUM(CASE WHEN m.movement_type = 'initial' THEN m.qty_change ELSE 0 END) AS opening, - SUM(CASE WHEN m.qty_change > 0 AND m.movement_type IN ('in','adjust') THEN m.qty_change ELSE 0 END) AS received, - SUM(CASE WHEN m.qty_change < 0 AND m.movement_type = 'distribution' THEN -m.qty_change ELSE 0 END) AS distributed, - SUM(CASE WHEN m.qty_change < 0 AND m.movement_type = 'out' THEN -m.qty_change ELSE 0 END) AS other_out, - SUM(CASE WHEN m.movement_type = 'adjust' THEN m.qty_change ELSE 0 END) AS adjust_net - ")) - ->whereIn('m.item_id', $itemIds) - ->groupBy('m.item_id'); - - if (!$isAllYears) { - $qb->where('m.school_year', $selectedYear); - } - - foreach ($qb->get()->toArray() as $r) { - $aggById[(int)$r->item_id] = [ - 'opening' => (int)($r->opening ?? 0), - 'received' => (int)($r->received ?? 0), - 'distributed' => (int)($r->distributed ?? 0), - 'other_out' => (int)($r->other_out ?? 0), - 'adjust_net' => (int)($r->adjust_net ?? 0), - ]; - } - - foreach ($items as $it) { - $it = (array)$it; - $id = (int)$it['id']; - $a = $aggById[$id] ?? ['opening' => 0, 'received' => 0, 'distributed' => 0, 'other_out' => 0, 'adjust_net' => 0]; - - $opening = (int)$a['opening']; - $received = (int)$a['received']; - $distributed = (int)$a['distributed']; - $otherOut = (int)$a['other_out']; - $adjustNet = (int)$a['adjust_net']; - - $ending = $opening + $received - $distributed - $otherOut + $adjustNet; - $onhand = (int)$it['quantity']; - $variance = $onhand - $ending; - - $rows[] = [ - 'id' => $id, - 'name' => (string)$it['name'], - 'type' => (string)$it['type'], - 'category' => (string)($it['category_name'] ?? ''), - 'opening' => $opening, - 'received' => $received, - 'distributed' => $distributed, - 'other_out' => $otherOut, - 'adjust_net' => $adjustNet, - 'ending' => $ending, - 'onhand' => $onhand, - 'variance' => $variance, - ]; - - $totals['opening'] += $opening; - $totals['received'] += $received; - $totals['distributed'] += $distributed; - $totals['other_out'] += $otherOut; - $totals['adjust_net'] += $adjustNet; - $totals['ending'] += $ending; - $totals['onhand'] += $onhand; - $totals['variance'] += $variance; - } - - $schoolYears = $this->getSchoolYearsForType('book') ?: [$this->schoolYear, 'All']; - - return $this->success([ - 'selectedYear' => $selectedYear, - 'currentYear' => $this->schoolYear, - 'schoolYears' => $schoolYears, - 'rows' => $rows, - 'totals' => $totals, - 'selectedType' => $selectedType, - ], 'Summary retrieved successfully'); - } - - /** - * GET /api/v1/inventory/{id}/adjust - * Get adjust form data - */ - public function adjustForm($itemId) - { - $item = $this->item->find($itemId); - if (!$item) { - return $this->error('Item not found.', Response::HTTP_NOT_FOUND); - } - return $this->success(['item' => $item, 'type' => $item['type']], 'Adjust form data retrieved successfully'); - } - - /** - * POST /api/v1/inventory/{id}/adjust - * Adjust inventory quantity - */ - public function adjustStore($itemId) - { - $item = $this->item->find($itemId); - if (!$item) { - return $this->error('Item not found.', Response::HTTP_NOT_FOUND); - } - - $mode = $this->request->getPost('mode'); - $qty = (int) $this->request->getPost('quantity'); - $reason = (string) $this->request->getPost('reason'); - $note = (string) $this->request->getPost('note'); - - if ($qty <= 0) { - return $this->error('Quantity must be > 0.', Response::HTTP_UNPROCESSABLE_ENTITY); - } - - $delta = ($mode === 'out') ? -abs($qty) : (($mode === 'in') ? abs($qty) : $qty); - $type = ($mode === 'out') ? 'out' : (($mode === 'in') ? 'in' : 'adjust'); - - if (!$this->recordMovement((int)$itemId, $delta, $type, $reason, $note)) { - return $this->error('Failed to record movement', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - return $this->success(['type' => $item['type']], 'Stock updated.'); - } - - /** - * GET /api/v1/inventory/books/distribute - * Get teacher book distribution form data - */ - public function teacherDistributeForm() - { - $ctx = $this->buildTeacherClassContext(); - if (is_array($ctx) && isset($ctx['error'])) { - return $this->error($ctx['error'], $ctx['code'] ?? Response::HTTP_UNAUTHORIZED); - } - - $lockClassSection = is_array($ctx['classSectionIds'] ?? null) && count($ctx['classSectionIds']) === 1; - $classSectionId = $lockClassSection - ? (int) ($ctx['defaultClassSectionId'] ?? 0) - : (int) ($this->request->getGet('class_section_id') ?? ($ctx['defaultClassSectionId'] ?? 0)); - - $classID = null; - if (!empty($classSectionId)) { - $classID = $this->classSection->getClassId($classSectionId); - if ($classID !== null) $classID = (int) $classID; - } - - $itemId = (int) ($this->request->getGet('item_id') ?? 0); - - $db = DB::connection(); - $builder = $db->table('inventory_items i') - ->select('i.id,i.name,i.isbn,i.edition,i.quantity,i.category_id,i.type, - c.name AS category_name, c.grade_min, c.grade_max') - ->leftJoin('inventory_categories c', 'c.id', '=', 'i.category_id') - ->where('i.type', 'book'); - - if (!empty($classID)) { - $builder->where(function($query) use ($classID) { - $query->where(function($q) use ($classID) { - $q->whereNotNull('c.grade_min') - ->whereNotNull('c.grade_max') - ->where('c.grade_min', '<=', (int)$classID) - ->where('c.grade_max', '>=', (int)$classID); - }) - ->orWhere(function($q) use ($classID) { - $q->whereNotNull('c.grade_min') - ->whereNull('c.grade_max') - ->where('c.grade_min', (int)$classID); - }) - ->orWhere(function($q) use ($classID) { - $q->whereNotNull('c.grade_max') - ->whereNull('c.grade_min') - ->where('c.grade_max', (int)$classID); - }); - }); - } - - $rows = $builder->orderBy('i.name', 'ASC')->get()->toArray(); - - $labelFor = static function (?array $r): string { - $name = $r['category_name'] ?? 'Uncategorized'; - $gmin = $r['grade_min'] ?? null; - $gmax = $r['grade_max'] ?? null; - if ($gmin === null && $gmax === null) return $name; - if ($gmin !== null && $gmax !== null) return $name . ' (G' . (int)$gmin . '–G' . (int)$gmax . ')'; - if ($gmin !== null) return $name . ' (G' . (int)$gmin . '+)'; - return $name . ' (≤G' . (int)$gmax . ')'; - }; - - $booksGrouped = []; - foreach ($rows as $r) { - $r = (array)$r; - $catId = (int)($r['category_id'] ?? 0); - if (!isset($booksGrouped[$catId])) { - $booksGrouped[$catId] = [ - 'label' => $labelFor($r), - 'items' => [], - ]; - } - $display = $r['name']; - if (!empty($r['isbn'])) $display .= ' — ISBN ' . $r['isbn']; - if (!empty($r['edition'])) $display .= ' (' . $r['edition'] . ')'; - - $booksGrouped[$catId]['items'][] = [ - 'id' => (int)$r['id'], - 'display' => $display, - 'quantity' => (int)($r['quantity'] ?? 0), - ]; - } - - uasort($booksGrouped, fn($a, $b) => strnatcasecmp($a['label'], $b['label'])); - foreach ($booksGrouped as &$grp) { - usort($grp['items'], fn($x, $y) => strnatcasecmp($x['display'], $y['display'])); - } - unset($grp); - - $students = $ctx['studentsData'][$classSectionId] ?? []; - - $already = []; - if ($itemId && $classSectionId) { - $rowsHist = $this->mov - ->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty') - ->where([ - 'item_id' => $itemId, - 'movement_type' => 'distribution', - 'class_section_id' => $classSectionId, - 'school_year' => $ctx['schoolYear'], - ]) - ->groupBy('student_id') - ->findAll(); - - foreach ($rowsHist as $r) { - $sid = (int) ($r['student_id'] ?? 0); - if ($sid) $already[$sid] = (int) ($r['qty'] ?? 0); - } - } - - $onHand = 0; - if ($itemId) { - $book = $this->item->find($itemId); - if ($book && ($book['type'] ?? '') === 'book') { - $onHand = (int) ($book['quantity'] ?? 0); - } - } - - return $this->success([ - 'booksGrouped' => $booksGrouped, - 'items' => $rows, - 'classes' => $ctx['classes'], - 'class_section_id' => $classSectionId, - 'lockClassSection' => $lockClassSection, - 'item_id' => $itemId, - 'students' => $students, - 'already' => $already, - 'onHand' => $onHand, - 'schoolYear' => $ctx['schoolYear'], - 'semester' => $ctx['semester'], - ], 'Distribution form data retrieved successfully'); - } - - /** - * POST /api/v1/inventory/books/distribute - * Distribute books to students - */ - public function teacherDistributeStore() - { - $ctx = $this->buildTeacherClassContext(); - if (is_array($ctx) && isset($ctx['error'])) { - return $this->error($ctx['error'], $ctx['code'] ?? Response::HTTP_UNAUTHORIZED); - } - - $itemId = (int)$this->request->getPost('item_id'); - $postedClassId = (int)$this->request->getPost('class_section_id'); - $selectedIds = (array)$this->request->getPost('student_ids'); - $note = (string)$this->request->getPost('note'); - - $classIds = $ctx['classSectionIds']; - $classSectionId = (count($classIds) === 1) - ? (int)$classIds[0] - : (in_array($postedClassId, $classIds, true) ? $postedClassId : 0); - - if (!$classSectionId) { - return $this->error('Invalid class selection.', Response::HTTP_UNPROCESSABLE_ENTITY); - } - - $book = $this->item->find($itemId); - if (!$book || $book['type'] !== 'book') { - return $this->error('Invalid book.', Response::HTTP_NOT_FOUND); - } - - $students = $ctx['studentsData'][$classSectionId] ?? []; - $validIds = []; - foreach ($students as $st) { - $sid = $st['student_id'] ?? ($st['id'] ?? ($st['user_id'] ?? null)); - if ($sid) $validIds[(int)$sid] = true; - } - $selectedIds = array_values(array_unique(array_map('intval', $selectedIds))); - $selectedIds = array_values(array_filter($selectedIds, fn($sid) => isset($validIds[$sid]))); - - $already = []; - $rows = $this->mov - ->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty') - ->where([ - 'item_id' => $itemId, - 'movement_type' => 'distribution', - 'class_section_id' => $classSectionId, - 'school_year' => $ctx['schoolYear'], - ])->groupBy('student_id')->findAll(); - foreach ($rows as $r) { - $sid = (int)($r['student_id'] ?? 0); - if ($sid) $already[$sid] = (int)$r['qty']; - } - - $toGive = []; - foreach ($selectedIds as $sid) { - if (($already[$sid] ?? 0) < 1) $toGive[] = $sid; - } - - $needed = count($toGive); - $onHand = (int)$book['quantity']; - - if ($needed > $onHand) { - return $this->error('Not enough stock: need ' . $needed . ', on hand ' . $onHand . '.', Response::HTTP_UNPROCESSABLE_ENTITY); - } - - $okCount = 0; - foreach ($toGive as $sid) { - $ok = $this->recordMovement($itemId, -1, 'distribution', 'Teacher distribution', $note, $classSectionId, $sid); - if ($ok) $okCount++; - } - - if ($okCount === 0) { - return $this->error('No changes (everyone selected already has this book).', Response::HTTP_UNPROCESSABLE_ENTITY); - } - - return $this->success(['distributed_count' => $okCount], 'Distributed to ' . $okCount . ' student(s).'); - } - - /** - * GET /api/v1/inventory/movements - * List inventory movements - */ - public function movementsIndex() - { - $selectedYear = (string)($this->request->getGet('school_year') ?? ''); - $selectedSem = (string)($this->request->getGet('semester') ?? ''); - - $builder = DB::table('inventory_movements m') - ->select([ - 'm.*', - 'ii.name AS item_name', - DB::raw("CONCAT(pb.firstname, ' ', pb.lastname) AS performed_by_name"), - DB::raw("CONCAT(t.firstname, ' ', t.lastname) AS teacher_name"), - DB::raw("CONCAT(s.firstname, ' ', s.lastname) AS student_name"), - 'cs.class_section_name', - ]) - ->leftJoin('inventory_items ii', 'ii.id', '=', 'm.item_id') - ->leftJoin('users pb', 'pb.id', '=', 'm.performed_by') - ->leftJoin('users t', 't.id', '=', 'm.teacher_id') - ->leftJoin('students s', 's.id', '=', 'm.student_id') - ->leftJoin('classSection cs', 'cs.class_section_id', '=', 'm.class_section_id') - ->orderBy('m.id', 'DESC'); - - if ($selectedYear !== '') $builder->where('m.school_year', $selectedYear); - if ($selectedSem !== '') $builder->where('m.semester', $selectedSem); - - $movements = $builder->get()->toArray(); - - return $this->success([ - 'movements' => $movements, - 'schoolYear' => $selectedYear, - 'semester' => $selectedSem, - ], 'Movements retrieved successfully'); - } - - /** - * GET /api/v1/inventory/movements/create - * Get form data for creating a movement - */ - public function movementsCreate() - { - return $this->success([ - 'movement' => $this->blankMovement(), - 'movementTypes' => $this->movementTypes, - 'semesters' => $this->semesters, - 'items' => $this->getItems(), - 'classSections' => $this->getClassSections(), - 'teachers' => $this->getTeachers(), - 'students' => $this->getStudents(), - ], 'Form data retrieved successfully'); - } - - /** - * POST /api/v1/inventory/movements - * Create a movement - */ - public function movementsStore() - { - $rules = [ - 'item_id' => 'required|integer', - 'qty_change' => 'required|integer', - 'movement_type' => 'required|in_list[' . implode(',', $this->movementTypes) . ']', - 'reason' => 'permit_empty|max_length[120]', - 'note' => 'permit_empty|string', - 'semester' => 'permit_empty|in_list[' . implode(',', $this->semesters) . ']', - 'school_year' => 'permit_empty|max_length[16]', - 'teacher_id' => 'permit_empty|integer', - 'student_id' => 'permit_empty|integer', - 'class_section_id' => 'permit_empty|integer', - ]; - - $payload = $this->payloadData(); - $errors = $this->validateRequest($payload, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $userId = (int) ($this->getCurrentUserId() ?? 0); - - $data = [ - 'item_id' => (int) $payload['item_id'], - 'qty_change' => (int) $payload['qty_change'], - 'movement_type' => $payload['movement_type'], - 'reason' => trim((string)($payload['reason'] ?? '')), - 'note' => trim((string)($payload['note'] ?? '')), - 'semester' => $payload['semester'] ?? null, - 'school_year' => $payload['school_year'] ?? null, - 'performed_by' => $userId ?: null, - 'teacher_id' => $this->nullableInt($payload['teacher_id'] ?? null), - 'student_id' => $this->nullableInt($payload['student_id'] ?? null), - 'class_section_id' => $this->nullableInt($payload['class_section_id'] ?? null), - 'created_at' => utc_now(), - 'updated_at' => utc_now(), - ]; - - try { - $ok = DB::table('inventory_movements')->insert($data); - if (!$ok) { - return $this->error('Could not save movement.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - return $this->respondCreated($data, 'Movement created.'); - } catch (\Throwable $e) { - log_message('error', 'Movement creation error: ' . $e->getMessage()); - return $this->error('Could not save movement: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/inventory/movements/{id} - * Get a single movement - */ - public function movementsEdit($id) - { - $movement = DB::table('inventory_movements')->where('id', $id)->first(); - if (!$movement) { - return $this->error('Movement not found.', Response::HTTP_NOT_FOUND); - } - - $movement = $this->hydrateNames((array)$movement); - - return $this->success([ - 'movement' => $movement, - 'movementTypes' => $this->movementTypes, - 'semesters' => $this->semesters, - 'items' => $this->getItems(), - 'classSections' => $this->getClassSections(), - 'teachers' => $this->getTeachers(), - 'students' => $this->getStudents(), - ], 'Movement retrieved successfully'); - } - - /** - * PATCH /api/v1/inventory/movements/{id} - * Update a movement - */ - public function movementsUpdate($id) - { - $rules = [ - 'qty_change' => 'required|integer', - 'movement_type' => 'required|in_list[' . implode(',', $this->movementTypes) . ']', - 'reason' => 'permit_empty|max_length[120]', - 'note' => 'permit_empty|string', - 'semester' => 'permit_empty|in_list[' . implode(',', $this->semesters) . ']', - 'school_year' => 'permit_empty|max_length[16]', - 'teacher_id' => 'permit_empty|integer', - 'student_id' => 'permit_empty|integer', - 'class_section_id' => 'permit_empty|integer', - ]; - - $payload = $this->payloadData(); - $errors = $this->validateRequest($payload, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $existing = DB::table('inventory_movements')->where('id', $id)->first(); - if (!$existing) { - return $this->error('Movement not found.', Response::HTTP_NOT_FOUND); - } - - $data = [ - 'qty_change' => (int) $payload['qty_change'], - 'movement_type' => $payload['movement_type'], - 'reason' => trim((string)($payload['reason'] ?? '')), - 'note' => trim((string)($payload['note'] ?? '')), - 'semester' => $payload['semester'] ?? null, - 'school_year' => $payload['school_year'] ?? null, - 'teacher_id' => $this->nullableInt($payload['teacher_id'] ?? null), - 'student_id' => $this->nullableInt($payload['student_id'] ?? null), - 'class_section_id' => $this->nullableInt($payload['class_section_id'] ?? null), - 'updated_at' => utc_now(), - ]; - - try { - $ok = DB::table('inventory_movements')->where('id', $id)->update($data); - if (!$ok) { - return $this->error('Could not update movement.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - $updated = DB::table('inventory_movements')->where('id', $id)->first(); - return $this->success($updated, 'Movement updated.'); - } catch (\Throwable $e) { - log_message('error', 'Movement update error: ' . $e->getMessage()); - return $this->error('Could not update movement: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * DELETE /api/v1/inventory/movements/{id} - * Delete a movement - */ - public function movementsDelete($id) - { - log_message('info', 'Inventory: delete one attempt', ['id' => $id, 'user' => (int)($this->getCurrentUserId() ?? 0)]); - try { - $ok = DB::table('inventory_movements')->where('id', $id)->delete(); - log_message('info', 'Inventory: delete one result', ['id' => $id, 'ok' => (bool)$ok]); - if (!$ok) { - return $this->error('Could not delete movement.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - return $this->success(null, 'Movement deleted.'); - } catch (\Throwable $e) { - log_message('error', 'Movement deletion error: ' . $e->getMessage()); - return $this->error('Could not delete movement: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * POST /api/v1/inventory/movements/bulk-delete - * Bulk delete movements - */ - public function movementsBulkDelete() - { - $ids = (array) $this->request->getPost('ids'); - $ids = array_values(array_filter(array_map('intval', $ids), fn($v) => $v > 0)); - - if (empty($ids)) { - log_message('warning', 'Inventory: bulk delete called with empty ids', ['user' => (int)($this->getCurrentUserId() ?? 0)]); - return $this->error('No movements selected.', Response::HTTP_BAD_REQUEST); - } - - log_message('info', 'Inventory: bulk delete attempt', ['count' => count($ids), 'ids' => $ids, 'user' => (int)($this->getCurrentUserId() ?? 0)]); - try { - $ok = DB::table('inventory_movements')->whereIn('id', $ids)->delete(); - log_message('info', 'Inventory: bulk delete result', ['ok' => (bool)$ok]); - if (!$ok) { - return $this->error('Bulk delete failed.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - return $this->success(['deleted_count' => count($ids)], count($ids) . ' movement(s) deleted.'); - } catch (\Throwable $e) { - log_message('error', 'Bulk delete error: ' . $e->getMessage()); - return $this->error('Bulk delete failed: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - // ========== Private Helper Methods ========== - - private function normalizeType(?string $type): string - { - $type = strtolower((string)$type); - return in_array($type, $this->types, true) ? $type : 'classroom'; - } - - private function filterItemData(array $data, ?string $lockedType = null): array - { - $type = $lockedType ?? $this->normalizeType($data['type'] ?? 'classroom'); - - $qtyRaw = $data['quantity'] ?? null; - $quantity = is_numeric($qtyRaw) ? (int)$qtyRaw : 0; - - $base = [ - 'type' => $type, - 'category_id' => (isset($data['category_id']) && $data['category_id'] !== null && $data['category_id'] !== '') ? (int)$data['category_id'] : null, - 'name' => (string) ($data['name'] ?? ''), - 'description' => $data['description'] ?? null, - 'quantity' => $quantity, - 'unit' => $data['unit'] ?? null, - 'sku' => $data['sku'] ?? null, - 'notes' => $data['notes'] ?? null, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'updated_by' => $this->getCurrentUserId(), - ]; - - $base['condition'] = ($type === 'classroom') ? ($data['condition'] ?? null) : null; - - if ($type === 'book') { - $base['isbn'] = $data['isbn'] ?? null; - $base['edition'] = $data['edition'] ?? null; - } else { - $base['isbn'] = $base['edition'] = null; - } - - return $base; - } - - private function ensureInitialMovement(int $itemId): void - { - $hasMov = $this->mov->where('item_id', $itemId)->limit(1)->countAllResults(); - if ($hasMov > 0) return; - - $item = $this->item->select('quantity')->find($itemId); - $initialQty = (int)($item['quantity'] ?? 0); - - $this->mov->insert([ - 'item_id' => $itemId, - 'qty_change' => $initialQty, - 'movement_type' => 'initial', - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'performed_by' => $this->getCurrentUserId(), - ], false); - } - - private function recordMovement( - int $itemId, - int $qtyChange, - string $type, - ?string $reason = null, - ?string $note = null, - ?int $classSectionId = null, - ?int $studentId = null - ): bool { - $me = $this->getCurrentUserId(); - $isInitial = ($type === 'initial'); - - if (!$isInitial) { - $this->ensureInitialMovement($itemId); - $this->ensureInitialMovementForYear($itemId, $this->schoolYear); - $this->recalcQuantity($itemId); - } - - if (in_array($type, ['out', 'distribution'], true) && $qtyChange > 0) { - $qtyChange = -abs($qtyChange); - } - - if (in_array($type, ['out', 'distribution'], true)) { - $onHand = (int) ($this->item->select('quantity')->where('id', $itemId)->first()['quantity'] ?? 0); - if ($onHand + $qtyChange < 0) { - return false; - } - } - - $ok = $this->mov->insert([ - 'item_id' => $itemId, - 'qty_change' => $qtyChange, - 'movement_type' => $type, - 'reason' => $reason, - 'note' => $note, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'performed_by' => $me, - 'teacher_id' => $me, - 'student_id' => $studentId, - 'class_section_id' => $classSectionId, - ], false); - - if ($ok) { - $this->recalcQuantity($itemId); - return true; - } - return false; - } - - private function recalcQuantity(int $itemId): void - { - $sumRow = $this->mov - ->selectSum('qty_change') - ->where('item_id', $itemId) - ->first(); - - $sum = (int)($sumRow['qty_change'] ?? 0); - if ($sum < 0) $sum = 0; - - $item = $this->item - ->select('id, type, quantity, needs_repair_qty, good_qty') - ->find($itemId); - - if (!$item) return; - - $updates = []; - - if ((int)$item['quantity'] !== $sum) { - $updates['quantity'] = $sum; - } - - if (($item['type'] ?? null) === 'classroom') { - $needsRepair = (int)($item['needs_repair_qty'] ?? 0); - $goodCalc = max(0, $sum - $needsRepair); - if (!array_key_exists('good_qty', $item) || (int)$item['good_qty'] !== $goodCalc) { - $updates['good_qty'] = $goodCalc; - } - } - - if (!empty($updates)) { - try { - $this->item->update($itemId, $updates); - } catch (\Throwable $e) { - log_message('error', 'recalcQuantity() skipped update: ' . $e->getMessage()); - } - } - } - - private function userNamesByIds(array $ids): array - { - $ids = array_values(array_unique(array_filter($ids, fn($v) => !empty($v)))); - if (!$ids) return []; - - if (!DB::getSchemaBuilder()->hasTable('users')) return []; - - $cols = DB::getSchemaBuilder()->getColumnListing('users'); - $hasFirst = in_array('first_name', $cols, true) || in_array('firstname', $cols, true); - $hasLast = in_array('last_name', $cols, true) || in_array('lastname', $cols, true); - $firstCol = in_array('first_name', $cols, true) ? 'first_name' : (in_array('firstname', $cols, true) ? 'firstname' : null); - $lastCol = in_array('last_name', $cols, true) ? 'last_name' : (in_array('lastname', $cols, true) ? 'lastname' : null); - $nameCol = in_array('name', $cols, true) ? 'name' : null; - $emailCol = in_array('email', $cols, true) ? 'email' : null; - - if ($firstCol || $lastCol) { - $select = "id, TRIM(CONCAT(IFNULL($firstCol,''),' ',IFNULL($lastCol,''))) AS display_name"; - } elseif ($nameCol) { - $select = "id, $nameCol AS display_name"; - } elseif ($emailCol) { - $select = "id, $emailCol AS display_name"; - } else { - $out = []; - foreach ($ids as $id) $out[(int)$id] = 'User #' . $id; - return $out; - } - - $rows = DB::table('users')->select(DB::raw($select))->whereIn('id', $ids)->get()->toArray(); - $map = []; - foreach ($rows as $r) { - $r = (array)$r; - $disp = trim((string)($r['display_name'] ?? '')); - $map[(int)$r['id']] = $disp !== '' ? $disp : ('User #' . $r['id']); - } - return $map; - } - - private function getSchoolYearsForType(string $type): array - { - $years = $this->item - ->select('school_year') - ->where('type', $type) - ->whereRaw('school_year IS NOT NULL') - ->groupBy('school_year') - ->orderBy('school_year', 'DESC') - ->pluck('school_year') - ->toArray(); - - $years = array_values(array_unique(array_filter(array_map('trim', $years)))); - - $currentYear = $this->schoolYear; - if ($currentYear && !in_array($currentYear, $years, true)) { - array_unshift($years, $currentYear); - $years = array_values(array_unique($years)); - } - - return $years; - } - - private function ensureInitialMovementForYear(int $itemId, string $schoolYear): void - { - $hasAnyThisYear = $this->mov - ->where('item_id', $itemId) - ->where('school_year', $schoolYear) - ->limit(1)->countAllResults(); - - if ($hasAnyThisYear === 0) { - $onHand = (int) ($this->item->select('quantity')->where('id', $itemId)->first()['quantity'] ?? 0); - $this->mov->insert([ - 'item_id' => $itemId, - 'qty_change' => $onHand, - 'movement_type' => 'initial', - 'school_year' => $schoolYear, - 'semester' => $this->semester ?? null, - 'performed_by' => $this->getCurrentUserId(), - ], false); - return; - } - - $hasInitialThisYear = $this->mov - ->where('item_id', $itemId) - ->where('school_year', $schoolYear) - ->where('movement_type', 'initial') - ->limit(1)->countAllResults(); - - if ($hasInitialThisYear === 0) { - $onHand = (int) ($this->item->select('quantity')->where('id', $itemId)->first()['quantity'] ?? 0); - $netYear = (int) ($this->mov->selectSum('qty_change') - ->where('item_id', $itemId) - ->where('school_year', $schoolYear) - ->first()['qty_change'] ?? 0); - - $opening = $onHand - $netYear; - $this->mov->insert([ - 'item_id' => $itemId, - 'qty_change' => $opening, - 'movement_type' => 'initial', - 'school_year' => $schoolYear, - 'semester' => $this->semester ?? null, - 'performed_by' => $this->getCurrentUserId(), - ], false); - } - } - - private function buildTeacherClassContext() - { - $user_id = $this->getCurrentUserId(); - if (!$user_id) { - return ['error' => 'Please log in first', 'code' => Response::HTTP_UNAUTHORIZED]; - } - - $classes = $this->teacherClass->getClassAssignmentsByUserId($user_id, $this->schoolYear); - - if (empty($classes)) { - return ['error' => 'You do not have an assigned class yet. Please contact the administration.', 'code' => Response::HTTP_FORBIDDEN]; - } - - $user = $this->user->find($user_id); - if (!$user) { - return ['error' => 'User not found', 'code' => Response::HTTP_NOT_FOUND]; - } - - $roles = DB::table('user_roles ur') - ->select('r.name') - ->join('roles r', 'r.id', '=', 'ur.role_id') - ->where('ur.user_id', $user_id) - ->get()->toArray(); - - $roleNames = array_map(fn($r) => $r->name, $roles); - - if (in_array('teacher', $roleNames, true)) { - $user['role_name'] = 'teacher'; - } elseif (in_array('teacher_assistant', $roleNames, true)) { - $user['role_name'] = 'teacher_assistant'; - } else { - return ['error' => 'Access denied', 'code' => Response::HTTP_FORBIDDEN]; - } - - $classSectionIds = array_column($classes, 'class_section_id'); - $studentsData = []; - - if (!empty($classSectionIds)) { - $students = $this->studentClass->getStudentsByClassSectionIds($classSectionIds); - foreach ($students as $student) { - $studentsData[$student['class_section_id']][] = $student; - } - } - - $taAssignments = $this->teacherClass - ->whereIn('class_section_id', $classSectionIds) - ->where('school_year', $this->schoolYear) - ->where('position', 'ta') - ->findAll(); - - $taNames = []; - foreach ($taAssignments as $ta) { - $taUser = $this->user->find($ta['teacher_id']); - if ($taUser) { - $csid = $ta['class_section_id']; - $taNames[$csid][] = ($taUser['firstname'] ?? '') . ' ' . ($taUser['lastname'] ?? ''); - } - } - - $defaultClassSectionId = $classSectionIds[0] ?? null; - - return [ - 'user' => $user, - 'roleNames' => $roleNames, - 'classes' => $classes, - 'classSectionIds' => $classSectionIds, - 'studentsData' => $studentsData, - 'taNames' => $taNames, - 'defaultClassSectionId' => $defaultClassSectionId, - 'schoolYear' => $this->schoolYear, - 'semester' => $this->semester, - 'user_id' => $user_id, - ]; - } - - private function blankMovement(): array - { - return [ - 'id' => null, - 'item_id' => null, - 'qty_change' => 0, - 'movement_type' => 'adjust', - 'reason' => '', - 'note' => '', - 'semester' => null, - 'school_year' => null, - 'performed_by' => $this->getCurrentUserId(), - 'teacher_id' => null, - 'student_id' => null, - 'class_section_id' => null, - 'created_at' => null, - 'updated_at' => null, - ]; - } - - private function nullableInt($val): ?int - { - if ($val === null || $val === '' || $val === '0') { - return null; - } - return (int) $val; - } - - private function hydrateNames(array $m): array - { - if (!empty($m['item_id'])) { - $row = DB::table('inventory_items')->select('name')->where('id', $m['item_id'])->first(); - $m['item_name'] = $row->name ?? null; - } - - if (!empty($m['performed_by'])) { - $row = DB::table('users') - ->select(DB::raw("CONCAT(firstname, ' ', lastname) AS fullname")) - ->where('id', $m['performed_by']) - ->first(); - $m['performed_by_name'] = $row->fullname ?? null; - } - - if (!empty($m['teacher_id'])) { - $row = DB::table('users') - ->select(DB::raw("CONCAT(firstname, ' ', lastname) AS fullname")) - ->where('id', $m['teacher_id']) - ->first(); - $m['teacher_name'] = $row->fullname ?? null; - } - - if (!empty($m['student_id'])) { - $row = DB::table('students') - ->select(DB::raw("CONCAT(firstname, ' ', lastname) AS fullname")) - ->where('id', $m['student_id']) - ->first(); - $m['student_name'] = $row->fullname ?? null; - } - - if (!empty($m['class_section_id'])) { - $row = DB::table('classSection') - ->select('class_section_name') - ->where('class_section_id', $m['class_section_id']) - ->first(); - $m['class_section_name'] = $row->class_section_name ?? null; - } - - return $m; - } - - private function getTeachers(): array - { - $rows = $this->teacher->getTeachersAndTAs(); - - return array_map(static function ($r) { - $fullname = trim(($r['firstname'] ?? '') . ' ' . ($r['lastname'] ?? '')); - return [ - 'id' => (int) $r['id'], - 'firstname' => $r['firstname'] ?? '', - 'lastname' => $r['lastname'] ?? '', - 'fullname' => $fullname, - 'email' => $r['email'] ?? '', - 'cellphone' => $r['cellphone'] ?? '', - 'role' => $r['role'] ?? '', - ]; - }, $rows); - } - - private function getItems(): array - { - return DB::table('inventory_items')->select('id', 'name')->orderBy('name', 'ASC')->get()->toArray(); - } - - private function getClassSections(): array - { - return DB::table('classSection') - ->select('class_section_id', 'class_section_name') - ->orderBy('class_section_name', 'ASC')->get()->toArray(); - } - - private function getStudents(): array - { - return DB::table('students') - ->select(DB::raw("id, CONCAT(firstname, ' ', lastname) AS fullname")) - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->get() - ->toArray(); - } -} diff --git a/app/Http/Controllers/Api/InvoiceController.php b/app/Http/Controllers/Api/InvoiceController.php deleted file mode 100755 index 88ce1521..00000000 --- a/app/Http/Controllers/Api/InvoiceController.php +++ /dev/null @@ -1,1085 +0,0 @@ -additionalCharge = model(AdditionalCharge::class); - $this->classSection = model(ClassSection::class); - $this->invoice = model(Invoice::class); - $this->student = model(Student::class); - $this->enrollment = model(Enrollment::class); - $this->config = model(Configuration::class); - $this->user = model(User::class); - $this->studentClass = model(StudentClass::class); - $this->charges = model(EventCharges::class); - $this->discountUsage = model(DiscountUsage::class); - $this->refund = model(Refund::class); - $this->payment = model(Payment::class); - - $this->gradeFee = (int) ($this->config->getConfig('grade_fee') ?? 9); - $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); - $this->semester = (string) ($this->config->getConfig('semester') ?? ''); - $this->dueDate = $this->config->getConfig('due_date'); - $this->firstStudentFee = (float) ($this->config->getConfig('first_student_fee') ?? 350); - $this->secondStudentFee = (float) ($this->config->getConfig('second_student_fee') ?? 200); - $this->youthFee = (float) ($this->config->getConfig('youth_fee') ?? 180); - $refundDeadlineRaw = $this->config->getConfig('refund_deadline'); - $this->refundDeadline = $refundDeadlineRaw ? date('Y-m-d', strtotime($refundDeadlineRaw)) : date('Y-m-d'); - } - - /** - * GET /api/v1/invoices - * List invoices with optional filters - */ - public function index(): JsonResponse - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $parentId = $this->request->getGet('parent_id'); - $status = $this->request->getGet('status'); - $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; - - $query = $this->invoice->newQuery(); - - if ($parentId) { - $query->where('parent_id', (int) $parentId); - } - - if ($status) { - $query->where('status', $status); - } - - if ($schoolYear) { - $query->where('school_year', $schoolYear); - } - - $result = $this->paginate($query, $page, $perPage); - - return $this->success($result, 'Invoices retrieved successfully'); - } - - /** - * GET /api/v1/invoices/{id} - * Get a single invoice - */ - public function show($id = null): JsonResponse - { - $invoice = $this->invoice->find($id); - if (!$invoice) { - return $this->error('Invoice not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($invoice, 'Invoice retrieved successfully'); - } - - /** - * GET /api/v1/invoices/management-data - * Invoice management composite data (used by invoice_management view) - */ - public function managementData(): JsonResponse - { - $schoolYear = trim((string) ($this->request->getGet('schoolYear') ?? $this->request->getGet('year') ?? '')); - if ($schoolYear === '') { - $schoolYear = $this->schoolYear; - } - - $invoiceData = []; - - try { - // Distinct school years (for selector) - $yearsRows = $this->invoice - ->select('school_year') - ->distinct() - ->orderBy('school_year', 'DESC') - ->findAll(); - - $schoolYears = array_values(array_filter(array_map(static function ($r) { - return isset($r['school_year']) ? (string) $r['school_year'] : null; - }, $yearsRows))); - - if (empty($schoolYears)) { - $schoolYears = [$this->schoolYear]; - } - - $parents = $this->user->getUsersByRoleAndSchoolYear('parent', $schoolYear); - - foreach ($parents as $parent) { - $students = $this->student->where('parent_id', $parent['id'])->findAll(); - - $parentData = [ - 'parent_name' => trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')), - 'parent_id' => (int) $parent['id'], - 'enrolledKids' => [], - 'withdrawnKids' => [], - 'invoice_amount' => 0, - 'refund_amount' => 0, - 'last_updated' => null, - 'invoice_date' => null, - 'invoice_id' => null, - ]; - - // Latest invoice - $invoices = $this->invoice->getInvoicesByParentId($parent['id'], $schoolYear); - foreach ($invoices as $invoice) { - if ($invoice) { - $parentData['invoice_amount'] = (float) ($invoice['total_amount'] ?? 0); - $parentData['last_updated'] = $invoice['updated_at'] ?? null; - - // Prefer issue_date (UTC) -> local; fall back to updated_at/created_at - if (!empty($invoice['issue_date'])) { - $tzName = user_timezone(); - $parentData['invoice_date'] = (new CarbonImmutable($invoice['issue_date'], new DateTimeZone('UTC'))) - ->setTimezone(new DateTimeZone($tzName)) - ->format('Y-m-d H:i:s'); - } else { - $parentData['invoice_date'] = date('Y-m-d H:i:s', strtotime($invoice['updated_at'] ?? $invoice['created_at'] ?? 'now')); - } - $parentData['invoice_id'] = $invoice['id'] ?? null; - - // Refund total paid for parent/year (Partial/Paid) - $refund = DB::table('refunds') - ->select(DB::raw('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')) - ->where('parent_id', $parent['id']) - ->where('school_year', $schoolYear) - ->whereIn('status', ['Partial', 'Paid']) - ->first(); - - $parentData['refund_amount'] = (float) ($refund->refund_paid_amount ?? 0.0); - break; // only most recent as before - } - } - - // Build kids lists based on enrollment statuses - foreach ($students as $student) { - $studentClass = DB::table('student_class') - ->where('student_id', $student['id']) - ->where('school_year', $schoolYear) - ->first(); - - $grade = 'N/A'; - if ($studentClass && isset($studentClass->class_section_id)) { - $classSection = DB::table('class_sections') - ->where('class_section_id', $studentClass->class_section_id) - ->first(); - - if ($classSection && isset($classSection->class_section_name)) { - $grade = $classSection->class_section_name; - } - } - - $enrollments = $this->enrollment - ->where('student_id', $student['id']) - ->where('school_year', $schoolYear) - ->findAll(); - - foreach ($enrollments as $enrollment) { - $kid = [ - 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), - 'grade' => $grade, - 'tuition_fee' => (float) ($enrollment['tuition_fee'] ?? 0), - ]; - - switch ($enrollment['enrollment_status']) { - case 'payment pending': - case 'enrolled': - $parentData['enrolledKids'][] = $kid; - break; - case 'withdraw under review': - case 'withdrawn': - case 'refund pending': - $parentData['withdrawnKids'][] = $kid; - break; - default: - // ignore others for invoice summary - break; - } - } - } - - if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) { - $invoiceData[] = $parentData; - } - } - - return $this->success([ - 'ok' => true, - 'schoolYear' => $schoolYear, - 'schoolYears' => $schoolYears, - 'invoices' => $invoiceData, - ], 'Management data retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'managementData error: ' . $e->getMessage()); - return $this->respondError('Server error: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * POST /api/v1/invoices/generate - * Generate or update invoice for a parent - */ - public function generateInvoice(): JsonResponse - { - $payload = $this->payloadData(); - $parentId = (int) ($payload['parent_id'] ?? $this->request->getPost('parent_id') ?? 0); - - if (!$parentId) { - return $this->respondError('Parent ID is required', Response::HTTP_BAD_REQUEST); - } - - // Fetch enrolled + withdrawn students - $enrollments = $this->enrollment - ->where('parent_id', $parentId) - ->where('school_year', $this->schoolYear) - ->findAll(); - - if (empty($enrollments)) { - return $this->respondError('No enrollment records found.', Response::HTTP_NOT_FOUND); - } - - $registeredKids = []; - $withdrawnKids = []; - - foreach ($enrollments as $enrollment) { - $studentData = [ - 'student_id' => $enrollment['student_id'], - 'parent_id' => $enrollment['parent_id'], - 'class_section_id' => $enrollment['class_section_id'], - 'enrollment_status' => $enrollment['enrollment_status'], - 'school_year' => $enrollment['school_year'], - 'semester' => $enrollment['semester'], - 'admission_status' => $enrollment['admission_status'], - 'is_withdrawn' => $enrollment['is_withdrawn'] ?? false, - ]; - - if (in_array($enrollment['enrollment_status'], ['enrolled', 'payment pending'], true)) { - $registeredKids[] = $studentData; - } elseif (in_array($enrollment['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) { - $withdrawnKids[] = $studentData; - } else { - log_message('info', "Enrollment skipped for student_id {$enrollment['student_id']} with status: {$enrollment['enrollment_status']}"); - } - } - - // Calculate tuition fee - $fees = $this->calculateTuitionFee($registeredKids, $withdrawnKids); - $tuitionFee = $fees['tuition_fee']; - - // Fetch event charges - $eventsList = $this->charges->getChargesWithEventInfo($parentId, $this->schoolYear); - $eventchargeTotal = array_sum(array_column($eventsList, 'charged')); - - $totalDiscount = $this->recalculateAndUpdateDiscount( - $parentId, - $this->schoolYear, - $tuitionFee, - $enrollments - ); - - $semester = $this->semester; - - // Refunds PAID to the parent for this year (Partial/Paid) - $refundPaid = (float) $this->refund->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $this->schoolYear); - - $totalPaid = $this->payment->getTotalPaidByParentId($parentId, $this->schoolYear); - - $discountedTuition = max(0, $tuitionFee); - $totalAmount = $discountedTuition + $eventchargeTotal; - - // Your invoice fetch - $existingInvoices = $this->invoice->getInvoicesByParentId($parentId, $this->schoolYear); - - $updated = false; - $updatedIds = []; - - if (!empty($existingInvoices)) { - foreach ($existingInvoices as $invoice) { - if (!isset($invoice['id'])) { - continue; - } - - // Preserve applied additional charges and recalc this invoice only - $extrasSum = 0.0; - try { - $rows = DB::table('additional_charges') - ->select('charge_type', 'amount') - ->where('invoice_id', (int) $invoice['id']) - ->where('school_year', $this->schoolYear) - ->where('status', 'applied') - ->get(); - - 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); - } - $extrasSum += $amt; - } - } catch (\Throwable $e) { - log_message('error', 'additional_charges sum failed for invoice ' . (int) $invoice['id'] . ': ' . $e->getMessage()); - } - - $newTotal = round($totalAmount + $extrasSum, 2); - - // Per-invoice discount - $invDiscount = 0.0; - try { - $d = DB::table('discount_usages') - ->select(DB::raw('COALESCE(SUM(discount_amount),0) AS tot')) - ->where('invoice_id', (int) $invoice['id']) - ->first(); - $invDiscount = (float) ($d->tot ?? 0.0); - } catch (\Throwable $e) { - log_message('error', 'discount sum failed for invoice ' . (int) $invoice['id'] . ': ' . $e->getMessage()); - } - - // Per-invoice refunds paid - $invRefunds = 0.0; - try { - $r = DB::table('refunds') - ->select(DB::raw('COALESCE(SUM(refund_paid_amount),0) AS tot')) - ->where('invoice_id', (int) $invoice['id']) - ->where('school_year', $this->schoolYear) - ->whereIn('status', ['Partial', 'Paid']) - ->first(); - $invRefunds = (float) ($r->tot ?? 0.0); - } catch (\Throwable $e) { - log_message('error', 'refund sum failed for invoice ' . (int) $invoice['id'] . ': ' . $e->getMessage()); - } - - // Payments recorded on this invoice - $paidOnInv = (float) ($invoice['paid_amount'] ?? 0.0); - - $newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv; - $newStatus = $newBalance <= 0.00001 ? 'paid' : 'unpaid'; - - $this->invoice->update($invoice['id'], [ - 'total_amount' => $newTotal, - 'paid_amount' => $paidOnInv, - 'balance' => $newBalance, - 'status' => $newStatus, - 'updated_at' => utc_now(), - ]); - - $updatedIds[] = (int) $invoice['id']; - log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}."); - $updated = true; - } - } else { - // Generate invoice number - $schoolId = $this->user->getSchoolIdByUserId($parentId); - if (!empty($schoolId)) { - $invoiceNumber = 'INV-' . $schoolId . '-' . uniqid(); - } else { - log_message('warning', "No school ID found for parent_id {$parentId}, generating fallback invoice number."); - $invoiceNumber = uniqid('INV-'); - } - - $issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); - - // Due date: interpret the date in configured/user local TZ - $dueUtc = null; - if (!empty($this->dueDate)) { - $tzName = user_timezone(); - $dueLocal = new DateTime($this->dueDate . ' 19:59:59', new DateTimeZone($tzName)); - $dueLocal->setTimezone(new DateTimeZone('UTC')); - $dueUtc = $dueLocal->format('Y-m-d H:i:s'); - } - - $insertId = $this->invoice->insert([ - 'parent_id' => $parentId, - 'invoice_number' => $invoiceNumber, - 'total_amount' => $totalAmount, - 'paid_amount' => 0, - 'balance' => $totalAmount, - 'status' => 'unpaid', - 'school_year' => $this->schoolYear, - 'semester' => $semester, - 'issue_date' => $issueUtc, - 'due_date' => $dueUtc, - 'created_at' => utc_now(), - 'updated_at' => utc_now(), - ]); - - if (!$insertId) { - log_message('error', 'Invoice insert failed: ' . json_encode($this->invoice->errors())); - return $this->respondError('Failed to create invoice.', Response::HTTP_INTERNAL_SERVER_ERROR); - } else { - log_message('info', "Invoice created successfully. Insert ID: {$insertId}"); - } - $updated = false; - } - - return $this->success([ - 'ok' => true, - 'updated' => $updated, - 'updated_ids' => $updatedIds, - 'insert_id' => isset($insertId) ? (int) $insertId : null, - ], $updated ? 'Invoice updated successfully' : 'Invoice created successfully'); - } - - /** - * GET /api/v1/invoices/{id}/pdf - * Generate PDF invoice - */ - public function generatePdfInvoice($invoiceId): JsonResponse - { - $data = $this->prepareInvoiceData($invoiceId); - if (isset($data['error'])) { - return $this->respondError($data['error'], Response::HTTP_NOT_FOUND); - } - - // Fetch discount_amount from discount_usages - $discountResult = DB::table('discount_usages as du') - ->select(DB::raw('SUM(du.discount_amount) as total_discount')) - ->join('invoices as i', 'du.invoice_id', '=', 'i.id') - ->where('du.invoice_id', $invoiceId) - ->where('i.school_year', $this->schoolYear) - ->first(); - - $discountAmount = isset($discountResult->total_discount) ? (float) $discountResult->total_discount : 0.00; - - // Attach discount amount to data - $data['discount_amount'] = $discountAmount; - - // Note: PDF generation would need to be handled separately or via a service - // For now, return the prepared data - return $this->success($data, 'Invoice data prepared for PDF generation'); - } - - /** - * GET /api/v1/invoices/parent/{parentId} - * Get invoices by parent ID - */ - public function getByParent($parentId): JsonResponse - { - $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; - $invoices = $this->invoice->getInvoicesByParentId($parentId, $schoolYear); - - return $this->success($invoices, 'Invoices retrieved successfully'); - } - - /** - * POST /api/v1/invoices - * Create a new invoice - */ - public function store(): JsonResponse - { - $payload = $this->payloadData(); - if (empty($payload)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $invoiceData = array_intersect_key($payload, array_flip($this->invoice->getFillable())); - $invoiceData['school_year'] = $invoiceData['school_year'] ?? $this->schoolYear; - $invoiceData['semester'] = $invoiceData['semester'] ?? $this->semester; - - try { - $invoice = $this->invoice->create($invoiceData); - return $this->respondCreated($invoice->toArray(), 'Invoice created successfully'); - } catch (\Throwable $e) { - log_message('error', 'Invoice create error: ' . $e->getMessage()); - return $this->respondError('Failed to create invoice', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * PATCH /api/v1/invoices/{id} - * Update invoice - */ - public function update($id = null): JsonResponse - { - $invoice = $this->invoice->find($id); - if (!$invoice) { - return $this->error('Invoice not found', Response::HTTP_NOT_FOUND); - } - - $payload = $this->payloadData(); - if (empty($payload)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $allowedFields = ['status', 'paid_amount', 'balance', 'description']; - $updateData = []; - foreach ($allowedFields as $field) { - if (array_key_exists($field, $payload)) { - $updateData[$field] = $payload[$field]; - } - } - - if (empty($updateData)) { - return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST); - } - - try { - $this->invoice->update($id, $updateData); - $updatedInvoice = $this->invoice->find($id); - return $this->success($updatedInvoice, 'Invoice updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Invoice update error: ' . $e->getMessage()); - return $this->respondError('Failed to update invoice', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * PATCH /api/v1/invoices/{id}/status - * Update invoice status - */ - public function updateStatus($id = null): JsonResponse - { - $invoice = $this->invoice->find($id); - if (!$invoice) { - return $this->error('Invoice not found', Response::HTTP_NOT_FOUND); - } - - $payload = $this->payloadData(); - $status = $payload['status'] ?? null; - - if (!$status) { - return $this->respondError('Status is required', Response::HTTP_BAD_REQUEST); - } - - if ($this->invoice->updateInvoiceStatus($id, $status)) { - $updatedInvoice = $this->invoice->find($id); - return $this->success($updatedInvoice, 'Invoice status updated successfully'); - } else { - return $this->respondError('Failed to update invoice status', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/invoices/unpaid - * Get unpaid invoices - */ - public function unpaidInvoices(): JsonResponse - { - $unpaidInvoices = $this->invoice->getUnpaidInvoices(); - return $this->success($unpaidInvoices, 'Unpaid invoices retrieved successfully'); - } - - /** - * POST /api/v1/invoices/check-and-generate - * Check and generate invoice when enrollment status changes - */ - public function checkAndGenerateInvoice(): JsonResponse - { - $payload = $this->payloadData(); - $parentId = (int) ($payload['parent_id'] ?? 0); - $status = $payload['status'] ?? ''; - - if (!$parentId) { - return $this->respondError('Parent ID is required', Response::HTTP_BAD_REQUEST); - } - - if ($status === 'payment pending' || $status === 'withdrawn' || $status === 'enrolled') { - // Call generateInvoice logic - $this->generateInvoice(); - return $this->success(null, 'Invoice checked and generated if needed'); - } - - return $this->success(null, 'No invoice generation needed for this status'); - } - - // ==================== Private Helper Methods ==================== - - private function recalculateAndUpdateDiscount( - int $parentId, - string $schoolYear, - float $tuitionFee, - array $enrollments - ): float { - $totalDiscount = 0.00; - - // Get all invoices for this parent and school year - $invoices = $this->invoice->getInvoicesByParentId($parentId, $schoolYear); - - if (empty($invoices)) { - log_message('info', "No invoices found for parent ID $parentId in school year $schoolYear."); - return 0.00; - } - - foreach ($invoices as $invoice) { - if (!isset($invoice['id'])) { - continue; - } - - $invoiceId = $invoice['id']; - - // Get discount usage + voucher details - $discountUsage = DB::table('discount_usages as du') - ->select('du.id', 'dv.id as voucher_id', 'dv.discount_type', 'dv.discount_value') - ->join('discount_vouchers as dv', 'du.voucher_id', '=', 'dv.id') - ->join('invoices as i', 'du.invoice_id', '=', 'i.id') - ->where('du.invoice_id', $invoiceId) - ->where('i.school_year', $schoolYear) - ->first(); - - if (!$discountUsage) { - log_message('info', "No discount applied to invoice ID $invoiceId."); - continue; - } - - // Recalculate discount - if ($discountUsage->discount_type === 'percent') { - $discountAmount = round(($tuitionFee * $discountUsage->discount_value) / 100, 2); - } else { - $discountAmount = min($discountUsage->discount_value, $tuitionFee); - } - - // Update discount usage - DB::table('discount_usages') - ->where('id', $discountUsage->id) - ->update([ - 'discount_amount' => $discountAmount, - 'updated_at' => utc_now(), - 'updated_by' => $this->getCurrentUserId(), - ]); - - $totalDiscount += $discountAmount; - - // Log enrollment summary - $added = []; - $withdrawn = []; - foreach ($enrollments as $e) { - if ( - in_array($e['enrollment_status'], ['enrolled', 'payment pending']) && - ($e['admission_status'] ?? '') === 'accepted' - ) { - $added[] = $e['student_id']; - } elseif (in_array($e['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) { - $withdrawn[] = $e['student_id']; - } - } - - log_message('info', "Recalculated discount for invoice ID $invoiceId: Added students [" . implode(',', $added) . "], Withdrawn students [" . implode(',', $withdrawn) . "]. Discount updated to $discountAmount."); - } - - return $totalDiscount; - } - - /** - * Returns an array: - * [ - * 'tuition_fee' =>{$greeting}
-{$intro}
-Current Outstanding Balance: {$balanceFmt}
-As a friendly reminder, our tuition installments are due at the beginning of each month.
-You can review your invoice and payment options by logging in to your parent portal.
-Thank you for your prompt attention.
-Reminder Period: {$monthYear}
- - HTML; - - // Try to wrap in email layout view, fallback to plain HTML - try { - $body = view('emails._wrap_layout', [ - 'title' => 'Monthly Tuition Reminder', - 'body_html' => $bodyHtml, - ], ['saveData' => false]); - } catch (\Throwable $e) { - // Fallback if view doesn't exist - $body = $bodyHtml; - } - - return [$subject, $body]; - }; - - $results = [ - 'sent' => 0, - 'skipped' => 0, - 'failed' => 0, - 'details' => [], - ]; - - if ($parentId > 0) { - // Single parent mode - [$balance, $latestInv] = $computeBalance($parentId); - - // Detect type if not provided - if ($typeInput === 'no_payment' || $typeInput === 'installment') { - $type = $typeInput; - } else { - $hasPayments = $this->payment - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->countAllResults() > 0; - $type = $hasPayments ? 'installment' : 'no_payment'; - } - - // Idempotency: skip if already sent this month for this type - if (!$force && $this->log->existsForPeriod($parentId, $year, $month, $type)) { - $results['skipped']++; - $results['details'][] = ['parent_id' => $parentId, 'result' => 'already_sent']; - } elseif ($balance <= 0 && !$force) { - $results['skipped']++; - $results['details'][] = ['parent_id' => $parentId, 'result' => 'no_balance']; - } else { - // Compute current-month installment suggestion - $installmentEndRaw = (string) $this->config->getConfig('installment_date'); - $tz = new \DateTimeZone($tzName); - $nowD = new \DateTimeImmutable('today', $tz); - $endD = null; - - try { - if ($installmentEndRaw) { - $endD = new \DateTimeImmutable($installmentEndRaw, $tz); - } - } catch (\Throwable $e) { - $endD = null; - } - - $remMonths = 0; - if ($endD) { - $y = (int) $endD->format('Y') - (int) $nowD->format('Y'); - $m = (int) $endD->format('n') - (int) $nowD->format('n'); - $remMonths = $y * 12 + $m; - if ((int) $endD->format('j') > (int) $nowD->format('j')) { - $remMonths += 1; - } - if ($remMonths < 0) { - $remMonths = 0; - } - } - - $remInst = max(1, $remMonths); - $instDue = round($balance / $remInst, 2); - - $toEmail = $this->user->select('email')->find($parentId)['email'] ?? null; - $toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null; - $ccEmail = $getSecondary($parentId); - - [$subject, $body] = $compose($parentId, $balance); - - $ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false; - - if ($ok && $ccEmail && strcasecmp($ccEmail, (string) $toEmail) !== 0) { - $this->emailService->send($ccEmail, $subject, $body, 'finance'); - } - - $this->log->insert([ - 'parent_id' => $parentId, - 'invoice_id' => $latestInv, - 'school_year' => $schoolYear, - 'period_year' => $year, - 'period_month' => $month, - 'type' => $type, - 'to_email' => $toEmail, - 'cc_email' => $ccEmail, - 'head_fa_notified' => 0, - 'subject' => $subject, - 'body' => $body, - 'status' => $ok ? 'sent' : 'failed', - 'error_message' => $ok ? null : 'Missing/invalid email or send failed', - 'balance_snapshot' => $balance, - 'sent_at' => $now->format('Y-m-d H:i:s'), - ]); - - if ($ok) { - $results['sent']++; - $results['details'][] = [ - 'parent_id' => $parentId, - 'result' => 'sent', - 'balance' => $balance, - 'installment_due' => $instDue, - 'remaining' => $remInst, - ]; - } else { - $results['failed']++; - $results['details'][] = ['parent_id' => $parentId, 'result' => 'failed']; - } - } - } else { - // Bulk mode: parents with positive balance in selected year - $rows = DB::table('invoices') - ->select(DB::raw('DISTINCT parent_id')) - ->where('school_year', $schoolYear) - ->get(); - - $pids = array_values(array_unique($rows->pluck('parent_id')->map(fn($id) => (int) $id)->toArray())); - - foreach ($pids as $pid) { - [$balance, $latestInv] = $computeBalance($pid); - - if ($balance <= 0 && !$force) { - $results['skipped']++; - continue; - } - - $hasPayments = $this->payment - ->where('parent_id', $pid) - ->where('school_year', $schoolYear) - ->countAllResults() > 0; - $type = $hasPayments ? 'installment' : 'no_payment'; - - if (!$force && $this->log->existsForPeriod($pid, $year, $month, $type)) { - $results['skipped']++; - continue; - } - - $toEmail = $this->user->select('email')->find($pid)['email'] ?? null; - $toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null; - $ccEmail = $getSecondary($pid); - - [$subject, $body] = $compose($pid, $balance); - - $ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false; - - if ($ok && $ccEmail && strcasecmp($ccEmail, (string) $toEmail) !== 0) { - $this->emailService->send($ccEmail, $subject, $body, 'finance'); - } - - // Optional: notify head of finance in-app - $this->notifyHeadOfFinance($pid, $type, $balance); - - $this->log->insert([ - 'parent_id' => $pid, - 'invoice_id' => $latestInv, - 'school_year' => $schoolYear, - 'period_year' => $year, - 'period_month' => $month, - 'type' => $type, - 'to_email' => $toEmail, - 'cc_email' => $ccEmail, - 'head_fa_notified' => 1, - 'subject' => $subject, - 'body' => $body, - 'status' => $ok ? 'sent' : 'failed', - 'error_message' => $ok ? null : 'Missing/invalid email or send failed', - 'balance_snapshot' => $balance, - 'sent_at' => $now->format('Y-m-d H:i:s'), - ]); - - if ($ok) { - $results['sent']++; - } else { - $results['failed']++; - } - } - } - - return $this->success([ - 'ok' => true, - ] + $results + [ - 'csrf_token' => csrf_token(), - 'csrf_hash' => csrf_hash(), - ], 'Payment notifications processed'); - } - - /** - * Notify head of finance users about payment reminder sent - */ - private function notifyHeadOfFinance(int $parentId, string $type, float $balance): void - { - try { - $heads = $this->getHeadOfFinanceUsers(); - foreach ($heads as $u) { - // Create in-app notification if notification system exists - // This is optional functionality - can be implemented later - Log::info(sprintf( - 'Payment reminder sent: %s reminder to parent #%d (balance: $%0.2f)', - $type, - $parentId, - $balance - )); - } - } catch (\Throwable $e) { - // Silently fail - this is optional - Log::debug('Failed to notify head of finance: ' . $e->getMessage()); - } - } - - /** - * Get head of finance users - */ - private function getHeadOfFinanceUsers(): array - { - $heads = $this->user->getUsersByRole('head of department (finance)'); - if (!empty($heads)) { - return $heads; - } - - $acct = $this->user->getUsersByRole('accountant'); - return $acct ?: []; - } -} diff --git a/app/Http/Controllers/Api/PaymentTransactionController.php b/app/Http/Controllers/Api/PaymentTransactionController.php deleted file mode 100755 index af8c8a34..00000000 --- a/app/Http/Controllers/Api/PaymentTransactionController.php +++ /dev/null @@ -1,197 +0,0 @@ -transaction = model(PaymentTransaction::class); - $this->config = model(Configuration::class); - } - - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $paymentId = $this->request->getGet('payment_id'); - $status = $this->request->getGet('status'); - - $query = $this->transaction->newQuery(); - - if ($paymentId) { - $query->where('payment_id', $paymentId); - } - - if ($status) { - $query->where('payment_status', $status); - } - - $result = $this->paginate($query, $page, $perPage); - - return $this->success($result, 'Payment transactions retrieved successfully'); - } - - public function show($id = null) - { - $transaction = $this->transaction->find($id); - if (!$transaction) { - return $this->error('Payment transaction not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($transaction, 'Payment transaction retrieved successfully'); - } - - /** - * GET /api/v1/payment-transactions/payment/{paymentId} - * Get all transactions for a specific payment - */ - public function getByPayment($paymentId = null) - { - if (!$paymentId) { - return $this->error('Payment ID is required', Response::HTTP_BAD_REQUEST); - } - - try { - $transactions = $this->transaction->getTransactionsByPaymentId($paymentId); - - if (empty($transactions)) { - return $this->error('Transactions not found for the payment', Response::HTTP_NOT_FOUND); - } - - return $this->success($transactions, 'Payment transactions retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Get transactions by payment error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve payment transactions', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * POST /api/v1/payment-transactions - * Create a new payment transaction (installment) - */ - public function store() - { - $data = $this->payloadData(); - if (empty($data)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'payment_id' => 'required|integer', - 'transaction_id' => 'required|unique:payment_transactions,transaction_id', - 'amount' => 'required|numeric|min:0', - 'payment_method' => 'required|string|max:50', - 'transaction_date' => 'permit_empty|valid_date[Y-m-d H:i:s]', - 'transaction_fee' => 'permit_empty|numeric|min:0', - 'payment_reference' => 'permit_empty|string|max:255', - 'is_full_payment' => 'permit_empty|in:0,1', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $schoolYear = $this->config->getConfig('school_year'); - $semester = $this->config->getConfig('semester'); - - $transactionData = [ - 'transaction_id' => $data['transaction_id'], - 'payment_id' => (int) $data['payment_id'], - 'transaction_date' => $data['transaction_date'] ?? date('Y-m-d H:i:s'), - 'amount' => (float) $data['amount'], - 'payment_method' => $data['payment_method'], - 'payment_status' => 'Pending', // Default status as per CodeIgniter version - 'transaction_fee' => isset($data['transaction_fee']) ? (float) $data['transaction_fee'] : 0, - 'payment_reference' => $data['payment_reference'] ?? null, - 'is_full_payment' => isset($data['is_full_payment']) ? ((int) $data['is_full_payment'] ? 1 : 0) : 0, - 'school_year' => $schoolYear, - 'semester' => $semester, - ]; - - try { - $transaction = $this->transaction->create($transactionData); - return $this->respondCreated($transaction->toArray(), 'Payment transaction created successfully'); - } catch (\Throwable $e) { - log_message('error', 'Payment transaction creation error: ' . $e->getMessage()); - return $this->respondError('Failed to create payment transaction', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * PATCH /api/v1/payment-transactions/{transactionId}/status - * Update payment status by transaction ID - */ - public function updateStatus($transactionId = null) - { - if (!$transactionId) { - return $this->error('Transaction ID is required', Response::HTTP_BAD_REQUEST); - } - - $data = $this->payloadData(); - if (empty($data) || !isset($data['status'])) { - return $this->error('Status is required', Response::HTTP_BAD_REQUEST); - } - - $status = (string) $data['status']; - - $rules = [ - 'status' => 'required|string|max:50', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - try { - // First check if transaction exists by transaction_id (string) or id (integer) - $transaction = null; - if (is_numeric($transactionId)) { - $transaction = $this->transaction->find((int) $transactionId); - } - - if (!$transaction) { - $transaction = $this->transaction->getTransactionById($transactionId); - } - - if (!$transaction) { - return $this->error('Transaction not found', Response::HTTP_NOT_FOUND); - } - - // Update by transaction_id if it's a string, otherwise by id - $updated = false; - if (is_numeric($transactionId)) { - $updated = $this->transaction->update((int) $transactionId, ['payment_status' => $status]); - } else { - $updated = $this->transaction->updateTransactionStatus($transactionId, $status); - } - - if ($updated) { - // Fetch updated transaction - $updatedTransaction = is_numeric($transactionId) - ? $this->transaction->find((int) $transactionId) - : $this->transaction->getTransactionById($transactionId); - - return $this->success([ - 'status' => 'success', - 'transaction' => $updatedTransaction, - ], 'Transaction status updated successfully'); - } else { - return $this->respondError('Failed to update transaction status', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } catch (\Throwable $e) { - log_message('error', 'Update transaction status error: ' . $e->getMessage()); - return $this->respondError('Failed to update transaction status', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/PaypalTransactionsController.php b/app/Http/Controllers/Api/PaypalTransactionsController.php deleted file mode 100755 index 27a219e1..00000000 --- a/app/Http/Controllers/Api/PaypalTransactionsController.php +++ /dev/null @@ -1,140 +0,0 @@ -paypalPayment = model(PayPalPayment::class); - } - - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $keyword = $this->request->getGet('q'); - - $query = $this->paypalPayment->newQuery()->orderBy('created_at', 'DESC'); - - if ($keyword) { - $query->where(function ($q) use ($keyword) { - $q->where('transaction_id', 'LIKE', "%{$keyword}%") - ->orWhere('payer_email', 'LIKE', "%{$keyword}%") - ->orWhere('event_type', 'LIKE', "%{$keyword}%") - ->orWhere('order_id', 'LIKE', "%{$keyword}%") - ->orWhere('parent_school_id', 'LIKE', "%{$keyword}%"); - }); - } - - $result = $this->paginate($query, $page, $perPage); - return $this->success($result, 'PayPal transactions retrieved successfully'); - } - - public function show($id = null) - { - $transaction = $this->paypalPayment->find($id); - if (!$transaction) { - return $this->error('PayPal transaction not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($transaction, 'PayPal transaction retrieved successfully'); - } - - /** - * GET /api/v1/paypal-transactions/transaction/{transactionId} - * Get transaction by PayPal transaction ID - */ - public function getByTransactionId($transactionId = null) - { - $transaction = $this->paypalPayment->newQuery() - ->where('transaction_id', $transactionId) - ->first(); - - if (!$transaction) { - return $this->error('PayPal transaction not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($transaction, 'PayPal transaction retrieved successfully'); - } - - /** - * GET /api/v1/paypal-transactions/export-csv - * Export PayPal transactions to CSV file - */ - public function exportCsv(): StreamedResponse - { - $keyword = $this->request->getGet('q'); - - $query = $this->paypalPayment->newQuery()->orderBy('created_at', 'DESC'); - - if ($keyword) { - $query->where(function ($q) use ($keyword) { - $q->where('transaction_id', 'LIKE', "%{$keyword}%") - ->orWhere('payer_email', 'LIKE', "%{$keyword}%") - ->orWhere('event_type', 'LIKE', "%{$keyword}%") - ->orWhere('order_id', 'LIKE', "%{$keyword}%") - ->orWhere('parent_school_id', 'LIKE', "%{$keyword}%"); - }); - } - - $transactions = $query->get()->toArray(); - - $filename = 'paypal_transactions_' . date('Ymd_His') . '.csv'; - - $headers = [ - 'Content-Type' => 'text/csv', - 'Content-Disposition' => "attachment; filename=\"{$filename}\"", - 'Pragma' => 'no-cache', - 'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0', - 'Expires' => '0', - ]; - - return ResponseFacade::streamDownload(function () use ($transactions) { - $output = fopen('php://output', 'w'); - - // CSV headers - fputcsv($output, [ - 'ID', - 'Transaction ID', - 'Order ID', - 'Parent School ID', - 'Email', - 'Amount', - 'Net Amount', - 'Currency', - 'Status', - 'Event Type', - 'Created At', - ]); - - foreach ($transactions as $t) { - $t = (array) $t; // Convert to array for consistent access - fputcsv($output, [ - $t['id'] ?? '', - $t['transaction_id'] ?? '', - $t['order_id'] ?? '', - $t['parent_school_id'] ?? '', - $t['payer_email'] ?? '', - $t['amount'] ?? '', - $t['net_amount'] ?? '', - $t['currency'] ?? '', - $t['status'] ?? '', - $t['event_type'] ?? '', - $t['created_at'] ?? '', - ]); - } - - fclose($output); - }, $filename, $headers); - } -} diff --git a/app/Http/Controllers/Api/PaypalWebhook.php b/app/Http/Controllers/Api/PaypalWebhook.php deleted file mode 100755 index cfc8052d..00000000 --- a/app/Http/Controllers/Api/PaypalWebhook.php +++ /dev/null @@ -1,162 +0,0 @@ -config = model(Configuration::class); - $this->payment = model(PayPalPayment::class); - } - - public function handle() - { - $config = $this->loadConfig(); - if (!$config) { - return $this->respondError('PayPal configuration incomplete', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - $payload = $this->payloadData(); - if (empty($payload)) { - return $this->respondError('Invalid JSON payload', Response::HTTP_BAD_REQUEST); - } - - $requiredHeaders = [ - 'paypal-transmission-id', - 'paypal-transmission-time', - 'paypal-cert-url', - 'paypal-auth-algo', - 'paypal-transmission-sig', - ]; - - foreach ($requiredHeaders as $header) { - if (!$this->laravelRequest->headers->has($header)) { - return $this->respondError("Missing required header: {$header}", Response::HTTP_BAD_REQUEST); - } - } - - $accessToken = $this->getAccessToken($config['client_id'], $config['secret'], $config['token_url']); - if (!$accessToken) { - return $this->respondError('Failed to get access token', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - $verificationData = [ - 'auth_algo' => $this->laravelRequest->headers->get('paypal-auth-algo'), - 'cert_url' => $this->laravelRequest->headers->get('paypal-cert-url'), - 'transmission_id' => $this->laravelRequest->headers->get('paypal-transmission-id'), - 'transmission_sig' => $this->laravelRequest->headers->get('paypal-transmission-sig'), - 'transmission_time' => $this->laravelRequest->headers->get('paypal-transmission-time'), - 'webhook_id' => $config['webhook_id'], - 'webhook_event' => $payload, - ]; - - if (!$this->verifyWebhookSignature($config['verify_url'], $accessToken, $verificationData)) { - return $this->respondError('Webhook verification failed', Response::HTTP_UNAUTHORIZED); - } - - try { - $this->processPayment($payload); - return $this->success(['status' => 'success'], 'Webhook processed successfully'); - } catch (\Throwable $e) { - log_message('error', 'Payment processing error: ' . $e->getMessage()); - return $this->respondError('Payment processing error', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - protected function loadConfig(): ?array - { - $mode = (string) ($this->config->getConfig('paypal_mode') ?? ''); - - $configs = [ - 'sandbox' => [ - 'webhook_id' => $this->config->getConfig('sandbox_webhookid_paypal'), - 'client_id' => $this->config->getConfig('sandbox_clientid_paypal'), - 'secret' => $this->config->getConfig('sandbox_secret_paypal'), - 'verify_url' => $this->config->getConfig('sandbox_verifylink_paypal'), - 'token_url' => $this->config->getConfig('sandbox_tokenurl_paypal'), - ], - 'production' => [ - 'webhook_id' => $this->config->getConfig('production_webhookid_paypal'), - 'client_id' => $this->config->getConfig('production_clientid_paypal'), - 'secret' => $this->config->getConfig('production_secret_paypal'), - 'verify_url' => $this->config->getConfig('production_verifylink_paypal'), - 'token_url' => $this->config->getConfig('production_tokenurl_paypal'), - ], - ]; - - if (!isset($configs[$mode])) { - return null; - } - - $cfg = $configs[$mode]; - if (in_array(null, $cfg, true) || in_array('', $cfg, true)) { - return null; - } - - return $cfg + ['mode' => $mode]; - } - - protected function getAccessToken(string $clientId, string $secret, string $urlToken): ?string - { - try { - $response = Http::asForm() - ->withBasicAuth($clientId, $secret) - ->acceptJson() - ->post($urlToken, ['grant_type' => 'client_credentials']); - - if (!$response->successful()) { - log_message('error', 'Access token fetch failed: HTTP ' . $response->status()); - return null; - } - - return $response->json('access_token'); - } catch (\Throwable $e) { - log_message('error', 'Access token fetch failed: ' . $e->getMessage()); - return null; - } - } - - protected function verifyWebhookSignature(string $verifyUrl, string $accessToken, array $verificationData): bool - { - try { - $response = Http::withToken($accessToken) - ->acceptJson() - ->post($verifyUrl, $verificationData); - - if (!$response->successful()) { - log_message('error', 'Webhook verification failed: HTTP ' . $response->status()); - return false; - } - - return ($response->json('verification_status') ?? '') === 'SUCCESS'; - } catch (\Throwable $e) { - log_message('error', 'Webhook verification failed: ' . $e->getMessage()); - return false; - } - } - - protected function processPayment(array $eventData): void - { - $resource = $eventData['resource'] ?? []; - - $this->payment->create([ - 'webhook_id' => $eventData['id'] ?? null, - 'transaction_id' => $resource['id'] ?? null, - 'status' => $resource['status'] ?? null, - 'amount' => $resource['amount']['value'] ?? null, - 'currency' => $resource['amount']['currency_code'] ?? null, - 'payer_email' => $resource['payer']['email_address'] ?? null, - 'raw_payload' => json_encode($eventData), - ]); - } -} diff --git a/app/Http/Controllers/Api/PolicyController.php b/app/Http/Controllers/Api/PolicyController.php deleted file mode 100755 index eefa2da3..00000000 --- a/app/Http/Controllers/Api/PolicyController.php +++ /dev/null @@ -1,87 +0,0 @@ -success([], 'Policy index'); - } - - /** - * GET /api/v1/policy/picture - * Get school picture policy content - */ - public function schoolPicturePolicy() - { - $partialPath = base_path('resources/views/policy/picture_policy_partial.php'); - $viewPath = base_path('resources/views/policy/picture_policy.blade.php'); - - if (!is_readable($partialPath)) { - return $this->respondError('Picture policy partial not found', Response::HTTP_NOT_FOUND); - } - - try { - // Include the PHP partial file (may return data array or output HTML) - $content = include $partialPath; - - // If partial returns an array, try to render the view with it - // Otherwise, use the content directly - if (is_array($content)) { - if (is_readable($viewPath)) { - $html = view('policy.picture_policy', $content)->render(); - } else { - // Fallback: if view doesn't exist, return the content as-is - $html = is_string($content) ? $content : json_encode($content); - } - } else { - // Content is already HTML string - $html = (string) $content; - } - } catch (\Throwable $e) { - log_message('error', 'Picture policy load error: ' . $e->getMessage()); - return $this->respondError('Failed to load picture policy', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - return $this->success(['html' => $html], 'Picture policy content retrieved'); - } - - /** - * GET /api/v1/policy/school - * Get school policy content - */ - public function schoolPolicy() - { - $metaPath = base_path('resources/views/policy/school_policy_partial.php'); - $viewPath = base_path('resources/views/policy/school_policy.blade.php'); - - if (!is_readable($metaPath)) { - return $this->respondError('School policy partial not found', Response::HTTP_NOT_FOUND); - } - - try { - // Include the PHP partial file (returns data array) - $content = include $metaPath; - $meta = is_array($content) ? $content : []; - - // Render the Blade view with the meta data - if (is_readable($viewPath)) { - $html = view('policy.school_policy', $meta)->render(); - } else { - // Fallback: if view doesn't exist, return meta as JSON - $html = json_encode($meta, JSON_PRETTY_PRINT); - } - } catch (\Throwable $e) { - log_message('error', 'School policy load error: ' . $e->getMessage()); - return $this->respondError('Failed to load school policy', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - return $this->success([ - 'meta' => $meta, - 'html' => $html, - ], 'School policy content retrieved'); - } -} diff --git a/app/Http/Controllers/Api/PreferencesController.php b/app/Http/Controllers/Api/PreferencesController.php deleted file mode 100755 index e040e441..00000000 --- a/app/Http/Controllers/Api/PreferencesController.php +++ /dev/null @@ -1,114 +0,0 @@ -preferences = model(Preferences::class); - } - - public function index() - { - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - try { - $preferences = $this->preferences->newQuery() - ->where('user_id', $user->id) - ->first(); - - if (!$preferences) { - $preferences = [ - 'notification_email' => 1, - 'notification_sms' => 1, - 'theme' => 'light', - 'language' => 'en', - ]; - } - - $styleConfig = config('Style'); - $preferences['style_color'] = session()->get('style_color') ?? ($styleConfig->defaultStyle ?? 'blue'); - $preferences['menu_color'] = session()->get('menu_color') ?? ($styleConfig->defaultMenu ?? 'white'); - - return $this->success($preferences, 'Preferences retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'Preferences retrieval error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve preferences', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function update($id = null) - { - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $styleConfig = config('Style'); - $styleOptions = implode(',', array_keys($styleConfig->stylePalettes ?? [])); - $menuOptions = implode(',', array_keys($styleConfig->menuPalettes ?? [])); - - $rules = [ - 'notification_email' => 'permit_empty|in_list[0,1]', - 'notification_sms' => 'permit_empty|in_list[0,1]', - 'theme' => 'permit_empty|in_list[light,dark]', - 'language' => 'permit_empty|in_list[en,fr,es]', - 'style_color' => $styleOptions ? 'permit_empty|in_list[' . $styleOptions . ']' : 'permit_empty', - 'menu_color' => $menuOptions ? 'permit_empty|in_list[' . $menuOptions . ']' : 'permit_empty', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - try { - $existing = $this->preferences->newQuery() - ->where('user_id', $user->id) - ->first(); - - $prefData = [ - 'user_id' => $user->id, - 'notification_email' => $data['notification_email'] ?? 1, - 'notification_sms' => $data['notification_sms'] ?? 1, - 'theme' => $data['theme'] ?? 'light', - 'language' => $data['language'] ?? 'en', - ]; - - if ($existing) { - $this->preferences->update($existing['id'], $prefData); - $preferences = $this->preferences->find($existing['id']); - } else { - $pref = $this->preferences->create($prefData); - $preferences = $pref instanceof \Illuminate\Database\Eloquent\Model ? $pref->toArray() : $pref; - } - - if (isset($data['style_color'])) { - session()->put('style_color', $data['style_color']); - } - if (isset($data['menu_color'])) { - session()->put('menu_color', $data['menu_color']); - } - - return $this->success($preferences, 'Preferences updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Preferences update error: ' . $e->getMessage()); - return $this->respondError('Failed to update preferences', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/PrintablesReportsController.php b/app/Http/Controllers/Api/PrintablesReportsController.php deleted file mode 100755 index c7cf7d9b..00000000 --- a/app/Http/Controllers/Api/PrintablesReportsController.php +++ /dev/null @@ -1,624 +0,0 @@ -student = model(Student::class); - $this->classSection = model(ClassSection::class); - $this->config = model(Configuration::class); - $this->badgePrintLog = model(BadgePrintLog::class); - $this->semesterScore = model(SemesterScore::class); - $this->studentClass = model(StudentClass::class); - $this->teacherClass = model(TeacherClass::class); - $this->user = model(User::class); - $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); - $this->semester = (string) ($this->config->getConfig('semester') ?? ''); - } - - /** - * GET /api/v1/printables/report-card-meta - * Get report card metadata for UI hydration - */ - public function reportCardMeta(): JsonResponse - { - try { - $year = trim((string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? '')); - $sem = trim((string) ($this->request->getGet('semester') ?? $this->semester ?? '')); - - if ($year === '') { - // Fallback to latest present in classSection or semester_score - $yr = DB::table('classSection') - ->select('school_year') - ->orderBy('school_year', 'DESC') - ->first(); - $year = (string) ($yr->school_year ?? ''); - } - - // Build schoolYears from classSection and semester_scores - $schoolYears = []; - try { - $q1 = DB::table('classSection') - ->select(DB::raw('DISTINCT school_year')) - ->whereNotNull('school_year') - ->orderBy('school_year', 'DESC') - ->pluck('school_year') - ->map(fn($r) => (string) $r) - ->filter() - ->values() - ->toArray(); - $schoolYears = $q1; - } catch (\Throwable $e) { - // ignore - } - - try { - $q2 = DB::table('semester_scores') - ->select(DB::raw('DISTINCT school_year')) - ->whereNotNull('school_year') - ->orderBy('school_year', 'DESC') - ->pluck('school_year') - ->map(fn($r) => (string) $r) - ->filter() - ->toArray(); - - foreach ($q2 as $val) { - if ($val !== '' && !in_array($val, $schoolYears, true)) { - $schoolYears[] = $val; - } - } - } catch (\Throwable $e) { - // ignore - } - - rsort($schoolYears); - - // Semesters available for the selected year - $semesters = []; - try { - $rs = DB::table('semester_scores') - ->select(DB::raw('DISTINCT semester')) - ->where('school_year', $year) - ->whereNotNull('semester') - ->orderBy('semester', 'ASC') - ->pluck('semester') - ->map(fn($r) => (string) $r) - ->filter() - ->values() - ->toArray(); - $semesters = $rs; - } catch (\Throwable $e) { - // ignore - } - - if (empty($semesters)) { - // Provide a sensible default list - $defaults = array_filter([(string) ($this->semester ?? ''), 'Fall', 'Spring'], fn($v) => $v !== ''); - $semesters = array_values(array_unique($defaults)); - } - - if ($sem === '' && !empty($semesters)) { - $sem = $semesters[0]; - } - - // Students: prefer those with scores in selected year (+semester if provided); fallback to all - $students = []; - try { - $stuRows = DB::table('semester_scores ss') - ->select(DB::raw('DISTINCT s.id, s.firstname, s.lastname')) - ->join('students s', 's.id', '=', 'ss.student_id') - ->where('ss.school_year', $year) - ->where(function ($query) use ($sem) { - if ($sem !== '') { - $query->where('ss.semester', $sem); - } - }) - ->orderBy('s.lastname', 'ASC') - ->get() - ->toArray(); - - if (!empty($stuRows)) { - $students = array_map(fn($r) => [ - 'id' => (int) $r->id, - 'firstname' => (string) ($r->firstname ?? ''), - 'lastname' => (string) ($r->lastname ?? ''), - ], $stuRows); - } - } catch (\Throwable $e) { - // ignore - } - - if (empty($students)) { - $allStudents = $this->student - ->select('id', 'firstname', 'lastname') - ->orderBy('lastname', 'ASC') - ->findAll(); - $students = array_map(fn($s) => [ - 'id' => (int) ($s['id'] ?? 0), - 'firstname' => (string) ($s['firstname'] ?? ''), - 'lastname' => (string) ($s['lastname'] ?? ''), - ], $allStudents); - } - - // Class sections for selected year (fallback to all) - $classSections = $this->classSection - ->select('id', 'class_section_id', 'class_section_name', 'school_year') - ->where('school_year', $year) - ->orderBy('class_section_name', 'ASC') - ->findAll(); - - if (empty($classSections)) { - $classSections = $this->classSection - ->select('id', 'class_section_id', 'class_section_name', 'school_year') - ->orderBy('class_section_name', 'ASC') - ->findAll(); - } - - $classSectionsFormatted = array_map(static fn($r) => [ - 'id' => (int) ($r['id'] ?? 0), - 'class_section_id' => (int) ($r['class_section_id'] ?? ($r['id'] ?? 0)), - 'class_section_name' => (string) ($r['class_section_name'] ?? 'Section'), - 'school_year' => (string) ($r['school_year'] ?? ''), - ], $classSections); - - return $this->success([ - 'ok' => true, - 'schoolYears' => $schoolYears, - 'selectedYear' => $year, - 'semesters' => $semesters, - 'selectedSemester' => $sem, - 'students' => $students, - 'classSections' => $classSectionsFormatted, - 'csrf_token' => csrf_token(), - 'csrf_hash' => csrf_hash(), - ], 'Report card metadata retrieved successfully'); - } catch (\Throwable $e) { - Log::error('Report card meta error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve report card metadata', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/printables/badge-status - * Get badge print status for given users - */ - public function badgePrintStatus(): JsonResponse - { - $ids = $this->request->getGet('user_ids'); - if (is_string($ids)) { - $ids = array_filter(array_map('trim', explode(',', $ids)), 'strlen'); - } - if (!is_array($ids)) { - $ids = $ids ? [$ids] : []; - } - $ids = array_values(array_unique(array_map(static fn($v) => (int) $v, $ids))); - - $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; - - try { - $status = $this->badgePrintLog->getStatus($ids, is_string($schoolYear) ? $schoolYear : null); - } catch (\Throwable $e) { - Log::error('Badge print status error: ' . $e->getMessage()); - return $this->error('Failed to query status', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - return $this->success([ - 'ok' => true, - 'data' => $status, - 'csrf_token' => csrf_token(), - 'csrf_hash' => csrf_hash(), - ], 'Badge print status retrieved successfully'); - } - - /** - * POST /api/v1/printables/badge-status - * Log a set of badge prints explicitly - */ - public function logBadgePrint(): JsonResponse - { - $data = $this->payloadData(); - if (empty($data)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $ids = $data['user_ids'] ?? []; - if (!is_array($ids)) { - $ids = $ids ? [$ids] : []; - } - $ids = array_values(array_unique(array_map(static fn($v) => (int) $v, $ids))); - - $roles = $data['roles'] ?? []; - $class = $data['classes'] ?? []; - if (!is_array($roles)) { - $roles = []; - } - if (!is_array($class)) { - $class = []; - } - - $schoolYear = $data['school_year'] ?? $this->schoolYear; - $actorId = $this->getCurrentUserId(); - - try { - $cnt = $this->badgePrintLog->logPrints( - $ids, - $actorId, - is_string($schoolYear) ? $schoolYear : null, - $roles, - $class, - 1 - ); - - return $this->success([ - 'ok' => true, - 'inserted' => $cnt, - 'csrf_token' => csrf_token(), - 'csrf_hash' => csrf_hash(), - ], 'Badge prints logged successfully', Response::HTTP_CREATED); - } catch (\Throwable $e) { - Log::error('Log badge print error: ' . $e->getMessage()); - return $this->error('Failed to log badge prints: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/printables/students-by-class/{classSectionId} - * Get students by class section - */ - public function getByClass($classSectionId): JsonResponse - { - try { - $students = DB::table('student_class sc') - ->select('s.id', 's.firstname', 's.lastname', 's.registration_grade', 's.gender') - ->join('students s', 's.id', '=', 'sc.student_id') - ->where('sc.class_section_id', (int) $classSectionId) - ->where('sc.school_year', $this->schoolYear) - ->orderBy('s.lastname', 'asc') - ->get() - ->toArray(); - - return $this->success($students, 'Students retrieved successfully'); - } catch (\Throwable $e) { - Log::error('Get students by class error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve students', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/printables/students-by-class/{classSectionId} - * Alternative endpoint name for students by class - */ - public function studentsByClass($classSectionId): JsonResponse - { - try { - $rows = $this->student - ->select('students.id', 'students.firstname', 'students.lastname', 'students.gender', 'cs.class_section_name AS registration_grade') - ->join('student_class sc', 'sc.student_id', '=', 'students.id', 'inner') - ->join('classSection cs', 'cs.class_section_id', '=', 'sc.class_section_id', 'inner') - ->where('sc.school_year', $this->schoolYear) - ->where('sc.class_section_id', (int) $classSectionId) - ->groupBy('students.id', 'students.firstname', 'students.lastname', 'students.gender', 'cs.class_section_name') - ->orderBy('students.firstname', 'ASC') - ->orderBy('students.lastname', 'ASC') - ->findAll(1000); - - return $this->success($rows, 'Students retrieved successfully'); - } catch (\Throwable $e) { - Log::error('Students by class error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve students', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/printables/preview-sticker-counts - * Preview sticker counts per student - */ - public function previewStickerCounts(): JsonResponse - { - try { - $classId = (int) ($this->request->getGet('class_id') ?? 0); - - if ($classId > 0) { - $result = $this->getStickerCountsPerStudentForClass($this->schoolYear, $classId, $this->semester); - } else { - $result = $this->getStickerCountsPerStudent($this->schoolYear, $this->semester); - } - - return $this->success([ - 'status' => 'ok', - 'data' => $result, - ], 'Sticker counts retrieved successfully'); - } catch (\Throwable $e) { - Log::error('Preview sticker counts error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve sticker counts', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * Compute sticker counts per student for entire school year - */ - protected function getStickerCountsPerStudent(string $schoolYear, ?string $semester = null): array - { - $specialMap = [ - 'KG' => 1, - '1-A' => 3, - '1-B' => 4, - '2-A' => 5, - '2-B' => 5, - '9' => 2, - ]; - - $isUpperBlock = static function (string $g): bool { - return (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g)); - }; - - $isGrade5 = static function (string $g): bool { - return (bool) preg_match('/^5(\-|$)/', trim($g)); - }; - - $builder = DB::table('student_class sc') - ->select([ - 'sc.student_id', - 's.firstname', - 's.lastname', - 's.is_new', - 'cs.class_section_name AS grade_label', - ]) - ->join('students s', 's.id', '=', 'sc.student_id') - ->join('classSection cs', 'cs.class_section_id', '=', 'sc.class_section_id') - ->join('classes c', 'c.id', '=', 'cs.class_id') - ->where('sc.school_year', $schoolYear); - - if ($semester !== null && $semester !== '') { - $builder->where('sc.semester', $semester); - } - - $rows = $builder->get()->toArray(); - - // Filter out Youth and empty labels - $rows = array_values(array_filter($rows, static function ($r) { - $g = trim((string) ($r->grade_label ?? '')); - return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0; - })); - - $out = []; - $totalPrimary = 0; - $totalSecondary = 0; - - foreach ($rows as $r) { - $first = trim((string) ($r->firstname ?? '')); - $last = trim((string) ($r->lastname ?? '')); - $grade = trim((string) ($r->grade_label ?? '')); - $isNew = ((int) ($r->is_new ?? 0)) === 1; - - $primary = 0; - $secondary = 0; - - if (array_key_exists($grade, $specialMap)) { - $primary = (int) $specialMap[$grade]; - $secondary = 0; - } elseif ($isUpperBlock($grade)) { - if ($isNew) { - $primary = 4; - $secondary = 0; - } else { - $primary = $isGrade5($grade) ? 3 : 2; - $secondary = max(0, 4 - $primary); - } - } else { - $primary = 4; - $secondary = 0; - } - - $totalPrimary += $primary; - $totalSecondary += $secondary; - - $out[] = [ - 'student_id' => (int) $r->student_id, - 'firstname' => $first, - 'lastname' => $last, - 'grade_label' => $grade, - 'primary_count' => $primary, - 'secondary_count' => $secondary, - ]; - } - - return [ - 'students' => $out, - 'totals' => [ - 'primary' => $totalPrimary, - 'secondary' => $totalSecondary, - 'students' => count($out), - ], - ]; - } - - /** - * Compute sticker counts per student for one class section - */ - protected function getStickerCountsPerStudentForClass( - string $schoolYear, - int $classSectionId, - ?string $semester = null - ): array { - $specialMap = [ - '1-A' => 3, - '1-B' => 4, - '2-A' => 5, - '2-B' => 5, - '9' => 2, - ]; - - $isUpperBlock = static fn($g) => (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g)); - $isGrade5 = static fn($g) => (bool) preg_match('/^5(\-|$)/', trim($g)); - - $b = DB::table('student_class sc') - ->select('sc.student_id', 's.firstname', 's.lastname', 's.is_new', 'cs.class_section_name AS grade_label') - ->join('students s', 's.id', '=', 'sc.student_id') - ->join('classSection cs', 'cs.class_section_id', '=', 'sc.class_section_id') - ->join('classes c', 'c.id', '=', 'cs.class_id') - ->where('sc.school_year', $schoolYear) - ->where('sc.class_section_id', $classSectionId); - - if (!empty($semester)) { - $b->where('sc.semester', $semester); - } - - $rows = $b->get()->toArray(); - - $rows = array_values(array_filter($rows, static function ($r) { - $g = trim((string) ($r->grade_label ?? '')); - return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0; - })); - - $out = []; - $tp = 0; - $ts = 0; - - foreach ($rows as $r) { - $g = trim((string) ($r->grade_label ?? '')); - $isNew = ((int) ($r->is_new ?? 0)) === 1; - - $p = 0; - $s = 0; - - if (isset($specialMap[$g])) { - $p = $specialMap[$g]; - } elseif ($isUpperBlock($g)) { - if ($isNew) { - $p = 4; - } else { - $p = $isGrade5($g) ? 3 : 2; - $s = max(0, 4 - $p); - } - } else { - $p = 4; - } - - $tp += $p; - $ts += $s; - - $out[] = [ - 'student_id' => (int) $r->student_id, - 'firstname' => trim((string) ($r->firstname ?? '')), - 'lastname' => trim((string) ($r->lastname ?? '')), - 'grade_label' => $g, - 'primary_count' => $p, - 'secondary_count' => $s, - ]; - } - - return [ - 'students' => $out, - 'totals' => [ - 'primary' => $tp, - 'secondary' => $ts, - 'students' => count($out), - ], - ]; - } - - /** - * POST /api/v1/printables/badge - * Generate staff badge PDF - * - * Note: This method requires FPDF library. The PDF generation logic from the - * CodeIgniter version should be integrated here when FPDF is available. - */ - public function badge(): Response - { - // TODO: Implement PDF badge generation using FPDF - // This requires the FPDF library to be installed and the drawBadgeInCell method - // to be implemented. For now, return an error indicating it's not implemented. - - return $this->respondError( - 'Badge PDF generation is not yet implemented. FPDF library integration required.', - Response::HTTP_NOT_IMPLEMENTED - ); - } - - /** - * POST /api/v1/printables/generate-stickers - * Generate student name stickers PDF - * - * Note: This method requires FPDF library. The PDF generation logic from the - * CodeIgniter version should be integrated here when FPDF is available. - */ - public function generateStickers(): Response - { - // TODO: Implement PDF sticker generation using FPDF - // This requires the FPDF library to be installed. - // For now, return an error indicating it's not implemented. - - return $this->respondError( - 'Sticker PDF generation is not yet implemented. FPDF library integration required.', - Response::HTTP_NOT_IMPLEMENTED - ); - } - - /** - * GET /api/v1/printables/report-card/{studentId} - * Generate single student report card PDF - * - * Note: This method requires FPDF library. The PDF generation logic from the - * CodeIgniter version should be integrated here when FPDF is available. - */ - public function generateSingleReport($studentId): Response - { - // TODO: Implement PDF report card generation using FPDF - // This requires the FPDF library to be installed and the formatReportPDF - // and prepareStudentReportData methods to be implemented. - - return $this->respondError( - 'Report card PDF generation is not yet implemented. FPDF library integration required.', - Response::HTTP_NOT_IMPLEMENTED - ); - } - - /** - * GET /api/v1/printables/report-card/class/{classSectionId} - * Generate class report cards PDF - * - * Note: This method requires FPDF library. The PDF generation logic from the - * CodeIgniter version should be integrated here when FPDF is available. - */ - public function generateClassReport($classSectionId): Response - { - // TODO: Implement PDF class report cards generation using FPDF - // This requires the FPDF library to be installed. - - return $this->respondError( - 'Class report cards PDF generation is not yet implemented. FPDF library integration required.', - Response::HTTP_NOT_IMPLEMENTED - ); - } -} diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php deleted file mode 100755 index 72ea85e0..00000000 --- a/app/Http/Controllers/Api/ProjectController.php +++ /dev/null @@ -1,621 +0,0 @@ -project = model(Project::class); - $this->config = model(Configuration::class); - $this->student = model(Student::class); - $this->studentClass = model(StudentClass::class); - $this->teacherClass = model(TeacherClass::class); - $this->semesterScoreService = app(SemesterScoreService::class); - - $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); - $this->semester = (string) ($this->config->getConfig('semester') ?? ''); - } - - /** - * GET /api/v1/projects - * List projects with optional filters - */ - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $studentId = $this->request->getGet('student_id'); - $classSectionId = $this->request->getGet('class_section_id'); - - $query = $this->project->newQuery() - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester); - - if ($studentId) { - $query->where('student_id', $studentId); - } - - if ($classSectionId) { - $query->where('class_section_id', $classSectionId); - } - - $result = $this->paginate($query, $page, $perPage); - - return $this->success($result, 'Projects retrieved successfully'); - } - - /** - * GET /api/v1/projects/{id} - * Get a single project - */ - public function show($id = null) - { - $project = $this->project->find($id); - if (!$project) { - return $this->error('Project not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($project, 'Project retrieved successfully'); - } - - /** - * GET /api/v1/projects/student/{id} - * Get projects for a specific student - */ - public function getByStudent($id = null) - { - $projects = $this->project->newQuery() - ->where('student_id', $id) - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) - ->orderBy('project_index', 'ASC') - ->get() - ->toArray(); - - return $this->success($projects, 'Projects retrieved successfully'); - } - - /** - * POST /api/v1/projects - * Create a new project entry - */ - public function store() - { - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'student_id' => 'required|integer', - 'class_section_id' => 'required|integer', - 'project_index' => 'required|integer', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $projectData = [ - 'student_id' => $data['student_id'], - 'class_section_id' => $data['class_section_id'], - 'project_index' => $data['project_index'], - 'score' => $data['score'] ?? null, - 'comment' => $data['comment'] ?? null, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'updated_by' => $user->id, - ]; - - try { - $project = $this->project->create($projectData); - return $this->success($project->toArray(), 'Project created successfully', Response::HTTP_CREATED); - } catch (\Throwable $e) { - log_message('error', 'Project creation error: ' . $e->getMessage()); - return $this->respondError('Failed to create project', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * PATCH /api/v1/projects/{id} - * Update a project entry - */ - public function update($id = null) - { - $project = $this->project->find($id); - if (!$project) { - return $this->error('Project not found', Response::HTTP_NOT_FOUND); - } - - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $allowedFields = ['score', 'comment', 'project_index']; - $updateData = []; - foreach ($allowedFields as $field) { - if (array_key_exists($field, $data)) { - $updateData[$field] = $data[$field]; - } - } - - if (empty($updateData)) { - return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST); - } - - $updateData['updated_by'] = $user->id; - $updateData['updated_at'] = utc_now(); - - try { - $this->project->update($id, $updateData); - $updatedProject = $this->project->find($id); - return $this->success($updatedProject, 'Project updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Project update error: ' . $e->getMessage()); - return $this->respondError('Failed to update project', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/projects/add-project - * Get project data for teacher's class section (equivalent to addProject view) - */ - public function addProject() - { - $updatedBy = $this->getCurrentUserId(); - if (!$updatedBy) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $teacherClass = $this->teacherClass->where('teacher_id', $updatedBy)->first(); - if (!$teacherClass) { - return $this->respondError('No class section found for the current teacher.', Response::HTTP_NOT_FOUND); - } - - $classSectionId = $teacherClass['class_section_id']; - session()->put('class_section_id', $classSectionId); - - $projectRows = $this->project - ->select('id, project_index') - ->where('class_section_id', $classSectionId) - ->where('updated_by', $updatedBy) - ->where('semester', $this->semester) - ->where('school_year', $this->schoolYear) - ->orderBy('project_index', 'ASC') - ->findAll(); - - $projectHeaders = []; - foreach ($projectRows as $row) { - $projectHeaders[$row['project_index']][] = $row['id']; - } - - $headerLabels = array_keys($projectHeaders); - - $studentsClasses = $this->studentClass->where('class_section_id', $classSectionId)->findAll(); - $students = []; - - foreach ($studentsClasses as $studentClass) { - $studentId = $studentClass['student_id']; - $student = $this->student->find($studentId); - if ($student) { - $scores = []; - foreach ($projectHeaders as $index => $ids) { - $entry = $this->project - ->where('student_id', $studentId) - ->where('project_index', $index) - ->where('class_section_id', $classSectionId) - ->where('semester', $this->semester) - ->first(); - - $scores[$index] = $entry['score'] ?? ''; - } - - $students[] = [ - 'student_id' => $studentId, - 'firstname' => $student['firstname'], - 'lastname' => $student['lastname'], - 'scores' => $scores - ]; - } - } - - usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname'])); - - return $this->success([ - 'students' => $students, - 'project_headers' => $headerLabels, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'class_section_id' => $classSectionId - ], 'Project data retrieved successfully'); - } - - /** - * POST /api/v1/projects/update-scores - * Update project scores for multiple students - */ - public function updateProjectScores() - { - $updatedBy = $this->getCurrentUserId(); - if (!$updatedBy) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $payload = $this->payloadData(); - $scores = $payload['scores'] ?? null; - $classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0); - - if ($classSectionId <= 0) { - return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST); - } - - if (!is_array($scores)) { - return $this->respondError('No project scores submitted.', Response::HTTP_BAD_REQUEST); - } - - try { - foreach ($scores as $studentId => $projects) { - if (!is_numeric($studentId)) continue; - - $student = $this->student->find($studentId); - if (!$student) continue; - - foreach ($projects as $index => $score) { - if (!is_numeric($score)) continue; - - $existing = $this->project->where([ - 'student_id' => $studentId, - 'project_index' => $index, - 'class_section_id' => $classSectionId, - 'semester' => $this->semester - ])->first(); - - $data = [ - 'student_id' => $studentId, - 'school_id' => $student['school_id'] ?? '', - 'class_section_id' => $classSectionId, - 'updated_by' => $updatedBy, - 'project_index' => $index, - 'score' => is_numeric($score) ? (float)$score : null, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'updated_at' => utc_now(), - ]; - - if ($existing) { - $this->project->update($existing['id'], $data); - } else { - $data['created_at'] = utc_now(); - $this->project->insert($data); - } - } - } - - $studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId); - - // Call the updateScoresForStudents method - try { - $this->semesterScoreService->updateScoresForStudents($studentTeacherInfo); - } catch (RuntimeException $e) { - log_message('error', 'Failed to update semester scores: ' . $e->getMessage()); - } - - return $this->success(null, 'Project scores updated successfully.'); - } catch (\Throwable $e) { - log_message('error', 'Update project scores error: ' . $e->getMessage()); - return $this->respondError('Failed to update project scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * POST /api/v1/projects/add-column - * Add a new project column for all students in a class section - */ - public function addNextProjectColumn() - { - $updatedBy = $this->getCurrentUserId(); - if (!$updatedBy) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $payload = $this->payloadData(); - $classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0); - - if ($classSectionId <= 0) { - return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST); - } - - try { - $existingIndexes = $this->project - ->select('project_index') - ->where('class_section_id', $classSectionId) - ->where('updated_by', $updatedBy) - ->where('semester', $this->semester) - ->where('school_year', $this->schoolYear) - ->groupBy('project_index') - ->orderBy('project_index', 'DESC') - ->findAll(); - - $maxIndex = 0; - foreach ($existingIndexes as $row) { - if (isset($row['project_index']) && is_numeric($row['project_index'])) { - $maxIndex = max($maxIndex, (int)$row['project_index']); - } - } - - $nextIndex = $maxIndex + 1; - - $students = $this->studentClass->where('class_section_id', $classSectionId)->findAll(); - - $insertedCount = 0; - foreach ($students as $student) { - $exists = $this->project->where([ - 'student_id' => $student['student_id'], - 'project_index' => $nextIndex, - 'class_section_id' => $classSectionId, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - ])->first(); - - if (!$exists) { - $studentRecord = $this->student->find($student['student_id']); - $this->project->insert([ - 'student_id' => $student['student_id'], - 'school_id' => $studentRecord['school_id'] ?? '', - 'class_section_id' => $classSectionId, - 'updated_by' => $updatedBy, - 'project_index' => $nextIndex, - 'score' => null, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'created_at' => utc_now(), - 'updated_at' => utc_now() - ]); - $insertedCount++; - } - } - - return $this->success([ - 'status' => 'success', - 'project_index' => $nextIndex, - 'students_processed' => $insertedCount - ], 'Project column added successfully'); - } catch (\Throwable $e) { - log_message('error', 'Add project column error: ' . $e->getMessage()); - return $this->respondError('Failed to add project column: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/projects/management - * Get project management data for a class section - */ - public function showProjectMngt() - { - // Accept POST first, then GET (query string), then session fallback - $incomingId = $this->request->getPost('class_section_id'); - if (!$incomingId) { - $incomingId = $this->request->getGet('class_section_id'); - } - - $classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0)); - - if ($classSectionId <= 0) { - return $this->respondError('No class section found for the current teacher or selection.', Response::HTTP_BAD_REQUEST); - } - - session()->put('class_section_id', $classSectionId); - - $projectScores = $this->getProjectScoresByStudent($classSectionId, $this->semester, $this->schoolYear); - $students = $this->getStudentsByClassSectionAndYear($classSectionId, $this->schoolYear); - $projectHeaders = $this->getProjectHeaders($classSectionId, $this->semester, $this->schoolYear); - - return $this->success([ - 'project_scores' => $projectScores, - 'students' => $students, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'project_headers' => $projectHeaders, - 'class_section_id' => $classSectionId, - ], 'Project management data retrieved successfully'); - } - - /** - * POST /api/v1/projects/update - * Update project scores (management endpoint) - */ - public function updateProject() - { - $payload = $this->payloadData(); - $scores = $payload['scores'] ?? null; - $studentIds = $payload['student_ids'] ?? null; - $semester = $payload['semester'] ?? $this->semester; - $schoolYear = $payload['school_year'] ?? $this->schoolYear; - $updatedBy = $payload['teacher_id'] ?? $this->getCurrentUserId(); - $classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0); - - if (!$updatedBy) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - if ($classSectionId <= 0) { - return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST); - } - - if ($scores && $studentIds && $semester && $schoolYear) { - try { - $this->saveProjectScores($scores, $studentIds, $semester, $schoolYear, $updatedBy, $classSectionId); - - $studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId); - - // Call the updateScoresForStudents method - try { - $this->semesterScoreService->updateScoresForStudents($studentTeacherInfo); - } catch (RuntimeException $e) { - log_message('error', 'Failed to update semester scores: ' . $e->getMessage()); - } - - return $this->success(null, 'Project scores updated successfully.'); - } catch (\Throwable $e) { - log_message('error', 'Update project error: ' . $e->getMessage()); - return $this->respondError('Failed to update scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - return $this->respondError('Failed to update scores. Missing required data.', Response::HTTP_BAD_REQUEST); - } - - /** - * Helper: Save project scores for multiple students - */ - private function saveProjectScores(array $scores, array $studentIds, string $semester, string $schoolYear, int $updatedBy, int $classSectionId) - { - $now = utc_now(); - - foreach ($studentIds as $studentId) { - if (!isset($scores[$studentId])) { - continue; - } - - foreach ($scores[$studentId] as $index => $score) { - // Check if the record exists - $existing = $this->project - ->where('student_id', $studentId) - ->where('project_index', $index) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->where('class_section_id', $classSectionId) - ->where('updated_by', $updatedBy) - ->first(); - - $student = $this->student->find($studentId); - $data = [ - 'student_id' => $studentId, - 'class_section_id' => $classSectionId, - 'updated_by' => $updatedBy, - 'project_index' => $index, - 'score' => is_numeric($score) ? floatval($score) : null, - 'semester' => $semester, - 'school_year' => $schoolYear, - 'updated_at' => $now, - ]; - - if ($existing && isset($existing['id'])) { - $this->project->update($existing['id'], $data); - } else { - $data['created_at'] = $now; - $data['school_id'] = $student['school_id'] ?? ''; - $this->project->insert($data); - } - } - } - } - - /** - * Helper: Get project headers for a class section - */ - private function getProjectHeaders($classSectionId, $semester, $schoolYear) - { - $rows = $this->project - ->select('project_index') - ->where('class_section_id', $classSectionId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->orderBy('project_index', 'ASC') - ->findAll(); - - $headers = []; - foreach ($rows as $row) { - $index = $row['project_index']; - if (!is_array($index)) { - $headers[$index] = true; - } - } - - ksort($headers); - return array_keys($headers); - } - - /** - * Helper: Get students by class section and year - */ - private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear) - { - // Step 1: Get student IDs from student_class table - $studentClassRows = $this->studentClass - ->select('student_id') - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->findAll(); - - $studentIds = array_column($studentClassRows, 'student_id'); - - if (empty($studentIds)) { - return []; // No students found - } - - // Step 2: Get student data from students table - $students = $this->student - ->whereIn('id', $studentIds) - ->orderBy('lastname', 'ASC') - ->findAll(); - - return $students; - } - - /** - * Helper: Get project scores by student - */ - private function getProjectScoresByStudent($classSectionId, $semester, $schoolYear) - { - $rows = $this->project - ->select('student_id, project_index, score') - ->where('class_section_id', $classSectionId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->findAll(); - - $studentScores = []; - foreach ($rows as $row) { - $studentId = $row['student_id']; - $index = $row['project_index']; - $score = $row['score']; - - $studentScores[$studentId]['scores'][$index] = $score; - } - - return $studentScores; - } -} diff --git a/app/Http/Controllers/Api/PurchaseOrderController.php b/app/Http/Controllers/Api/PurchaseOrderController.php deleted file mode 100755 index 2ba6aaea..00000000 --- a/app/Http/Controllers/Api/PurchaseOrderController.php +++ /dev/null @@ -1,352 +0,0 @@ -po = model(PurchaseOrder::class); - $this->item = model(PurchaseOrderItem::class); - $this->supplier = model(Supplier::class); - $this->inventoryItem = model(InventoryItem::class); - $this->movement = model(InventoryMovement::class); - } - - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage= min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $keyword= trim((string) ($this->request->getGet('q') ?? '')); - - $query = $this->po->newQuery() - ->select('purchase_orders.*', 'suppliers.name AS supplier_name') - ->leftJoin('suppliers', 'suppliers.id', '=', 'purchase_orders.supplier_id') - ->orderByDesc('purchase_orders.created_at'); - - if ($keyword !== '') { - $query->where(function ($q) use ($keyword) { - $q->where('po_number', 'LIKE', "%{$keyword}%") - ->orWhere('suppliers.name', 'LIKE', "%{$keyword}%"); - }); - } - - $result = $this->paginate($query, $page, $perPage); - return $this->success($result, 'Purchase orders retrieved successfully'); - } - - public function show($id = null) - { - $po = $this->po->newQuery() - ->select('purchase_orders.*', 'suppliers.name AS supplier_name') - ->leftJoin('suppliers', 'suppliers.id', '=', 'purchase_orders.supplier_id') - ->where('purchase_orders.id', $id) - ->first(); - - if (!$po) { - return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND); - } - - $items = $this->item->newQuery() - ->select('purchase_order_items.*', 'inventory_items.name AS supply_name', 'inventory_items.unit AS supply_unit') - ->leftJoin('inventory_items', 'inventory_items.id', '=', 'purchase_order_items.supply_id') - ->where('purchase_order_id', $id) - ->get() - ->toArray(); - - $po['items'] = $items; - - return $this->success($po, 'Purchase order retrieved successfully'); - } - - public function store() - { - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $payload = $this->payloadData(); - if (empty($payload)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - // Support both CodeIgniter-style form arrays and modern JSON format - $supplyIds = $payload['item_supply_id'] ?? $payload['items'] ?? []; - $descs = $payload['item_description'] ?? []; - $qtys = $payload['item_quantity'] ?? []; - $unitCosts = $payload['item_unit_cost'] ?? []; - - // If items array is provided (modern format), extract data - if (isset($payload['items']) && is_array($payload['items'])) { - $items = []; - foreach ($payload['items'] as $item) { - if (isset($item['supply_id']) && isset($item['quantity']) && $item['quantity'] > 0) { - $items[] = [ - 'supply_id' => $item['supply_id'], - 'description' => $item['description'] ?? '', - 'quantity' => (int)$item['quantity'], - 'unit_cost' => (float)($item['unit_cost'] ?? 0), - ]; - } - } - } else { - // CodeIgniter-style form arrays - if (empty($supplyIds) || !is_array($supplyIds)) { - return $this->respondError('Add at least one line item.', Response::HTTP_BAD_REQUEST); - } - - $items = []; - foreach ($supplyIds as $i => $sid) { - $q = max(0, (int)($qtys[$i] ?? 0)); - $uc = (float)($unitCosts[$i] ?? 0); - if ($sid && $q > 0) { - $items[] = [ - 'supply_id' => (int)$sid, - 'description' => trim($descs[$i] ?? ''), - 'quantity' => $q, - 'unit_cost' => $uc, - ]; - } - } - } - - if (empty($items)) { - return $this->respondError('Valid line items required.', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'po_number' => 'required|max_length:255', - 'supplier_id' => 'required|integer', - 'order_date' => 'required|date', - ]; - - $errors = $this->validateRequest($payload, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - try { - DB::beginTransaction(); - - // Compute totals - $subtotal = 0.0; - foreach ($items as $item) { - $subtotal += $item['quantity'] * $item['unit_cost']; - } - - $tax = (float)($payload['tax'] ?? 0.00); - $total = $subtotal + $tax; - - $status = $payload['status'] ?? 'ordered'; - $status = in_array($status, ['draft', 'ordered'], true) ? $status : 'ordered'; - - $poData = [ - 'po_number' => $payload['po_number'], - 'supplier_id' => $payload['supplier_id'], - 'order_date' => $payload['order_date'], - 'expected_date' => $payload['expected_date'] ?? null, - 'status' => $status, - 'subtotal' => $subtotal, - 'tax' => $tax, - 'total' => $total, - 'notes' => $payload['notes'] ?? null, - ]; - - $po = $this->po->create($poData); - $poId = $po->id ?? null; - - if (!$poId) { - DB::rollBack(); - return $this->respondError('Failed to create purchase order', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - foreach ($items as $item) { - $this->item->create([ - 'purchase_order_id' => $poId, - 'supply_id' => $item['supply_id'], - 'description' => $item['description'], - 'quantity' => $item['quantity'], - 'unit_cost' => $item['unit_cost'], - 'received_qty' => 0, - ]); - } - - DB::commit(); - - $poArray = $this->show($poId)->getData(true)['data']; - return $this->success($poArray, 'Purchase order created successfully', Response::HTTP_CREATED); - } catch (\Throwable $e) { - DB::rollBack(); - log_message('error', 'Purchase order creation error: ' . $e->getMessage()); - return $this->respondError('Failed to create purchase order: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function update($id = null) - { - $po = $this->po->find($id); - if (!$po) { - return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND); - } - - $payload = $this->payloadData(); - if (empty($payload)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $allowedFields = ['status', 'expected_date', 'notes']; - $updateData = []; - foreach ($allowedFields as $field) { - if (array_key_exists($field, $payload)) { - $updateData[$field] = $payload[$field]; - } - } - - if (empty($updateData)) { - return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST); - } - - try { - $this->po->update($id, $updateData); - $updatedPo = $this->po->find($id); - return $this->success($updatedPo, 'Purchase order updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Purchase order update error: ' . $e->getMessage()); - return $this->respondError('Failed to update purchase order', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * POST /api/v1/purchase-orders/{id}/receive - * Receive items (partial or full) from a purchase order - */ - public function receive($id = null) - { - $po = $this->po->find($id); - if (!$po) { - return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND); - } - - if (in_array($po['status'], ['canceled', 'received'], true)) { - return $this->respondError('PO not receivable. Status: ' . $po['status'], Response::HTTP_BAD_REQUEST); - } - - $payload = $this->payloadData(); - $received = $payload['received'] ?? []; // [itemId => qty] - - if (empty($received) || !is_array($received)) { - return $this->respondError('No items to receive.', Response::HTTP_BAD_REQUEST); - } - - $user = $this->getCurrentUser(); - $issuedBy = $user ? ($user->email ?? $user->username ?? 'system') : 'system'; - - try { - DB::beginTransaction(); - - $completed = true; - - foreach ($received as $itemId => $qty) { - $qty = (int)$qty; - if ($qty <= 0) continue; - - $item = $this->item->where('purchase_order_id', $id)->find($itemId); - if (!$item) { - $completed = false; - continue; - } - - $remaining = (int)$item['quantity'] - (int)$item['received_qty']; - $toReceive = min($remaining, $qty); - - if ($toReceive <= 0) continue; - - // Update item received qty - $this->item->update($itemId, [ - 'received_qty' => (int)$item['received_qty'] + $toReceive - ]); - - // Update inventory item on hand (if supply_id maps to inventory_items) - if (!empty($item['supply_id'])) { - $inventoryItem = $this->inventoryItem->find($item['supply_id']); - if ($inventoryItem) { - $newQty = (int)($inventoryItem['quantity'] ?? 0) + $toReceive; - $this->inventoryItem->update($inventoryItem['id'], [ - 'quantity' => $newQty - ]); - - // Log transaction IN using inventory movements - $this->movement->insert([ - 'item_id' => $inventoryItem['id'], - 'qty_change' => $toReceive, - 'movement_type' => 'in', - 'reason' => 'PO ' . $po['po_number'], - 'note' => 'Received against PO', - 'performed_by' => $this->getCurrentUserId() ?? null, - ]); - } - } - - if (($item['received_qty'] + $toReceive) < $item['quantity']) { - $completed = false; - } - } - - // Set PO status - $this->po->update($id, [ - 'status' => $completed ? 'received' : 'ordered' - ]); - - DB::commit(); - - $message = $completed ? 'PO fully received.' : 'PO partially received.'; - return $this->success(null, $message); - } catch (\Throwable $e) { - DB::rollBack(); - log_message('error', 'Receive PO error: ' . $e->getMessage()); - return $this->respondError('Failed to receive items: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * POST /api/v1/purchase-orders/{id}/cancel - * Cancel a purchase order - */ - public function cancel($id = null) - { - $po = $this->po->find($id); - if (!$po) { - return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND); - } - - if ($po['status'] === 'received') { - return $this->respondError('Cannot cancel a received PO.', Response::HTTP_BAD_REQUEST); - } - - try { - $this->po->update($id, ['status' => 'canceled']); - $updatedPo = $this->po->find($id); - return $this->success($updatedPo, 'PO canceled.'); - } catch (\Throwable $e) { - log_message('error', 'Cancel PO error: ' . $e->getMessage()); - return $this->respondError('Failed to cancel purchase order: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/QuizController.php b/app/Http/Controllers/Api/QuizController.php deleted file mode 100755 index 7a5b0abe..00000000 --- a/app/Http/Controllers/Api/QuizController.php +++ /dev/null @@ -1,656 +0,0 @@ -quiz = model(Quiz::class); - $this->config = model(Configuration::class); - $this->student = model(Student::class); - $this->studentClass = model(StudentClass::class); - $this->teacherClass = model(TeacherClass::class); - $this->user = model(User::class); - $this->semesterScoreService = app(SemesterScoreService::class); - - $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); - $this->semester = (string) ($this->config->getConfig('semester') ?? ''); - } - - /** - * GET /api/v1/quizzes - * List quizzes with optional filters - */ - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $studentId = $this->request->getGet('student_id'); - $classSectionId = $this->request->getGet('class_section_id'); - - $query = $this->quiz->newQuery() - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester); - - if ($studentId) { - $query->where('student_id', $studentId); - } - - if ($classSectionId) { - $query->where('class_section_id', $classSectionId); - } - - $result = $this->paginate($query, $page, $perPage); - - return $this->success($result, 'Quizzes retrieved successfully'); - } - - /** - * GET /api/v1/quizzes/{id} - * Get a single quiz - */ - public function show($id = null) - { - $quiz = $this->quiz->find($id); - if (!$quiz) { - return $this->error('Quiz not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($quiz, 'Quiz retrieved successfully'); - } - - /** - * GET /api/v1/quizzes/student/{id} - * Get quizzes for a specific student - */ - public function getByStudent($id = null) - { - $quizzes = $this->quiz->newQuery() - ->where('student_id', $id) - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) - ->orderBy('quiz_index', 'ASC') - ->get() - ->toArray(); - - return $this->success($quizzes, 'Quizzes retrieved successfully'); - } - - /** - * POST /api/v1/quizzes - * Create a new quiz entry - */ - public function store() - { - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'student_id' => 'required|integer', - 'class_section_id' => 'required|integer', - 'quiz_index' => 'required|integer', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $quizData = [ - 'student_id' => $data['student_id'], - 'class_section_id' => $data['class_section_id'], - 'quiz_index' => $data['quiz_index'], - 'score' => $data['score'] ?? null, - 'comment' => $data['comment'] ?? null, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'updated_by' => $user->id, - ]; - - try { - $quiz = $this->quiz->create($quizData); - return $this->success($quiz->toArray(), 'Quiz created successfully', Response::HTTP_CREATED); - } catch (\Throwable $e) { - log_message('error', 'Quiz creation error: ' . $e->getMessage()); - return $this->respondError('Failed to create quiz', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * PATCH /api/v1/quizzes/{id} - * Update a quiz entry - */ - public function update($id = null) - { - $quiz = $this->quiz->find($id); - if (!$quiz) { - return $this->error('Quiz not found', Response::HTTP_NOT_FOUND); - } - - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $allowedFields = ['score', 'comment', 'quiz_index']; - $updateData = []; - foreach ($allowedFields as $field) { - if (array_key_exists($field, $data)) { - $updateData[$field] = $data[$field]; - } - } - - if (empty($updateData)) { - return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST); - } - - $updateData['updated_by'] = $user->id; - $updateData['updated_at'] = utc_now(); - - try { - $this->quiz->update($id, $updateData); - $updatedQuiz = $this->quiz->find($id); - return $this->success($updatedQuiz, 'Quiz updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Quiz update error: ' . $e->getMessage()); - return $this->respondError('Failed to update quiz', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/quizzes/add-quiz - * Get quiz data for teacher's class section (equivalent to addQuiz view) - */ - public function addQuiz() - { - // Get class_section_id from POST -> GET -> session - $incoming = $this->request->getPost('class_section_id') - ?? $this->request->getGet('class_section_id') - ?? session()->get('class_section_id'); - - $classSectionId = (int) ($incoming ?: 0); - - if ($classSectionId <= 0) { - return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST); - } - - session()->put('class_section_id', $classSectionId); - - // Get distinct quiz_index values for this class/term - $quizHeaderRows = $this->quiz - ->select('quiz_index') - ->where('class_section_id', $classSectionId) - ->where('school_year', $this->schoolYear) - ->groupBy('quiz_index') - ->orderBy('quiz_index', 'ASC') - ->findAll(); - - $quizHeaders = array_map(static fn($r) => (int)$r['quiz_index'], $quizHeaderRows); - - // Get roster (distinct students for this section/term) - $roster = DB::table('student_class as sc') - ->select('s.id AS student_id', 's.firstname', 's.lastname', 's.school_id') - ->distinct() - ->join('students as s', 's.id', '=', 'sc.student_id') - ->where('sc.class_section_id', $classSectionId) - ->where('sc.school_year', $this->schoolYear) - ->orderBy('s.lastname', 'ASC') - ->orderBy('s.firstname', 'ASC') - ->get() - ->toArray(); - - // Get all existing quiz scores - $scoreRows = $this->quiz - ->select('id, student_id, quiz_index, score') - ->where('class_section_id', $classSectionId) - ->where('school_year', $this->schoolYear) - ->findAll(); - - $scoresMap = []; // [student_id][quiz_index] => score - $idMap = []; // [student_id][quiz_index] => id - - foreach ($scoreRows as $r) { - $sid = (int)$r['student_id']; - $qi = (int)$r['quiz_index']; - $scoresMap[$sid][$qi] = $r['score']; - $idMap[$sid][$qi] = (int)$r['id']; - } - - // Build students array with per-quiz scores - $students = []; - foreach ($roster as $st) { - $sid = (int)$st->student_id; - $quizScores = []; - - foreach ($quizHeaders as $qi) { - $quizScores[$qi] = $scoresMap[$sid][$qi] ?? ''; - } - - $students[] = [ - 'student_id' => $sid, - 'firstname' => $st->firstname, - 'lastname' => $st->lastname, - 'school_id' => $st->school_id ?? null, - 'scores' => $quizScores, - 'existingIds' => $idMap[$sid] ?? [], - ]; - } - - return $this->success([ - 'students' => $students, - 'quiz_headers' => $quizHeaders, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'class_section_id' => $classSectionId - ], 'Quiz data retrieved successfully'); - } - - /** - * POST /api/v1/quizzes/update-scores - * Update quiz scores for multiple students - */ - public function updateQuizScores() - { - $updatedBy = $this->getCurrentUserId(); - if (!$updatedBy) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $payload = $this->payloadData(); - $scores = $payload['scores'] ?? null; - $classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0); - - if ($classSectionId <= 0) { - return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST); - } - - if (!is_array($scores)) { - return $this->respondError('No quiz scores submitted.', Response::HTTP_BAD_REQUEST); - } - - try { - foreach ($scores as $studentId => $quizData) { - if (!is_numeric($studentId) || $studentId <= 0) continue; - - // Normalize: if single input submitted - if (!is_array($quizData)) { - $quizData = [1 => $quizData]; - } - - foreach ($quizData as $quizNumber => $score) { - if (!is_numeric($quizNumber) || !is_numeric($score)) continue; - - $existing = $this->quiz->where([ - 'student_id' => $studentId, - 'quiz_index' => $quizNumber, - 'class_section_id' => $classSectionId, - 'semester' => $this->semester - ])->first(); - - if ($existing) { - $this->quiz->update($existing['id'], [ - 'score' => is_numeric($score) ? (float)$score : null, - 'updated_at' => utc_now() - ]); - } else { - $student = $this->student->find($studentId); - $this->quiz->insert([ - 'student_id' => $studentId, - 'school_id' => $student['school_id'] ?? '', - 'class_section_id' => $classSectionId, - 'updated_by' => $updatedBy, - 'quiz_index' => $quizNumber, - 'score' => is_numeric($score) ? (float)$score : null, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'created_at' => utc_now(), - 'updated_at' => utc_now() - ]); - } - } - } - - $studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId); - - // Call the updateScoresForStudents method - try { - $this->semesterScoreService->updateScoresForStudents($studentTeacherInfo); - } catch (RuntimeException $e) { - log_message('error', 'Failed to update semester scores: ' . $e->getMessage()); - } - - return $this->success(null, 'Quiz scores updated successfully.'); - } catch (\Throwable $e) { - log_message('error', 'Update quiz scores error: ' . $e->getMessage()); - return $this->respondError('Failed to update quiz scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * POST /api/v1/quizzes/add-column - * Add a new quiz column for all students in a class section - */ - public function addNextQuizColumn() - { - $updatedBy = $this->getCurrentUserId(); - if (!$updatedBy) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $payload = $this->payloadData(); - $classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0); - - if ($classSectionId <= 0) { - return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST); - } - - try { - $existingQuizNumbers = $this->quiz - ->select('quiz_index') - ->where('class_section_id', $classSectionId) - ->where('updated_by', $updatedBy) - ->where('semester', $this->semester) - ->where('school_year', $this->schoolYear) - ->groupBy('quiz_index') - ->orderBy('quiz_index', 'DESC') - ->findAll(); - - $maxQuizNumber = 0; - foreach ($existingQuizNumbers as $row) { - if (is_numeric($row['quiz_index'])) { - $maxQuizNumber = max($maxQuizNumber, (int)$row['quiz_index']); - } - } - - $nextQuizNumber = $maxQuizNumber + 1; - - if ($nextQuizNumber <= 0) { - return $this->respondError('Invalid quiz number', Response::HTTP_BAD_REQUEST); - } - - $students = $this->studentClass - ->where('class_section_id', $classSectionId) - ->findAll(); - - if (empty($students)) { - return $this->respondError('No students found in this class section.', Response::HTTP_NOT_FOUND); - } - - $firstInsertedId = null; - $insertedCount = 0; - - foreach ($students as $i => $student) { - $exists = $this->quiz->where([ - 'student_id' => $student['student_id'], - 'quiz_index' => $nextQuizNumber, - 'class_section_id' => $classSectionId, - 'updated_by' => $updatedBy, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - ])->first(); - - if ($exists) continue; - - $studentRecord = $this->student->find($student['student_id']); - $insertData = [ - 'student_id' => $student['student_id'], - 'school_id' => $studentRecord['school_id'] ?? '', - 'class_section_id' => $classSectionId, - 'updated_by' => $updatedBy, - 'quiz_index' => $nextQuizNumber, - 'score' => null, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'created_at' => utc_now(), - 'updated_at' => utc_now(), - ]; - - $id = $this->quiz->insert($insertData); - if ($i === 0) { - $firstInsertedId = $id; - } - $insertedCount++; - } - - return $this->success([ - 'status' => 'success', - 'quiz_index' => $nextQuizNumber, - 'new_quiz_id' => $firstInsertedId, - 'students_processed' => $insertedCount - ], 'Quiz column added successfully'); - } catch (\Throwable $e) { - log_message('error', 'Add quiz column error: ' . $e->getMessage()); - return $this->respondError('Failed to add quiz column: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/quizzes/management - * Get quiz management data for a class section - */ - public function showQuizMngt() - { - // Accept POST first, then GET (query string), then session fallback - $incomingId = $this->request->getPost('class_section_id'); - if (!$incomingId) { - $incomingId = $this->request->getGet('class_section_id'); - } - - $classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0)); - - if ($classSectionId <= 0) { - return $this->respondError('No class section found for the current teacher or selection.', Response::HTTP_BAD_REQUEST); - } - - session()->put('class_section_id', $classSectionId); - - $quizScores = $this->getQuizScoresByStudent($classSectionId, $this->semester, $this->schoolYear); - $students = $this->getStudentsByClassSectionAndYear($classSectionId, $this->schoolYear); - $quizHeaders = $this->getQuizHeaders($classSectionId, $this->semester, $this->schoolYear); - - return $this->success([ - 'quiz_scores' => $quizScores, - 'students' => $students, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'quiz_headers' => $quizHeaders, - 'class_section_id' => $classSectionId, - ], 'Quiz management data retrieved successfully'); - } - - /** - * POST /api/v1/quizzes/update - * Update quiz scores (management endpoint) - */ - public function updateQuiz() - { - $payload = $this->payloadData(); - $scores = $payload['scores'] ?? null; - $studentIds = $payload['student_ids'] ?? null; - $semester = $payload['semester'] ?? $this->semester; - $schoolYear = $payload['school_year'] ?? $this->schoolYear; - $updatedBy = $payload['teacher_id'] ?? $this->getCurrentUserId(); - $classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0); - - if (!$updatedBy) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - if ($classSectionId <= 0) { - return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST); - } - - if ($scores && $studentIds && $semester && $schoolYear) { - try { - // Update scores using the same logic as updateQuizScores - foreach ($scores as $studentId => $quizData) { - if (!is_numeric($studentId) || $studentId <= 0) continue; - - // Normalize: if single input submitted - if (!is_array($quizData)) { - $quizData = [1 => $quizData]; - } - - foreach ($quizData as $quizNumber => $score) { - if (!is_numeric($quizNumber) || !is_numeric($score)) continue; - - $existing = $this->quiz->where([ - 'student_id' => $studentId, - 'quiz_index' => $quizNumber, - 'class_section_id' => $classSectionId, - 'semester' => $semester - ])->first(); - - if ($existing) { - $this->quiz->update($existing['id'], [ - 'score' => is_numeric($score) ? (float)$score : null, - 'updated_at' => utc_now() - ]); - } else { - $student = $this->student->find($studentId); - $this->quiz->insert([ - 'student_id' => $studentId, - 'school_id' => $student['school_id'] ?? '', - 'class_section_id' => $classSectionId, - 'updated_by' => $updatedBy, - 'quiz_index' => $quizNumber, - 'score' => is_numeric($score) ? (float)$score : null, - 'semester' => $semester, - 'school_year' => $schoolYear, - 'created_at' => utc_now(), - 'updated_at' => utc_now() - ]); - } - } - } - - $studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId); - - // Call the updateScoresForStudents method - try { - $this->semesterScoreService->updateScoresForStudents($studentTeacherInfo); - } catch (RuntimeException $e) { - log_message('error', 'Failed to update semester scores: ' . $e->getMessage()); - } - - return $this->success(null, 'Quiz scores updated successfully.'); - } catch (\Throwable $e) { - log_message('error', 'Update quiz error: ' . $e->getMessage()); - return $this->respondError('Failed to update scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - return $this->respondError('Failed to update scores. Missing required data.', Response::HTTP_BAD_REQUEST); - } - - /** - * Helper: Get students by class section and year - */ - private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear) - { - // Step 1: Get student IDs from student_class table - $studentClassRows = $this->studentClass - ->select('student_id') - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->findAll(); - - $studentIds = array_column($studentClassRows, 'student_id'); - - if (empty($studentIds)) { - return []; // No students found - } - - // Step 2: Get student data from students table - $students = $this->student - ->whereIn('id', $studentIds) - ->orderBy('lastname', 'ASC') - ->findAll(); - - return $students; - } - - /** - * Helper: Get quiz headers for a class section - */ - private function getQuizHeaders($classSectionId, $semester, $schoolYear) - { - $rows = $this->quiz - ->select('quiz_index') - ->where('class_section_id', $classSectionId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->orderBy('quiz_index', 'ASC') - ->findAll(); - - $headers = []; - foreach ($rows as $row) { - $index = $row['quiz_index']; - if (!is_array($index)) { - $headers[$index] = true; - } - } - - ksort($headers); - return array_keys($headers); - } - - /** - * Helper: Get quiz scores by student - */ - private function getQuizScoresByStudent($classSectionId, $semester, $schoolYear) - { - $rows = $this->quiz - ->select('student_id, quiz_index, score') - ->where('class_section_id', $classSectionId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->findAll(); - - $studentScores = []; - foreach ($rows as $row) { - $studentId = $row['student_id']; - $index = $row['quiz_index']; - $score = $row['score']; - - $studentScores[$studentId]['scores'][$index] = $score; - } - - return $studentScores; - } -} diff --git a/app/Http/Controllers/Api/RFIDController.php b/app/Http/Controllers/Api/RFIDController.php deleted file mode 100755 index 1ce1e363..00000000 --- a/app/Http/Controllers/Api/RFIDController.php +++ /dev/null @@ -1,126 +0,0 @@ -user = model(User::class); - $this->student = model(Student::class); - } - - public function process() - { - $data = $this->payloadData(); - if (empty($data)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'rfid' => 'required', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - try { - $rfidTag = $data['rfid']; - - $user = $this->user->where('rfid_tag', $rfidTag)->first(); - - $student = null; - if (!$user) { - $student = $this->student->where('rfid_tag', $rfidTag)->first(); - } - - if (!$user && !$student) { - return $this->error('RFID tag not recognized', Response::HTTP_NOT_FOUND); - } - - $logData = [ - 'user_id' => $user['id'] ?? null, - 'student_id' => $student['id'] ?? null, - 'card_id' => $rfidTag, - 'scan_time' => Carbon::now('UTC')->toDateTimeString(), - ]; - - DB::table('scan_log')->insert($logData); - - $result = [ - 'rfid_tag' => $rfidTag, - 'user' => $user ? [ - 'id' => $user['id'], - 'name' => trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')), - ] : null, - 'student' => $student ? [ - 'id' => $student['id'], - 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), - ] : null, - ]; - - return $this->success($result, 'RFID processed successfully'); - } catch (\Throwable $e) { - log_message('error', 'RFID process error: ' . $e->getMessage()); - return $this->respondError('Failed to process RFID', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function logs() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - - try { - $builder = DB::table('scan_log as sl') - ->select([ - 'u.firstname as user_firstname', - 'u.lastname as user_lastname', - 's.firstname as student_firstname', - 's.lastname as student_lastname', - 'sl.card_id', - 'sl.scan_time', - ]) - ->leftJoin('users as u', 'u.id', '=', 'sl.user_id') - ->leftJoin('students as s', 's.rfid_tag', '=', 'sl.card_id'); - - $offset = ($page - 1) * $perPage; - - $total = (clone $builder)->count(); - - $logs = (clone $builder) - ->orderBy('sl.scan_time', 'DESC') - ->offset($offset) - ->limit($perPage) - ->get() - ->toArray(); - - $result = [ - 'data' => $logs, - 'pagination' => [ - 'current_page' => $page, - 'per_page' => $perPage, - 'total' => $total, - 'total_pages' => (int) ceil($total / $perPage), - ], - ]; - - return $this->success($result, 'RFID scan logs retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'RFID logs error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve RFID logs', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/RefundController.php b/app/Http/Controllers/Api/RefundController.php deleted file mode 100755 index 6771d13d..00000000 --- a/app/Http/Controllers/Api/RefundController.php +++ /dev/null @@ -1,151 +0,0 @@ -refund = model(Refund::class); - $this->config = model(Configuration::class); - } - - public function index() - { - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); - $parentId = $this->request->getGet('parent_id'); - $status = $this->request->getGet('status'); - - $query = $this->refund->newQuery(); - - if ($parentId) { - $query->where('parent_id', $parentId); - } - - if ($status) { - $query->where('status', $status); - } - - $result = $this->paginate($query, $page, $perPage); - - return $this->success($result, 'Refunds retrieved successfully'); - } - - public function show($id = null) - { - $refund = $this->refund->find($id); - if (!$refund) { - return $this->error('Refund not found', Response::HTTP_NOT_FOUND); - } - - return $this->success($refund, 'Refund retrieved successfully'); - } - - public function store() - { - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'parent_id' => 'required|integer', - 'invoice_id' => 'required|integer', - 'refund_amount' => 'required|numeric', - 'reason' => 'required', - ]; - - $errors = $this->validateRequest($data, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $schoolYear = $this->config->getConfig('school_year'); - $semester = $this->config->getConfig('semester'); - - $refundData = [ - 'parent_id' => $data['parent_id'], - 'invoice_id' => $data['invoice_id'], - 'refund_amount' => $data['refund_amount'], - 'reason' => $data['reason'], - 'request' => $data['request'] ?? null, - 'status' => 'pending', - 'requested_at' => Carbon::now('UTC')->toDateTimeString(), - 'school_year' => $schoolYear, - 'semester' => $semester, - ]; - - try { - $refund = $this->refund->create($refundData); - return $this->success($refund->toArray(), 'Refund request created successfully', Response::HTTP_CREATED); - } catch (\Throwable $e) { - log_message('error', 'Refund creation error: ' . $e->getMessage()); - return $this->respondError('Failed to create refund request', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function update($id = null) - { - $refund = $this->refund->find($id); - if (!$refund) { - return $this->error('Refund not found', Response::HTTP_NOT_FOUND); - } - - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - if (empty($data)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $allowedFields = ['status', 'refund_paid_amount', 'refund_method', 'check_nbr', 'note']; - $updateData = []; - foreach ($allowedFields as $field) { - if (array_key_exists($field, $data)) { - $updateData[$field] = $data[$field]; - } - } - - if (isset($updateData['status']) && $updateData['status'] === 'approved') { - $updateData['approved_at'] = Carbon::now('UTC')->toDateTimeString(); - $updateData['approved_by'] = $user->id; - } - - if (isset($updateData['status']) && $updateData['status'] === 'refunded') { - $updateData['refunded_at'] = Carbon::now('UTC')->toDateTimeString(); - } - - if (empty($updateData)) { - return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST); - } - - $updateData['updated_by'] = $user->id; - - try { - $this->refund->update($id, $updateData); - $updatedRefund = $this->refund->find($id); - return $this->success($updatedRefund, 'Refund updated successfully'); - } catch (\Throwable $e) { - log_message('error', 'Refund update error: ' . $e->getMessage()); - return $this->respondError('Failed to update refund', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/RegisterController.php b/app/Http/Controllers/Api/RegisterController.php deleted file mode 100755 index 3d113b38..00000000 --- a/app/Http/Controllers/Api/RegisterController.php +++ /dev/null @@ -1,357 +0,0 @@ -config = model(Configuration::class); - $this->user = model(User::class); - $this->userRole = model(UserRole::class); - $this->role = model(Role::class); - $this->parent = model(ParentModel::class); - $this->schoolIdService = app(SchoolIdService::class); - $this->emailService = app(EmailService::class); - $this->phoneFormatter = app(PhoneFormatterService::class); - - $this->semester = (string) ($this->config->getConfig('semester') ?? ''); - $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); - } - - public function index(): JsonResponse - { - $session = session(); - log_message('debug', 'CAPTCHA session value: ' . ($session->get('captcha_answer') ?? '')); - - if (!$session->has('captcha_answer')) { - $length = random_int(4, 8); - $session->put('captcha_answer', $this->generateCaptchaText($length)); - } - - return $this->respondSuccess([ - 'captcha_question' => $session->get('captcha_answer'), - ], 'Captcha generated'); - } - - public function registrationSuccess(): JsonResponse - { - $session = session(); - if (!$session->has('user_email')) { - return $this->respondError('No registration session found.', Response::HTTP_BAD_REQUEST); - } - - return $this->respondSuccess([ - 'email' => $session->get('user_email'), - ], 'Registration success data'); - } - - public function register(): JsonResponse - { - $payload = $this->payloadData(); - log_message('debug', 'CAPTCHA session value: ' . (session()->get('captcha_answer') ?? '')); - - $formatted = $this->formatUserInput($payload); - $validationPayload = array_merge($payload, $formatted); - $captcha = (string) ($payload['captcha'] ?? ''); - $isParent = !empty($payload['is_parent']) && (string) $payload['is_parent'] === '1'; - $noSecondParentInfo = !empty($payload['no_second_parent_info']) && (string) $payload['no_second_parent_info'] === '1'; - - $rules = [ - 'firstname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'], - 'lastname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'], - 'gender' => ['required', Rule::in(['Male', 'Female'])], - 'email' => ['required', 'email', 'max:50'], - 'confirm_email' => ['required', 'same:email'], - 'cellphone' => ['required', 'regex:/^[\d\s\-\(\)\.]+$/', 'min:10', 'max:20'], - 'address_street' => ['nullable', 'regex:/^[a-zA-Z0-9\s\-]+$/', 'max:150'], - 'apt' => ['nullable', 'regex:/^[a-zA-Z0-9\s-]+$/', 'max:10'], - 'city' => ['required', 'regex:/^[a-zA-Z\s]+$/', 'min:2', 'max:30'], - 'state' => ['required', Rule::in(['CT', 'ME', 'MA', 'NH', 'NY', 'RI', 'VT'])], - 'zip' => ['required', 'regex:/^\d{5}$/'], - 'accept_school_policy' => ['required', 'accepted'], - 'captcha' => ['required', 'alpha_num', 'min:4', 'max:10'], - ]; - - if ($isParent && !$noSecondParentInfo) { - $rules = array_merge($rules, [ - 'second_firstname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'], - 'second_lastname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'], - 'second_gender' => ['required', Rule::in(['Male', 'Female'])], - 'second_email' => ['required', 'email', 'max:50'], - 'second_cellphone' => ['required', 'regex:/^[\d\s\-\(\)\.]+$/', 'min:10', 'max:20'], - ]); - } - - $errors = $this->validateRequest($validationPayload, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - if ($isParent && !$noSecondParentInfo && !$this->hasSecondParentData($formatted)) { - return $this->respondValidationError([ - 'second_parent_info' => ['As a parent, please provide second parent information or check "No second parent info".'], - ]); - } - - if ($captcha !== (string) session()->get('captcha_answer')) { - return $this->respondValidationError([ - 'captcha' => ['Incorrect CAPTCHA answer. Please try again.'], - ], 'Incorrect CAPTCHA answer.'); - } - - $email = $formatted['email'] ?? ''; - $existingUser = $this->user->newQuery()->where('email', $email)->first(); - - if ($existingUser) { - $isVerified = (int) ($existingUser->is_verified ?? 0); - $token = $existingUser->token ?? null; - - if (!empty($token) && $isVerified === 0) { - return $this->respondError( - 'This email address is already registered and is pending activation. Please check your email to activate your account.', - Response::HTTP_CONFLICT - ); - } - - return $this->respondError( - 'The email address you entered is already in use. Please try a different one.', - Response::HTTP_CONFLICT - ); - } - - $roleName = $isParent ? 'parent' : 'guest'; - $role = $this->role->newQuery()->where('name', $roleName)->first(); - if (!$role) { - return $this->respondError('Role not found', Response::HTTP_BAD_REQUEST); - } - - $token = bin2hex(random_bytes(48)); - $userData = [ - 'firstname' => $formatted['firstname'] ?? null, - 'lastname' => $formatted['lastname'] ?? null, - 'gender' => $formatted['gender'] ?? null, - 'email' => $email, - 'cellphone' => $formatted['cellphone'] ?? null, - 'address_street' => $formatted['address_street'] ?? null, - 'apt' => $formatted['apt'] ?? null, - 'city' => $formatted['city'] ?? null, - 'state' => $formatted['state'] ?? null, - 'zip' => $formatted['zip'] ?? null, - 'token' => $token, - 'is_verified' => 0, - 'accept_school_policy' => (int) ($formatted['accept_school_policy'] ?? 0), - 'status' => 'Inactive', - 'school_id' => $this->schoolIdService->generate(), - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - ]; - - DB::beginTransaction(); - try { - $user = $this->user->newQuery()->create($userData); - $firstParentId = $user->id ?? null; - - if (!$firstParentId) { - DB::rollBack(); - return $this->respondError('Registration failed, please try again.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - $this->userRole->newQuery()->create([ - 'user_id' => $firstParentId, - 'role_id' => $role->id, - 'semester' => $this->semester, - 'school_year'=> $this->schoolYear, - 'created_at' => utc_now(), - ]); - - if ($isParent && !$noSecondParentInfo && $this->hasSecondParentData($formatted)) { - $this->parent->newQuery()->create([ - 'secondparent_firstname' => $formatted['second_firstname'] ?? null, - 'secondparent_lastname' => $formatted['second_lastname'] ?? null, - 'secondparent_gender' => $formatted['second_gender'] ?? null, - 'secondparent_email' => $formatted['second_email'] ?? null, - 'secondparent_phone' => $formatted['second_cellphone'] ?? null, - 'firstparent_id' => $firstParentId, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - ]); - } - - DB::commit(); - } catch (\Throwable $e) { - DB::rollBack(); - log_message('error', 'Registration failed: ' . $e->getMessage()); - return $this->respondError('Registration failed, please try again.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - $recipientName = trim(($formatted['firstname'] ?? '') . ' ' . ($formatted['lastname'] ?? '')); - $emailData = [ - 'recipientName' => $recipientName, - 'activationLink'=> url('/user/confirm/' . $token), - 'orgName' => 'Al Rahma Sunday School', - 'contactInfo' => 'alrahma.isgl@gmail.com', - 'logoUrl' => 'https://alrahmaisgl.org/assets/images/alrahma_logo.png', - ]; - - $html = $this->buildEmailBody($emailData, $isParent); - $this->emailService->send($email, 'Email Confirmation', $html, 'general'); - - session()->put('user_email', $email); - session()->forget('captcha_answer'); - - return $this->respondSuccess([ - 'email' => $email, - ], 'Registration successful'); - } - - private function generateCaptchaText(int $length): string - { - $characters = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghjkmnopqrstuvwxyz0123456789'; - $captchaText = ''; - - for ($i = 0; $i < $length; $i++) { - $captchaText .= $characters[random_int(0, strlen($characters) - 1)]; - } - - return $captchaText; - } - - private function formatUserInput(array $post): array - { - $g = static function (string $key) use ($post): string { - return trim((string) ($post[$key] ?? '')); - }; - - $data = [ - 'firstname' => $this->formatName($g('firstname')), - 'lastname' => $this->formatName($g('lastname')), - 'gender' => $g('gender'), - 'email' => $this->formatEmail($g('email')), - 'confirm_email' => $this->formatEmail($g('confirm_email')), - 'cellphone' => $this->phoneFormatter->formatPhoneNumber($g('cellphone')), - 'address_street' => $this->nullIfEmpty($this->formatAddress($g('address_street'))), - 'apt' => $this->nullIfEmpty(strtoupper($g('apt'))), - 'city' => $this->formatName($g('city')), - 'state' => strtoupper($g('state')), - 'zip' => $g('zip'), - 'accept_school_policy' => (int) ($post['accept_school_policy'] ?? 0), - ]; - - $noSecondInfo = !empty($post['no_second_parent_info']); - - $second_firstname = $this->formatName($g('second_firstname')); - $second_lastname = $this->formatName($g('second_lastname')); - $second_gender = $g('second_gender'); - $second_email = $this->formatEmail($g('second_email')); - $second_cellphone = $this->phoneFormatter->formatPhoneNumber($g('second_cellphone')); - - $secondProvided = !$noSecondInfo && ( - $second_firstname !== '' || - $second_lastname !== '' || - $second_gender !== '' || - $second_email !== '' || - preg_replace('/\D/', '', $second_cellphone) !== '' - ); - - if ($secondProvided) { - $data['second_firstname'] = $second_firstname; - $data['second_lastname'] = $second_lastname; - $data['second_gender'] = $second_gender; - $data['second_email'] = $second_email; - $data['second_cellphone'] = $second_cellphone; - } - - return $data; - } - - private function formatName(string $name): string - { - $name = trim($name); - return ucwords(strtolower($name), " -"); - } - - private function formatEmail(?string $email): string - { - return strtolower(trim((string) $email)); - } - - private function formatAddress(?string $address): string - { - $address = trim((string) $address); - if ($address === '') { - return ''; - } - - return ucwords(strtolower($address)); - } - - private function nullIfEmpty(?string $value): ?string - { - if ($value === null) { - return null; - } - - $value = trim((string) $value); - return $value === '' ? null : $value; - } - - private function hasSecondParentData(array $data): bool - { - return !empty($data['second_firstname']) - || !empty($data['second_lastname']) - || !empty($data['second_gender']) - || !empty($data['second_email']) - || (!empty($data['second_cellphone']) && preg_replace('/\D/', '', $data['second_cellphone']) !== ''); - } - - private function buildEmailBody(array $data, bool $isParent): string - { - $audience = $isParent ? 'Parent' : 'Staff Member'; - - return << - - - -Dear {$data['recipientName']},
-Thank you for registering with {$data['orgName']} as a {$audience}. Please confirm your email address by clicking the link below:
- -If the link above does not work, copy and paste this URL into your browser:
-{$data['activationLink']}
-For questions, reach us at {$data['contactInfo']}.
-Thank you,
{$data['orgName']}
A staff time-off request was submitted from the teacher portal.
' - . '| Name | ' . esc($fullName) . ' |
| ' . esc($userEmail) . ' | |
| Role | ' . esc((string) $role) . ' |
| Semester | ' . esc($this->semester) . ' |
| School Year | ' . esc($this->schoolYear) . ' |
| Reason Type | ' . esc($reasonType ?: '-') . ' |
| Reason | ' . esc($reasonText) . ' |
| Dates | ' . esc($dateList ?: '-') . ' |
| Assigned Class(es) | ' . esc($assignedText) . ' |
| Submitted At | ' . esc($submittedAt) . ' |
Click the link below to reset your password:
'; - } - - $this->emailService->send($email, 'Password Reset Request', $html, 'general'); - } - - protected function fetchUserRoles(int $userId): array - { - return $this->userRole->newQuery() - ->select('roles.name') - ->join('roles', 'roles.id', '=', 'user_roles.role_id') - ->where('user_roles.user_id', $userId) - ->pluck('name') - ->toArray(); - } - - protected function buildLoginPayload(array $user, array $roles): array - { - $now = time(); - $exp = $now + 60 * 60 * 24; - - $payload = [ - 'sub' => (int) $user['id'], - 'name' => trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')), - 'roles' => $roles, - 'iat' => $now, - 'exp' => $exp, - ]; - - $token = $this->encodeJwt($payload); - $rolesMap = []; - foreach ($roles as $role) { - $rolesMap[$role] = true; - } - - return [ - 'token' => $token, - 'user' => [ - 'id' => (int) $user['id'], - 'name' => $payload['name'], - 'email' => $user['email'], - 'roles' => (object) $rolesMap, - ], - ]; - } - - protected function encodeJwt(array $payload): string - { - $header = ['alg' => 'HS256', 'typ' => 'JWT']; - $secret = env('JWT_SECRET', 'change-me-in-env'); - - $segments = [ - $this->base64UrlEncode(json_encode($header)), - $this->base64UrlEncode(json_encode($payload)), - ]; - - $signature = hash_hmac('sha256', implode('.', $segments), $secret, true); - $segments[] = $this->base64UrlEncode($signature); - - return implode('.', $segments); - } - - protected function base64UrlEncode(string $data): string - { - return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); - } - - protected function verifyPassword(string $password, string $storedHash): bool - { - $delimiter = str_contains($storedHash, '$') ? '$' : ':'; - $parts = explode($delimiter, $storedHash); - if (count($parts) !== 4) { - return false; - } - - [$algo, $iterations, $salt, $hash] = $parts; - $calc = hash_pbkdf2($algo, $password, $salt, (int) $iterations, strlen($hash)); - return hash_equals($hash, $calc); - } - - protected function hashPassword(string $password): string - { - $algo = 'sha256'; - $iterations = 200000; - $salt = bin2hex(random_bytes(16)); - $hash = hash_pbkdf2($algo, $password, $salt, $iterations, 64); - - return implode(':', [$algo, $iterations, $salt, $hash]); - } - - protected function buildLoginActivityPayload(int $perPage, int $page): array - { - $perPage = max(1, min($perPage, 200)); - $page = max(1, $page); - - $query = $this->loginActivity->newQuery()->orderBy('login_time', 'DESC'); - $total = (int) $query->count(); - $activities = $query->forPage($page, $perPage)->get()->map(fn($row) => (array) $row)->toArray(); - - $normalized = array_map(static function ($activity) { - 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); - - $pageCount = (int) max(1, ceil($total / $perPage)); - - return [ - 'activities' => $normalized, - 'pagination' => [ - 'total' => $total, - 'perPage' => $perPage, - 'currentPage' => $page, - 'pageCount' => $pageCount, - 'hasNext' => $page < $pageCount, - 'hasPrevious' => $page > 1, - ], - ]; - } - - /** - * POST /api/v1/users/select-role - * Select and update user's role in database - */ - public function selectRole(): JsonResponse - { - $user = $this->getCurrentUser(); - if (!$user) { - return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); - } - - $data = $this->payloadData(); - $roleKey = (string) ($data['role'] ?? ''); - - if ($roleKey === '') { - return $this->respondError('Role is required', Response::HTTP_BAD_REQUEST); - } - - log_message('info', 'Processing setRole form submission for user: ' . $user->id); - log_message('info', 'Role selected: ' . $roleKey); - - $route = $this->role->getRouteByNameOrSlug($roleKey); - - if ($route === null) { - log_message('error', 'Invalid or inactive role selected: ' . $roleKey); - return $this->respondError('Invalid role selected.', Response::HTTP_BAD_REQUEST); - } - - try { - // Update the user's role field in the database - $this->user->update($user->id, ['role' => $roleKey]); - - log_message('info', 'Role updated in database for user: ' . $user->id); - log_message('info', 'Redirecting to role dashboard: ' . $route); - - return $this->success([ - 'role' => $roleKey, - 'route' => $route, - ], 'Role selected successfully'); - } catch (\Throwable $e) { - log_message('error', 'Role selection error: ' . $e->getMessage()); - return $this->respondError('Failed to select role', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * DELETE /api/v1/users/roles/{roleId} - * Delete a role and reassign users to Guest role - */ - public function deleteRole(int $roleId): JsonResponse - { - $role = $this->role->find($roleId); - if (!$role) { - return $this->respondError('Role not found', Response::HTTP_NOT_FOUND); - } - - // Fetch the "Guest" role - $guestRole = $this->role->where('name', 'Guest')->first(); - if (!$guestRole) { - return $this->respondError('Guest role not found', Response::HTTP_NOT_FOUND); - } - - DB::beginTransaction(); - try { - // Update users with the deleted role to have the "Guest" role - $this->user->newQuery() - ->where('role_id', $roleId) - ->update([ - 'role_id' => $guestRole['id'], - 'role' => 'Guest', - 'status' => 'Inactive', - ]); - - // Also update user_roles table - $this->userRole->newQuery() - ->where('role_id', $roleId) - ->update(['role_id' => $guestRole['id']]); - - // Delete the role - $this->role->delete($roleId); - - DB::commit(); - - log_message('info', 'Role deleted: ' . $roleId . ', users reassigned to Guest role'); - - return $this->respondSuccess(null, 'Role deleted successfully. Users with this role have been assigned the Guest role.'); - } catch (\Throwable $e) { - DB::rollBack(); - log_message('error', 'Failed to delete role: ' . $e->getMessage()); - return $this->respondError('Failed to delete role', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } -} diff --git a/app/Http/Controllers/Api/WhatsappController.php b/app/Http/Controllers/Api/WhatsappController.php deleted file mode 100755 index 243913a4..00000000 --- a/app/Http/Controllers/Api/WhatsappController.php +++ /dev/null @@ -1,1180 +0,0 @@ -link = model(WhatsappGroupLink::class); - $this->membership = model(WhatsappGroupMembership::class); - $this->config = model(Configuration::class); - $this->classSection = model(ClassSection::class); - $this->student = model(Student::class); - $this->user = model(User::class); - $this->enrollment = model(Enrollment::class); - $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); - $this->semester = (string) ($this->config->getConfig('semester') ?? ''); - } - - public function links() - { - try { - $links = $this->link->newQuery() - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) - ->orderBy('class_section_name', 'ASC') - ->get() - ->getResultArray(); - - return $this->success($links, 'WhatsApp links retrieved successfully'); - } catch (\Throwable $e) { - log_message('error', 'WhatsApp links error: ' . $e->getMessage()); - return $this->respondError('Failed to retrieve WhatsApp links', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function saveLink() - { - $payload = $this->payloadData(); - if (empty($payload)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $rules = [ - 'class_section_id' => 'required|integer', - 'invite_link' => 'required|string', - 'active' => 'nullable|in:0,1', - ]; - - $errors = $this->validateRequest($payload, $rules); - if (!empty($errors)) { - return $this->respondValidationError($errors); - } - - $sectionId = (int) $payload['class_section_id']; - $sectionName = trim((string) ($payload['class_section_name'] ?? '')); - $inviteLink = trim((string) $payload['invite_link']); - $active = isset($payload['active']) ? (int) $payload['active'] : 1; - - if ($inviteLink === '') { - return $this->error('Invite link is required', Response::HTTP_UNPROCESSABLE_ENTITY); - } - - try { - $existing = $this->link->where([ - 'class_section_id' => $sectionId, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - ])->first(); - - // Resolve display name if not provided - if ($sectionName === '') { - $row = $this->link->getLinkForSection($sectionId, $this->schoolYear, $this->semester); - $sectionName = $row['class_section_name'] ?? ''; - if ($sectionName === '') { - $sectionName = $this->classSection->getClassSectionNameBySectionId($sectionId) ?? ('Section ' . $sectionId); - } - } - - $linkData = [ - 'class_section_id' => $sectionId, - 'class_section_name' => $sectionName, - 'invite_link' => $inviteLink, - 'active' => $active ? 1 : 0, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - ]; - - if ($existing) { - $linkId = (int) $existing['id']; - $this->link->update($linkId, $linkData); - } else { - $linkId = (int) $this->link->insert($linkData); - } - - if (!$linkId) { - return $this->respondError('Failed to save WhatsApp link', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - $link = $this->link->find($linkId); - return $this->success($link, 'WhatsApp link saved successfully'); - } catch (\Throwable $e) { - log_message('error', 'WhatsApp link save error: ' . $e->getMessage()); - return $this->respondError('Failed to save WhatsApp link', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - public function updateMembership() - { - $payload = $this->payloadData(); - if (empty($payload)) { - return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); - } - - $sid = (int) ($payload['class_section_id'] ?? 0); - $primaryId = (int) ($payload['primary_id'] ?? 0); - $secondId = (int) ($payload['second_id'] ?? 0); - - $isChecked = static function ($v): bool { - if (is_array($v)) { - foreach ($v as $one) { - if ($one === '1' || strtolower((string) $one) === 'on') return true; - } - return false; - } - return ($v === '1' || strtolower((string) $v) === 'on'); - }; - - $primaryValueRaw = $payload['primary_member'] ?? null; - $secondValueRaw = $payload['second_member'] ?? null; - - if ($sid <= 0) { - return $this->respondError('Invalid class section.', Response::HTTP_BAD_REQUEST); - } - - $schoolYear = trim((string) ($payload['school_year'] ?? $this->schoolYear ?? '')); - $semester = trim((string) ($payload['semester'] ?? $this->semester ?? '')); - - $verifiedBy = $this->getCurrentUserId(); - - try { - $saved = []; - - if ($primaryId > 0) { - $isMember = $isChecked($primaryValueRaw); - $id = $this->membership->upsertMembership($sid, $schoolYear, $semester, 'primary', $primaryId, $isMember, $verifiedBy); - if ($id > 0) { - $saved[] = 'primary=' . $primaryId . ':' . ($isMember ? '1' : '0'); - } - } - - if ($secondId > 0) { - $isMember = $isChecked($secondValueRaw); - $id = $this->membership->upsertMembership($sid, $schoolYear, $semester, 'second', $secondId, $isMember, $verifiedBy); - if ($id > 0) { - $saved[] = 'second=' . $secondId . ':' . ($isMember ? '1' : '0'); - } - } - - if (empty($saved)) { - return $this->respondError('Nothing to save (missing parent IDs).', Response::HTTP_BAD_REQUEST); - } - - return $this->success([ - 'saved' => $saved, - 'term' => ['school_year' => $schoolYear, 'semester' => $semester], - ], 'Membership updated successfully'); - } catch (\Throwable $e) { - Log::error('Failed to update WhatsApp membership: ' . $e->getMessage()); - return $this->respondError('Failed to save membership.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * GET /api/v1/whatsapp/index - * Get initial data for WhatsApp management page - */ - public function index() - { - [$parents, $sections] = $this->prepareIndexData(); - - // Build links map - $links = $this->link->getAllForTerm($this->schoolYear, $this->semester); - $linksBySection = []; - foreach ($links as $l) { - $linksBySection[(int) $l['class_section_id']] = $l; - } - - return $this->success([ - 'sections' => $sections, - 'parents' => $parents, - 'linksBySection' => $linksBySection, - 'schoolYear' => $this->schoolYear, - 'semester' => $this->semester, - ], 'WhatsApp management data retrieved successfully'); - } - - /** - * POST /api/v1/whatsapp/send-invites - * Send invites in three modes: 'parents' | 'class' | 'all' - */ - public function sendInvites() - { - $data = $this->payloadData(); - $mode = trim((string) ($data['mode'] ?? '')); - $parentIdsPicked = array_map('intval', (array) ($data['parent_ids'] ?? [])); - $rawClassId = $data['class_section_id'] ?? null; - $classSectionId = ($rawClassId === '' || $rawClassId === null) ? 0 : (int) $rawClassId; - - if (!in_array($mode, ['parents', 'class', 'all'], true)) { - return $this->respondError('Invalid mode.', Response::HTTP_BAD_REQUEST); - } - - if ($mode === 'parents' && empty(array_filter($parentIdsPicked))) { - return $this->respondError('Please select at least one parent.', Response::HTTP_BAD_REQUEST); - } - - if ($mode === 'class' && $classSectionId <= 0) { - return $this->respondError('Please select a class/section.', Response::HTTP_BAD_REQUEST); - } - - // Load all links for the term once - $links = $this->link->getAllForTerm($this->schoolYear, $this->semester); - $linkBySection = []; - foreach ($links as $l) { - $cid = (int) ($l['class_section_id'] ?? 0); - if ($cid) $linkBySection[$cid] = $l; - } - - // Build per-family bundles - $payloads = []; - - if ($mode === 'parents') { - foreach ($parentIdsPicked as $pickedId) { - if ($pickedId <= 0) continue; - $bundle = $this->bundleForSingleParent($pickedId, $linkBySection); - if ($bundle && !empty($bundle['emails']) && !empty($bundle['links'])) { - $payloads[] = $bundle; - } - } - } elseif ($mode === 'class') { - $bundles = $this->bundleForSingleClass($classSectionId, $linkBySection); - foreach ($bundles as $b) { - if (!empty($b['emails']) && !empty($b['links'])) { - $payloads[] = $b; - } - } - } else { // 'all' - $bundles = $this->bundlesForAllParentsAllClasses($linkBySection); - foreach ($bundles as $b) { - if (!empty($b['emails']) && !empty($b['links'])) { - $payloads[] = $b; - } - } - } - - if (empty($payloads)) { - return $this->respondError('No recipients found (missing links or emails).', Response::HTTP_BAD_REQUEST); - } - - // Consolidate per-student bundles into one bundle per parent - $normEmailsList = static function (array $list): array { - $out = []; - foreach ($list as $e) { - $e = strtolower(trim((string) $e)); - if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) { - $out[$e] = true; - } - } - return array_keys($out); - }; - - $mergeUniqueList = static function (array $a, array $b): array { - $map = []; - foreach (array_merge($a, $b) as $v) { - $k = is_array($v) ? md5(json_encode($v)) : (string) $v; - $map[$k] = $v; - } - return array_values($map); - }; - - $mergeSections = static function (array $A, array $B): array { - $byKey = []; - $put = static function (&$byKey, $row) { - if (is_array($row)) { - $id = $row['class_section_id'] ?? null; - $name = $row['class_section_name'] ?? null; - $key = $id !== null ? ('id:' . (int) $id) : ('name:' . (string) $name); - $byKey[$key] = $row; - } else { - $byKey['name:' . (string) $row] = $row; - } - }; - foreach ($A as $r) $put($byKey, $r); - foreach ($B as $r) $put($byKey, $r); - return array_values($byKey); - }; - - $mergeLinks = static function (array $A, array $B): array { - $index = []; - $add = static function (&$index, $item) { - if (is_array($item)) { - $key = (isset($item['class_section_id']) && $item['class_section_id'] !== null) - ? 'id:' . (int) $item['class_section_id'] - : ('url:' . (string) ($item['invite_link'] ?? json_encode($item))); - $index[$key] = $item; - } else { - $index['url:' . (string) $item] = $item; - } - }; - foreach ($A as $it) $add($index, $it); - foreach ($B as $it) $add($index, $it); - return array_values($index); - }; - - $grouped = []; - foreach ($payloads as $b) { - $parent = $b['parent'] ?? []; - $pid = (int) ($parent['id'] ?? 0); - $primary = $normEmailsList((array) ($b['emails'] ?? [])); - $groupKey = $pid > 0 ? ('pid:' . $pid) : ('mail:' . implode(',', $primary)); - - if (!isset($grouped[$groupKey])) { - $grouped[$groupKey] = [ - 'parent' => $parent, - 'emails' => $primary, - 'sections' => (array) ($b['sections'] ?? []), - 'links' => (array) ($b['links'] ?? []), - 'teachers' => (array) ($b['teachers'] ?? []), - 'items' => (array) ($b['items'] ?? []), - ]; - } else { - $grouped[$groupKey]['parent'] = !empty($parent) ? $parent : $grouped[$groupKey]['parent']; - $grouped[$groupKey]['emails'] = $normEmailsList(array_merge($grouped[$groupKey]['emails'], $primary)); - $grouped[$groupKey]['sections'] = $mergeSections($grouped[$groupKey]['sections'], (array) ($b['sections'] ?? [])); - $grouped[$groupKey]['links'] = $mergeLinks($grouped[$groupKey]['links'], (array) ($b['links'] ?? [])); - $grouped[$groupKey]['teachers'] = $mergeUniqueList($grouped[$groupKey]['teachers'], (array) ($b['teachers'] ?? [])); - $grouped[$groupKey]['items'] = $mergeLinks($grouped[$groupKey]['items'], (array) ($b['items'] ?? [])); - } - } - - $payloads = array_values($grouped); - - $norm = static function (array $list): array { - $out = []; - foreach ($list as $e) { - $e = strtolower(trim((string) $e)); - if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) { - $out[$e] = true; - } - } - return array_keys($out); - }; - - $getSecondaryEmails = function (array $bundle) use ($norm) { - $collected = []; - if (!empty($bundle['emails_secondary'])) { - $collected = array_merge($collected, (array) $bundle['emails_secondary']); - } - if (!empty($bundle['secondary_parents'])) { - foreach ((array) $bundle['secondary_parents'] as $sp) { - if (!empty($sp['email'])) $collected[] = $sp['email']; - } - } - $p = $bundle['parent'] ?? []; - foreach (['secondary_email', 'secondparent_email', 'sec_email', 'email_secondary'] as $k) { - if (!empty($p[$k])) $collected[] = $p[$k]; - } - if (!empty($p['secondparent']['email'])) $collected[] = $p['secondparent']['email']; - - try { - $primaryId = (int) ($p['id'] ?? 0); - if ($primaryId > 0) { - $row = DB::table('parents') - ->select('secondparent_email') - ->where('firstparent_id', $primaryId) - ->orderBy('updated_at', 'DESC') - ->first(); - if ($row && !empty($row->secondparent_email)) { - $collected[] = $row->secondparent_email; - } - } - } catch (\Throwable $e) { - Log::error('Secondary email DB lookup failed: ' . $e->getMessage()); - } - - return $norm($collected); - }; - - // Trigger one event per family - $triggered = 0; - foreach ($payloads as $b) { - try { - $primaryEmails = $norm((array) ($b['emails'] ?? [])); - $secondaryEmails = $getSecondaryEmails($b); - - $to = $primaryEmails; - $cc = []; - - if (!empty($primaryEmails) && !empty($secondaryEmails)) { - $cc = array_values(array_diff($secondaryEmails, $primaryEmails)); - } elseif (empty($primaryEmails) && !empty($secondaryEmails)) { - $to = $secondaryEmails; - } - - if (empty($to)) { - Log::warning('Skipping whatsapp_invites.send: no recipient email(s) for parent_id ' . ($b['parent']['id'] ?? null)); - continue; - } - - Event::dispatch('whatsapp_invites.send', [ - 'parent' => $b['parent'] ?? null, - 'sections' => $b['sections'] ?? [], - 'schoolYear' => $this->schoolYear, - 'semester' => $this->semester, - 'links' => $b['links'] ?? [], - 'teachers' => $b['teachers'] ?? [], - 'to_emails' => $to, - 'cc_emails' => $cc, - ]); - - $triggered++; - } catch (\Throwable $e) { - Log::error('whatsapp_invites.send failed: ' . $e->getMessage()); - } - } - - return $this->success([ - 'triggered' => $triggered, - 'total' => count($payloads), - ], "Invite processing started for {$triggered} recipient group(s)."); - } - - /** - * GET /api/v1/whatsapp/parent-contacts - * Get parent contacts list - */ - public function parentContacts() - { - $schoolYear = $this->schoolYear ?? (string) ($this->request->getGet('school_year') ?? ''); - $semester = $this->semester ?? (string) ($this->request->getGet('semester') ?? ''); - - $b = DB::table('parents sp') - ->select([ - 'sp.id as parents_row_id', - 'sp.firstparent_id as firstparent_id', - 'sp.secondparent_firstname as sp_firstname', - 'sp.secondparent_lastname as sp_lastname', - 'sp.secondparent_email as sp_email', - 'sp.secondparent_phone as sp_phone', - 'sp.school_year as sp_school_year', - 'sp.semester as sp_semester', - 'u.id as u_id', - 'u.firstname as u_firstname', - 'u.lastname as u_lastname', - 'u.email as u_email', - 'u.cellphone as u_phone', - 'u.school_year as u_school_year', - 'u.semester as u_semester', - ]) - ->leftJoin('users u', 'u.id', '=', 'sp.firstparent_id'); - - if ($schoolYear !== '') { - $b->where('sp.school_year', $schoolYear); - } - if ($semester !== '') { - $b->where('sp.semester', $semester); - } - - $rows = $b->get(); - - $contacts = []; - foreach ($rows as $r) { - if (!empty($r->u_id)) { - $contacts[] = [ - 'firstname' => (string) ($r->u_firstname ?? ''), - 'lastname' => (string) ($r->u_lastname ?? ''), - 'email' => (string) ($r->u_email ?? ''), - 'phone' => (string) ($r->u_phone ?? ''), - 'type' => 'Primary Parent', - ]; - } - - $hasSecond = !empty($r->sp_firstname) || !empty($r->sp_lastname) || !empty($r->sp_email) || !empty($r->sp_phone); - if ($hasSecond) { - $contacts[] = [ - 'firstname' => (string) ($r->sp_firstname ?? ''), - 'lastname' => (string) ($r->sp_lastname ?? ''), - 'email' => (string) ($r->sp_email ?? ''), - 'phone' => (string) ($r->sp_phone ?? ''), - 'type' => 'Second Parent', - ]; - } - } - - $contacts = array_values(array_filter($contacts, function ($c) { - $name = trim(($c['firstname'] ?? '') . ($c['lastname'] ?? '')); - $contact = trim(($c['phone'] ?? '') . ($c['email'] ?? '')); - return $name !== '' || $contact !== ''; - })); - - usort($contacts, function ($a, $b) { - $la = strtolower($a['lastname'] ?? ''); - $lb = strtolower($b['lastname'] ?? ''); - if ($la !== $lb) return $la <=> $lb; - $fa = strtolower($a['firstname'] ?? ''); - $fb = strtolower($b['firstname'] ?? ''); - if ($fa !== $fb) return $fa <=> $fb; - $ta = ($a['type'] ?? '') === 'Primary Parent' ? 0 : 1; - $tb = ($b['type'] ?? '') === 'Primary Parent' ? 0 : 1; - return $ta <=> $tb; - }); - - return $this->success([ - 'contacts' => $contacts, - 'schoolYear' => $schoolYear, - 'semester' => $semester, - ], 'Parent contacts retrieved successfully'); - } - - /** - * GET /api/v1/whatsapp/parent-contacts-by-class - * Get parent contacts grouped by class section - */ - public function parentContactsByClass() - { - $schoolYear = $this->schoolYear ?? (string) ($this->request->getGet('school_year') ?? ''); - $semester = $this->semester ?? (string) ($this->request->getGet('semester') ?? ''); - - // Get distinct class_section_id from student_class - $csb = DB::table('student_class sc') - ->select('sc.class_section_id', DB::raw('MAX(sc.semester) as semester'), DB::raw('MAX(sc.school_year) as school_year')); - - if ($schoolYear !== '') $csb->where('sc.school_year', $schoolYear); - if ($semester !== '') $csb->where('sc.semester', $semester); - - $sections = $csb->groupBy('sc.class_section_id') - ->orderBy('sc.class_section_id', 'ASC') - ->get(); - - $sectionIds = array_values(array_filter(array_unique($sections->pluck('class_section_id')->map(fn($id) => (int) $id)->toArray()))); - - // Fetch names from classSection - $namesById = []; - if (!empty($sectionIds)) { - $nb = DB::table('classSection') - ->select('class_section_id', 'class_section_name') - ->whereIn('class_section_id', $sectionIds) - ->get(); - - foreach ($nb as $row) { - $namesById[(int) $row->class_section_id] = (string) ($row->class_section_name ?? ''); - } - } - - $classSections = []; - foreach ($sections as $s) { - $sid = (int) ($s->class_section_id ?? 0); - if (!$sid) continue; - - $classSections[$sid] = [ - 'class_section_id' => $sid, - 'class_section_name' => $namesById[$sid] ?? ('Section ' . $sid), - 'semester' => (string) ($s->semester ?? ''), - 'school_year' => (string) ($s->school_year ?? ''), - 'description' => '', - 'main_teachers' => [], - 'teacher_assistants' => [], - 'parents' => [], - ]; - } - - // Parents per class - $pb = DB::table('student_class sc') - ->select([ - 'sc.class_section_id', - 'u.id as primary_id', - 'u.firstname as primary_firstname', - 'u.lastname as primary_lastname', - 'u.email as primary_email', - 'u.cellphone as primary_phone', - 'sp.id as second_id', - 'sp.secondparent_firstname as second_firstname', - 'sp.secondparent_lastname as second_lastname', - 'sp.secondparent_email as second_email', - 'sp.secondparent_phone as second_phone', - ]) - ->join('students s', 's.id', '=', 'sc.student_id') - ->join('users u', 'u.id', '=', 's.parent_id') - ->leftJoin('parents sp', function ($join) { - $join->on('sp.firstparent_id', '=', 'u.id') - ->whereColumn('sp.school_year', 'sc.school_year') - ->where(function ($q) { - $q->whereColumn('sp.semester', 'sc.semester') - ->orWhereNull('sp.semester') - ->orWhere('sp.semester', ''); - }); - }); - - if ($schoolYear !== '') $pb->where('sc.school_year', $schoolYear); - if ($semester !== '') $pb->where('sc.semester', $semester); - - $rows = $pb->orderBy('sc.class_section_id', 'ASC') - ->orderBy('u.lastname', 'ASC') - ->orderBy('u.firstname', 'ASC') - ->get(); - - $seen = []; - foreach ($rows as $r) { - $sid = (int) ($r->class_section_id ?? 0); - $pid = (int) ($r->primary_id ?? 0); - if (!$sid || !$pid || !isset($classSections[$sid])) continue; - - $key = $sid . ':' . $pid; - if (isset($seen[$key])) continue; - $seen[$key] = true; - - $classSections[$sid]['parents'][] = [ - 'class_section_id' => $sid, - 'primary_id' => $pid, - 'primary_name' => trim(($r->primary_lastname ?? '') . ', ' . ($r->primary_firstname ?? '')), - 'primary_phone' => (string) ($r->primary_phone ?? ''), - 'primary_email' => (string) ($r->primary_email ?? ''), - 'second_id' => (int) ($r->second_id ?? 0), - 'second_name' => trim(($r->second_lastname ?? '') . ', ' . ($r->second_firstname ?? '')), - 'second_phone' => (string) ($r->second_phone ?? ''), - 'second_email' => (string) ($r->second_email ?? ''), - 'primary_member' => null, - 'second_member' => null, - ]; - } - - // Annotate membership flags - try { - $sectionIds = array_keys($classSections); - $m = $this->membership->getBySectionsAndTerm($sectionIds, $schoolYear, $semester); - foreach ($classSections as $sid => &$sec) { - foreach ($sec['parents'] as &$p) { - $pk1 = sprintf('%d:primary:%d', (int) $p['class_section_id'], (int) $p['primary_id']); - if (isset($m[$pk1])) { - $p['primary_member'] = (int) ($m[$pk1]['is_member'] ?? 0); - } - $secondId = (int) ($p['second_id'] ?? 0); - if ($secondId > 0) { - $pk2 = sprintf('%d:second:%d', (int) $p['class_section_id'], $secondId); - if (isset($m[$pk2])) { - $p['second_member'] = (int) ($m[$pk2]['is_member'] ?? 0); - } - } - } - unset($p); - } - unset($sec); - } catch (\Throwable $e) { - // ignore membership annotation errors - } - - // Teachers - try { - $tb = DB::table('teacher_class tc') - ->select('tc.class_section_id', 'tc.position', 'u.firstname', 'u.lastname') - ->leftJoin('users u', 'u.id', '=', 'tc.teacher_id'); - - if ($schoolYear !== '') $tb->where('tc.school_year', $schoolYear); - if ($semester !== '') $tb->where('tc.semester', $semester); - - $teachers = $tb->get(); - - foreach ($teachers as $t) { - $sid = (int) ($t->class_section_id ?? 0); - if (!isset($classSections[$sid])) continue; - - $name = trim(($t->lastname ?? '') . ', ' . ($t->firstname ?? '')); - $pos = strtolower((string) ($t->position ?? 'main')); - - if ($pos === 'ta' || $pos === 'assistant') { - $classSections[$sid]['teacher_assistants'][] = $name; - } else { - $classSections[$sid]['main_teachers'][] = $name; - } - } - } catch (\Throwable $e) { - // ignore if table not present - } - - foreach ($classSections as &$sec) { - $sec['main_teachers'] = array_values(array_filter($sec['main_teachers'] ?? [])); - $sec['teacher_assistants'] = array_values(array_filter($sec['teacher_assistants'] ?? [])); - $sec['parents'] = array_values($sec['parents'] ?? []); - } - unset($sec); - - return $this->success([ - 'classSections' => array_values($classSections), - ], 'Parent contacts by class retrieved successfully'); - } - - /** - * Build initial lists for the page - */ - private function prepareIndexData(): array - { - [$yearAliases, $normalizedYear, $startYear] = $this->syAliases($this->schoolYear); - $semAliases = $this->semAliases($this->semester); - - // Sections list - $secQ = DB::table('student_class sc') - ->select(DB::raw('DISTINCT sc.class_section_id')); - - $secQ->where(function ($q) use ($yearAliases, $normalizedYear, $startYear) { - $q->whereIn('sc.school_year', $yearAliases) - ->orWhereRaw("REPLACE(REPLACE(sc.school_year,'/','-'),'–','-') = ?", [$normalizedYear]) - ->orWhere('sc.school_year', (string) $startYear); - }); - - if (!empty($semAliases)) { - $secQ->where(function ($q) use ($semAliases) { - foreach ($semAliases as $i => $s) { - if ($i === 0) { - $q->whereRaw('LOWER(sc.semester) = ?', [strtolower($s)]); - } else { - $q->orWhereRaw('LOWER(sc.semester) = ?', [strtolower($s)]); - } - } - $q->orWhere('sc.semester', $this->semester); - }); - } else { - $secQ->where('sc.semester', $this->semester); - } - - $secRows = $secQ->orderBy('sc.class_section_id', 'ASC')->get(); - - // Fallback to enrollments - if ($secRows->isEmpty()) { - $secQ = DB::table('enrollments e') - ->select(DB::raw('DISTINCT e.class_section_id')); - - $secQ->where(function ($q) use ($yearAliases, $normalizedYear, $startYear) { - $q->whereIn('e.school_year', $yearAliases) - ->orWhereRaw("REPLACE(REPLACE(e.school_year,'/','-'),'–','-') = ?", [$normalizedYear]) - ->orWhere('e.school_year', (string) $startYear); - }); - - if (!empty($semAliases)) { - $secQ->where(function ($q) use ($semAliases) { - foreach ($semAliases as $i => $s) { - if ($i === 0) { - $q->whereRaw('LOWER(e.semester) = ?', [strtolower($s)]); - } else { - $q->orWhereRaw('LOWER(e.semester) = ?', [strtolower($s)]); - } - } - $q->orWhere('e.semester', $this->semester); - }); - } else { - $secQ->where('e.semester', $this->semester); - } - - $secRows = $secQ->orderBy('e.class_section_id', 'ASC')->get(); - } - - $sections = []; - foreach ($secRows as $r) { - $cid = (int) ($r->class_section_id ?? 0); - if (!$cid) continue; - - $linkRow = $this->link->getLinkForSection($cid, $this->schoolYear, $this->semester); - $name = $this->classSection->getClassSectionNameBySectionId($cid) ?? ('Section ' . $cid); - - $sections[] = [ - 'class_section_id' => $cid, - 'class_section_name' => $name, - ]; - } - - // Parents list - $pidQ = DB::table('student_class sc') - ->select(DB::raw('DISTINCT s.parent_id')) - ->leftJoin('students s', 's.id', '=', 'sc.student_id'); - - $pidQ->where(function ($q) use ($yearAliases, $normalizedYear, $startYear) { - $q->whereIn('sc.school_year', $yearAliases) - ->orWhereRaw("REPLACE(REPLACE(sc.school_year,'/','-'),'–','-') = ?", [$normalizedYear]) - ->orWhere('sc.school_year', (string) $startYear); - }); - - if (!empty($semAliases)) { - $pidQ->where(function ($q) use ($semAliases) { - foreach ($semAliases as $i => $s) { - if ($i === 0) { - $q->whereRaw('LOWER(sc.semester) = ?', [strtolower($s)]); - } else { - $q->orWhereRaw('LOWER(sc.semester) = ?', [strtolower($s)]); - } - } - $q->orWhere('sc.semester', $this->semester); - }); - } else { - $pidQ->where('sc.semester', $this->semester); - } - - $pidQ->whereNotNull('s.parent_id'); - - $primaryIdRows = $pidQ->get(); - - // Fallback to enrollments - if ($primaryIdRows->isEmpty()) { - $pidQ = DB::table('enrollments e') - ->select(DB::raw('DISTINCT s.parent_id')) - ->leftJoin('students s', 's.id', '=', 'e.student_id'); - - $pidQ->where(function ($q) use ($yearAliases, $normalizedYear, $startYear) { - $q->whereIn('e.school_year', $yearAliases) - ->orWhereRaw("REPLACE(REPLACE(e.school_year,'/','-'),'–','-') = ?", [$normalizedYear]) - ->orWhere('e.school_year', (string) $startYear); - }); - - if (!empty($semAliases)) { - $pidQ->where(function ($q) use ($semAliases) { - foreach ($semAliases as $i => $s) { - if ($i === 0) { - $q->whereRaw('LOWER(e.semester) = ?', [strtolower($s)]); - } else { - $q->orWhereRaw('LOWER(e.semester) = ?', [strtolower($s)]); - } - } - $q->orWhere('e.semester', $this->semester); - }); - } else { - $pidQ->where('e.semester', $this->semester); - } - - $pidQ->whereNotNull('s.parent_id'); - $primaryIdRows = $pidQ->get(); - } - - $primaryIds = []; - foreach ($primaryIdRows as $r) { - $pid = (int) ($r->parent_id ?? 0); - if ($pid) $primaryIds[$pid] = true; - } - $primaryIds = array_keys($primaryIds); - - $primaryProfiles = []; - if (!empty($primaryIds)) { - $primRows = DB::table('users') - ->select('id', 'firstname', 'lastname', 'email') - ->whereIn('id', $primaryIds) - ->get(); - - foreach ($primRows as $u) { - $uid = (int) ($u->id ?? 0); - if (!$uid) continue; - - $primaryProfiles[$uid] = [ - 'id' => $uid, - 'firstname' => (string) ($u->firstname ?? ''), - 'lastname' => (string) ($u->lastname ?? ''), - 'email' => (string) ($u->email ?? ''), - 'source' => 'users', - ]; - } - } - - // Second parents - $secondaryProfiles = []; - if (!empty($primaryIds)) { - $secRows = DB::table('parents') - ->select('id', 'firstparent_id', 'secondparent_firstname', 'secondparent_lastname', 'secondparent_email', 'secondparent_phone') - ->whereIn('firstparent_id', $primaryIds) - ->orderBy('updated_at', 'DESC') - ->get(); - - foreach ($secRows as $r) { - $primaryId = (int) $r->firstparent_id; - $secondaryProfiles[$primaryId] = [ - 'id' => (int) $r->id, - 'firstname' => (string) $r->secondparent_firstname, - 'lastname' => (string) $r->secondparent_lastname, - 'email' => (string) $r->secondparent_email, - 'phone' => (string) $r->secondparent_phone, - 'source' => 'parents', - ]; - } - } - - // Merge + sort - $parents = array_values($primaryProfiles); - foreach ($secondaryProfiles as $sp) $parents[] = $sp; - - usort($parents, function ($a, $b) { - $ln = strcasecmp($a['lastname'] ?? '', $b['lastname'] ?? ''); - if ($ln !== 0) return $ln; - $fn = strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? ''); - if ($fn !== 0) return $fn; - $sa = ($a['source'] ?? '') === 'users' ? 0 : 1; - $sb = ($b['source'] ?? '') === 'users' ? 0 : 1; - return $sa <=> $sb; - }); - - return [$parents, $sections]; - } - - /** - * School-year aliases - */ - private function syAliases(string $schoolYear): array - { - $trim = trim($schoolYear); - if (preg_match('/\b(20\d{2})\b/', $trim, $m)) { - $start = (int) $m[1]; - } else { - $start = (int) date('Y'); - } - - $end = $start + 1; - $normalized = "{$start}-{$end}"; - $aliases = [ - $normalized, - "{$start}/{$end}", - "{$start}–{$end}", - sprintf('%d-%02d', $start, $end % 100), - (string) $start, - ]; - - return [array_values(array_unique($aliases)), $normalized, $start]; - } - - /** - * Semester aliases - */ - private function semAliases(string $semester): array - { - $s = strtolower(trim($semester)); - if ($s === 'fall') return ['fall', 'sem1', 'semester 1', '1', 'f']; - if ($s === 'spring') return ['spring', 'sem2', 'semester 2', '2', 's']; - if ($s === 'summer') return ['summer', 'sem3', '3']; - return [$s]; - } - - /** - * Bundle for single parent - */ - private function bundleForSingleParent(int $pickedId, array $linkBySection): ?array - { - $isPrimary = (bool) DB::table('users')->where('id', $pickedId)->first(); - $primaryId = $pickedId; - - if (!$isPrimary) { - $map = DB::table('parents') - ->select('firstparent_id', 'id') - ->where('id', $pickedId) - ->first(); - - if (!$map || empty($map->firstparent_id)) { - return null; - } - $primaryId = (int) $map->firstparent_id; - } - - $p = DB::table('users') - ->select('id', 'firstname', 'lastname', 'email') - ->where('id', $primaryId) - ->first(); - - if (!$p) return null; - - $sec = DB::table('parents') - ->select('id', 'secondparent_firstname', 'secondparent_lastname', 'secondparent_email') - ->where('firstparent_id', $primaryId) - ->where('id', '>', 0) - ->first(); - - // Sections for this primary - $secRows = DB::table('student_class sc') - ->select(DB::raw('DISTINCT sc.class_section_id')) - ->leftJoin('students s', 's.id', '=', 'sc.student_id') - ->where('sc.school_year', $this->schoolYear) - ->where('sc.semester', $this->semester) - ->where('s.parent_id', $primaryId) - ->get(); - - $sections = []; - $linksSet = []; - - foreach ($secRows as $r) { - $cid = (int) ($r->class_section_id ?? 0); - if (!$cid) continue; - - $link = (string) ($linkBySection[$cid]['invite_link'] ?? ''); - if ($link === '') continue; - - $sections[] = [ - 'class_section_id' => $cid, - 'class_section_name' => (string) ($linkBySection[$cid]['class_section_name'] ?? ('Section ' . $cid)), - 'invite_link' => $link, - ]; - - $linksSet[$link] = true; - } - - if (empty($sections)) return null; - - $emailsSet = []; - if (!empty($p->email)) $emailsSet[$p->email] = true; - if ($sec && !empty($sec->secondparent_email)) $emailsSet[$sec->secondparent_email] = true; - - if (empty($emailsSet)) return null; - - return [ - 'parent' => [ - 'id' => (int) $p->id, - 'firstname' => (string) ($p->firstname ?? ''), - 'lastname' => (string) ($p->lastname ?? ''), - 'email' => (string) ($p->email ?? ''), - 'source' => 'users', - ], - 'secondary_parents' => $sec && !empty($sec->id) ? [[ - 'id' => (int) $sec->id, - 'firstname' => (string) ($sec->secondparent_firstname ?? ''), - 'lastname' => (string) ($sec->secondparent_lastname ?? ''), - 'email' => (string) ($sec->secondparent_email ?? ''), - 'source' => 'parents', - ]] : [], - 'sections' => $sections, - 'links' => array_keys($linksSet), - 'emails' => array_keys($emailsSet), - ]; - } - - /** - * Bundle for single class - */ - private function bundleForSingleClass(int $classSectionId, array $linkBySection): array - { - $link = (string) ($linkBySection[$classSectionId]['invite_link'] ?? ''); - if ($link === '') return []; - - // Students in this class - $stuRows = DB::table('student_class') - ->select('student_id') - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) - ->where('class_section_id', $classSectionId) - ->get(); - - if ($stuRows->isEmpty()) return []; - - $studentIds = array_values(array_unique($stuRows->pluck('student_id')->map(fn($id) => (int) $id)->toArray())); - - // Map students -> primary parent id - $pRows = DB::table('students') - ->select('id', 'parent_id') - ->whereIn('id', $studentIds) - ->get(); - - $parentIds = []; - foreach ($pRows as $r) { - $pid = (int) ($r->parent_id ?? 0); - if ($pid) $parentIds[$pid] = true; - } - $parentIds = array_keys($parentIds); - - if (empty($parentIds)) return []; - - // Load primary parent profiles - $primRows = DB::table('users') - ->select('id', 'firstname', 'lastname', 'email') - ->whereIn('id', $parentIds) - ->get(); - - $profiles = []; - foreach ($primRows as $u) { - $uid = (int) $u->id; - $profiles[$uid] = [ - 'id' => $uid, - 'firstname' => (string) ($u->firstname ?? ''), - 'lastname' => (string) ($u->lastname ?? ''), - 'email' => (string) ($u->email ?? ''), - 'source' => 'users', - ]; - } - - // Second-parents - $secRows = DB::table('parents') - ->select('firstparent_id', 'id', 'secondparent_firstname', 'secondparent_lastname', 'secondparent_email') - ->where('id', '>', 0) - ->whereIn('firstparent_id', $parentIds) - ->get(); - - $secByPrimary = []; - foreach ($secRows as $r) { - $fp = (int) $r->firstparent_id; - $secByPrimary[$fp] = [ - 'id' => (int) ($r->id ?? 0), - 'firstname' => (string) ($r->secondparent_firstname ?? ''), - 'lastname' => (string) ($r->secondparent_lastname ?? ''), - 'email' => (string) ($r->secondparent_email ?? ''), - 'source' => 'parents', - ]; - } - - // One bundle per primary parent - $bundles = []; - foreach ($parentIds as $pid) { - $profile = $profiles[$pid] ?? null; - if (!$profile) continue; - - $emails = []; - if (!empty($profile['email'])) $emails[$profile['email']] = true; - if (!empty($secByPrimary[$pid]['email'])) $emails[$secByPrimary[$pid]['email']] = true; - - if (empty($emails)) continue; - - $bundles[] = [ - 'parent' => $profile, - 'secondary_parents' => !empty($secByPrimary[$pid]['id']) ? [$secByPrimary[$pid]] : [], - 'sections' => [[ - 'class_section_id' => $classSectionId, - 'class_section_name' => (string) ($linkBySection[$classSectionId]['class_section_name'] ?? ('Section ' . $classSectionId)), - 'invite_link' => $link, - ]], - 'links' => [$link], - 'emails' => array_keys($emails), - ]; - } - - return $bundles; - } - - /** - * Bundles for all parents all classes - */ - private function bundlesForAllParentsAllClasses(array $linkBySection): array - { - $secRows = DB::table('student_class') - ->select(DB::raw('DISTINCT class_section_id')) - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) - ->orderBy('class_section_id', 'ASC') - ->get(); - - $bundles = []; - foreach ($secRows as $r) { - $cid = (int) ($r->class_section_id ?? 0); - if (!$cid) continue; - - $classBundles = $this->bundleForSingleClass($cid, $linkBySection); - foreach ($classBundles as $b) { - $bundles[] = $b; - } - } - - return $bundles; - } -} - diff --git a/app/Http/Controllers/AssignmentController.php b/app/Http/Controllers/AssignmentController.php deleted file mode 100644 index 4cabbb2f..00000000 --- a/app/Http/Controllers/AssignmentController.php +++ /dev/null @@ -1,41 +0,0 @@ -buildClassAssignments([ - 'semester' => (string) $request->query('semester', ''), - 'school_year' => (string) $request->query('school_year', ''), - 'for_api' => true, - ]); - - return response()->json($payload); - } - - public function store(StoreStudentAssignmentRequest $request, AssignmentService $service): JsonResponse - { - $assignment = $service->saveStudentAssignment( - $request->validated(), - $request->user()?->id - ); - - return response()->json([ - 'message' => 'Assignment saved successfully', - 'data' => $assignment, - ], 201); - } - - public function classAssignmentData(Request $request, AssignmentService $service): JsonResponse - { - return $this->index($request, $service); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/DocsController.php b/app/Http/Controllers/DocsController.php new file mode 100644 index 00000000..a84c1d25 --- /dev/null +++ b/app/Http/Controllers/DocsController.php @@ -0,0 +1,16 @@ +json([ + 'message' => 'API documentation is available at /api/docs.', + 'format' => 'swagger', + ]); + } +} diff --git a/app/Http/Controllers/old/BroadcastEmailController.php b/app/Http/Controllers/old/BroadcastEmailController.php new file mode 100644 index 00000000..bdeab9d7 --- /dev/null +++ b/app/Http/Controllers/old/BroadcastEmailController.php @@ -0,0 +1,256 @@ +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