From feb1b29a328919a917e68b0c2ce955a9d9492552 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Jul 2026 00:59:00 -0400 Subject: [PATCH] apply the school year concept --- ...GLOBAL_SELECTOR_IMPLEMENTATION_REVIEWED.md | 1156 +++++++++++++++++ app/Config/Feature.php | 5 + app/Config/Filters.php | 1 + app/Config/Routes.php | 2 + app/Config/Services.php | 25 +- .../Admin/CompetitionWinnersController.php | 13 +- app/Controllers/AdminProgressController.php | 187 ++- .../SchoolYearClosingController.php | 96 +- .../Administrator/SchoolYearController.php | 89 ++ app/Controllers/BaseController.php | 99 ++ .../ParentReportCardController.php | 6 +- .../View/AdministratorController.php | 221 +++- app/Controllers/View/AssignmentController.php | 16 +- app/Controllers/View/AttendanceController.php | 11 +- .../View/AttendanceTrackingController.php | 22 - app/Controllers/View/EventController.php | 11 +- app/Controllers/View/FinancialController.php | 100 +- app/Controllers/View/FlagController.php | 5 +- app/Controllers/View/InvoiceController.php | 17 +- .../View/LateSlipLogsController.php | 2 +- .../View/NotificationsController.php | 5 + app/Controllers/View/ParentController.php | 5 +- app/Controllers/View/PaymentController.php | 15 +- .../View/ReimbursementController.php | 76 +- .../View/SchoolYearSelectionController.php | 84 ++ app/Controllers/View/ScorePredictor.php | 56 +- app/Controllers/View/StudentController.php | 43 +- app/Controllers/View/TrophyController.php | 6 +- app/Controllers/View/UserController.php | 12 +- app/Controllers/View/WhatsappController.php | 26 +- app/Controllers/WinnersController.php | 16 +- .../InvalidSchoolYearSelectionException.php | 9 + .../SchoolYearConfigurationException.php | 9 + .../SchoolYearNotFoundException.php | 9 + .../SchoolYearWriteConflictException.php | 9 + app/Filters/RequireSchoolYearFilter.php | 44 +- app/Filters/SchoolYearWritableFilter.php | 48 + app/Helpers/GlobalConfigHelper.php | 10 + app/Models/ConfigurationModel.php | 29 + app/Models/ExpenseModel.php | 56 +- app/Models/SchoolYearModel.php | 10 +- app/Models/StudentModel.php | 15 +- app/Models/WhatsappGroupLinkModel.php | 40 +- app/Services/SchoolYearAccessPolicy.php | 39 + app/Services/SchoolYearClosingService.php | 388 +++++- app/Services/SchoolYearContextService.php | 140 +- app/Services/SchoolYearManagementService.php | 12 + app/Services/SchoolYearViewDataService.php | 29 + app/Services/SchoolYearWriteGuard.php | 8 +- app/Support/SchoolYear/SchoolYearContext.php | 13 +- app/Support/SchoolYear/SchoolYearStatus.php | 5 + app/Views/admin/certificates/index.php | 16 - app/Views/admin/class_progress_list.php | 19 +- app/Views/admin/competition_winners/index.php | 9 +- app/Views/administrator/removed_students.php | 5 +- app/Views/class_prep/list.php | 290 ++--- app/Views/errors/school_year.php | 8 + .../invoice_payment/invoice_management.php | 89 +- app/Views/layout/main.php | 1 + app/Views/layout/main_layout.php | 7 +- app/Views/layout/management_layout.php | 14 + app/Views/partials/academic_filter.php | 34 +- app/Views/partials/navbar.php | 12 +- app/Views/partials/school_year_selector.php | 54 + app/Views/printables_reports/badge_form.php | 394 +++++- app/Views/school_years/closing_preview.php | 261 +++- app/Views/school_years/index.php | 198 ++- app/Views/score_analysis/score_prediction.php | 8 +- app/Views/teacher/teacher_navbar.php | 6 +- app/Views/winners/competitions/index.php | 7 +- app/Views/winners/competitions/show.php | 3 + .../Services/SchoolYearContextServiceTest.php | 117 ++ .../SchoolYear/SchoolYearContextTest.php | 6 +- 73 files changed, 4288 insertions(+), 620 deletions(-) create mode 100644 SCHOOL_YEAR_GLOBAL_SELECTOR_IMPLEMENTATION_REVIEWED.md create mode 100644 app/Controllers/View/SchoolYearSelectionController.php create mode 100644 app/Exceptions/SchoolYear/InvalidSchoolYearSelectionException.php create mode 100644 app/Exceptions/SchoolYear/SchoolYearConfigurationException.php create mode 100644 app/Exceptions/SchoolYear/SchoolYearNotFoundException.php create mode 100644 app/Exceptions/SchoolYear/SchoolYearWriteConflictException.php create mode 100644 app/Filters/SchoolYearWritableFilter.php create mode 100644 app/Services/SchoolYearAccessPolicy.php create mode 100644 app/Services/SchoolYearViewDataService.php create mode 100644 app/Views/errors/school_year.php create mode 100644 app/Views/partials/school_year_selector.php create mode 100644 tests/app/Services/SchoolYearContextServiceTest.php diff --git a/SCHOOL_YEAR_GLOBAL_SELECTOR_IMPLEMENTATION_REVIEWED.md b/SCHOOL_YEAR_GLOBAL_SELECTOR_IMPLEMENTATION_REVIEWED.md new file mode 100644 index 0000000..4891d88 --- /dev/null +++ b/SCHOOL_YEAR_GLOBAL_SELECTOR_IMPLEMENTATION_REVIEWED.md @@ -0,0 +1,1156 @@ +# Global School-Year Selector and Historical Read-Only Mode — Reviewed Implementation Plan v2 + +## Purpose + +Add a global school-year selector to the authenticated CodeIgniter 4 application so a user can: + +1. See the active school year by default. +2. Select another available school year. +3. View data belonging to the selected school year across all school-year-aware pages. +4. Clearly see **READ-ONLY** when a non-active school year is selected. +5. Never create, update, delete, approve, publish, charge, pay, enroll, assign, or otherwise mutate school-year-owned business data while a non-active school year is selected. + +This is not merely a view change. The selected year must control school-year-aware database reads, AJAX/API requests, exports, print views, and year-owned write endpoints. A decorative dropdown that does not change the query scope would be impressively useless. + +--- + +## Current Project Audit + +The project already contains part of the required foundation: + +- `app/Support/SchoolYear/SchoolYearContext.php` +- `app/Services/SchoolYearContextService.php` +- `app/Services/SchoolYearWriteGuard.php` +- `app/Models/Concerns/SchoolYearScopedModelTrait.php` +- `app/Filters/RequireSchoolYearFilter.php` +- `app/Models/SchoolYearModel.php` +- school-year lifecycle management under `app/Controllers/Administrator/SchoolYearController.php` +- an existing page-level filter at `app/Views/partials/academic_filter.php` + +However, the current implementation is incomplete and inconsistent: + +- The application has approximately **372 PHP view files**. +- Approximately **229 application views** use one of the shared layouts: + - `layout/management_layout` + - `layout/main_layout` + - `layout/main` +- Approximately **33 views** directly include `partials/academic_filter.php`, which would create duplicate school-year selectors after a global selector is added. +- Approximately **57 controllers** still read the school year directly from session or configuration instead of resolving `SchoolYearContext`. +- Approximately **63 models** reference `school_year` or `school_year_id`, but the scoped-model trait is not currently used by those models. +- `RequireSchoolYearFilter` exists as an alias but is not broadly applied to the relevant route groups. +- There are no dedicated automated tests covering school-year selection, scoping, or historical write protection. + +The implementation must consolidate these competing patterns rather than adding a third one. + + +### Additional findings from the review + +The first plan was directionally correct, but the project audit exposed several implementation blockers that must be resolved before the selector is released: + +- `BaseController::syncActiveSchoolYearSession()` currently removes `selected_school_year_id` whenever the configured active year differs from the session. That method would erase a historical selection on the next request and must be removed or replaced. +- `AuthController`, `SchoolYearManagementService`, and `SchoolYearClosingService` still write the active year name into session. The active configuration value may remain a temporary legacy mirror, but it cannot remain a request-time source of truth. +- Approximately **30 views contain their own school-year `` +- all allowed options +- current request URI in a hidden `return_to` field +- auto-submit on change or a clear Apply button +- active status label +- `READ-ONLY` button-style label when non-active +- accessible label and keyboard behavior + +Suggested markup: + +```php + +
+
+ + getUri()->getQuery(); ?> + + + + + + + isReadonly()): ?> + + READ-ONLY + + + ACTIVE + +
+
+ +``` + +Preserve relevant existing query parameters when redirecting, but remove stale `school_year_id`, `school_year`, `schoolYear`, and legacy `year` parameters so the session selection is not immediately overridden by an old URL. + +### 7. Insert the partial into shared layouts + +Update: + +- `app/Views/layout/management_layout.php` +- `app/Views/layout/main_layout.php` +- `app/Views/layout/main.php` + +Place the selector immediately below the application navbar and above page content. + +Do not add it to: + +- `layout/register_layout.php` +- `layout/email_layout.php` +- PDFs +- print-only layouts +- public pages +- fragments/modals/partials + +This covers most UI pages without editing hundreds of files individually. + +### 8. Remove duplicate page-level school-year selectors + +Refactor: + +`app/Views/partials/academic_filter.php` + +The partial is currently used by approximately 33 views and combines school year with semester. After the global selector is introduced: + +- remove the school-year control from this partial, or +- add a parameter such as `$showSchoolYearControl = false` and default it to false. + +Retain page-specific controls such as semester and class section. + +All affected controllers must use the global `SchoolYearContext` instead of trusting `$_GET['school_year']` from the old partial. + +### 9. Migrate all custom selectors and shared navigation queries + +`partials/academic_filter.php` is only one source of duplication. The audit found approximately 30 views with a school-year ` +``` + +Before a write, verify that: + +1. the submitted context ID matches the currently resolved context; +2. the context is active; +3. the target record belongs to that context. + +Reject mismatches with HTTP 409 and ask the user to reload. This prevents a stale form in one tab from writing after another tab changes the global selection. + +### Redirects and links + +Because selection persists in session, ordinary internal links do not need to append the school year. For shareable reports or exports, explicit `school_year_id` may be included in the URL and must take precedence over session. + +--- + +## Model Migration Contract + +Apply `SchoolYearScopedModelTrait` to models that directly own a school-year column. + +Example: + +```php +use App\Models\Concerns\SchoolYearScopedModelTrait; + +class AttendanceRecordModel extends Model +{ + use SchoolYearScopedModelTrait; +} +``` + +Do not apply the trait blindly to models that only mention a school year in joined tables or helper methods. Confirm the model’s base table owns either: + +- `school_year_id`, or +- `school_year` + +Prefer `school_year_id` for new schema work. Continue supporting name-based legacy tables during migration. + +Do not perform database schema introspection inside every `forSchoolYear()` call. Each scoped model should declare its year column explicitly, for example: + +```php +protected string $schoolYearColumn = 'school_year'; +``` + +The trait should fail loudly when no declared year column exists. Silent guessing or filtering a nonexistent `school_year_id` column turns a safety mechanism into theater. + +### Query rule + +Every query against a year-owned table must include one of: + +```sql +WHERE school_year_id = :selected_id: +``` + +or: + +```sql +WHERE school_year = :selected_name: +``` + +No school-year-aware list query may rely on the absence of duplicate rows across years. + +### Join rule + +When joining multiple year-owned tables, scope every relevant table or ensure the join keys and source table guarantee the same year. Do not filter one table and assume the others follow politely. + +Example: + +```php +$builder + ->where('student_class.school_year', $year) + ->where('teacher_class.school_year', $year); +``` + +--- + +## Services, Libraries, Listeners, CLI, and Scheduled Work + +The context migration extends beyond controllers and models. + +Audit and update: + +- `GlobalConfigHelper::getSchoolYear()` +- `FeeCalculationService` +- `SemesterScoreService` +- tuition forecast services +- attendance tracking defaults +- `SchoolEventListener` +- all report, notification, email, and calculation services that read active configuration +- CLI commands such as exam-draft reminders and payment notifications + +Rules: + +- Browser-driven services receive `SchoolYearContext` explicitly from the controller or request-scoped service. +- Event payloads include `school_year_id` or immutable year name. Listeners must not re-read the current active configuration and assume it matches the event. +- CLI commands require `--school-year-id` when operating on a historical year. Active-year jobs may explicitly resolve the active row, but never use session state. +- Queued or deferred jobs must persist the year ID in the job payload. A job executed after the active year changes must still operate on the originally requested year. +- Scheduled jobs that should only affect the active year must assert that policy before writing. + +## Data-Integrity Preflight and Historical Accuracy + +Before enabling the selector, run a read-only audit over every year-owned table: + +- null or empty school-year values +- values not present in `school_years` +- malformed year strings +- duplicate business keys that omit school year +- joins whose related rows belong to different years +- missing indexes on year filters +- rows whose date falls outside the declared school-year range, where that rule applies + +Do not silently backfill unknown rows into the active year. Produce an exception report and resolve or quarantine ambiguous records. + +The team must also decide what “historical data” means for master data that can change. A historical grade joined to a currently renamed class, section, student, fee rule, or event may not reproduce what users saw at the time. For legally or operationally significant reports, use effective-dated master data or snapshots rather than live mutable joins. + +## AJAX and API Requests + +All page AJAX requests must inherit the current context. + +Preferred approach: + +- session selection for authenticated same-origin browser requests +- explicit `school_year_id` for report endpoints, exports, and requests that may be opened independently +- an explicit `X-School-Year-ID` header or documented query parameter for JWT APIs that do not use browser sessions + +The resolver must not treat a posted business-data field as the authoritative context. API context belongs in the route, query, or dedicated header; the business payload is then forced to that resolved context. + +Apply `schoolYear` validation to relevant API/AJAX route groups. + +JSON school-year write endpoints must return HTTP `409 Conflict` for historical context: + +```json +{ + "status": 409, + "error": "School year is read-only", + "school_year": "2024-2025" +} +``` + +Do not return a successful response with no mutation. Silent failure is not protection; it is future debugging folklore. + +--- + +## Caching, Browser History, and Generated Artifacts + +Any server-side or client-side cache containing school-year data must include the selected year ID and user/permission scope in its cache key. + +For school-year-aware HTML responses: + +- send appropriate HTTP `Cache-Control` headers, not merely `` tags; +- test browser back/forward cache behavior after switching years; +- ensure DataTables, AJAX caches, local storage, and saved filters do not reuse active-year responses in a historical context. + +Exports and generated files must include the selected year in their filename and metadata. Historical generation must not overwrite an existing artifact for another year. When generation writes an audit log, that log is an allowed operational side effect; when generation updates a year-owned business record or replaces an official historical artifact, it requires explicit policy and normally remains blocked. + +## Observability and Audit + +Log at least: + +- year selection and reset events +- invalid, conflicting, or unauthorized year requests +- blocked historical write attempts, including user, route, method, selected year, and target record type/ID when safe +- active-year integrity failures +- uses of legacy configuration/session/request fallbacks during the migration period + +Add a temporary migration metric showing how many requests still read the active year from legacy sources. The feature is not done while that count remains nonzero on school-year-aware routes. + +## UI Read-Only Rules + +Create a reusable helper or partial for action availability instead of scattering status comparisons everywhere. + +### Required UI behavior in historical mode + +Disable or omit: + +- Create/Add/New buttons +- Edit buttons +- Delete/Archive row actions unrelated to school-year lifecycle +- Save/Submit/Approve/Publish buttons +- payment collection and refund actions +- student enrollment/withdrawal mutation +- class and teacher assignment mutation +- attendance entry or correction +- score entry, grading, and finalization +- imports that write records +- bulk actions +- drag-and-drop mutations +- inline editable controls + +Keep available: + +- View/details +- filtering/search +- pagination +- sorting +- export +- print +- PDF generation +- audit history + +Use actual disabled form controls where possible, but continue enforcing the server guard. + +### Historical banner + +In addition to the compact navbar label, display a page-width warning on application content pages: + +```html + +``` + +The warning should be rendered once by the shared layout, not copied into every view. + +--- + +## Exemptions + +The global selector and read-only banner must not appear in: + +- public landing pages +- login/logout pages +- account activation and password reset +- registration flow before authentication +- email templates +- PDFs and print fragments unless the selected year must be printed as report metadata +- CLI and framework error pages +- Swagger/API documentation +- partial views and modal fragments rendered inside another page + +The selector may appear on global authenticated pages, but those pages must not claim their data is filtered when it is not. + +--- + +## Implementation Phases + +### Phase 0: Inventory, policy, and data preflight + +- [ ] Produce a route/action inventory with method, controller, queried tables, policy classification, migration status, and test status. +- [ ] Inventory custom school-year selectors and hidden form fields. +- [ ] Audit controllers, models, services, libraries, listeners, commands, helpers, navbars, and layouts. +- [ ] Run the data-integrity preflight and resolve ambiguous/null year data. +- [ ] Decide accessible-year policy by role/permission. +- [ ] Decide historical snapshot requirements for mutable master data. +- [ ] Add a feature flag; keep the global selector hidden in production. + +### Phase 1: Source of truth and lifecycle integrity + +- [ ] Remove `BaseController::syncActiveSchoolYearSession()`. +- [ ] Make `school_years` authoritative and configuration a compatibility mirror only. +- [ ] Prevent renaming in-use/non-draft years. +- [ ] Enforce exactly one active year with service checks, locking, and a database guard where supported. +- [ ] Add domain-specific exceptions and consistent HTML/JSON responses. +- [ ] Clear explicit selection on login/account change and revalidate on role switch. + +### Phase 2: Context, selection, and dependent state + +- [ ] Change global historical determination to non-active = domain-read-only. +- [ ] Preserve separate lifecycle metadata-lock semantics. +- [ ] Allow valid authorized non-active session selections. +- [ ] Add selectable-year listing through an access policy. +- [ ] Add selection and reset methods. +- [ ] Selecting active clears the explicit override. +- [ ] Add safe redirect validation and strip all legacy year query aliases. +- [ ] Clear or revalidate class, section, student, and semester session state. +- [ ] Add stale-form context tokens. + +### Phase 3: Read scoping before public UI + +Migrate high-risk domains first: + +1. financial, invoice, payment, refund, and reimbursement +2. enrollment, promotion, student-class, and teacher-class assignment +3. attendance, late slips, and dismissals +4. grading, scores, decisions, report cards, and certificates +5. events, charges, calendar, and communications +6. inventory, class preparation, progress, and dashboards + +For every action: + +- [ ] classify it as global/year-read/year-write/operational/lifecycle +- [ ] resolve and authorize context +- [ ] replace direct configuration/session year reads +- [ ] scope base queries, joins, subqueries, unions, counts, and totals +- [ ] scope exports, PDFs, print views, dashboard widgets, and DataTables count queries +- [ ] scope AJAX child endpoints +- [ ] migrate services, listeners, helpers, and calculations used by the action +- [ ] add isolation tests before marking the action complete + +### Phase 4: Write protection and route hygiene + +- [ ] Add `SchoolYearWritableFilter` to known year-owned mutation routes. +- [ ] Add `assertSchoolYearWritable()` at controller and domain-service boundaries. +- [ ] Verify submitted context token and target-record ownership. +- [ ] Force inserted year fields from context. +- [ ] Reject posted mismatched year fields. +- [ ] Convert legacy GET mutations to POST/DELETE with CSRF. +- [ ] Guard bulk actions, imports, background-trigger endpoints, listeners, and commands. +- [ ] Define allowed operational side effects and block historical artifact replacement. + +### Phase 5: Shared UI and custom-selector migration + +- [ ] Inject request-scoped shared context data. +- [ ] Create selector and read-only banner partials. +- [ ] Update all shared layouts, navbars, counters, and class switchers. +- [ ] Remove or repurpose all duplicate school-year selectors, not only `academic_filter.php`. +- [ ] Disable/hide year-owned write actions while preserving permission checks. +- [ ] Add global JavaScript context metadata for AJAX consumers. +- [ ] Verify mobile and accessibility behavior. + +The global feature flag remains off during this phase. + +### Phase 6: Caching, observability, and compatibility removal + +- [ ] Vary caches by year and authorization scope. +- [ ] Add HTTP cache headers and browser-history tests. +- [ ] Log selections, invalid requests, blocked writes, and active-year integrity failures. +- [ ] Add metrics for remaining legacy year-source usage. +- [ ] Migrate `schoolYear`, `school_year`, `year`, and session-name compatibility paths. +- [ ] Remove the legacy `selected_school_year` name key and direct config/session reads. + +### Phase 7: Release and regression review + +- [ ] Run the full required test matrix. +- [ ] Verify every inventoried school-year route is migrated or explicitly global. +- [ ] Enable the selector in a staging environment. +- [ ] Perform cross-tab, back-button, role-switch, and stale-form testing. +- [ ] Enable production access by domain or role only after coverage is complete. +- [ ] Monitor blocked-write and legacy-fallback metrics. +- [ ] Remove the feature flag only after the selector cannot misrepresent query scope. + +## Required Test Matrix + +### Context resolution + +| Case | Expected result | +|---|---| +| No request/session selection | Active year | +| Valid active session selection | Active year, writable | +| Valid closed session selection | Closed year, read-only | +| Valid archived request ID | Archived year, read-only | +| Invalid request ID | 400 validation response | +| Conflicting ID and name | 400 validation response | +| Deleted session year | Clear session and use active year | +| No active year and no valid selection | Clear configuration error | + +### Read scoping + +Create fixtures with identical student/class/parent relationships in two years and verify: + +- active selection returns only active-year rows +- historical selection returns only historical rows +- totals and counts change with selection +- pagination does not mix years +- exports/PDFs use the selected year +- dashboard widgets use the selected year or are explicitly marked global + +### Write protection + +For each high-risk domain, test: + +- active year write succeeds with permission +- closed year write fails +- archived year write fails +- closing year write fails +- draft global-context write fails +- manually posted `school_year=active` while historical context is selected still fails +- active context cannot update a historical record by ID + +### Session and concurrency + +Verify: + +- selecting the active year clears the explicit override +- activating a new year automatically changes the default for users without an override +- switching year clears or revalidates class and semester state +- switching roles invalidates unauthorized draft selections +- a stale write form opened in another tab fails with 409 after context changes +- browser back/forward navigation does not display a mismatched year and dataset + +### Non-HTTP execution + +Verify: + +- CLI commands require or deliberately resolve a year +- queued/deferred work preserves the original year ID after active-year transition +- listeners use the event's year rather than current configuration +- scheduled active-only jobs reject historical writes + +### Data integrity + +Verify: + +- multiple active rows cause a hard integrity failure +- an in-use school-year name cannot be changed +- unknown/null legacy year rows are reported rather than silently assigned +- compound uniqueness prevents duplicates across and within years as intended +- cache keys prevent cross-year response reuse + +### UI + +Verify: + +- selector appears once, not twice +- active year is default +- selected historical year persists across pages +- `READ-ONLY` label and warning appear for non-active years +- action buttons disappear or disable +- view/export/print actions remain available +- mobile navbar remains usable + +--- + +## Acceptance Criteria + +The feature is complete only when all of the following are true: + +1. Every authenticated application layout displays one global school-year selector. +2. The active year is selected by default. +3. A user can select an allowed historical year and the selection persists across navigation. +4. Every school-year-aware page reads data only from the selected year. +5. Global pages remain global and do not pretend to be year-filtered. +6. A non-active year displays a button-style **READ-ONLY** label and a warning banner. +7. No school-year-owned record in a non-active year can be mutated through HTML, AJAX, API, direct URL, forged POST data, record-ID substitution, CLI, listeners, or scheduled work. +8. Existing page-level school-year dropdowns are removed or converted to non-year filters. +9. Exports, PDFs, print views, totals, and dashboard metrics use the same selected context. +10. Automated tests cover resolution, persistence, isolation, and write rejection. +11. School-year lifecycle administration continues to work through its dedicated controller and is not blocked by the global read-only UI. +12. No query silently falls back to unscoped data when context resolution fails. +13. Selecting the active year clears the session override, so future activation changes the default automatically. +14. Changing year cannot reuse an invalid class, section, student, or semester selection. +15. Global writes and allowed operational logs remain governed by their own permissions and are not accidentally blocked. +16. School-year-owned writes are blocked from HTML, AJAX, API, CLI, listeners, scheduled jobs, stale tabs, and forged IDs. +17. Exactly one active year is enforced and an in-use year name is immutable. +18. No navbar, dashboard counter, class switcher, export, or generated report uses a different year from the visible context. +19. All custom school-year selectors are removed, intentionally retained for comparison/reporting, or documented as lifecycle controls. +20. Legacy configuration/session/request sources are removed from school-year-aware execution paths. +21. The global selector is not released until all exposed routes are scoped and tested. + +--- + +## Definition of Done Checklist by File + +### Existing files to modify + +- `app/Support/SchoolYear/SchoolYearContext.php` +- `app/Services/SchoolYearContextService.php` +- `app/Services/SchoolYearWriteGuard.php` +- `app/Controllers/BaseController.php` +- `app/Config/Services.php` +- `app/Config/Routes.php` +- `app/Config/Filters.php` +- `app/Views/layout/management_layout.php` +- `app/Views/layout/main_layout.php` +- `app/Views/layout/main.php` +- `app/Views/partials/academic_filter.php` +- `app/Views/partials/navbar.php` +- `app/Views/teacher/teacher_navbar.php` +- `app/Helpers/GlobalConfigHelper.php` +- `app/Controllers/AuthController.php` +- school-year-aware services, libraries, listeners, and commands +- all custom school-year selector views +- all school-year-aware controllers +- all models that directly own a school-year column +- migrations enforcing active-year/name integrity and required indexes/constraints + +### New files recommended + +- `app/Controllers/View/SchoolYearSelectionController.php` +- `app/Filters/SchoolYearWritableFilter.php` +- `app/Policies/SchoolYearAccessPolicy.php` +- school-year domain exception classes +- `app/Services/SchoolYearViewDataService.php` +- `app/Views/partials/school_year_selector.php` +- `app/Views/partials/school_year_readonly_banner.php` +- `tests/Unit/SchoolYearContextTest.php` +- `tests/Unit/SchoolYearContextServiceTest.php` +- `tests/Feature/SchoolYearSelectionTest.php` +- `tests/Feature/HistoricalSchoolYearWriteGuardTest.php` +- domain-specific school-year isolation feature tests + +--- + +## Final Engineering Rule + +There must be exactly one authoritative selected school-year context per request. + +The following are not independent sources of truth and must not compete: + +- configuration value +- session `school_year` +- session `selected_school_year` +- GET `school_year` +- GET `school_year_id` +- POST `school_year` +- controller defaults +- model defaults + +All of them must resolve into one validated `SchoolYearContext`, and every school-year-aware read or write must use that object. Anything less will eventually display one year in the dropdown while querying another year in the database, which is the sort of bug humans call “intermittent” when they mean “we built several sources of truth.” + + +## Review Gate Before Coding + +Coding should not begin with the dropdown. The first deliverable is the route/data inventory and the removal of the session-sync behavior that currently destroys historical selection. The second is a working context and write guard proven on one high-risk vertical, preferably invoices or attendance. Only then should the shared layouts expose the selector. + +A convincing demo is not “the dropdown changes and one list refreshes.” A convincing demo is: the year changes, every visible count and child request changes with it, historical writes fail through every path, stale tabs are rejected, and returning to active restores normal writes without carrying invalid class or semester state. diff --git a/app/Config/Feature.php b/app/Config/Feature.php index 0bc45c6..04939a0 100644 --- a/app/Config/Feature.php +++ b/app/Config/Feature.php @@ -27,4 +27,9 @@ class Feature extends BaseConfig * Use improved new auto routing instead of the default legacy version. */ public bool $autoRoutesImproved = false; + + /** + * Show the global school-year selector in authenticated admin layouts. + */ + public bool $globalSchoolYearSelector = true; } diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 9694240..a837405 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -32,6 +32,7 @@ class Filters extends BaseConfig 'permission' => \App\Filters\PermissionFilter::class, 'timezone' => \App\Filters\TimezoneFilter::class, 'schoolYear' => \App\Filters\RequireSchoolYearFilter::class, + 'schoolYearWritable'=> \App\Filters\SchoolYearWritableFilter::class, ]; diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 6a3a8f5..bd92e8f 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -47,6 +47,8 @@ $routes->setAutoRoute(false); * -------------------------------------------------------------------- */ $routes->post('api/proofread', 'ProofreadController::check', ['filter' => 'auth']); +$routes->post('school-year/select', 'View\SchoolYearSelectionController::select', ['filter' => 'auth']); +$routes->post('school-year/reset', 'View\SchoolYearSelectionController::reset', ['filter' => 'auth']); /* * -------------------------------------------------------------------- diff --git a/app/Config/Services.php b/app/Config/Services.php index 156f3ca..85def68 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -188,7 +188,29 @@ class Services extends BaseService } return new \App\Services\SchoolYearContextService( - model(\App\Models\SchoolYearModel::class) + model(\App\Models\SchoolYearModel::class), + static::schoolYearAccessPolicy() + ); + } + + public static function schoolYearAccessPolicy(bool $getShared = true): \App\Services\SchoolYearAccessPolicy + { + if ($getShared) { + return static::getSharedInstance('schoolYearAccessPolicy'); + } + + return new \App\Services\SchoolYearAccessPolicy(); + } + + public static function schoolYearViewData(bool $getShared = true): \App\Services\SchoolYearViewDataService + { + if ($getShared) { + return static::getSharedInstance('schoolYearViewData'); + } + + return new \App\Services\SchoolYearViewDataService( + static::schoolYearContext(), + static::schoolYearAccessPolicy() ); } @@ -238,6 +260,7 @@ class Services extends BaseService model(\App\Models\SchoolYearModel::class), model(\App\Models\SchoolYearClosingBatchModel::class), model(\App\Models\SchoolYearClosingItemModel::class), + model(\App\Models\ConfigurationModel::class), static::schoolYearManagement(), \Config\Database::connect() ); diff --git a/app/Controllers/Admin/CompetitionWinnersController.php b/app/Controllers/Admin/CompetitionWinnersController.php index 3e450f2..cf55022 100644 --- a/app/Controllers/Admin/CompetitionWinnersController.php +++ b/app/Controllers/Admin/CompetitionWinnersController.php @@ -54,6 +54,11 @@ class CompetitionWinnersController extends BaseController public function index() { $competitionModel = new CompetitionModel(); + $schoolYear = $this->currentCompetitionSchoolYear(); + + if ($schoolYear !== '') { + $competitionModel->where('school_year', $schoolYear); + } $competitions = $competitionModel ->orderBy('id', 'DESC') @@ -62,12 +67,13 @@ class CompetitionWinnersController extends BaseController return view('admin/competition_winners/index', [ 'competitions' => $competitions, 'sectionMap' => $this->getClassSectionMap(), + 'schoolYear' => $schoolYear, ]); } public function create() { - $schoolYear = $this->configModel->getConfig('school_year'); + $schoolYear = $this->currentCompetitionSchoolYear(); $classCounts = $this->getClassStudentCounts($schoolYear); $classSections = $this->classSectionModel ->orderBy('class_section_name', 'ASC') @@ -1088,6 +1094,11 @@ class CompetitionWinnersController extends BaseController return $questionCounts; } + private function currentCompetitionSchoolYear(): string + { + return $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? '')); + } + private function getClassSectionMap(): array { $sections = $this->classSectionModel diff --git a/app/Controllers/AdminProgressController.php b/app/Controllers/AdminProgressController.php index 3ef8c63..cb7f4af 100644 --- a/app/Controllers/AdminProgressController.php +++ b/app/Controllers/AdminProgressController.php @@ -39,7 +39,13 @@ class AdminProgressController extends BaseController public function index() { + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? '')); + if ($schoolYear === '') { + $schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? '')); + } + $filters = [ + 'school_year' => $schoolYear, 'from' => (string) $this->request->getGet('from'), 'to' => (string) $this->request->getGet('to'), 'class_section_id' => (string) $this->request->getGet('class_section_id'), @@ -51,6 +57,8 @@ class AdminProgressController extends BaseController ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left'); + $this->applyProgressSchoolYearScope($builder, $schoolYear); + if ($filters['from']) { $builder->where('week_start >=', $filters['from']); } @@ -91,16 +99,17 @@ class AdminProgressController extends BaseController $reportGroupsBySection[$sectionId] = $groups; } - $classSections = $this->classSectionModel->getClassSections(); - $studentCounts = $this->studentClassModel->getStudentCountsBySection(); - $filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts) { + $classSections = $this->classSectionsForYear($schoolYear, $rows); + $studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null); + $filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts, $reportGroupsBySection) { $sectionId = (int) ($section['class_section_id'] ?? 0); - return isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0; + return (isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0) + || isset($reportGroupsBySection[$sectionId]); })); $filterStart = $this->normalizeDate($filters['from'] ?? ''); $filterEnd = $this->normalizeDate($filters['to'] ?? ''); - [$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates(); + [$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates($schoolYear); [$expectedDays, $activeDatesSet] = $this->resolveExpectedDays( $dateList, $noSchoolDays, @@ -132,6 +141,8 @@ class AdminProgressController extends BaseController return view('admin/class_progress_list', [ 'reportGroupsBySection' => $reportGroupsBySection, 'filters' => $filters, + 'schoolYear' => $schoolYear, + 'schoolYears' => $this->availableSchoolYears($schoolYear), 'classSections' => $filteredSections, 'statusOptions' => ClassProgressController::STATUS_OPTIONS, 'subjectSections' => ClassProgressController::SUBJECT_SECTIONS, @@ -142,6 +153,162 @@ class AdminProgressController extends BaseController ]); } + private function classSectionsForYear(string $schoolYear, array $reportRows = []): array + { + $db = db_connect(); + + if ($schoolYear !== '' && $db->fieldExists('school_year', 'classSection')) { + $sections = $db->table('classSection') + ->select('classSection.class_section_id, classSection.class_section_name, classes.class_name') + ->join('classes', 'classSection.class_id = classes.id', 'left') + ->where('classSection.school_year', $schoolYear) + ->orderBy('classSection.class_section_name', 'ASC') + ->get() + ->getResultArray(); + } else { + $sections = $this->classSectionModel->getClassSections(); + } + + $sectionsById = []; + foreach ($sections as $section) { + $sectionId = (int) ($section['class_section_id'] ?? 0); + if ($sectionId > 0) { + $sectionsById[$sectionId] = $section; + } + } + + foreach ($reportRows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + if ($sectionId <= 0 || isset($sectionsById[$sectionId])) { + continue; + } + + $sectionsById[$sectionId] = [ + 'class_section_id' => $sectionId, + 'class_section_name' => (string) ($row['class_section_name'] ?? ('Section #' . $sectionId)), + 'class_name' => '', + ]; + } + + uasort($sectionsById, static function (array $a, array $b): int { + return strnatcasecmp((string) ($a['class_section_name'] ?? ''), (string) ($b['class_section_name'] ?? '')); + }); + + return array_values($sectionsById); + } + + private function applyProgressSchoolYearScope($builder, string $schoolYear): void + { + if ($schoolYear === '') { + return; + } + + $db = db_connect(); + $hasReportYear = $db->fieldExists('school_year', 'class_progress_reports'); + $hasSectionYear = $db->fieldExists('school_year', 'classSection'); + [$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear); + + if ($hasReportYear) { + if ($this->hasAnyExplicitProgressSchoolYearRows()) { + $builder->where('class_progress_reports.school_year', $schoolYear); + return; + } + + if ($rangeStart !== '' && $rangeEnd !== '') { + $builder + ->groupStart() + ->groupStart() + ->where('class_progress_reports.school_year IS NULL', null, false) + ->orWhere('class_progress_reports.school_year', '') + ->groupEnd() + ->where('class_progress_reports.week_start >=', $rangeStart) + ->where('class_progress_reports.week_start <=', $rangeEnd) + ->groupEnd(); + return; + } + + $builder->where('class_progress_reports.school_year', $schoolYear); + return; + } + + if ($hasSectionYear) { + $builder->where('cs.school_year', $schoolYear); + return; + } + + if ($rangeStart !== '' && $rangeEnd !== '') { + $builder + ->where('class_progress_reports.week_start >=', $rangeStart) + ->where('class_progress_reports.week_start <=', $rangeEnd); + } + } + + private function hasAnyExplicitProgressSchoolYearRows(): bool + { + try { + return db_connect() + ->table('class_progress_reports') + ->where('school_year IS NOT NULL', null, false) + ->where('school_year !=', '') + ->limit(1) + ->get() + ->getRowArray() !== null; + } catch (\Throwable $e) { + log_message('warning', 'Unable to check explicit progress school-year rows: {message}', [ + 'message' => $e->getMessage(), + ]); + return true; + } + } + + private function availableSchoolYears(string $selectedYear): array + { + $years = []; + $addYear = static function (mixed $value) use (&$years): void { + $year = trim((string) $value); + if ($year !== '' && ! in_array($year, $years, true)) { + $years[] = $year; + } + }; + + $addYear($selectedYear); + $addYear($this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''))); + + $db = db_connect(); + foreach ([ + ['table' => 'school_years', 'column' => 'name'], + ['table' => 'class_progress_reports', 'column' => 'school_year'], + ['table' => 'classSection', 'column' => 'school_year'], + ['table' => 'student_class', 'column' => 'school_year'], + ] as $source) { + try { + if (! $db->tableExists($source['table']) || ! $db->fieldExists($source['column'], $source['table'])) { + continue; + } + + $rows = $db->table($source['table']) + ->select($source['column']) + ->distinct() + ->where($source['column'] . ' IS NOT NULL', null, false) + ->orderBy($source['column'], 'DESC') + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $addYear($row[$source['column']] ?? ''); + } + } catch (\Throwable $e) { + log_message('warning', 'Unable to load school-year options for admin progress: {message}', [ + 'message' => $e->getMessage(), + ]); + } + } + + rsort($years, SORT_NATURAL); + + return $years; + } + public function view($id) { $row = $this->reportModel @@ -277,11 +444,15 @@ class AdminProgressController extends BaseController return checkdate($m, $d, $y) ? $value : ''; } - protected function buildSemesterDates(): array + protected function buildSemesterDates(?string $schoolYear = null): array { - $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + $schoolYear = trim((string) ($schoolYear ?? '')); + if ($schoolYear === '') { + $schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? '')); + } + $semester = (string) ($this->configModel->getConfig('semester') ?? ''); - $schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string) ($this->configModel->getConfig('school_year') ?? ''); + $schoolYearForRange = $schoolYear !== '' ? $schoolYear : $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? '')); [$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYearForRange); $semesterNorm = $this->semesterRangeService->normalizeSemester($semester); if ($semesterNorm !== '' && $schoolYearForRange !== '') { diff --git a/app/Controllers/Administrator/SchoolYearClosingController.php b/app/Controllers/Administrator/SchoolYearClosingController.php index 20a9f9d..2d0ac31 100644 --- a/app/Controllers/Administrator/SchoolYearClosingController.php +++ b/app/Controllers/Administrator/SchoolYearClosingController.php @@ -12,9 +12,12 @@ class SchoolYearClosingController extends BaseController { try { $targetId = $this->normalizeInt($this->request->getGet('target_school_year_id')); + $preview = service('schoolYearClosing')->preview($id, $targetId); + $promotionTable = $this->promotionTablePayload($preview['promotion']['rows'] ?? []); return view('school_years/closing_preview', [ - 'preview' => service('schoolYearClosing')->preview($id, $targetId), + 'preview' => $preview, + 'promotionTable' => $promotionTable, 'latestBatch' => service('schoolYearClosing')->latestBatch($id), 'schoolYears' => (new SchoolYearModel())->orderBy('name', 'DESC')->findAll(), ]); @@ -32,7 +35,7 @@ class SchoolYearClosingController extends BaseController } service('schoolYearClosing')->start($id, $targetId, $this->userId()); - return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'Closing started.'); + return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'End-year process started.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } @@ -42,7 +45,7 @@ class SchoolYearClosingController extends BaseController { try { service('schoolYearClosing')->execute($id, $this->userId()); - return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'Carry-forward batch executed.'); + return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'Carry-forward confirmed.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } @@ -52,7 +55,7 @@ class SchoolYearClosingController extends BaseController { try { service('schoolYearClosing')->complete($id, $this->userId()); - return redirect()->to('/administrator/school-years')->with('success', 'School year closed.'); + return redirect()->to('/administrator/school-years')->with('success', 'School year ended and closed.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } @@ -62,7 +65,7 @@ class SchoolYearClosingController extends BaseController { try { service('schoolYearClosing')->cancel($id, $this->userId()); - return redirect()->to('/administrator/school-years')->with('success', 'Closing cancelled.'); + return redirect()->to('/administrator/school-years')->with('success', 'End-year process cancelled.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } @@ -73,6 +76,89 @@ class SchoolYearClosingController extends BaseController return is_numeric($value) && (int) $value > 0 ? (int) $value : null; } + private function promotionTablePayload(array $rows): array + { + $allowedSorts = ['student', 'school_id', 'class', 'year_score', 'decision', 'source', 'queue', 'target', 'status']; + $sort = (string) ($this->request->getGet('sort') ?? 'class'); + $sort = in_array($sort, $allowedSorts, true) ? $sort : 'class'; + $order = strtolower((string) ($this->request->getGet('order') ?? 'asc')) === 'desc' ? 'desc' : 'asc'; + $page = max(1, (int) ($this->request->getGet('page') ?? 1)); + $perPage = (int) ($this->request->getGet('per_page') ?? 25); + $allowedPerPage = [10, 25, 50, 100]; + $perPage = in_array($perPage, $allowedPerPage, true) ? $perPage : 25; + + usort($rows, function (array $a, array $b) use ($sort, $order): int { + $comparison = $this->comparePromotionRows($a, $b, $sort); + if ($comparison === 0 && $sort !== 'class') { + $comparison = $this->comparePromotionRows($a, $b, 'class'); + } + if ($comparison === 0 && $sort !== 'student') { + $comparison = $this->comparePromotionRows($a, $b, 'student'); + } + if ($comparison === 0) { + $comparison = ((int) ($a['student_id'] ?? 0)) <=> ((int) ($b['student_id'] ?? 0)); + } + + return $order === 'desc' ? -$comparison : $comparison; + }); + + $total = count($rows); + $pageCount = max(1, (int) ceil($total / $perPage)); + $page = min($page, $pageCount); + $offset = ($page - 1) * $perPage; + + return [ + 'rows' => array_slice($rows, $offset, $perPage), + 'sort' => $sort, + 'order' => $order, + 'page' => $page, + 'perPage' => $perPage, + 'total' => $total, + 'pageCount' => $pageCount, + 'allowedPerPage' => $allowedPerPage, + 'from' => $total === 0 ? 0 : $offset + 1, + 'to' => min($offset + $perPage, $total), + ]; + } + + private function comparePromotionRows(array $a, array $b, string $sort): int + { + $aValue = $this->promotionSortValue($a, $sort); + $bValue = $this->promotionSortValue($b, $sort); + + if ($sort === 'year_score') { + if ($aValue === null && $bValue === null) { + return 0; + } + if ($aValue === null) { + return 1; + } + if ($bValue === null) { + return -1; + } + + return $aValue <=> $bValue; + } + + return strnatcasecmp((string) $aValue, (string) $bValue); + } + + private function promotionSortValue(array $row, string $sort): mixed + { + return match ($sort) { + 'student' => (string) ($row['student_name'] ?? ''), + 'school_id' => (string) ($row['school_id'] ?? ''), + 'class' => (string) ($row['class_section_name'] ?? ''), + 'year_score' => is_numeric($row['year_score'] ?? null) ? (float) $row['year_score'] : null, + 'decision' => (string) ($row['decision'] ?? ''), + 'source' => (string) ($row['source'] ?? ''), + 'queue' => (string) ($row['queue_status'] ?? ''), + 'target' => trim((string) ($row['target_section'] ?? '') . ' ' . (string) ($row['target_class'] ?? '')), + 'status' => (string) ($row['status'] ?? ''), + default => '', + }; + } + private function userId(): ?int { $id = session('user_id') ?? session('id'); diff --git a/app/Controllers/Administrator/SchoolYearController.php b/app/Controllers/Administrator/SchoolYearController.php index 973ed3a..7f9f897 100644 --- a/app/Controllers/Administrator/SchoolYearController.php +++ b/app/Controllers/Administrator/SchoolYearController.php @@ -51,6 +51,8 @@ class SchoolYearController extends BaseController 'closingYear' => $closingYear, 'archivedCount' => $archivedCount, 'latestTransitions' => service('schoolYearManagement')->latestTransitionByYear(), + 'yearVerification' => $this->verificationByYear($schoolYears), + 'schoolYearNamesById' => array_column($schoolYears, 'name', 'id'), ]); } @@ -124,4 +126,91 @@ class SchoolYearController extends BaseController return is_numeric($id) ? (int) $id : null; } + + private function verificationByYear(array $schoolYears): array + { + $db = db_connect(); + $verification = []; + + $hasStudentClass = $db->tableExists('student_class'); + $hasStudentDecisions = $db->tableExists('student_decisions'); + $hasPromotionQueue = $db->tableExists('promotion_queue'); + $hasInvoices = $db->tableExists('invoices'); + $hasClosingBatches = $db->tableExists('school_year_closing_batches'); + + foreach ($schoolYears as $year) { + $id = (int) ($year['id'] ?? 0); + $name = (string) ($year['name'] ?? ''); + if ($id <= 0 || $name === '') { + continue; + } + + $summary = [ + 'students' => 0, + 'decisions' => 0, + 'promoted' => 0, + 'queued' => 0, + 'carry_forward_balance' => 0.0, + 'positive_balance' => 0.0, + 'credit_balance' => 0.0, + 'closing_status' => '', + ]; + + if ($hasStudentClass && $db->fieldExists('school_year', 'student_class')) { + $row = $db->table('student_class') + ->select('COUNT(DISTINCT student_id) AS total', false) + ->where('school_year', $name) + ->get() + ->getRowArray(); + $summary['students'] = (int) ($row['total'] ?? 0); + } + + if ($hasStudentDecisions && $db->fieldExists('school_year', 'student_decisions')) { + $row = $db->table('student_decisions') + ->select('COUNT(DISTINCT student_id) AS decisions', false) + ->select("COUNT(DISTINCT CASE WHEN LOWER(decision) = 'pass' THEN student_id END) AS promoted", false) + ->where('school_year', $name) + ->get() + ->getRowArray(); + $summary['decisions'] = (int) ($row['decisions'] ?? 0); + $summary['promoted'] = (int) ($row['promoted'] ?? 0); + } + + if ($hasPromotionQueue && $db->fieldExists('school_year_from', 'promotion_queue')) { + $row = $db->table('promotion_queue') + ->select('COUNT(DISTINCT student_id) AS total', false) + ->where('school_year_from', $name) + ->get() + ->getRowArray(); + $summary['queued'] = (int) ($row['total'] ?? 0); + } + + if ($hasInvoices && $db->fieldExists('school_year', 'invoices')) { + $row = $db->table('invoices') + ->select('COALESCE(SUM(balance), 0) AS carry_forward_balance') + ->select('COALESCE(SUM(CASE WHEN balance > 0 THEN balance ELSE 0 END), 0) AS positive_balance', false) + ->select('COALESCE(SUM(CASE WHEN balance < 0 THEN ABS(balance) ELSE 0 END), 0) AS credit_balance', false) + ->where('school_year', $name) + ->get() + ->getRowArray(); + $summary['carry_forward_balance'] = round((float) ($row['carry_forward_balance'] ?? 0), 2); + $summary['positive_balance'] = round((float) ($row['positive_balance'] ?? 0), 2); + $summary['credit_balance'] = round((float) ($row['credit_balance'] ?? 0), 2); + } + + if ($hasClosingBatches) { + $batch = $db->table('school_year_closing_batches') + ->select('status') + ->where('source_school_year_id', $id) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + $summary['closing_status'] = (string) ($batch['status'] ?? ''); + } + + $verification[$id] = $summary; + } + + return $verification; + } } diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index 25e651f..a0e6098 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -9,6 +9,7 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Psr\Log\LoggerInterface; use App\Services\ApiClient; +use App\Exceptions\SchoolYear\SchoolYearConfigurationException; use App\Support\SchoolYear\SchoolYearContext; use Throwable; @@ -60,6 +61,8 @@ abstract class BaseController extends Controller // Preload any models, libraries, etc, here. // E.g.: $this->session = \Config\Services::session(); session(); + $this->syncSchoolYearPropertyFromContext(); + $this->injectSchoolYearViewData(); // Shared API client available to all controllers $this->api = service('apiClient'); @@ -104,6 +107,23 @@ abstract class BaseController extends Controller } } + protected function currentSchoolYearName(?string $fallback = null): string + { + try { + return $this->resolveSchoolYearContext()->yearName(); + } catch (Throwable $e) { + if ($fallback !== null && $fallback !== '') { + return $fallback; + } + + try { + return (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? ''); + } catch (Throwable) { + return ''; + } + } + } + protected function assertSchoolYearWritable( SchoolYearContext $context, bool $allowDraftForAdmin = false, @@ -111,6 +131,85 @@ abstract class BaseController extends Controller ): void { service('schoolYearWriteGuard')->assertWritable($context, $allowDraftForAdmin, $isAdmin); } + + private function syncSchoolYearPropertyFromContext(): void + { + if (! property_exists($this, 'schoolYear')) { + return; + } + + if (! $this->request instanceof IncomingRequest || is_cli()) { + return; + } + + if (! session()->get('user_id')) { + return; + } + + try { + $this->schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); + } catch (Throwable $e) { + log_message('warning', 'Unable to sync controller school-year property: {message}', [ + 'message' => $e->getMessage(), + ]); + } + } + + private function injectSchoolYearViewData(): void + { + if (! config('Feature')->globalSchoolYearSelector) { + return; + } + + if (! $this->request instanceof IncomingRequest || is_cli()) { + return; + } + + if (! session()->get('user_id')) { + return; + } + + if (! $this->shouldShowSchoolYearSelector()) { + return; + } + + $accept = strtolower($this->request->getHeaderLine('Accept')); + if ($this->request->isAJAX() || str_contains($accept, 'application/json')) { + return; + } + + try { + service('renderer')->setData(service('schoolYearViewData')->forCurrentRequest($this->request)); + } catch (SchoolYearConfigurationException $e) { + throw $e; + } catch (Throwable $e) { + log_message('warning', 'Unable to inject school-year view data: {message}', [ + 'message' => $e->getMessage(), + ]); + } + } + + private function shouldShowSchoolYearSelector(): bool + { + $roles = (array) session()->get('roles'); + $singleRole = session()->get('role'); + if ($singleRole !== null && $singleRole !== '') { + $roles[] = $singleRole; + } + + $roles = array_map( + static fn ($role): string => strtolower(trim((string) $role)), + $roles + ); + + return (bool) array_intersect($roles, [ + 'admin', + 'administrator', + 'administrative staff', + 'principal', + 'vice principal', + ]); + } } ?> diff --git a/app/Controllers/ParentReportCardController.php b/app/Controllers/ParentReportCardController.php index ff3176d..c0665a2 100644 --- a/app/Controllers/ParentReportCardController.php +++ b/app/Controllers/ParentReportCardController.php @@ -31,7 +31,7 @@ class ParentReportCardController extends BaseController return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.'); } - $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->configModel->getConfig('school_year') ?? '')); + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->currentSchoolYearName())); $semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? '')); $builder = $this->db->table('students s') @@ -82,7 +82,7 @@ class ParentReportCardController extends BaseController throw new PageNotFoundException('Student not found.'); } - $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->configModel->getConfig('school_year') ?? '')); + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->currentSchoolYearName())); $semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? '')); $this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [ @@ -121,7 +121,7 @@ class ParentReportCardController extends BaseController return redirect()->back()->with('error', 'Please type your full name to sign.'); } - $schoolYear = trim((string) ($this->configModel->getConfig('school_year') ?? '')); + $schoolYear = trim((string) $this->currentSchoolYearName()); $semester = trim((string) ($this->configModel->getConfig('semester') ?? '')); $now = date('Y-m-d H:i:s'); $this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [ diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php index b7ae42e..1f37e00 100644 --- a/app/Controllers/View/AdministratorController.php +++ b/app/Controllers/View/AdministratorController.php @@ -699,7 +699,10 @@ class AdministratorController extends BaseController public function teacherSubmissionsReport() { $semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? ''); - $schoolYear = (string)($this->configModel->getConfig('school_year') ?? $this->schoolYear ?? ''); + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? '')); + if ($schoolYear === '') { + $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); + } $semesterResolver = new SemesterRangeService($this->configModel); $semesterNorm = $semesterResolver->normalizeSemester($semester); $semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester; @@ -1099,9 +1102,9 @@ class AdministratorController extends BaseController } $semesterResolver = new SemesterRangeService($this->configModel); - $schoolYear = (string)($this->configModel->getConfig('school_year') ?? ''); + $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); $semester = (string)($this->configModel->getConfig('semester') ?? ''); - $schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string)($this->configModel->getConfig('school_year') ?? ''); + $schoolYearForRange = $schoolYear !== '' ? $schoolYear : $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); [$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange); $semesterNorm = $semesterResolver->normalizeSemester($semester); if ($semesterNorm !== '' && $schoolYearForRange !== '') { @@ -2295,22 +2298,10 @@ class AdministratorController extends BaseController public function showEnrollmentWithdrawalPage() { try { - $selectedYear = trim((string)($this->request->getGet('schoolYear') ?? '')); - if ($selectedYear === '') { - $selectedYear = (string)$this->schoolYear; - } + $schoolYears = $this->availableSchoolYears(); + $selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears); - // Distinct school years from enrollments (for selector) - $yearsRows = $this->db->table('enrollments') - ->distinct() - ->select('school_year') - ->orderBy('school_year', 'DESC') - ->get()->getResultArray(); - $schoolYears = array_values(array_filter(array_map(static function ($r) { - return isset($r['school_year']) ? (string)$r['school_year'] : null; - }, $yearsRows))); - - $students = $this->studentModel->getStudentsWithClassAndEnrollment(); + $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear); $selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear); $removedPriorIds = []; @@ -2326,6 +2317,7 @@ class AdministratorController extends BaseController } } } + $returningStudentIds = $this->priorYearStudentIds($selectedYear); foreach ($students as &$s) { // ===== Ensure IDs needed by the modal ===== @@ -2360,6 +2352,9 @@ class AdministratorController extends BaseController // ===== New-student flags ===== $s['is_new'] = (int) ($s['is_new'] ?? 0); + if (isset($returningStudentIds[$s['student_id']])) { + $s['is_new'] = 0; + } $s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No'; // ===== Admission override ===== @@ -2369,6 +2364,9 @@ class AdministratorController extends BaseController $s['enrollment_status'] = $statusForYear; } elseif (($s['admission_status'] ?? null) === 'denied') { $s['enrollment_status'] = 'denied'; + } else { + $s['enrollment_status'] = 'admission under review'; + $s['admission_status'] = 'pending'; } // ===== Class section name for the selected year ===== @@ -2396,22 +2394,7 @@ class AdministratorController extends BaseController return strcasecmp($pa, $pb); }); - // ===== Provide classes for the modal select (like studentClassAssignment) ===== - // Filter to current term; loosen if your table doesn’t store these fields. - $classes = $this->classSectionModel - ->select('id, class_section_id, class_section_name, school_year, semester') - ->where('school_year', (string)$selectedYear) - ->where('semester', (string)$this->semester) - ->orderBy('class_section_name', 'ASC') - ->findAll(); - - // Fallback so the dropdown never shows empty if filters are too strict - if (empty($classes)) { - $classes = $this->classSectionModel - ->select('id, class_section_id, class_section_name') - ->orderBy('class_section_name', 'ASC') - ->findAll(); - } + $classes = $this->enrollmentClassOptions((string)$selectedYear); return view('enroll_withdraw/enrollment_withdrawal', [ 'students' => $students, @@ -2432,22 +2415,10 @@ class AdministratorController extends BaseController public function enrollmentWithdrawalData() { try { - $selectedYear = trim((string)($this->request->getGet('schoolYear') ?? '')); - if ($selectedYear === '') { - $selectedYear = (string)$this->schoolYear; - } + $schoolYears = $this->availableSchoolYears(); + $selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears); - // Distinct school years - $yearsRows = $this->db->table('enrollments') - ->distinct() - ->select('school_year') - ->orderBy('school_year', 'DESC') - ->get()->getResultArray(); - $schoolYears = array_values(array_filter(array_map(static function ($r) { - return isset($r['school_year']) ? (string)$r['school_year'] : null; - }, $yearsRows))); - - $students = $this->studentModel->getStudentsWithClassAndEnrollment(); + $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear); $selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear); $removedPriorIds = []; @@ -2463,6 +2434,7 @@ class AdministratorController extends BaseController } } } + $returningStudentIds = $this->priorYearStudentIds($selectedYear); foreach ($students as &$s) { $s['student_id'] = (int)($s['id'] ?? 0); @@ -2485,6 +2457,9 @@ class AdministratorController extends BaseController $s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf) ?: 'ZZZ Unknown Parent'; $s['is_new'] = (int) ($s['is_new'] ?? 0); + if (isset($returningStudentIds[$s['student_id']])) { + $s['is_new'] = 0; + } $s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No'; $statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear); @@ -2492,6 +2467,9 @@ class AdministratorController extends BaseController $s['enrollment_status'] = $statusForYear; } elseif (($s['admission_status'] ?? null) === 'denied') { $s['enrollment_status'] = 'denied'; + } else { + $s['enrollment_status'] = 'admission under review'; + $s['admission_status'] = 'pending'; } $className = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear); @@ -2516,18 +2494,7 @@ class AdministratorController extends BaseController return strcasecmp($pa, $pb); }); - $classes = $this->classSectionModel - ->select('id, class_section_id, class_section_name, school_year, semester') - ->where('school_year', (string)$selectedYear) - ->where('semester', (string)$this->semester) - ->orderBy('class_section_name', 'ASC') - ->findAll(); - if (empty($classes)) { - $classes = $this->classSectionModel - ->select('id, class_section_id, class_section_name') - ->orderBy('class_section_name', 'ASC') - ->findAll(); - } + $classes = $this->enrollmentClassOptions((string)$selectedYear); return $this->response->setJSON([ 'students' => $students, @@ -2543,6 +2510,138 @@ class AdministratorController extends BaseController } } + private function availableSchoolYears(): array + { + $years = []; + $addYear = static function (mixed $value) use (&$years): void { + $year = trim((string) $value); + if ($year !== '' && !in_array($year, $years, true)) { + $years[] = $year; + } + }; + + $addYear($this->schoolYear ?? null); + + if ($this->db->tableExists('school_years')) { + $rows = $this->db->table('school_years') + ->select('name') + ->orderBy('name', 'DESC') + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $addYear($row['name'] ?? null); + } + } + + if ($this->db->tableExists('enrollments')) { + $rows = $this->db->table('enrollments') + ->distinct() + ->select('school_year') + ->where('school_year IS NOT NULL', null, false) + ->orderBy('school_year', 'DESC') + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $addYear($row['school_year'] ?? null); + } + } + + usort($years, static fn(string $a, string $b): int => strnatcasecmp($b, $a)); + + $activeYear = trim((string) ($this->schoolYear ?? '')); + if ($activeYear !== '') { + $years = array_values(array_filter($years, static fn(string $year): bool => $year !== $activeYear)); + array_unshift($years, $activeYear); + } + + return $years; + } + + private function selectedEnrollmentSchoolYear(array $schoolYears): string + { + $requestedYear = trim((string) ($this->request->getGet('schoolYear') ?? '')); + if ($requestedYear !== '' && in_array($requestedYear, $schoolYears, true)) { + return $requestedYear; + } + + $activeYear = trim((string) ($this->schoolYear ?? '')); + if ($activeYear !== '') { + return $activeYear; + } + + return (string) ($schoolYears[0] ?? ''); + } + + private function enrollmentClassOptions(string $selectedYear): array + { + $select = ['id', 'class_section_id', 'class_section_name']; + $hasSchoolYear = $this->db->fieldExists('school_year', 'classSection'); + $hasSemester = $this->db->fieldExists('semester', 'classSection'); + + if ($hasSchoolYear) { + $select[] = 'school_year'; + } + if ($hasSemester) { + $select[] = 'semester'; + } + + $query = $this->classSectionModel + ->select(implode(', ', $select)) + ->orderBy('class_section_name', 'ASC'); + + if ($hasSchoolYear && $selectedYear !== '') { + $query->where('school_year', $selectedYear); + } + if ($hasSemester) { + $query->where('semester', (string)$this->semester); + } + + $classes = $query->findAll(); + + if (! empty($classes) || (! $hasSchoolYear && ! $hasSemester)) { + return $classes; + } + + return $this->classSectionModel + ->select('id, class_section_id, class_section_name') + ->orderBy('class_section_name', 'ASC') + ->findAll(); + } + + private function priorYearStudentIds(string $selectedYear): array + { + $selectedStartYear = $this->getSchoolYearStartYear($selectedYear); + if ($selectedStartYear === null) { + return []; + } + + $studentIds = []; + foreach (['enrollments', 'student_class'] as $table) { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { + continue; + } + + $rows = $this->db->table($table) + ->select('student_id, school_year') + ->where('student_id IS NOT NULL', null, false) + ->where('school_year IS NOT NULL', null, false) + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $rowStartYear = $this->getSchoolYearStartYear((string) ($row['school_year'] ?? '')); + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId > 0 && $rowStartYear !== null && $rowStartYear < $selectedStartYear) { + $studentIds[$studentId] = true; + } + } + } + + return $studentIds; + } + /** * Show only newly registered students, with contact modal support. * diff --git a/app/Controllers/View/AssignmentController.php b/app/Controllers/View/AssignmentController.php index 1a44497..6a45cca 100644 --- a/app/Controllers/View/AssignmentController.php +++ b/app/Controllers/View/AssignmentController.php @@ -46,7 +46,7 @@ class AssignmentController extends BaseController // Apply school year filter (default to current config) but avoid semester filtering so the full year is visible $selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? ''); - $year = (string)($this->request->getGet('school_year') ?? $this->schoolYear ?? ''); + $year = (string)($this->schoolYear ?? ''); $tcQ = $this->teacherClassModel; if ($year !== '') { @@ -172,11 +172,11 @@ class AssignmentController extends BaseController } $schoolYearsList = []; - try { - $db = Database::connect(); - $yearsQuery = $db->table('teacher_class') - ->select('DISTINCT school_year', false) - ->where('school_year IS NOT NULL', null, false) + try { + $db = Database::connect(); + $yearsQuery = $db->table('teacher_class') + ->select('DISTINCT school_year', false) + ->where('school_year IS NOT NULL', null, false) ->orderBy('school_year', 'DESC') ->get() ->getResultArray(); @@ -189,8 +189,8 @@ class AssignmentController extends BaseController } catch (\Throwable $e) { // ignore fallback below } - if (empty($schoolYearsList) && $this->schoolYear !== null && $this->schoolYear !== '') { - $schoolYearsList[] = (string)$this->schoolYear; + if ($this->schoolYear !== null && $this->schoolYear !== '' && !in_array((string)$this->schoolYear, $schoolYearsList, true)) { + array_unshift($schoolYearsList, (string)$this->schoolYear); } // Sort sections diff --git a/app/Controllers/View/AttendanceController.php b/app/Controllers/View/AttendanceController.php index 3fdb1d3..61e1886 100644 --- a/app/Controllers/View/AttendanceController.php +++ b/app/Controllers/View/AttendanceController.php @@ -64,11 +64,20 @@ class AttendanceController extends Controller $this->userRoleModel = new UserRoleModel(); $this->semesterScoreService = service('semesterScoreService'); $this->semester = $this->configModel->getConfig('semester'); - $this->schoolYear = $this->configModel->getConfig('school_year'); + $this->schoolYear = $this->currentSchoolYearName(); $this->enableAttendance = $this->configModel->getConfig('enable_attendance'); $this->semesterRangeService = new SemesterRangeService($this->configModel); } + private function currentSchoolYearName(): string + { + try { + return service('schoolYearContext')->resolve(service('request'))->yearName(); + } catch (\Throwable $e) { + return (string) ($this->configModel->getConfig('school_year') ?? ''); + } + } + /** * GET: filter + grid for a single date/section. diff --git a/app/Controllers/View/AttendanceTrackingController.php b/app/Controllers/View/AttendanceTrackingController.php index bb1f765..13b279f 100644 --- a/app/Controllers/View/AttendanceTrackingController.php +++ b/app/Controllers/View/AttendanceTrackingController.php @@ -70,28 +70,6 @@ class AttendanceTrackingController extends BaseController ->findAll(); $debugInfo['class_students'] = count($classStudents); - // Fallback: if the configured school_year has no class assignments, look up the latest term - if (empty($classStudents)) { - $latestTerm = $this->db->table('student_class') - ->select('school_year, semester') - ->orderBy('id', 'DESC') - ->limit(1) - ->get() - ->getRowArray(); - - if ($latestTerm && !empty($latestTerm['school_year'])) { - $schoolYear = (string)$latestTerm['school_year']; - if (!empty($latestTerm['semester'])) { - $semester = (string)$latestTerm['semester']; - } - - $classStudents = $this->studentClassModel - ->active() - ->where('student_class.school_year', $schoolYear) - ->findAll(); - } - } - // Gather candidate student identifiers (numeric IDs and code-like IDs) $studentIds = []; $studentCodes = []; diff --git a/app/Controllers/View/EventController.php b/app/Controllers/View/EventController.php index 891e901..aa76174 100644 --- a/app/Controllers/View/EventController.php +++ b/app/Controllers/View/EventController.php @@ -62,7 +62,7 @@ class EventController extends ResourceController $this->parentModel = new ParentModel(); $this->emailService = new EmailService(); - $this->schoolYear = $this->configModel->getConfig('school_year'); + $this->schoolYear = $this->currentSchoolYearName(); $this->semester = $this->configModel->getConfig('semester'); $this->categories = [ 'workshops', @@ -88,6 +88,15 @@ class EventController extends ResourceController return $this->eventChargesHasCreatedBy; } + private function currentSchoolYearName(): string + { + try { + return service('schoolYearContext')->resolve(service('request'))->yearName(); + } catch (\Throwable $e) { + return (string) ($this->configModel->getConfig('school_year') ?? ''); + } + } + private function eventChargesSupportsWaiverSigned(): bool { if ($this->eventChargesHasWaiverSigned !== null) { diff --git a/app/Controllers/View/FinancialController.php b/app/Controllers/View/FinancialController.php index e64d2e5..6d55238 100644 --- a/app/Controllers/View/FinancialController.php +++ b/app/Controllers/View/FinancialController.php @@ -20,6 +20,60 @@ class FinancialController extends BaseController $accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? '')); return $this->request->isAJAX() || str_contains($accept, 'application/json'); } + + private function selectedSchoolYear(): string + { + $requested = trim((string) ($this->request->getGet('school_year') ?? '')); + if ($requested !== '') { + return $requested; + } + + return $this->currentSchoolYearName(); + } + + private function financialSchoolYearOptions(?string $selectedYear = null): array + { + $schoolYears = []; + $addYear = static function (mixed $value) use (&$schoolYears): void { + $year = trim((string) $value); + if ($year !== '' && ! in_array($year, $schoolYears, true)) { + $schoolYears[] = $year; + } + }; + + $addYear($selectedYear); + $addYear($this->currentSchoolYearName()); + + try { + $db = \Config\Database::connect(); + + if ($db->tableExists('school_years')) { + $rows = $db->table('school_years') + ->select('name') + ->orderBy('name', 'DESC') + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $addYear($row['name'] ?? ''); + } + } + + if ($db->tableExists('invoices') && $db->fieldExists('school_year', 'invoices')) { + $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 $row) { + $addYear($row['school_year'] ?? ''); + } + } + } catch (\Throwable $e) { + } + + return $schoolYears; + } public function financialReport() { @@ -32,7 +86,7 @@ public function financialReport() $dateFrom = $this->request->getGet('date_from'); $dateTo = $this->request->getGet('date_to'); - $schoolYear = $this->request->getGet('school_year'); + $schoolYear = $this->selectedSchoolYear(); // === Apply filters to models (invoice/refund/discount/expense/reimb) === if ($schoolYear) { @@ -235,22 +289,7 @@ public function financialReport() ->findAll(); // --- School year options (for detailed view & JSON) --- - $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($schoolYear)) { - $schoolYears[] = (string)$schoolYear; - } + $schoolYears = $this->financialSchoolYearOptions($schoolYear); $eventFeesTotal = $this->getEventFeesTotal($schoolYear, $dateFrom, $dateTo); @@ -298,28 +337,11 @@ public function financialReport() { $dateFrom = $this->request->getGet('date_from'); $dateTo = $this->request->getGet('date_to'); - $schoolYear = $this->request->getGet('school_year'); + $schoolYear = $this->selectedSchoolYear(); $data = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear); - // Build school year options from invoices table (fallback to configured year) - $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; + $data['schoolYears'] = $this->financialSchoolYearOptions((string)($data['schoolYear'] ?? $schoolYear)); if ($this->wantsJson() || strtolower((string)($this->request->getGet('format') ?? '')) === 'json') { return $this->response->setJSON(['ok' => true] + $data + [ 'csrf_token' => csrf_token(), @@ -524,7 +546,7 @@ public function financialReport() private function getFinancialSummaryDetailSections(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array { $configModel = new \App\Models\ConfigurationModel(); - $schoolYear = $schoolYear ?: $configModel->getConfig('school_year'); + $schoolYear = $schoolYear ?: $this->currentSchoolYearName(); $derivedDateFrom = null; $derivedDateTo = null; @@ -1612,7 +1634,7 @@ public function financialReport() $additionalModel = new \App\Models\AdditionalChargeModel(); // Allow override via parameter; fallback to configured year - $schoolYear = $schoolYear ?: $configModel->getConfig('school_year'); + $schoolYear = $schoolYear ?: $this->currentSchoolYearName(); $derivedDateFrom = null; $derivedDateTo = null; if (!empty($schoolYear) && empty($dateFrom) && empty($dateTo)) { @@ -2068,7 +2090,7 @@ public function financialReport() $configModel = new \App\Models\ConfigurationModel(); $schoolYear = trim((string)($this->request->getGet('school_year') ?? '')); if ($schoolYear === '') { - $schoolYear = (string) ($configModel->getConfig('school_year') ?? date('Y')); + $schoolYear = $this->currentSchoolYearName((string) ($configModel->getConfig('school_year') ?? date('Y'))); } // Build school year options from invoices diff --git a/app/Controllers/View/FlagController.php b/app/Controllers/View/FlagController.php index 6a62edc..d141d23 100644 --- a/app/Controllers/View/FlagController.php +++ b/app/Controllers/View/FlagController.php @@ -35,8 +35,11 @@ class FlagController extends Controller $grades = []; // Retrieve flags and class sections that currently have active students - $flags = $currentFlagModel->findAll(); $schoolYear = $configModel->getConfig('school_year'); + if ((string)$schoolYear !== '' && $this->db->fieldExists('school_year', 'current_flag')) { + $currentFlagModel->where('school_year', (string)$schoolYear); + } + $flags = $currentFlagModel->findAll(); $studentCounts = $studentClassModel->getStudentCountsBySection($schoolYear); $classSections = []; diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index f5e79ac..d9a205d 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -67,17 +67,17 @@ class InvoiceController extends ResourceController $this->chargesModel = new EventChargesModel(); $this->discountUsageModel = new DiscountUsageModel(); $this->refundModel = new RefundModel(); + $this->db = \Config\Database::connect(); + $this->request = \Config\Services::request(); $this->gradeFee = $this->configModel->getConfig('grade_fee'); - $this->schoolYear = $this->configModel->getConfig('school_year'); + $this->schoolYear = $this->currentSchoolYearName(); $this->semester = $this->configModel->getConfig('semester'); $this->dueDate = $this->configModel->getConfig('due_date'); $this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350); $this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200); $this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200); $this->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline'))); - $this->db = \Config\Database::connect(); - $this->request = \Config\Services::request(); } public function index($schoolYear = null) @@ -213,6 +213,15 @@ class InvoiceController extends ResourceController ]); } + private function currentSchoolYearName(): string + { + try { + return service('schoolYearContext')->resolve($this->request)->yearName(); + } catch (\Throwable $e) { + return (string) ($this->configModel->getConfig('school_year') ?? ''); + } + } + /** * API: Invoice management composite data (used by invoice_management view) * Returns the same structure previously rendered server-side in index(). @@ -221,7 +230,7 @@ class InvoiceController extends ResourceController { $schoolYear = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('year') ?? '')); if ($schoolYear === '') { - $schoolYear = $this->schoolYear; + $schoolYear = $this->currentSchoolYearName(); } $invoiceData = []; diff --git a/app/Controllers/View/LateSlipLogsController.php b/app/Controllers/View/LateSlipLogsController.php index 443442d..1428813 100644 --- a/app/Controllers/View/LateSlipLogsController.php +++ b/app/Controllers/View/LateSlipLogsController.php @@ -21,7 +21,7 @@ class LateSlipLogsController extends BaseController { $req = $this->request; - $defaultYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + $defaultYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); $defaultSem = (string) ($this->configModel->getConfig('semester') ?? ''); $schoolYear = trim((string) ($req->getGet('school_year') ?? $defaultYear)); $semester = trim((string) ($req->getGet('semester') ?? $defaultSem)); diff --git a/app/Controllers/View/NotificationsController.php b/app/Controllers/View/NotificationsController.php index fc55590..d0e282a 100644 --- a/app/Controllers/View/NotificationsController.php +++ b/app/Controllers/View/NotificationsController.php @@ -149,6 +149,11 @@ class NotificationsController extends BaseController $targetGroup = null; } + $schoolYear = (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? ''); + if ($schoolYear !== '' && db_connect()->fieldExists('school_year', 'notifications')) { + $this->notificationModel->where('school_year', $schoolYear); + } + $rows = $this->notificationModel->getActiveNotifications($targetGroup); if (!is_array($rows)) { $rows = []; diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index c441804..6118574 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -164,8 +164,7 @@ class ParentController extends BaseController return redirect()->back()->with('error', 'Parent session not found.'); } - // Get current school year from config - $currentSchoolYear = $this->configModel->getConfig('school_year'); + $currentSchoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); // Get selected school year (no semester filter on parent view) $selectedYear = $this->request->getVar('school_year') ?? $currentSchoolYear; @@ -1784,7 +1783,7 @@ $existing = $this->studentModel public function parentEventPage() { - $schoolYear = session()->get('school_year'); + $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); $semester = session()->get('semester'); $parentId = session()->get('user_id'); diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index 74460c1..f5438bc 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -63,7 +63,7 @@ class PaymentController extends ResourceController $this->enrollmentModel = new EnrollmentModel(); $this->studentModel = new StudentModel(); $this->studentClassModel = new StudentClassModel(); - $this->schoolYear = $this->configModel->getConfig('school_year'); + $this->schoolYear = $this->currentSchoolYearName(); $this->semester = $this->configModel->getConfig('semester'); //installment_date $this->installmentDate = $this->configModel->getConfig('installment_date'); $this->discountUsageModel = new DiscountUsageModel(); @@ -137,11 +137,20 @@ class PaymentController extends ResourceController private function getSelectedPaymentHistoryYear(): ?string { - $selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear; + $selectedYear = $this->request->getGet('school_year') ?? $this->currentSchoolYearName(); return $selectedYear !== '' ? $selectedYear : null; } + private function currentSchoolYearName(): string + { + try { + return service('schoolYearContext')->resolve($this->request)->yearName(); + } catch (\Throwable $e) { + return (string) ($this->configModel->getConfig('school_year') ?? ''); + } + } + // View: Create a new payment plan public function create() { @@ -204,7 +213,7 @@ class PaymentController extends ResourceController public function eventChargesShow() { $parents = $this->userModel->getParents(); - $school_Year = $this->request->getGet('school_year') ?? $this->schoolYear; + $school_Year = $this->request->getGet('school_year') ?? $this->currentSchoolYearName(); $seme_ster = $this->request->getGet('semester') ?? $this->semester; // Join with users and students for full info diff --git a/app/Controllers/View/ReimbursementController.php b/app/Controllers/View/ReimbursementController.php index fa73c52..a9ff4cf 100644 --- a/app/Controllers/View/ReimbursementController.php +++ b/app/Controllers/View/ReimbursementController.php @@ -1235,6 +1235,10 @@ public function updateBatchAssignment() $status = $this->request->getGet('status') ?: null; $filterYear = $this->request->getGet('school_year') ?: $this->schoolYear; $userId = $this->request->getGet('user_id') ?: null; + $hasBatchSchoolYear = $this->db->fieldExists('school_year', 'reimbursement_batches'); + $hasExpenseSchoolYear = $this->db->fieldExists('school_year', 'expenses'); + $hasExpenseSemester = $this->db->fieldExists('semester', 'expenses'); + $hasReimbursementSchoolYear = $this->db->fieldExists('school_year', 'reimbursements'); $filters = [ 'school_year' => $filterYear, @@ -1251,13 +1255,11 @@ public function updateBatchAssignment() // Build closed batch summaries/details (all closed batches) $batchSummaries = []; $batchDetails = []; - $batchQuery = $this->db->table('reimbursement_batches b') - ->select(" + $batchSelect = " b.id AS batch_id, b.title AS batch_title, b.yearly_batch_number, b.closed_at, - b.school_year AS batch_school_year, e.id AS expense_id, e.amount AS expense_amount, e.description, @@ -1268,7 +1270,13 @@ public function updateBatchAssignment() u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname, r.amount AS reimb_amount - ") + "; + if ($hasBatchSchoolYear) { + $batchSelect .= ', b.school_year AS batch_school_year'; + } + + $batchQuery = $this->db->table('reimbursement_batches b') + ->select($batchSelect) ->join('reimbursement_batch_items bi', 'bi.batch_id = b.id', 'inner') ->join('expenses e', 'e.id = bi.expense_id', 'inner') ->join('users u', 'u.id = e.purchased_by', 'left') @@ -1277,10 +1285,16 @@ public function updateBatchAssignment() ->where('bi.unassigned_at IS NULL', null, false); if (!empty($filterYear)) { - $batchQuery->groupStart() - ->where('b.school_year', $filterYear) - ->orWhere('e.school_year', $filterYear) - ->groupEnd(); + if ($hasBatchSchoolYear && $hasExpenseSchoolYear) { + $batchQuery->groupStart() + ->where('b.school_year', $filterYear) + ->orWhere('e.school_year', $filterYear) + ->groupEnd(); + } elseif ($hasBatchSchoolYear) { + $batchQuery->where('b.school_year', $filterYear); + } elseif ($hasExpenseSchoolYear) { + $batchQuery->where('e.school_year', $filterYear); + } } if (!empty($userId)) { $batchQuery->where('e.purchased_by', $userId); @@ -1401,27 +1415,33 @@ public function updateBatchAssignment() 'items' => [], 'checks' => [], ]; - $donationQuery = $this->db->table('expenses e') - ->select(' + $donationSelect = ' e.id AS expense_id, e.amount AS expense_amount, e.description, e.retailor, e.receipt_path AS expense_receipt, e.purchased_by, - e.school_year, - e.semester, e.status, u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname - ') + '; + if ($hasExpenseSchoolYear) { + $donationSelect .= ', e.school_year'; + } + if ($hasExpenseSemester) { + $donationSelect .= ', e.semester'; + } + + $donationQuery = $this->db->table('expenses e') + ->select($donationSelect) ->join('users u', 'u.id = e.purchased_by', 'left') ->where('e.category', 'Donation'); - if (!empty($filterYear)) { + if (!empty($filterYear) && $hasExpenseSchoolYear) { $donationQuery->where('e.school_year', $filterYear); } - if (!empty($semester)) { + if (!empty($semester) && $hasExpenseSemester) { $donationQuery->where('e.semester', $semester); } if (!empty($userId)) { @@ -1488,13 +1508,25 @@ public function updateBatchAssignment() $users = $this->recipientOptions(); - $years = $this->db->table('reimbursements') - ->select('school_year') - ->distinct() - ->orderBy('school_year', 'DESC') - ->get() - ->getResultArray(); - $schoolYears = array_column($years, 'school_year'); + $schoolYears = []; + if ($hasReimbursementSchoolYear) { + $years = $this->db->table('reimbursements') + ->select('school_year') + ->distinct() + ->orderBy('school_year', 'DESC') + ->get() + ->getResultArray(); + $schoolYears = array_column($years, 'school_year'); + } elseif ($hasExpenseSchoolYear) { + $years = $this->db->table('expenses') + ->select('school_year') + ->distinct() + ->where('school_year IS NOT NULL', null, false) + ->orderBy('school_year', 'DESC') + ->get() + ->getResultArray(); + $schoolYears = array_column($years, 'school_year'); + } // Add school years from fallback expenses (closed batches without reimbursements) if (!empty($fallbackRows)) { diff --git a/app/Controllers/View/SchoolYearSelectionController.php b/app/Controllers/View/SchoolYearSelectionController.php new file mode 100644 index 0000000..92532ff --- /dev/null +++ b/app/Controllers/View/SchoolYearSelectionController.php @@ -0,0 +1,84 @@ +request->getPost('school_year_id') ?? 0); + $returnTo = (string) ($this->request->getPost('return_to') ?? ''); + + if ($schoolYearId <= 0) { + return redirect()->to($this->safeReturnTo($returnTo)) + ->with('error', 'Please select a valid school year.'); + } + + try { + service('schoolYearContext')->select($schoolYearId); + return redirect()->to($this->safeReturnTo($returnTo)); + } catch (\Throwable $e) { + log_message('warning', 'School-year selection failed: {message}', [ + 'message' => $e->getMessage(), + ]); + + return redirect()->to($this->safeReturnTo($returnTo)) + ->with('error', 'The selected school year is not available.'); + } + } + + public function reset(): RedirectResponse + { + service('schoolYearContext')->clearSelection(); + + return redirect()->to($this->safeReturnTo((string) ($this->request->getPost('return_to') ?? ''))); + } + + private function safeReturnTo(string $returnTo): string + { + $fallback = $this->dashboardRoute(); + $returnTo = trim($returnTo); + + if ($returnTo === '') { + return $fallback; + } + + $parts = parse_url($returnTo); + if ($parts === false) { + return $fallback; + } + + if (isset($parts['host']) && strcasecmp((string) $parts['host'], (string) $this->request->getUri()->getHost()) !== 0) { + return $fallback; + } + + $path = '/' . ltrim((string) ($parts['path'] ?? ''), '/'); + if ($path === '/' || str_starts_with($path, '//')) { + return $fallback; + } + + $query = []; + if (! empty($parts['query'])) { + parse_str((string) $parts['query'], $query); + foreach (['school_year_id', 'school_year', 'schoolYear', 'year'] as $key) { + unset($query[$key]); + } + } + + $cleanQuery = http_build_query($query); + + return $path . ($cleanQuery !== '' ? '?' . $cleanQuery : ''); + } + + private function dashboardRoute(): string + { + try { + return service('roleService')->bestDashboardRouteFor((array) session()->get('roles')); + } catch (\Throwable) { + return '/landing_page/guest_dashboard'; + } + } +} diff --git a/app/Controllers/View/ScorePredictor.php b/app/Controllers/View/ScorePredictor.php index 80d54f5..c2afb3e 100644 --- a/app/Controllers/View/ScorePredictor.php +++ b/app/Controllers/View/ScorePredictor.php @@ -2,12 +2,11 @@ namespace App\Controllers\View; +use App\Controllers\BaseController; use App\Models\ConfigurationModel; use App\Models\ClassSectionModel; -use CodeIgniter\Controller; - -class ScorePredictor extends Controller +class ScorePredictor extends BaseController { protected $db; protected $configModel; @@ -60,17 +59,30 @@ class ScorePredictor extends Controller public function combinedReport() { $request = service('request'); - $currentSchoolYear = $this->schoolYear; + $currentSchoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); $selectedYear = $request->getVar('school_year') ?? $currentSchoolYear; $classSectionId = $request->getVar('class_section_id'); + $yearEsc = $this->db->escape($selectedYear); + $acceptedEnrollmentStatuses = ['payment pending', 'enrolled']; // Only show class sections that have at least one student in the selected year $classSections = $this->db->table('classSection cs') ->select('cs.*') + ->distinct() ->join('student_class sc', 'sc.class_section_id = cs.class_section_id', 'inner') + ->join('enrollments e', 'e.student_id = sc.student_id AND e.school_year = ' . $yearEsc, 'inner') ->where('sc.school_year', $selectedYear) + ->whereIn('e.enrollment_status', $acceptedEnrollmentStatuses) + ->where('e.admission_status', 'accepted') + ->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd() + ->groupStart() + ->where('sc.is_event_only', 0) + ->orWhere('sc.is_event_only', null) + ->groupEnd() ->notLike('cs.class_section_name', 'KG', 'after') - ->groupBy('cs.id') ->orderBy('cs.class_section_name', 'ASC') ->get() ->getResultArray(); @@ -85,12 +97,23 @@ class ScorePredictor extends Controller MAX(spring.semester_score) as spring_score'); // Reduce duplication from restored students while keeping a stable class section. $builder->select('MAX(sc.class_section_id) as class_section_id'); - $yearEsc = $this->db->escape($selectedYear); - $builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'left'); + $builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'inner'); + $builder->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $yearEsc, 'inner'); $builder->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left'); - $builder->join('semester_scores fall', 'fall.student_id = s.id AND fall.semester = "fall" AND fall.school_year = "' . $selectedYear . '"', 'left'); - $builder->join('semester_scores spring', 'spring.student_id = s.id AND spring.semester = "spring" AND spring.school_year = "' . $selectedYear . '"', 'left'); + $builder->join('semester_scores fall', 'fall.student_id = s.id AND fall.semester = "fall" AND fall.school_year = ' . $yearEsc, 'left'); + $builder->join('semester_scores spring', 'spring.student_id = s.id AND spring.semester = "spring" AND spring.school_year = ' . $yearEsc, 'left'); $builder->where('s.is_active', 1); + $builder->where('sc.class_section_id IS NOT NULL', null, false); + $builder->whereIn('e.enrollment_status', $acceptedEnrollmentStatuses); + $builder->where('e.admission_status', 'accepted'); + $builder->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd(); + $builder->groupStart() + ->where('sc.is_event_only', 0) + ->orWhere('sc.is_event_only', null) + ->groupEnd(); $builder->groupStart() ->where('cs.class_section_name IS NULL') ->orNotLike('cs.class_section_name', 'KG', 'after') @@ -106,11 +129,22 @@ class ScorePredictor extends Controller // Stats for spring $springStatsQuery = $this->db->table('semester_scores') ->select('AVG(semester_scores.semester_score) as mean, STDDEV(semester_scores.semester_score) as std') - ->join('student_class', 'student_class.student_id = semester_scores.student_id') + ->join('student_class', 'student_class.student_id = semester_scores.student_id AND student_class.school_year = ' . $yearEsc) + ->join('enrollments e', 'e.student_id = semester_scores.student_id AND e.school_year = ' . $yearEsc, 'inner') ->join('classSection cs', 'cs.class_section_id = student_class.class_section_id', 'left') ->where('semester_scores.semester', 'spring') ->where('semester_scores.school_year', $selectedYear) - ->where('student_class.school_year', $selectedYear) + ->where('student_class.class_section_id IS NOT NULL', null, false) + ->whereIn('e.enrollment_status', $acceptedEnrollmentStatuses) + ->where('e.admission_status', 'accepted') + ->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd() + ->groupStart() + ->where('student_class.is_event_only', 0) + ->orWhere('student_class.is_event_only', null) + ->groupEnd() ->groupStart() ->where('cs.class_section_name IS NULL') ->orNotLike('cs.class_section_name', 'KG', 'after') diff --git a/app/Controllers/View/StudentController.php b/app/Controllers/View/StudentController.php index c5a08db..05386a9 100644 --- a/app/Controllers/View/StudentController.php +++ b/app/Controllers/View/StudentController.php @@ -386,21 +386,8 @@ class StudentController extends BaseController { $schoolYear = (string)($this->schoolYear ?? ''); - $activeStudents = $this->studentModel - ->select('id, school_id, firstname, lastname, gender, age') - ->where('is_active', 1) - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->findAll(); - - $removedStudents = $this->studentModel - ->select('id, school_id, firstname, lastname, gender, age') - ->where('is_active', 0) - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->findAll(); - $classMap = []; + $activeYearStudentIds = []; $classQuery = $this->db->table('student_class sc') ->select('sc.student_id, cs.class_section_name') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left'); @@ -411,11 +398,35 @@ class StudentController extends BaseController foreach ($classRows as $row) { $sid = (int)($row['student_id'] ?? 0); + if ($sid > 0) { + $activeYearStudentIds[$sid] = true; + } + $name = trim((string)($row['class_section_name'] ?? '')); if ($sid <= 0 || $name === '') continue; $classMap[$sid][] = $name; } + $students = $this->studentModel + ->select('id, school_id, firstname, lastname, gender, age, is_active') + ->orderBy('lastname', 'ASC') + ->orderBy('firstname', 'ASC') + ->findAll(); + + $activeStudents = []; + $removedStudents = []; + foreach ($students as $student) { + $studentId = (int)($student['id'] ?? 0); + $isGloballyActive = (int)($student['is_active'] ?? 0) === 1; + $hasSelectedYearClass = $studentId > 0 && isset($activeYearStudentIds[$studentId]); + + if ($isGloballyActive && $hasSelectedYearClass) { + $activeStudents[] = $student; + } else { + $removedStudents[] = $student; + } + } + $attachClassNames = static function (array $students) use ($classMap): array { foreach ($students as &$student) { $sid = (int)($student['id'] ?? 0); @@ -432,6 +443,7 @@ class StudentController extends BaseController 'active_students' => $attachClassNames($activeStudents), 'removed_students' => $attachClassNames($removedStudents), 'school_year' => $schoolYear, + 'active_school_year' => (string)($this->schoolYear ?? ''), ]); } @@ -1671,8 +1683,7 @@ class StudentController extends BaseController ->findAll(); } } elseif (in_array($role, ['teacher', 'teacher_assistant', 'teacher_dashboard'], true)) { - $configModel = new \App\Models\ConfigurationModel(); - $schoolYear = session()->get('school_year') ?? $configModel->getConfig('school_year'); + $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); $classSectionId = (int)(session()->get('class_section_id') ?? 0); if ($classSectionId > 0 && !empty($schoolYear)) { diff --git a/app/Controllers/View/TrophyController.php b/app/Controllers/View/TrophyController.php index 719cf27..66812d3 100644 --- a/app/Controllers/View/TrophyController.php +++ b/app/Controllers/View/TrophyController.php @@ -20,7 +20,7 @@ class TrophyController extends BaseController { $db = \Config\Database::connect(); - $currentYear = $this->configModel->getConfig('school_year') ?? ''; + $currentYear = $this->currentSchoolYearName(); $selectedYear = $this->request->getGet('school_year') ?? $currentYear; $percentile = (float) ($this->request->getGet('percentile') ?? 75); @@ -166,7 +166,7 @@ class TrophyController extends BaseController { $db = \Config\Database::connect(); - $currentYear = $this->configModel->getConfig('school_year') ?? ''; + $currentYear = $this->currentSchoolYearName(); $selectedYear = $this->request->getGet('school_year') ?? $currentYear; $percentile = (float) ($this->request->getGet('percentile') ?? 75); $percentile = max(1.0, min(99.0, $percentile)); @@ -283,7 +283,7 @@ class TrophyController extends BaseController { $db = \Config\Database::connect(); - $currentYear = $this->configModel->getConfig('school_year') ?? ''; + $currentYear = $this->currentSchoolYearName(); $selectedYear = $this->request->getGet('school_year') ?? $currentYear; $percentile = (float) ($this->request->getGet('percentile') ?? 75); $percentile = max(1.0, min(99.0, $percentile)); diff --git a/app/Controllers/View/UserController.php b/app/Controllers/View/UserController.php index 2f7e64e..d0f9008 100644 --- a/app/Controllers/View/UserController.php +++ b/app/Controllers/View/UserController.php @@ -961,12 +961,18 @@ class UserController extends BaseController $perPage = max(1, min($perPage, 200)); $page = max(1, $page); - $totalActivities = (int) $this->loginActivityModel->countAll(); - $activities = $this->loginActivityModel + $schoolYear = (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? ''); + $activityModel = $this->loginActivityModel; + if ($schoolYear !== '' && db_connect()->fieldExists('school_year', 'login_activity')) { + $activityModel->where('school_year', $schoolYear); + } + + $totalActivities = (int) $activityModel->countAllResults(false); + $activities = $activityModel ->orderBy('login_time', 'DESC') ->paginate($perPage, 'loginActivity', $page) ?? []; - $pager = $this->loginActivityModel->pager; + $pager = $activityModel->pager; $currentPage = $pager ? (int) $pager->getCurrentPage('loginActivity') : $page; $pageCount = $pager ? (int) $pager->getPageCount('loginActivity') : (int) max(1, ceil($totalActivities / $perPage)); diff --git a/app/Controllers/View/WhatsappController.php b/app/Controllers/View/WhatsappController.php index a55e1de..cab4455 100644 --- a/app/Controllers/View/WhatsappController.php +++ b/app/Controllers/View/WhatsappController.php @@ -103,24 +103,14 @@ class WhatsappController extends BaseController $sectionName = $row['class_section_name'] ?? ('Section ' . $sectionId); } - $existing = $this->linkModel->where([ - 'class_section_id' => $sectionId, - 'school_year' => $this->schoolYear, - ])->first(); - - $payload = [ - 'class_section_id' => $sectionId, - 'class_section_name' => $sectionName, - 'school_year' => $this->schoolYear, - 'invite_link' => trim($inviteLink), - 'active' => $active, - ]; - - if ($existing) { - $this->linkModel->update($existing['id'], $payload); - } else { - $this->linkModel->insert($payload); - } + $this->linkModel->upsertLinkForSection( + $sectionId, + $sectionName, + (string) $this->schoolYear, + '', + $inviteLink, + $active === 1 + ); return redirect()->back()->with('success', 'WhatsApp group link saved.'); } diff --git a/app/Controllers/WinnersController.php b/app/Controllers/WinnersController.php index c70eb2e..852d54f 100644 --- a/app/Controllers/WinnersController.php +++ b/app/Controllers/WinnersController.php @@ -13,6 +13,11 @@ class WinnersController extends BaseController public function competitionIndex() { $competitionModel = new CompetitionModel(); + $schoolYear = $this->currentSchoolYearName(); + + if ($schoolYear !== '') { + $competitionModel->where('school_year', $schoolYear); + } $competitions = $competitionModel ->where('is_published', 1) @@ -21,6 +26,7 @@ class WinnersController extends BaseController return view('winners/competitions/index', [ 'competitions' => $competitions, + 'schoolYear' => $schoolYear, ]); } @@ -31,7 +37,14 @@ class WinnersController extends BaseController $classSectionModel = new ClassSectionModel(); $classWinnerModel = new CompetitionClassWinnerModel(); - $competition = $competitionModel->where('is_published', 1)->find($id); + $schoolYear = $this->currentSchoolYearName(); + + $competitionModel->where('is_published', 1); + if ($schoolYear !== '') { + $competitionModel->where('school_year', $schoolYear); + } + + $competition = $competitionModel->find($id); if (!$competition) { return redirect()->to('/winners/competitions'); } @@ -81,6 +94,7 @@ class WinnersController extends BaseController 'winnersByClass' => $winnersByClass, 'sectionMap' => $sectionMap, 'questionCounts' => $questionCounts, + 'schoolYear' => $schoolYear, ]); } } diff --git a/app/Exceptions/SchoolYear/InvalidSchoolYearSelectionException.php b/app/Exceptions/SchoolYear/InvalidSchoolYearSelectionException.php new file mode 100644 index 0000000..d4fd3c5 --- /dev/null +++ b/app/Exceptions/SchoolYear/InvalidSchoolYearSelectionException.php @@ -0,0 +1,9 @@ + $e->getMessage(), ]); + $status = $this->statusCodeFor($e); + + if ($request->isAJAX() || str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json')) { + return service('response') + ->setStatusCode($status) + ->setJSON([ + 'status' => $status, + 'error' => 'Invalid school year', + 'message' => $e->getMessage(), + ]); + } + return service('response') - ->setStatusCode(400) - ->setJSON([ - 'status' => 400, - 'error' => 'Invalid school year', - 'message' => $e->getMessage(), - ]); + ->setStatusCode($status) + ->setBody(view('errors/school_year', [ + 'message' => $status >= 500 + ? 'School-year configuration is invalid. Please contact an administrator.' + : $e->getMessage(), + ])); } } @@ -38,4 +53,21 @@ final class RequireSchoolYearFilter implements FilterInterface { return null; } + + private function statusCodeFor(Throwable $e): int + { + if ($e instanceof InvalidSchoolYearSelectionException) { + return 400; + } + + if ($e instanceof SchoolYearNotFoundException) { + return 404; + } + + if ($e instanceof SchoolYearConfigurationException) { + return 500; + } + + return 400; + } } diff --git a/app/Filters/SchoolYearWritableFilter.php b/app/Filters/SchoolYearWritableFilter.php new file mode 100644 index 0000000..441a7cd --- /dev/null +++ b/app/Filters/SchoolYearWritableFilter.php @@ -0,0 +1,48 @@ +assertWritable( + service('schoolYearContext')->resolve($request) + ); + + return null; + } catch (SchoolYearWriteConflictException $e) { + log_message('warning', 'Historical school-year write blocked: {message}', [ + 'message' => $e->getMessage(), + ]); + + if ($request->isAJAX() || str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json')) { + return service('response') + ->setStatusCode(409) + ->setJSON([ + 'status' => 409, + 'error' => 'Read-only school year', + 'message' => $e->getMessage(), + ]); + } + + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + return null; + } +} diff --git a/app/Helpers/GlobalConfigHelper.php b/app/Helpers/GlobalConfigHelper.php index a94ec50..e537d1a 100644 --- a/app/Helpers/GlobalConfigHelper.php +++ b/app/Helpers/GlobalConfigHelper.php @@ -12,6 +12,16 @@ if (!function_exists('getSemester')) { if (!function_exists('getSchoolYear')) { function getSchoolYear() { + if (! is_cli() && session()->get('user_id')) { + try { + return service('schoolYearContext')->resolve(service('request'))->yearName(); + } catch (\Throwable $e) { + log_message('warning', 'Falling back to configured school year: {message}', [ + 'message' => $e->getMessage(), + ]); + } + } + $configModel = new \App\Models\ConfigurationModel(); return $configModel->getConfig('school_year'); } diff --git a/app/Models/ConfigurationModel.php b/app/Models/ConfigurationModel.php index 0a6463d..101e594 100644 --- a/app/Models/ConfigurationModel.php +++ b/app/Models/ConfigurationModel.php @@ -77,6 +77,13 @@ class ConfigurationModel extends Model public function getConfig($key) { $key = (string) $key; + if ($key === 'school_year') { + $activeSchoolYear = $this->activeSchoolYearName(); + if ($activeSchoolYear !== null) { + return $activeSchoolYear; + } + } + if ($key === 'semester') { try { $semester = (new \App\Services\SemesterRangeService($this))->getSemesterForDate(); @@ -90,4 +97,26 @@ class ConfigurationModel extends Model return $this->getConfigValueByKey($key); } + + private function activeSchoolYearName(): ?string + { + try { + if (! $this->db->tableExists('school_years')) { + return null; + } + + $row = $this->db->table('school_years') + ->select('name') + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + $name = trim((string) ($row['name'] ?? '')); + + return $name !== '' ? $name : null; + } catch (\Throwable) { + return null; + } + } } diff --git a/app/Models/ExpenseModel.php b/app/Models/ExpenseModel.php index ef5ba28..f7b639e 100644 --- a/app/Models/ExpenseModel.php +++ b/app/Models/ExpenseModel.php @@ -39,23 +39,59 @@ class ExpenseModel extends Model { $db = \Config\Database::connect(); $builder = $db->table('expenses'); - $builder->select('expenses.*, - r.id AS reimb_id, - r.amount AS reimb_amount, r.reimbursement_method, r.check_number, - r.receipt_path AS reimb_receipt, r.status AS reimb_status, - r.school_year, r.semester, r.created_at AS reimb_created_at, - r.reimbursed_to AS reimb_recipient_id, - reimb.firstname AS reimb_firstname, reimb.lastname AS reimb_lastname, - approver.firstname AS approver_firstname, approver.lastname AS approver_lastname'); + $hasReimbursementSchoolYear = $db->fieldExists('school_year', 'reimbursements'); + $hasReimbursementSemester = $db->fieldExists('semester', 'reimbursements'); + $hasExpenseSchoolYear = $db->fieldExists('school_year', 'expenses'); + $hasExpenseSemester = $db->fieldExists('semester', 'expenses'); + + $select = [ + 'expenses.*', + 'r.id AS reimb_id', + 'r.amount AS reimb_amount', + 'r.reimbursement_method', + 'r.check_number', + 'r.receipt_path AS reimb_receipt', + 'r.status AS reimb_status', + 'r.created_at AS reimb_created_at', + 'r.reimbursed_to AS reimb_recipient_id', + 'reimb.firstname AS reimb_firstname', + 'reimb.lastname AS reimb_lastname', + 'approver.firstname AS approver_firstname', + 'approver.lastname AS approver_lastname', + ]; + + if ($hasReimbursementSchoolYear) { + $select[] = 'r.school_year'; + } elseif ($hasExpenseSchoolYear) { + $select[] = 'expenses.school_year AS school_year'; + } + + if ($hasReimbursementSemester) { + $select[] = 'r.semester'; + } elseif ($hasExpenseSemester) { + $select[] = 'expenses.semester AS semester'; + } else { + $select[] = 'NULL AS semester'; + } + + $builder->select(implode(",\n", $select), false); $builder->join('reimbursements r', 'r.expense_id = expenses.id', 'inner'); $builder->join('users reimb', 'reimb.id = r.reimbursed_to', 'left'); $builder->join('users approver', 'approver.id = r.approved_by', 'left'); if (!empty($filters['school_year'])) { - $builder->where('r.school_year', $filters['school_year']); + if ($hasReimbursementSchoolYear) { + $builder->where('r.school_year', $filters['school_year']); + } elseif ($hasExpenseSchoolYear) { + $builder->where('expenses.school_year', $filters['school_year']); + } } if (!empty($filters['semester'])) { - $builder->where('r.semester', $filters['semester']); + if ($hasReimbursementSemester) { + $builder->where('r.semester', $filters['semester']); + } elseif ($hasExpenseSemester) { + $builder->where('expenses.semester', $filters['semester']); + } } if (!empty($filters['status'])) { $builder->where('r.status', $filters['status']); diff --git a/app/Models/SchoolYearModel.php b/app/Models/SchoolYearModel.php index eead4b1..b5819aa 100644 --- a/app/Models/SchoolYearModel.php +++ b/app/Models/SchoolYearModel.php @@ -40,8 +40,14 @@ class SchoolYearModel extends Model public function active(): ?array { - return $this->where('status', 'active') + $rows = $this->where('status', 'active') ->orderBy('id', 'DESC') - ->first(); + ->findAll(2); + + if (count($rows) !== 1) { + return null; + } + + return $rows[0]; } } diff --git a/app/Models/StudentModel.php b/app/Models/StudentModel.php index 2cf6833..e5b9a65 100644 --- a/app/Models/StudentModel.php +++ b/app/Models/StudentModel.php @@ -341,8 +341,17 @@ class StudentModel extends Model return array_column($results, 'id'); } - public function getStudentsWithClassAndEnrollment() + public function getStudentsWithClassAndEnrollment(?string $schoolYear = null) { + $schoolYear = trim((string) $schoolYear); + $studentClassJoin = 'student_class.student_id = students.id'; + $enrollmentJoin = 'enrollments.student_id = students.id'; + + if ($schoolYear !== '') { + $studentClassJoin .= ' AND student_class.school_year = ' . $this->db->escape($schoolYear); + $enrollmentJoin .= ' AND enrollments.school_year = ' . $this->db->escape($schoolYear); + } + return $this->select(' students.*, student_class.class_section_id, @@ -351,8 +360,8 @@ class StudentModel extends Model u.firstname AS parent_firstname, u.lastname AS parent_lastname ') - ->join('student_class', 'student_class.student_id = students.id', 'left') - ->join('enrollments', 'enrollments.student_id = students.id', 'left') + ->join('student_class', $studentClassJoin, 'left') + ->join('enrollments', $enrollmentJoin, 'left') ->join('users u', 'u.id = students.parent_id', 'left') // keep your original grouping as-is so behavior doesn't change ->groupBy('students.id, student_class.class_section_id, enrollments.enrollment_status, enrollments.admission_status') diff --git a/app/Models/WhatsappGroupLinkModel.php b/app/Models/WhatsappGroupLinkModel.php index 30f1a71..47c5552 100644 --- a/app/Models/WhatsappGroupLinkModel.php +++ b/app/Models/WhatsappGroupLinkModel.php @@ -16,6 +16,17 @@ class WhatsappGroupLinkModel extends Model ]; protected $useTimestamps = true; // requires created_at / updated_at columns + private ?bool $hasSchoolYearColumn = null; + + private function hasSchoolYearColumn(): bool + { + if ($this->hasSchoolYearColumn === null) { + $this->hasSchoolYearColumn = $this->db->fieldExists('school_year', $this->table); + } + + return $this->hasSchoolYearColumn; + } + /** * Get the link for a specific section in a specific term. * @@ -36,8 +47,11 @@ class WhatsappGroupLinkModel extends Model $sem = trim($sem); $b = $this->asArray() - ->where('class_section_id', $sectionId) - ->where('school_year', $year); + ->where('class_section_id', $sectionId); + + if ($this->hasSchoolYearColumn()) { + $b = $b->where('school_year', $year); + } if ($onlyActive) { $b = $b->where('active', 1); @@ -69,8 +83,11 @@ class WhatsappGroupLinkModel extends Model $year = trim($year); $sem = trim($sem); - $b = $this->asArray() - ->where('school_year', $year); + $b = $this->asArray(); + + if ($this->hasSchoolYearColumn()) { + $b = $b->where('school_year', $year); + } if ($onlyActive === true) { $b = $b->where('active', 1); @@ -98,16 +115,23 @@ class WhatsappGroupLinkModel extends Model $payload = [ 'class_section_id' => $sectionId, 'class_section_name' => $sectionName, - 'school_year' => trim($year), 'invite_link' => trim($inviteLink), 'active' => $active ? 1 : 0, ]; + if ($this->hasSchoolYearColumn()) { + $payload['school_year'] = trim($year); + } + // Try to find existing row (exact term) $existing = $this->asArray() - ->where('class_section_id', $sectionId) - ->where('school_year', $payload['school_year']) - ->first(); + ->where('class_section_id', $sectionId); + + if ($this->hasSchoolYearColumn()) { + $existing = $existing->where('school_year', $payload['school_year']); + } + + $existing = $existing->first(); if ($existing) { $this->update((int)$existing['id'], $payload); diff --git a/app/Services/SchoolYearAccessPolicy.php b/app/Services/SchoolYearAccessPolicy.php new file mode 100644 index 0000000..0d81a50 --- /dev/null +++ b/app/Services/SchoolYearAccessPolicy.php @@ -0,0 +1,39 @@ +canViewDraft($userId); + } + + public function canSelect(?int $userId, array $year): bool + { + return $this->canView($userId, $year); + } + + public function canViewDraft(?int $userId): bool + { + if ($userId === null || $userId <= 0) { + return false; + } + + $roles = array_map( + static fn ($role): string => strtolower(trim((string) $role)), + (array) session()->get('roles') + ); + + return (bool) array_intersect($roles, [ + 'admin', + 'administrator', + 'administrative staff', + 'principal', + ]); + } +} diff --git a/app/Services/SchoolYearClosingService.php b/app/Services/SchoolYearClosingService.php index de42dba..c1f2832 100644 --- a/app/Services/SchoolYearClosingService.php +++ b/app/Services/SchoolYearClosingService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\SchoolYearClosingBatchModel; use App\Models\SchoolYearClosingItemModel; use App\Models\SchoolYearModel; +use App\Models\ConfigurationModel; use App\Support\SchoolYear\SchoolYearStatus; use CodeIgniter\Database\BaseConnection; use InvalidArgumentException; @@ -16,6 +17,7 @@ final class SchoolYearClosingService private readonly SchoolYearModel $schoolYearModel, private readonly SchoolYearClosingBatchModel $batchModel, private readonly SchoolYearClosingItemModel $itemModel, + private readonly ConfigurationModel $configurationModel, private readonly SchoolYearManagementService $managementService, private readonly BaseConnection $db, ) { @@ -50,6 +52,29 @@ final class SchoolYearClosingService $findings[] = $finding; } + $promotion = $this->promotionPreview($sourceName, $target !== null ? (string) ($target['name'] ?? '') : null); + if (($promotion['summary']['missing_decision'] ?? 0) > 0) { + $findings[] = $this->finding( + 'blocking', + 'Students missing promotion decisions', + $promotion['summary']['missing_decision'] . ' active student(s) do not have a saved promotion decision for this school year.' + ); + } + if (($promotion['summary']['pending_decision'] ?? 0) > 0) { + $findings[] = $this->finding( + 'blocking', + 'Students with pending promotion decisions', + $promotion['summary']['pending_decision'] . ' active student(s) still have pending promotion decisions.' + ); + } + if (($promotion['summary']['missing_queue'] ?? 0) > 0) { + $findings[] = $this->finding( + 'warning', + 'Passed students missing promotion queue', + $promotion['summary']['missing_queue'] . ' passed student(s) are not queued for the target school year. This will not block closing.' + ); + } + if ($this->countMissingSchoolYearRows('invoices') > 0) { $findings[] = $this->finding('blocking', 'Invoices missing school year', 'Some invoice records are not assigned to a school year.'); } @@ -63,6 +88,7 @@ final class SchoolYearClosingService 'target' => $target, 'overview' => $overview, 'finance' => $finance, + 'promotion' => $promotion, 'findings' => $findings, 'blockers' => $blockers, 'warnings' => $warnings, @@ -183,6 +209,12 @@ final class SchoolYearClosingService throw new InvalidArgumentException('All closing batch items must complete before the year can be closed.'); } + $targetYearId = (int) ($batch['target_school_year_id'] ?? 0); + $target = $targetYearId > 0 ? $this->schoolYearModel->find($targetYearId) : null; + if ($target === null) { + throw new InvalidArgumentException('A target school year is required before completing closing.'); + } + $this->db->transStart(); $now = date('Y-m-d H:i:s'); $this->batchModel->update((int) $batch['id'], [ @@ -195,8 +227,25 @@ final class SchoolYearClosingService 'closed_at' => $now, 'updated_by' => $userId, ]); + $this->schoolYearModel->update($targetYearId, [ + 'status' => SchoolYearStatus::ACTIVE, + 'previous_school_year_id' => $sourceYearId, + 'activated_at' => $target['activated_at'] ?? $now, + 'updated_by' => $userId, + ]); + $this->schoolYearModel->update($sourceYearId, [ + 'next_school_year_id' => $targetYearId, + ]); + $targetName = (string) ($target['name'] ?? ''); + $this->configurationModel->setConfigValueByKey('school_year', $targetName); + $this->syncActiveYearSession($targetName); $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSED, 'closing_complete', $userId, [ 'closing_batch_id' => (int) $batch['id'], + 'activated_school_year_id' => $targetYearId, + ]); + $this->managementService->log($targetYearId, (string) ($target['status'] ?? SchoolYearStatus::DRAFT), SchoolYearStatus::ACTIVE, 'activate_after_closing', $userId, [ + 'closed_school_year_id' => $sourceYearId, + 'closing_batch_id' => (int) $batch['id'], ]); $this->db->transComplete(); @@ -322,11 +371,21 @@ final class SchoolYearClosingService return []; } - $rows = $this->db->table('invoices i') + $builder = $this->db->table('invoices i') ->select('i.parent_id AS family_id') ->select('COALESCE(SUM(i.balance), 0) AS source_balance') - ->where('i.school_year', $schoolYear) - ->groupBy('i.parent_id') + ->where('i.school_year', $schoolYear); + + if ($this->db->tableExists('users')) { + $builder + ->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname, u.email AS parent_email') + ->join('users u', 'u.id = i.parent_id', 'left') + ->groupBy('i.parent_id, u.firstname, u.lastname, u.email'); + } else { + $builder->groupBy('i.parent_id'); + } + + $rows = $builder ->having('source_balance !=', 0) ->orderBy('i.parent_id', 'ASC') ->get() @@ -338,6 +397,8 @@ final class SchoolYearClosingService return [ 'family_id' => (int) $row['family_id'], 'family' => 'Family #' . (int) $row['family_id'], + 'parent' => trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: 'Parent #' . (int) $row['family_id'], + 'parent_email' => (string) ($row['parent_email'] ?? ''), 'source_balance' => $balance, 'credit_amount' => $balance < 0 ? abs($balance) : 0.0, 'adjustment_amount' => 0.0, @@ -346,6 +407,315 @@ final class SchoolYearClosingService }, $rows); } + private function promotionPreview(string $schoolYear, ?string $targetSchoolYear): array + { + $summary = [ + 'total_students' => 0, + 'with_decision' => 0, + 'missing_decision' => 0, + 'pending_decision' => 0, + 'pass' => 0, + 'other_decision' => 0, + 'queued' => 0, + 'assigned' => 0, + 'applied' => 0, + 'missing_queue' => 0, + ]; + + if (! $this->db->tableExists('student_class') || ! $this->db->tableExists('students')) { + return [ + 'summary' => $summary, + 'rows' => [], + ]; + } + + $hasEventOnly = $this->db->fieldExists('is_event_only', 'student_class'); + $hasActive = $this->db->fieldExists('is_active', 'students'); + $hasDob = $this->db->fieldExists('dob', 'students'); + $hasRegistrationGrade = $this->db->fieldExists('registration_grade', 'students'); + + $builder = $this->db->table('student_class sc') + ->select('sc.student_id, sc.class_section_id, sc.created_at, sc.updated_at') + ->select('s.school_id, s.firstname, s.lastname') + ->select('cs.class_section_name') + ->join('students s', 's.id = sc.student_id', 'inner') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->where('sc.school_year', $schoolYear) + ->where('sc.class_section_id IS NOT NULL', null, false); + + if ($hasDob) { + $builder->select('s.dob'); + } + + if ($hasRegistrationGrade) { + $builder->select('s.registration_grade'); + } + + if ($hasEventOnly) { + $builder->groupStart() + ->where('sc.is_event_only', 0) + ->orWhere('sc.is_event_only', null) + ->groupEnd(); + } + + if ($hasActive) { + $builder->where('s.is_active', 1); + } + + $assignmentRows = $builder + ->orderBy('sc.student_id', 'ASC') + ->orderBy('sc.updated_at', 'DESC') + ->orderBy('sc.created_at', 'DESC') + ->get() + ->getResultArray(); + + $students = []; + foreach ($assignmentRows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($students[$studentId])) { + continue; + } + + $students[$studentId] = [ + 'student_id' => $studentId, + 'school_id' => (string) ($row['school_id'] ?? ''), + 'student_name' => trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')), + 'class_section_id' => (int) ($row['class_section_id'] ?? 0), + 'class_section_name' => (string) ($row['class_section_name'] ?? ''), + 'dob' => (string) ($row['dob'] ?? ''), + 'registration_grade' => (string) ($row['registration_grade'] ?? ''), + 'auto_kg_pass' => false, + 'year_score' => null, + 'decision' => '', + 'source' => 'missing', + 'notes' => '', + 'status' => 'missing', + 'queue_status' => '', + 'target_class' => '', + 'target_section' => '', + ]; + } + + if ($students === []) { + return [ + 'summary' => $summary, + 'rows' => [], + ]; + } + + $decisions = $this->promotionDecisionRows(array_keys($students), $schoolYear); + $queueRows = $this->promotionQueueRows(array_keys($students), $schoolYear, $targetSchoolYear); + foreach ($students as $studentId => &$student) { + $decision = $decisions[$studentId] ?? null; + if ($decision !== null) { + $student['year_score'] = $decision['year_score']; + $student['decision'] = $decision['decision']; + $student['source'] = $decision['source']; + $student['notes'] = $decision['notes']; + $student['status'] = $decision['status']; + if (($decision['class_section_name'] ?? '') !== '') { + $student['class_section_name'] = $decision['class_section_name']; + } + } + + if ($decision === null && $this->isKgStudent($student)) { + $kgAgeStatus = $this->kgAgeStatusByTargetYearCutoff((string) $student['dob'], $targetSchoolYear); + if ($kgAgeStatus === 'pass') { + $student['decision'] = 'Pass'; + $student['source'] = 'automatic_kg_age'; + $student['notes'] = 'Auto-pass KG student: age 6 or older by Dec 31 of the next school year start year.'; + $student['status'] = 'decided'; + $student['auto_kg_pass'] = true; + } elseif ($kgAgeStatus === 'keep_kg') { + $student['decision'] = 'Keep KG'; + $student['source'] = 'automatic_kg_age'; + $student['notes'] = 'Auto-keep KG student: younger than 6 by Dec 31 of the next school year start year.'; + $student['status'] = 'decided'; + } + } + + $queue = $queueRows[$studentId] ?? null; + if ($queue !== null) { + $student['queue_status'] = $queue['status']; + $student['target_class'] = $queue['target_class']; + $student['target_section'] = $queue['target_section']; + } + + $summary['total_students']++; + if ($student['status'] === 'missing') { + $summary['missing_decision']++; + } elseif ($student['status'] === 'pending') { + $summary['pending_decision']++; + } else { + $summary['with_decision']++; + if (strcasecmp((string) $student['decision'], 'Pass') === 0) { + $summary['pass']++; + if ($queue === null && $student['auto_kg_pass'] !== true) { + $summary['missing_queue']++; + } + } else { + $summary['other_decision']++; + } + } + + if ($queue !== null && isset($summary[$queue['status']])) { + $summary[$queue['status']]++; + } + } + unset($student); + + $rows = array_values($students); + usort($rows, static function (array $a, array $b): int { + return [$a['class_section_name'], $a['student_name'], $a['student_id']] + <=> [$b['class_section_name'], $b['student_name'], $b['student_id']]; + }); + + return [ + 'summary' => $summary, + 'rows' => $rows, + ]; + } + + private function isKgStudent(array $student): bool + { + foreach (['class_section_name', 'registration_grade'] as $field) { + $value = strtoupper(trim((string) ($student[$field] ?? ''))); + if ($value === 'KG' || str_starts_with($value, 'KG-') || str_contains($value, 'KINDERGARTEN')) { + return true; + } + } + + return false; + } + + private function kgAgeStatusByTargetYearCutoff(string $dob, ?string $targetSchoolYear): string + { + $dob = trim($dob); + if ($dob === '' || $targetSchoolYear === null || ! preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches)) { + return ''; + } + + try { + $birthDate = new \DateTimeImmutable($dob); + $cutoff = new \DateTimeImmutable($matches[1] . '-12-31'); + } catch (\Throwable) { + return ''; + } + + if ($birthDate > $cutoff) { + return ''; + } + + $age = $birthDate->diff($cutoff)->y; + if ($age >= 6) { + return 'pass'; + } + + return 'keep_kg'; + } + + private function promotionDecisionRows(array $studentIds, string $schoolYear): array + { + if (! $this->db->tableExists('student_decisions')) { + return []; + } + + $select = ['student_id', 'class_section_name', 'decision', 'source', 'notes']; + $scoreField = null; + if ($this->db->fieldExists('year_score', 'student_decisions')) { + $scoreField = 'year_score'; + } elseif ($this->db->fieldExists('semester_score', 'student_decisions')) { + $scoreField = 'semester_score'; + } + + if ($scoreField !== null) { + $select[] = $scoreField . ' AS year_score'; + } + + $rows = $this->db->table('student_decisions') + ->select($select) + ->where('school_year', $schoolYear) + ->whereIn('student_id', $studentIds) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $decisions = []; + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($decisions[$studentId])) { + continue; + } + + $decision = trim((string) ($row['decision'] ?? '')); + $source = trim((string) ($row['source'] ?? '')); + $status = $decision === '' || $source === 'pending' ? 'pending' : 'decided'; + + $decisions[$studentId] = [ + 'class_section_name' => (string) ($row['class_section_name'] ?? ''), + 'year_score' => is_numeric($row['year_score'] ?? null) ? round((float) $row['year_score'], 2) : null, + 'decision' => $decision, + 'source' => $source !== '' ? $source : ($status === 'pending' ? 'pending' : 'manual'), + 'notes' => (string) ($row['notes'] ?? ''), + 'status' => $status, + ]; + } + + return $decisions; + } + + private function promotionQueueRows(array $studentIds, string $sourceSchoolYear, ?string $targetSchoolYear): array + { + if ( + $targetSchoolYear === null + || $targetSchoolYear === '' + || ! $this->db->tableExists('promotion_queue') + ) { + return []; + } + + $builder = $this->db->table('promotion_queue pq') + ->select('pq.student_id, pq.to_class_id, pq.to_class_section_id, pq.status') + ->select('c.class_name AS target_class') + ->select('cs.class_section_name AS target_section') + ->join('classes c', 'c.id = pq.to_class_id', 'left') + ->join('classSection cs', 'cs.class_section_id = pq.to_class_section_id', 'left') + ->where('pq.school_year_from', $sourceSchoolYear) + ->where('pq.school_year_to', $targetSchoolYear) + ->whereIn('pq.student_id', $studentIds) + ->orderBy('pq.updated_at', 'DESC') + ->orderBy('pq.id', 'DESC'); + + $rows = $builder->get()->getResultArray(); + + $queueRows = []; + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($queueRows[$studentId])) { + continue; + } + + $targetClass = trim((string) ($row['target_class'] ?? '')); + if ($targetClass === '' && (int) ($row['to_class_id'] ?? 0) > 0) { + $targetClass = 'Class #' . (int) $row['to_class_id']; + } + + $targetSection = trim((string) ($row['target_section'] ?? '')); + if ($targetSection === '' && (int) ($row['to_class_section_id'] ?? 0) > 0) { + $targetSection = 'Section #' . (int) $row['to_class_section_id']; + } + + $queueRows[$studentId] = [ + 'status' => (string) ($row['status'] ?? ''), + 'target_class' => $targetClass, + 'target_section' => $targetSection, + ]; + } + + return $queueRows; + } + private function countBySchoolYear(string $table, string $schoolYear, string $distinctField): int { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { @@ -423,8 +793,20 @@ final class SchoolYearClosingService 'source_id' => $preview['source']['id'] ?? null, 'target_id' => $preview['target']['id'] ?? null, 'finance' => $preview['finance'], + 'promotion' => $preview['promotion'], 'carry_forward' => $preview['carry_forward'], 'blockers' => $preview['blockers'], ], JSON_UNESCAPED_SLASHES)); } + + private function syncActiveYearSession(string $schoolYearName): void + { + if (session_status() !== PHP_SESSION_ACTIVE) { + return; + } + + session()->set('school_year', $schoolYearName); + session()->remove('selected_school_year_id'); + session()->remove('selected_school_year'); + } } diff --git a/app/Services/SchoolYearContextService.php b/app/Services/SchoolYearContextService.php index eb21c8c..7c1287a 100644 --- a/app/Services/SchoolYearContextService.php +++ b/app/Services/SchoolYearContextService.php @@ -2,15 +2,19 @@ namespace App\Services; +use App\Exceptions\SchoolYear\InvalidSchoolYearSelectionException; +use App\Exceptions\SchoolYear\SchoolYearConfigurationException; +use App\Exceptions\SchoolYear\SchoolYearNotFoundException; use App\Models\SchoolYearModel; +use App\Support\SchoolYear\SchoolYearStatus; use App\Support\SchoolYear\SchoolYearContext; use CodeIgniter\HTTP\IncomingRequest; -use RuntimeException; final class SchoolYearContextService { public function __construct( private readonly SchoolYearModel $schoolYearModel, + private readonly SchoolYearAccessPolicy $accessPolicy = new SchoolYearAccessPolicy(), ) { } @@ -18,29 +22,28 @@ final class SchoolYearContextService IncomingRequest $request, ?int $routeSchoolYearId = null ): SchoolYearContext { - $requestedId = $routeSchoolYearId - ?? $this->normalizeInt($request->getGet('school_year_id')); - - $requestedName = trim((string) $request->getGet('school_year')); + $requestedId = $routeSchoolYearId ?? $this->normalizeInt($request->getGet('school_year_id')); + $requestedName = $this->legacyYearName($request); + $userId = (int) (session()->get('user_id') ?? 0); if ($requestedId !== null && $requestedName !== '') { $row = $this->schoolYearModel->find($requestedId); if ($row === null || (string) ($row['name'] ?? '') !== $requestedName) { - throw new RuntimeException('Selected school-year parameters conflict.'); + throw new InvalidSchoolYearSelectionException('Selected school-year parameters conflict.'); } - return $this->fromRow($row, true); + return $this->authorizedContext($row, $userId, true); } if ($requestedId !== null) { $row = $this->schoolYearModel->find($requestedId); if ($row === null) { - throw new RuntimeException('Selected school year was not found.'); + throw new SchoolYearNotFoundException('Selected school year was not found.'); } - return $this->fromRow($row, true); + return $this->authorizedContext($row, $userId, true); } if ($requestedName !== '') { @@ -49,10 +52,10 @@ final class SchoolYearContextService ->first(); if ($row === null) { - throw new RuntimeException('Selected school year was not found.'); + throw new SchoolYearNotFoundException('Selected school year was not found.'); } - return $this->fromRow($row, true); + return $this->authorizedContext($row, $userId, true); } $sessionYearId = session('selected_school_year_id'); @@ -60,15 +63,73 @@ final class SchoolYearContextService if (is_numeric($sessionYearId)) { $row = $this->schoolYearModel->find((int) $sessionYearId); - if ($row !== null) { - return $this->fromRow($row, false); + if ($row !== null && $this->isSelectable($row, $userId)) { + return $this->fromRow($row, true); } + + $this->clearSelection(); } + return $this->active(); + } + + public function selectableYears(bool $includeDraft = false): array + { + $builder = $this->schoolYearModel + ->orderBy('name', 'DESC') + ->orderBy('id', 'DESC'); + + if (! $includeDraft) { + $builder->where('status !=', SchoolYearStatus::DRAFT); + } + + $userId = (int) (session()->get('user_id') ?? 0); + + return array_values(array_filter( + $builder->findAll(), + fn (array $row): bool => $this->isSelectable($row, $userId) + )); + } + + public function select(int $schoolYearId): SchoolYearContext + { + $row = $this->schoolYearModel->find($schoolYearId); + + if ($row === null) { + throw new SchoolYearNotFoundException('Selected school year was not found.'); + } + + $userId = (int) (session()->get('user_id') ?? 0); + $context = $this->authorizedContext($row, $userId, true); + $previousSelection = session('selected_school_year_id'); + + if ($context->isActive()) { + $this->clearSelection(); + $context = $this->fromRow($row, false); + } else { + session()->set('selected_school_year_id', $context->id()); + session()->remove('selected_school_year'); + } + + if ((string) $previousSelection !== (string) session('selected_school_year_id')) { + $this->clearDependentSessionState(); + } + + return $context; + } + + public function clearSelection(): void + { + session()->remove('selected_school_year_id'); + session()->remove('selected_school_year'); + } + + public function active(): SchoolYearContext + { $active = $this->schoolYearModel->active(); if ($active === null) { - throw new RuntimeException('No active school year is configured.'); + throw new SchoolYearConfigurationException('Exactly one active school year must be configured.'); } return $this->fromRow($active, false); @@ -83,12 +144,61 @@ final class SchoolYearContextService return (int) $value; } + private function legacyYearName(IncomingRequest $request): string + { + $names = []; + + foreach (['school_year', 'schoolYear', 'year'] as $key) { + $value = trim((string) ($request->getGet($key) ?? '')); + if ($value !== '') { + $names[$key] = $value; + } + } + + if (count(array_unique($names)) > 1) { + throw new InvalidSchoolYearSelectionException('Conflicting school-year parameters were provided.'); + } + + if (isset($names['schoolYear']) || isset($names['year'])) { + log_message('notice', 'Legacy school-year request alias used: {aliases}', [ + 'aliases' => implode(',', array_keys($names)), + ]); + } + + return $names === [] ? '' : (string) reset($names); + } + + private function authorizedContext(array $row, int $userId, bool $explicit): SchoolYearContext + { + if (! $this->isSelectable($row, $userId)) { + throw new SchoolYearNotFoundException('Selected school year was not found.'); + } + + return $this->fromRow($row, $explicit); + } + + private function isSelectable(array $row, int $userId): bool + { + return $this->accessPolicy->canSelect($userId > 0 ? $userId : null, $row); + } + + private function clearDependentSessionState(): void + { + session()->remove([ + 'class_section_id', + 'semester', + 'active_semester', + 'teacher_scores_selected_semester', + 'grading_selected_semester', + ]); + } + private function fromRow(array $row, bool $explicit): SchoolYearContext { return new SchoolYearContext( id: (int) $row['id'], yearName: (string) $row['name'], - status: (string) $row['status'], + status: strtolower((string) $row['status']), explicitSelection: $explicit, ); } diff --git a/app/Services/SchoolYearManagementService.php b/app/Services/SchoolYearManagementService.php index 6bd5af0..a664128 100644 --- a/app/Services/SchoolYearManagementService.php +++ b/app/Services/SchoolYearManagementService.php @@ -105,6 +105,7 @@ final class SchoolYearManagementService 'updated_by' => $userId, ]); $this->configurationModel->setConfigValueByKey('school_year', (string) $year['name']); + $this->syncActiveYearSession((string) $year['name']); $this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId); $this->db->transComplete(); @@ -310,4 +311,15 @@ final class SchoolYearManagementService return is_string($first) && $first !== '' ? $first : $fallback; } + + private function syncActiveYearSession(string $schoolYearName): void + { + if (session_status() !== PHP_SESSION_ACTIVE) { + return; + } + + session()->set('school_year', $schoolYearName); + session()->remove('selected_school_year_id'); + session()->remove('selected_school_year'); + } } diff --git a/app/Services/SchoolYearViewDataService.php b/app/Services/SchoolYearViewDataService.php new file mode 100644 index 0000000..f1cd157 --- /dev/null +++ b/app/Services/SchoolYearViewDataService.php @@ -0,0 +1,29 @@ +contextService->resolve($request); + $userId = (int) (session()->get('user_id') ?? 0); + + return [ + 'schoolYearContext' => $context, + 'schoolYearOptions' => $this->contextService->selectableYears( + $this->accessPolicy->canViewDraft($userId) + ), + 'schoolYearReadonly' => $context->isReadonly(), + 'schoolYearSelectorEnabled' => config('Feature')->globalSchoolYearSelector, + ]; + } +} diff --git a/app/Services/SchoolYearWriteGuard.php b/app/Services/SchoolYearWriteGuard.php index c370a8c..b2dde80 100644 --- a/app/Services/SchoolYearWriteGuard.php +++ b/app/Services/SchoolYearWriteGuard.php @@ -2,8 +2,8 @@ namespace App\Services; +use App\Exceptions\SchoolYear\SchoolYearWriteConflictException; use App\Support\SchoolYear\SchoolYearContext; -use RuntimeException; final class SchoolYearWriteGuard { @@ -12,14 +12,14 @@ final class SchoolYearWriteGuard bool $allowDraftForAdmin = false, bool $isAdmin = false ): void { - if ($context->status() === 'active') { + if (strtolower($context->status()) === 'active') { return; } - if ($context->status() === 'draft' && $allowDraftForAdmin && $isAdmin) { + if (strtolower($context->status()) === 'draft' && $allowDraftForAdmin && $isAdmin) { return; } - throw new RuntimeException('The selected school year is read-only.'); + throw new SchoolYearWriteConflictException('The selected school year is read-only.'); } } diff --git a/app/Support/SchoolYear/SchoolYearContext.php b/app/Support/SchoolYear/SchoolYearContext.php index 63f21f6..73c07c1 100644 --- a/app/Support/SchoolYear/SchoolYearContext.php +++ b/app/Support/SchoolYear/SchoolYearContext.php @@ -24,17 +24,22 @@ final class SchoolYearContext public function status(): string { - return $this->status; + return strtolower($this->status); } public function isActive(): bool { - return $this->status === 'active'; + return $this->status() === 'active'; } public function isReadonly(): bool { - return in_array($this->status, ['closed', 'archived'], true); + return ! $this->isActive(); + } + + public function isHistorical(): bool + { + return ! $this->isActive(); } public function isExplicitSelection(): bool @@ -47,7 +52,7 @@ final class SchoolYearContext return [ 'id' => $this->id, 'name' => $this->yearName, - 'status' => $this->status, + 'status' => $this->status(), 'readonly' => $this->isReadonly(), 'explicitSelection' => $this->explicitSelection, ]; diff --git a/app/Support/SchoolYear/SchoolYearStatus.php b/app/Support/SchoolYear/SchoolYearStatus.php index 2e372d5..1ac0989 100644 --- a/app/Support/SchoolYear/SchoolYearStatus.php +++ b/app/Support/SchoolYear/SchoolYearStatus.php @@ -32,6 +32,11 @@ final class SchoolYearStatus } public static function isReadonly(string $status): bool + { + return self::isLifecycleMetadataLocked($status); + } + + public static function isLifecycleMetadataLocked(string $status): bool { return in_array($status, [self::CLOSED, self::ARCHIVED], true); } diff --git a/app/Views/admin/certificates/index.php b/app/Views/admin/certificates/index.php index 3cf7f2c..6d12e9c 100644 --- a/app/Views/admin/certificates/index.php +++ b/app/Views/admin/certificates/index.php @@ -61,22 +61,6 @@ $decisionBadge = [ Generate Certificates - -
-
- - - -
-
- getFlashdata('error')): ?>