diff --git a/app/Http/Controllers/Api/Family/FamilyAdminController.php b/app/Http/Controllers/Api/Family/FamilyAdminController.php index df89cdab..47c95980 100644 --- a/app/Http/Controllers/Api/Family/FamilyAdminController.php +++ b/app/Http/Controllers/Api/Family/FamilyAdminController.php @@ -9,9 +9,9 @@ use App\Http\Requests\Families\FamilyAdminSearchRequest; use App\Http\Requests\Families\FamilyComposeEmailRequest; use App\Http\Resources\Families\FamilyResource; use App\Http\Resources\Families\FamilySearchItemResource; -use App\Models\Configuration; use App\Services\Families\FamilyNotificationService; use App\Services\Families\FamilyQueryService; +use App\Services\SchoolYears\SelectedSchoolYearContextService; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\Response; @@ -20,7 +20,8 @@ class FamilyAdminController extends BaseApiController { public function __construct( private FamilyQueryService $queryService, - private FamilyNotificationService $notificationService + private FamilyNotificationService $notificationService, + private SelectedSchoolYearContextService $schoolYears ) { parent::__construct(); } @@ -28,7 +29,7 @@ class FamilyAdminController extends BaseApiController public function index(FamilyAdminIndexRequest $request): JsonResponse { $payload = $request->validated(); - $schoolYear = $this->requestedSchoolYear($request, $payload); + $schoolYear = $this->requestedSchoolYear($request); $data = $this->queryService->adminIndexData( $payload['student_id'] ?? null, @@ -63,7 +64,7 @@ class FamilyAdminController extends BaseApiController public function card(FamilyAdminCardRequest $request): JsonResponse { $payload = $request->validated(); - $schoolYear = $this->requestedSchoolYear($request, $payload); + $schoolYear = $this->requestedSchoolYear($request); $family = $this->queryService->familyCard( $payload['student_id'] ?? null, @@ -79,24 +80,9 @@ class FamilyAdminController extends BaseApiController return $this->success(new FamilyResource($family)); } - private function requestedSchoolYear($request, array $payload): string + private function requestedSchoolYear($request): string { - $candidates = [ - $payload['school_year'] ?? null, - $request->query('school_year'), - $request->header('X-School-Year'), - $request->header('X-Selected-School-Year'), - Configuration::getConfig('school_year'), - ]; - - foreach ($candidates as $candidate) { - $value = trim((string) ($candidate ?? '')); - if ($value !== '') { - return $value; - } - } - - return ''; + return $this->schoolYears->fromRequest($request)->name; } public function composeEmail(FamilyComposeEmailRequest $request): JsonResponse diff --git a/app/Http/Controllers/Api/Family/ParentProfileAdminController.php b/app/Http/Controllers/Api/Family/ParentProfileAdminController.php new file mode 100644 index 00000000..9ac7bfaf --- /dev/null +++ b/app/Http/Controllers/Api/Family/ParentProfileAdminController.php @@ -0,0 +1,113 @@ +has('school_year_id')) { + return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $context = $this->schoolYears->fromRequest($request); + $page = $this->profiles->index( + $context->name, + trim((string) $request->query('q', '')), + (int) $request->query('per_page', 25), + ); + + return $this->success([ + 'school_year' => $context->name, + 'read_only' => $context->isReadOnly, + 'parents' => $page->getCollection() + ->map(fn ($row) => $this->profiles->parentSummary((array) $row, $context->name)) + ->values() + ->all(), + 'pagination' => [ + 'page' => $page->currentPage(), + 'per_page' => $page->perPage(), + 'total' => $page->total(), + ], + ]); + } + + public function search(Request $request): JsonResponse + { + if ($request->has('school_year_id')) { + return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $context = $this->schoolYears->fromRequest($request); + + return $this->success([ + 'school_year' => $context->name, + 'read_only' => $context->isReadOnly, + 'items' => $this->profiles->search( + $context->name, + trim((string) $request->query('q', '')), + (int) $request->query('limit', 10), + ), + ]); + } + + public function show(Request $request, int $parent): JsonResponse + { + if ($request->has('school_year_id')) { + return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $context = $this->schoolYears->fromRequest($request); + $payload = $this->profiles->show($parent, $context->name); + + if (! $payload) { + return $this->error('Parent profile not found.', Response::HTTP_NOT_FOUND); + } + + return $this->success([ + 'school_year' => $context->name, + 'read_only' => $context->isReadOnly, + ...$payload, + ]); + } + + public function update(Request $request, int $parent): JsonResponse + { + if ($request->has('school_year_id')) { + return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $payload = $request->validate([ + 'firstname' => ['sometimes', 'required', 'string', 'max:100'], + 'lastname' => ['sometimes', 'required', 'string', 'max:100'], + 'email' => ['sometimes', 'nullable', 'email', 'max:255'], + 'cellphone' => ['sometimes', 'nullable', 'string', 'max:30'], + 'address_street' => ['sometimes', 'nullable', 'string', 'max:255'], + 'city' => ['sometimes', 'nullable', 'string', 'max:100'], + 'state' => ['sometimes', 'nullable', 'string', 'max:100'], + 'zip' => ['sometimes', 'nullable', 'string', 'max:30'], + 'status' => ['sometimes', 'nullable', 'string', 'max:30'], + ]); + + $updated = $this->profiles->updateParent($parent, $payload); + if (! $updated) { + return $this->error('Parent profile not found.', Response::HTTP_NOT_FOUND); + } + + return $this->success(['parent' => $updated], 'Parent profile updated.'); + } +} diff --git a/app/Http/Controllers/Api/SchoolYears/SchoolYearController.php b/app/Http/Controllers/Api/SchoolYears/SchoolYearController.php index 7dbb9c3a..7a3dd723 100644 --- a/app/Http/Controllers/Api/SchoolYears/SchoolYearController.php +++ b/app/Http/Controllers/Api/SchoolYears/SchoolYearController.php @@ -8,6 +8,7 @@ use App\Http\Requests\SchoolYears\PreviewCloseSchoolYearRequest; use App\Http\Requests\SchoolYears\SaveSchoolYearRequest; use App\Services\SchoolYears\SchoolYearClosureService; use App\Services\SchoolYears\SchoolYearContextService; +use App\Services\SchoolYears\SelectedSchoolYearContextService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -17,7 +18,8 @@ class SchoolYearController extends BaseApiController { public function __construct( private SchoolYearClosureService $service, - private SchoolYearContextService $context + private SchoolYearContextService $context, + private SelectedSchoolYearContextService $selectedSchoolYear ) { parent::__construct(); } @@ -71,11 +73,28 @@ class SchoolYearController extends BaseApiController ]); } + public function updateSelected(SaveSchoolYearRequest $request): JsonResponse + { + $selected = $this->selectedSchoolYear->fromRequest($request); + + return response()->json([ + 'ok' => true, + 'data' => $this->service->updateYear($selected->id, $request->validated(), $this->getCurrentUserId()), + ]); + } + public function summary(int $schoolYear): JsonResponse { return response()->json(['ok' => true, 'data' => $this->service->summary($schoolYear)]); } + public function summarySelected(Request $request): JsonResponse + { + $selected = $this->selectedSchoolYear->fromRequest($request); + + return response()->json(['ok' => true, 'data' => $this->service->summaryByYearName($selected->name)]); + } + public function validateClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse { return response()->json([ @@ -84,6 +103,16 @@ class SchoolYearController extends BaseApiController ]); } + public function validateCloseSelected(PreviewCloseSchoolYearRequest $request): JsonResponse + { + $selected = $this->selectedSchoolYear->fromRequest($request); + + return response()->json([ + 'ok' => true, + 'validation' => $this->service->validateCloseByYearName($selected->name, $request->validated()), + ]); + } + public function previewClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse { return response()->json([ @@ -92,6 +121,16 @@ class SchoolYearController extends BaseApiController ]); } + public function previewCloseSelected(PreviewCloseSchoolYearRequest $request): JsonResponse + { + $selected = $this->selectedSchoolYear->fromRequest($request); + + return response()->json([ + 'ok' => true, + 'data' => $this->service->previewCloseByYearName($selected->name, $request->validated()), + ]); + } + public function close(CloseSchoolYearRequest $request, int $schoolYear): JsonResponse { try { @@ -108,6 +147,24 @@ class SchoolYearController extends BaseApiController return response()->json(['ok' => true, 'data' => $result]); } + public function closeSelected(CloseSchoolYearRequest $request): JsonResponse + { + $selected = $this->selectedSchoolYear->fromRequest($request); + + try { + $result = $this->service->closeByYearName($selected->name, $request->validated(), $this->getCurrentUserId()); + } catch (ValidationException $e) { + throw $e; + } catch (\Throwable $e) { + return response()->json([ + 'ok' => false, + 'message' => $e->getMessage(), + ], Response::HTTP_CONFLICT); + } + + return response()->json(['ok' => true, 'data' => $result]); + } + public function reopen(Request $request, int $schoolYear): JsonResponse { return response()->json([ @@ -116,6 +173,16 @@ class SchoolYearController extends BaseApiController ]); } + public function reopenSelected(Request $request): JsonResponse + { + $selected = $this->selectedSchoolYear->fromRequest($request); + + return response()->json([ + 'ok' => true, + 'data' => $this->service->reopenByYearName($selected->name, $this->getCurrentUserId()), + ]); + } + public function parentBalances(Request $request, int $schoolYear): JsonResponse { return response()->json([ @@ -124,6 +191,16 @@ class SchoolYearController extends BaseApiController ]); } + public function parentBalancesSelected(Request $request): JsonResponse + { + $selected = $this->selectedSchoolYear->fromRequest($request); + + return response()->json([ + 'ok' => true, + 'data' => $this->service->parentBalancesByYearName($selected->name, $request->query('to_school_year')), + ]); + } + public function promotionPreview(Request $request, int $schoolYear): JsonResponse { return response()->json([ @@ -132,6 +209,16 @@ class SchoolYearController extends BaseApiController ]); } + public function promotionPreviewSelected(Request $request): JsonResponse + { + $selected = $this->selectedSchoolYear->fromRequest($request); + + return response()->json([ + 'ok' => true, + 'data' => $this->service->promotionPreviewByYearName($selected->name, $request->query('to_school_year')), + ]); + } + public function closingReport(int $schoolYear): JsonResponse { return response()->json([ @@ -140,6 +227,16 @@ class SchoolYearController extends BaseApiController ]); } + public function closingReportSelected(Request $request): JsonResponse + { + $selected = $this->selectedSchoolYear->fromRequest($request); + + return response()->json([ + 'ok' => true, + 'data' => $this->service->closingReportByYearName($selected->name), + ]); + } + public function archive(Request $request, int $schoolYear): JsonResponse { return response()->json([ @@ -147,4 +244,14 @@ class SchoolYearController extends BaseApiController 'data' => $this->service->archive($schoolYear, $this->getCurrentUserId()), ]); } + + public function archiveSelected(Request $request): JsonResponse + { + $selected = $this->selectedSchoolYear->fromRequest($request); + + return response()->json([ + 'ok' => true, + 'data' => $this->service->archiveByYearName($selected->name, $this->getCurrentUserId()), + ]); + } } diff --git a/app/Http/Middleware/EnsureSchoolYearEditable.php b/app/Http/Middleware/EnsureSchoolYearEditable.php index dc96c1a9..b453e8a8 100644 --- a/app/Http/Middleware/EnsureSchoolYearEditable.php +++ b/app/Http/Middleware/EnsureSchoolYearEditable.php @@ -2,17 +2,20 @@ namespace App\Http\Middleware; -use App\Models\Configuration; +use App\Services\SchoolYears\SelectedSchoolYearContextService; use App\Services\SchoolYears\SchoolYearWriteGuard; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; -use RuntimeException; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpFoundation\Response; class EnsureSchoolYearEditable { - public function __construct(private SchoolYearWriteGuard $guard) {} + public function __construct( + private SchoolYearWriteGuard $guard, + private SelectedSchoolYearContextService $selectedSchoolYear + ) {} public function handle(Request $request, Closure $next): Response { @@ -22,11 +25,16 @@ class EnsureSchoolYearEditable try { $this->guard->assertEditable($this->resolveSchoolYears($request)); - } catch (RuntimeException $e) { + } catch (HttpExceptionInterface $e) { return response()->json([ 'ok' => false, 'message' => $e->getMessage(), - ], 409); + ], $e->getStatusCode()); + } catch (\RuntimeException $e) { + return response()->json([ + 'ok' => false, + 'message' => $e->getMessage(), + ], Response::HTTP_CONFLICT); } return $next($request); @@ -36,11 +44,11 @@ class EnsureSchoolYearEditable { $years = []; - foreach (['school_year', 'current_school_year'] as $field) { - $value = $request->input($field, $request->query($field)); - if (is_string($value) && trim($value) !== '') { - $years[] = trim($value); - } + $years[] = $this->selectedSchoolYear->fromRequest($request)->name; + + $currentSchoolYear = $request->input('current_school_year', $request->query('current_school_year')); + if (is_string($currentSchoolYear) && trim($currentSchoolYear) !== '') { + $years[] = trim($currentSchoolYear); } $routeYears = [ @@ -69,14 +77,6 @@ class EnsureSchoolYearEditable $years[] = $resolved; } } - - if ($years === []) { - $current = trim((string) (Configuration::getConfig('school_year') ?? '')); - if ($current !== '') { - $years[] = $current; - } - } - return $years; } diff --git a/app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php b/app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php index 946b3c55..3ddf487a 100644 --- a/app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php +++ b/app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php @@ -16,7 +16,7 @@ class ClassProgressIndexRequest extends ClassProgressFormRequest return [ 'class_section_id' => ['nullable', 'integer', 'min:1'], 'teacher_id' => ['nullable', 'integer', 'min:1'], - 'school_year_id' => ['nullable', 'integer', 'min:1'], + 'school_year_id' => ['prohibited'], 'school_year' => ['nullable', 'string', 'max:20'], 'semester' => ['nullable', 'string', 'max:20'], 'week_start' => ['nullable', 'date_format:Y-m-d'], diff --git a/app/Http/Requests/Families/FamilyAdminCardRequest.php b/app/Http/Requests/Families/FamilyAdminCardRequest.php index 4afee6de..74b32e60 100644 --- a/app/Http/Requests/Families/FamilyAdminCardRequest.php +++ b/app/Http/Requests/Families/FamilyAdminCardRequest.php @@ -17,6 +17,7 @@ class FamilyAdminCardRequest extends ApiFormRequest 'student_id' => ['nullable', 'integer', 'min:1'], 'guardian_id' => ['nullable', 'integer', 'min:1'], 'school_year' => ['nullable', 'string', 'max:20'], + 'school_year_id' => ['prohibited'], 'family_id' => ['nullable', 'integer', 'min:1'], ]; } diff --git a/app/Http/Requests/Families/FamilyAdminIndexRequest.php b/app/Http/Requests/Families/FamilyAdminIndexRequest.php index 250ee9ed..639e9331 100644 --- a/app/Http/Requests/Families/FamilyAdminIndexRequest.php +++ b/app/Http/Requests/Families/FamilyAdminIndexRequest.php @@ -17,6 +17,7 @@ class FamilyAdminIndexRequest extends ApiFormRequest 'student_id' => ['nullable', 'integer', 'min:1'], 'guardian_id' => ['nullable', 'integer', 'min:1'], 'school_year' => ['nullable', 'string', 'max:20'], + 'school_year_id' => ['prohibited'], ]; } } diff --git a/app/Http/Requests/Families/FamilyComposeEmailRequest.php b/app/Http/Requests/Families/FamilyComposeEmailRequest.php index caf48ad7..d45308f8 100644 --- a/app/Http/Requests/Families/FamilyComposeEmailRequest.php +++ b/app/Http/Requests/Families/FamilyComposeEmailRequest.php @@ -13,6 +13,10 @@ class FamilyComposeEmailRequest extends ApiFormRequest if (! $this->has('html_message') && $this->has('html')) { $this->merge(['html_message' => $this->input('html')]); } + + if (! $this->has('recipient') && $this->has('to')) { + $this->merge(['recipient' => $this->input('to')]); + } } public function authorize(): bool @@ -29,6 +33,7 @@ class FamilyComposeEmailRequest extends ApiFormRequest 'profile' => ['nullable', 'string', 'max:50'], 'reply_to_email' => ['nullable', 'email', 'max:255'], 'reply_to_name' => ['nullable', 'string', 'max:255'], + 'return_url' => ['nullable', 'string', 'max:2048'], ]; } } diff --git a/app/Http/Requests/Parents/ParentAttendanceRequest.php b/app/Http/Requests/Parents/ParentAttendanceRequest.php index 41efe5ec..b004f889 100644 --- a/app/Http/Requests/Parents/ParentAttendanceRequest.php +++ b/app/Http/Requests/Parents/ParentAttendanceRequest.php @@ -10,7 +10,7 @@ class ParentAttendanceRequest extends ApiFormRequest { return [ 'school_year' => ['nullable', 'string', 'max:20'], - 'school_year_id' => ['nullable', 'integer'], + 'school_year_id' => ['prohibited'], ]; } } diff --git a/app/Http/Requests/Staff/StaffIndexRequest.php b/app/Http/Requests/Staff/StaffIndexRequest.php index 33949291..7de986d9 100644 --- a/app/Http/Requests/Staff/StaffIndexRequest.php +++ b/app/Http/Requests/Staff/StaffIndexRequest.php @@ -12,7 +12,10 @@ class StaffIndexRequest extends ApiFormRequest 'page' => ['nullable', 'integer', 'min:1'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], 'role' => ['nullable', 'string', 'max:100'], + 'status' => ['nullable', 'string', 'max:40'], + 'search' => ['nullable', 'string', 'max:150'], 'school_year' => ['nullable', 'string', 'max:20'], + 'school_year_id' => ['prohibited'], 'semester' => ['nullable', 'string', 'max:20'], 'sort_by' => ['nullable', 'in:created_at,lastname,firstname'], 'sort_dir' => ['nullable', 'in:asc,desc'], diff --git a/app/Http/Requests/Staff/StaffStoreRequest.php b/app/Http/Requests/Staff/StaffStoreRequest.php index b948fc4f..dddae721 100644 --- a/app/Http/Requests/Staff/StaffStoreRequest.php +++ b/app/Http/Requests/Staff/StaffStoreRequest.php @@ -14,8 +14,11 @@ class StaffStoreRequest extends ApiFormRequest 'lastname' => ['required', 'string', 'max:100'], 'email' => ['required', 'email', 'max:150'], 'phone' => ['nullable', 'string', 'max:20'], - 'role_name' => ['required', 'string', 'max:100'], - 'school_year' => ['nullable', 'string', 'max:20'], + 'password' => ['nullable', 'string', 'min:8', 'max:120'], + 'role_id' => ['nullable', 'integer', 'min:1', 'exists:roles,id'], + 'role_name' => ['nullable', 'required_without:role_id', 'string', 'max:100'], + 'school_year' => ['required', 'string', 'max:20'], + 'school_year_id' => ['prohibited'], 'status' => ['nullable', 'string', 'max:40'], ]; } diff --git a/app/Http/Requests/Staff/StaffUpdateRequest.php b/app/Http/Requests/Staff/StaffUpdateRequest.php index 67319f9f..5ded9d66 100644 --- a/app/Http/Requests/Staff/StaffUpdateRequest.php +++ b/app/Http/Requests/Staff/StaffUpdateRequest.php @@ -13,8 +13,10 @@ class StaffUpdateRequest extends ApiFormRequest 'lastname' => ['nullable', 'string', 'max:100'], 'email' => ['nullable', 'email', 'max:150'], 'phone' => ['nullable', 'string', 'max:20'], + 'role_id' => ['nullable', 'integer', 'min:1', 'exists:roles,id'], 'role_name' => ['nullable', 'string', 'max:100'], 'school_year' => ['nullable', 'string', 'max:20'], + 'school_year_id' => ['prohibited'], 'status' => ['nullable', 'string', 'max:40'], ]; } diff --git a/app/Http/Resources/Staff/StaffResource.php b/app/Http/Resources/Staff/StaffResource.php index 9a880e21..49a38053 100644 --- a/app/Http/Resources/Staff/StaffResource.php +++ b/app/Http/Resources/Staff/StaffResource.php @@ -16,6 +16,12 @@ class StaffResource extends JsonResource 'lastname' => $this->lastname ?? null, 'email' => $this->email ?? null, 'phone' => $this->phone ?? null, + 'full_name' => trim((string) ($this->firstname ?? '').' '.(string) ($this->lastname ?? '')), + 'role' => [ + 'name' => $this->role_name ?? $this->active_role ?? null, + 'label' => $this->role_name ?? $this->active_role ?? null, + ], + 'status' => strtolower((string) ($this->active_role ?? '')) === 'inactive' ? 'inactive' : 'active', 'role_name' => $this->role_name ?? null, 'active_role' => $this->active_role ?? null, 'school_year' => $this->school_year ?? null, diff --git a/app/Models/ParentAccount.php b/app/Models/ParentAccount.php index 4c49b334..50209a94 100644 --- a/app/Models/ParentAccount.php +++ b/app/Models/ParentAccount.php @@ -9,12 +9,14 @@ class ParentAccount extends BaseModel protected $fillable = [ 'parent_id', 'school_year', + 'school_year_id', 'opening_balance', 'current_balance', ]; protected $casts = [ 'parent_id' => 'integer', + 'school_year_id' => 'integer', 'opening_balance' => 'decimal:2', 'current_balance' => 'decimal:2', ]; diff --git a/app/Models/ParentBalanceTransfer.php b/app/Models/ParentBalanceTransfer.php index 447e6c3d..08e40e10 100644 --- a/app/Models/ParentBalanceTransfer.php +++ b/app/Models/ParentBalanceTransfer.php @@ -10,18 +10,31 @@ class ParentBalanceTransfer extends BaseModel 'parent_id', 'from_school_year', 'to_school_year', + 'from_school_year_id', + 'to_school_year_id', 'amount', 'status', 'source_summary_json', + 'source_summary', 'new_invoice_id', + 'old_balance_invoice_id', + 'reversed_at', + 'reversed_by', + 'reversal_reason', 'created_by', ]; protected $casts = [ 'parent_id' => 'integer', + 'from_school_year_id' => 'integer', + 'to_school_year_id' => 'integer', 'amount' => 'decimal:2', 'source_summary_json' => 'array', + 'source_summary' => 'array', 'new_invoice_id' => 'integer', + 'old_balance_invoice_id' => 'integer', + 'reversed_at' => 'datetime', + 'reversed_by' => 'integer', 'created_by' => 'integer', ]; } diff --git a/app/Services/ClassProgress/ClassProgressQueryService.php b/app/Services/ClassProgress/ClassProgressQueryService.php index 454fc3c5..b6e38664 100644 --- a/app/Services/ClassProgress/ClassProgressQueryService.php +++ b/app/Services/ClassProgress/ClassProgressQueryService.php @@ -213,21 +213,7 @@ class ClassProgressQueryService private function resolveSchoolYearFilter(array $filters): string { - $schoolYear = trim((string) ($filters['school_year'] ?? '')); - if ($schoolYear !== '') { - return $schoolYear; - } - - $schoolYearId = (int) ($filters['school_year_id'] ?? 0); - if ($schoolYearId <= 0 || ! Schema::hasTable('school_years')) { - return ''; - } - - try { - return trim((string) DB::table('school_years')->where('id', $schoolYearId)->value('name')); - } catch (\Throwable) { - return ''; - } + return trim((string) ($filters['school_year'] ?? '')); } /** diff --git a/app/Services/Families/FamilyFinanceService.php b/app/Services/Families/FamilyFinanceService.php index 32835e93..5c6a3d81 100644 --- a/app/Services/Families/FamilyFinanceService.php +++ b/app/Services/Families/FamilyFinanceService.php @@ -6,9 +6,10 @@ use Illuminate\Support\Facades\DB; class FamilyFinanceService { - public function loadFinancialsForParents(array $parentIds, int $paymentLimit = 10): array + public function loadFinancialsForParents(array $parentIds, string $schoolYear, int $paymentLimit = 10): array { $parentIds = array_values(array_filter(array_map('intval', $parentIds))); + $schoolYear = trim($schoolYear); if (empty($parentIds)) { return [ @@ -20,6 +21,8 @@ class FamilyFinanceService 'total_amount' => 0.0, 'paid_amount' => 0.0, 'balance' => 0.0, + 'positive_unpaid_balance' => 0.0, + 'credit_balance' => 0.0, ], ]; } @@ -27,6 +30,7 @@ class FamilyFinanceService $invRows = DB::table('invoices') ->select('id', 'parent_id', 'invoice_number', 'status', 'total_amount', 'paid_amount', 'balance', 'issue_date', 'due_date') ->whereIn('parent_id', $parentIds) + ->when($schoolYear !== '', fn ($query) => $query->where('school_year', $schoolYear)) ->orderBy('issue_date', 'DESC') ->get() ->map(fn ($r) => (array) $r) @@ -38,19 +42,29 @@ class FamilyFinanceService 'total_amount' => 0.0, 'paid_amount' => 0.0, 'balance' => 0.0, + 'positive_unpaid_balance' => 0.0, + 'credit_balance' => 0.0, ]; foreach ($invRows as $ir) { $invoiceMap[(int) ($ir['id'] ?? 0)] = (string) ($ir['invoice_number'] ?? ''); + $balance = (float) ($ir['balance'] ?? 0); $summary['invoices_count']++; $summary['total_amount'] += (float) ($ir['total_amount'] ?? 0); $summary['paid_amount'] += (float) ($ir['paid_amount'] ?? 0); - $summary['balance'] += (float) ($ir['balance'] ?? 0); + $summary['balance'] += $balance; + if ($balance > 0) { + $summary['positive_unpaid_balance'] += $balance; + } elseif ($balance < 0) { + $summary['credit_balance'] += abs($balance); + } } - $payRows = DB::table('payments') - ->select('id', 'parent_id', 'invoice_id', 'paid_amount', 'balance', 'payment_method', 'payment_date', 'status') - ->whereIn('parent_id', $parentIds) - ->orderBy('payment_date', 'DESC') + $payRows = DB::table('payments as p') + ->join('invoices as i', 'i.id', '=', 'p.invoice_id') + ->select('p.id', 'p.parent_id', 'p.invoice_id', 'p.paid_amount', 'p.balance', 'p.payment_method', 'p.payment_date', 'p.status') + ->whereIn('p.parent_id', $parentIds) + ->when($schoolYear !== '', fn ($query) => $query->where('i.school_year', $schoolYear)) + ->orderBy('p.payment_date', 'DESC') ->limit($paymentLimit) ->get() ->map(fn ($r) => (array) $r) diff --git a/app/Services/Families/FamilyQueryService.php b/app/Services/Families/FamilyQueryService.php index cb0ac923..66a4c474 100644 --- a/app/Services/Families/FamilyQueryService.php +++ b/app/Services/Families/FamilyQueryService.php @@ -224,7 +224,7 @@ class FamilyQueryService static fn ($g) => (int) ($g['user_id'] ?? 0), $fam['guardians'] ))); - $finance = $this->finance->loadFinancialsForParents($parentIds); + $finance = $this->finance->loadFinancialsForParents($parentIds, $schoolYear); $fam['invoices'] = $finance['invoices']; $fam['payments'] = $finance['payments']; $fam['finance_summary'] = $finance['summary']; @@ -310,13 +310,13 @@ class FamilyQueryService $payload = $family->toArray(); $payload['guardians'] = $this->listGuardiansByFamily($familyId); $payload['students'] = $this->studentsForFamily($familyId, $schoolYear); - $payload['emergency_contacts'] = $this->emergencyContactsForParents($payload['guardians']); + $payload['emergency_contacts'] = $this->emergencyContactsForParents($payload['guardians'], $schoolYear); $parentIds = array_values(array_filter(array_map( static fn ($g) => (int) ($g['user_id'] ?? 0), $payload['guardians'] ))); - $finance = $this->finance->loadFinancialsForParents($parentIds); + $finance = $this->finance->loadFinancialsForParents($parentIds, $schoolYear); $payload['invoices'] = $finance['invoices']; $payload['payments'] = $finance['payments']; $payload['finance_summary'] = $finance['summary']; @@ -346,7 +346,7 @@ class FamilyQueryService return $rows; } - private function emergencyContactsForParents(array $guardians): array + private function emergencyContactsForParents(array $guardians, string $schoolYear): array { $parentIds = array_values(array_filter(array_map( static fn ($g) => (int) ($g['user_id'] ?? 0), @@ -368,6 +368,7 @@ class FamilyQueryService $rows = DB::table('emergency_contacts') ->select('id', 'parent_id', 'emergency_contact_name', 'relation', 'cellphone', 'email', 'school_year', 'semester', 'created_at', 'updated_at') ->whereIn('parent_id', $parentIds) + ->when(trim($schoolYear) !== '', fn ($query) => $query->where('school_year', trim($schoolYear))) ->orderBy('updated_at', 'DESC') ->get() ->map(fn ($r) => (array) $r) diff --git a/app/Services/Families/ParentProfileAdminQueryService.php b/app/Services/Families/ParentProfileAdminQueryService.php new file mode 100644 index 00000000..3b332359 --- /dev/null +++ b/app/Services/Families/ParentProfileAdminQueryService.php @@ -0,0 +1,267 @@ +select( + 'u.id', + 'u.firstname', + 'u.lastname', + 'u.email', + 'u.cellphone', + 'u.status', + DB::raw('COUNT(DISTINCT fg.family_id) as families_count'), + DB::raw('COUNT(DISTINCT s.id) as selected_year_students_count'), + DB::raw('COALESCE(SUM(DISTINCT i.balance), 0) as balance') + ) + ->leftJoin('family_guardians as fg', 'fg.user_id', '=', 'u.id') + ->leftJoin('family_students as fs', 'fs.family_id', '=', 'fg.family_id') + ->leftJoin('students as s', function ($join) use ($schoolYear) { + $join->on('s.id', '=', 'fs.student_id') + ->where('s.school_year', '=', $schoolYear); + }) + ->leftJoin('invoices as i', function ($join) use ($schoolYear) { + $join->on('i.parent_id', '=', 'u.id') + ->where('i.school_year', '=', $schoolYear); + }) + ->where($this->relevantParentConstraint($schoolYear)) + ->groupBy('u.id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone', 'u.status') + ->orderBy('u.lastname') + ->orderBy('u.firstname'); + + if ($search !== '') { + $needle = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%'; + $query->where(function ($inner) use ($needle) { + $inner->whereRaw("CONCAT_WS(' ', u.firstname, u.lastname) LIKE ?", [$needle]) + ->orWhere('u.email', 'like', $needle) + ->orWhere('u.cellphone', 'like', $needle) + ->orWhereExists(function ($exists) use ($needle) { + $exists->selectRaw('1') + ->from('family_guardians as sfg') + ->join('family_students as sfs', 'sfs.family_id', '=', 'sfg.family_id') + ->join('students as ss', 'ss.id', '=', 'sfs.student_id') + ->whereColumn('sfg.user_id', 'u.id') + ->where(function ($student) use ($needle) { + $student->whereRaw("CONCAT_WS(' ', ss.firstname, ss.lastname) LIKE ?", [$needle]); + }); + }); + }); + } + + return $query->paginate(max(1, min($perPage, 100))); + } + + public function search(string $schoolYear, string $search, int $limit = 10): array + { + if (trim($search) === '') { + return []; + } + + return $this->index($schoolYear, $search, $limit) + ->getCollection() + ->map(fn ($row) => $this->parentSummary((array) $row, $schoolYear)) + ->all(); + } + + public function show(int $parentId, string $schoolYear): ?array + { + $parent = DB::table('users') + ->select('id', 'firstname', 'lastname', 'email', 'cellphone', 'address_street', 'city', 'state', 'zip', 'status') + ->where('id', $parentId) + ->first(); + + if (! $parent) { + return null; + } + + $guardians = [ + [ + 'user_id' => $parentId, + 'firstname' => $parent->firstname, + 'lastname' => $parent->lastname, + ], + ]; + $finance = $this->finance->loadFinancialsForParents([$parentId], $schoolYear); + + return [ + 'parent' => (array) $parent, + 'families' => $this->familiesForParent($parentId, $schoolYear), + 'students' => $this->studentsForParent($parentId, $schoolYear), + 'emergency_contacts' => $this->emergencyContactsForParents($guardians, $schoolYear), + 'invoices' => $finance['invoices'], + 'payments' => $finance['payments'], + 'finance_summary' => $finance['summary'], + ]; + } + + public function updateParent(int $parentId, array $payload): ?array + { + $allowed = array_intersect_key($payload, array_flip([ + 'firstname', + 'lastname', + 'email', + 'cellphone', + 'address_street', + 'city', + 'state', + 'zip', + 'status', + ])); + + if ($allowed !== []) { + $allowed['updated_at'] = now(); + DB::table('users')->where('id', $parentId)->update($allowed); + } + + $parent = DB::table('users')->where('id', $parentId)->first(); + + return $parent ? (array) $parent : null; + } + + public function parentSummary(array $row, string $schoolYear): array + { + $balance = (float) ($row['balance'] ?? 0); + $primaryFamily = DB::table('family_guardians as fg') + ->join('families as f', 'f.id', '=', 'fg.family_id') + ->where('fg.user_id', (int) ($row['id'] ?? 0)) + ->orderByDesc('fg.is_primary') + ->orderBy('f.household_name') + ->select('f.id', 'f.household_name') + ->first(); + + return [ + 'id' => (int) ($row['id'] ?? 0), + 'firstname' => $row['firstname'] ?? null, + 'lastname' => $row['lastname'] ?? null, + 'email' => $row['email'] ?? null, + 'cellphone' => $row['cellphone'] ?? null, + 'is_active' => strtolower((string) ($row['status'] ?? '')) === 'active', + 'families_count' => (int) ($row['families_count'] ?? 0), + 'students_count' => (int) ($row['selected_year_students_count'] ?? 0), + 'selected_year_students_count' => (int) ($row['selected_year_students_count'] ?? 0), + 'balance' => $balance, + 'positive_unpaid_balance' => max(0, $balance), + 'credit_balance' => $balance < 0 ? abs($balance) : 0.0, + 'primary_family' => $primaryFamily ? (array) $primaryFamily : null, + 'school_year' => $schoolYear, + ]; + } + + private function relevantParentConstraint(string $schoolYear): callable + { + return function ($query) use ($schoolYear) { + $query->whereExists(function ($exists) use ($schoolYear) { + $exists->selectRaw('1') + ->from('family_guardians as rfg') + ->join('family_students as rfs', 'rfs.family_id', '=', 'rfg.family_id') + ->join('students as rs', 'rs.id', '=', 'rfs.student_id') + ->whereColumn('rfg.user_id', 'u.id') + ->where('rs.school_year', $schoolYear); + })->orWhereExists(function ($exists) use ($schoolYear) { + $exists->selectRaw('1') + ->from('students as direct_students') + ->whereColumn('direct_students.parent_id', 'u.id') + ->where('direct_students.school_year', $schoolYear); + })->orWhereExists(function ($exists) use ($schoolYear) { + $exists->selectRaw('1') + ->from('invoices as ri') + ->whereColumn('ri.parent_id', 'u.id') + ->where('ri.school_year', $schoolYear); + })->orWhereExists(function ($exists) use ($schoolYear) { + $exists->selectRaw('1') + ->from('parent_accounts as pa') + ->whereColumn('pa.parent_id', 'u.id') + ->where('pa.school_year', $schoolYear); + })->orWhereExists(function ($exists) use ($schoolYear) { + $exists->selectRaw('1') + ->from('emergency_contacts as ec') + ->whereColumn('ec.parent_id', 'u.id') + ->where('ec.school_year', $schoolYear); + }); + }; + } + + private function familiesForParent(int $parentId, string $schoolYear): array + { + return DB::table('family_guardians as fg') + ->join('families as f', 'f.id', '=', 'fg.family_id') + ->where('fg.user_id', $parentId) + ->whereExists(function ($exists) use ($schoolYear) { + $exists->selectRaw('1') + ->from('family_students as fs') + ->join('students as s', 's.id', '=', 'fs.student_id') + ->whereColumn('fs.family_id', 'f.id') + ->where('s.school_year', $schoolYear); + }) + ->orderBy('f.household_name') + ->select('f.*', 'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + } + + private function studentsForParent(int $parentId, string $schoolYear): array + { + $rows = DB::table('students as s') + ->select('s.id', 's.firstname', 's.lastname', 's.parent_id') + ->where('s.school_year', $schoolYear) + ->where(function ($query) use ($parentId) { + $query->where('s.parent_id', $parentId) + ->orWhereExists(function ($exists) use ($parentId) { + $exists->selectRaw('1') + ->from('family_guardians as fg') + ->join('family_students as fs', 'fs.family_id', '=', 'fg.family_id') + ->whereColumn('fs.student_id', 's.id') + ->where('fg.user_id', $parentId); + }); + }) + ->orderBy('s.lastname') + ->orderBy('s.firstname') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + foreach ($rows as &$row) { + $studentId = (int) ($row['id'] ?? 0); + $classSectionName = $studentId > 0 + ? (string) (StudentClass::getClassSectionsByStudentId($studentId, $schoolYear) ?? '') + : ''; + $row['class_section_name'] = $classSectionName; + $row['grade'] = $classSectionName; + } + unset($row); + + return $rows; + } + + private function emergencyContactsForParents(array $guardians, string $schoolYear): array + { + $parentIds = array_values(array_filter(array_map( + static fn ($g) => (int) ($g['user_id'] ?? 0), + $guardians + ))); + + if ($parentIds === []) { + return []; + } + + return DB::table('emergency_contacts') + ->select('id', 'parent_id', 'emergency_contact_name', 'relation', 'cellphone', 'email', 'school_year', 'semester', 'created_at', 'updated_at') + ->whereIn('parent_id', $parentIds) + ->where('school_year', $schoolYear) + ->orderByDesc('updated_at') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + } +} diff --git a/app/Services/SchoolYears/SchoolYearClosureService.php b/app/Services/SchoolYears/SchoolYearClosureService.php index d5e98726..57109131 100644 --- a/app/Services/SchoolYears/SchoolYearClosureService.php +++ b/app/Services/SchoolYears/SchoolYearClosureService.php @@ -145,12 +145,22 @@ class SchoolYearClosureService 'totals' => [ 'students_considered' => $promotions['summary']['total_students'], 'students_blocked' => $promotions['summary']['hold'], - 'parents_with_balances' => $balances['summary']['parents_with_non_zero_balance'], - 'net_balance_to_transfer' => $balances['summary']['net_balance_to_transfer'], + 'parents_with_balances' => $balances['summary']['parents_with_positive_balance'], + 'parents_with_positive_balance' => $balances['summary']['parents_with_positive_balance'], + 'parents_with_credit_balance' => $balances['summary']['parents_with_credit_balance'], + 'total_positive_unpaid_balance' => $balances['summary']['total_positive_unpaid_balance'], + 'total_credit_balance' => $balances['summary']['total_credit_balance'], + 'net_balance_to_transfer' => $balances['summary']['net_balance_impact'], + 'net_balance_impact' => $balances['summary']['net_balance_impact'], ], ]; } + public function summaryByYearName(string $schoolYear): array + { + return $this->summary((int) $this->findByNameOrFail($schoolYear)->id); + } + public function closingReport(int $id): array { $year = $this->findOrFail($id); @@ -200,12 +210,23 @@ class SchoolYearClosureService 'parents_with_unpaid_balances' => (int) ($balanceCounts['transferred_parents'] ?? $transferTotals['parents_with_unpaid_balances']), 'total_unpaid_balance_transferred' => round((float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred']), 2), 'parents_with_credit_balances' => (int) ($balanceCounts['credit_parents'] ?? $transferTotals['parents_with_credit_balances']), - 'total_credit_transferred' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_transferred']), 2), + 'total_credit_carried_or_reported' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2), + 'total_credit_transferred' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2), + 'net_balance_impact' => round( + (float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred']) + - (float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), + 2 + ), ], 'warnings' => $warnings, ]; } + public function closingReportByYearName(string $schoolYear): array + { + return $this->closingReport((int) $this->findByNameOrFail($schoolYear)->id); + } + public function validateClose(int $id, array $payload): array { $year = $this->findOrFail($id); @@ -270,6 +291,11 @@ class SchoolYearClosureService ]; } + public function validateCloseByYearName(string $schoolYear, array $payload): array + { + return $this->validateClose((int) $this->findByNameOrFail($schoolYear)->id, $payload); + } + public function previewClose(int $id, array $payload): array { $year = $this->findOrFail($id); @@ -284,6 +310,11 @@ class SchoolYearClosureService ]; } + public function previewCloseByYearName(string $schoolYear, array $payload): array + { + return $this->previewClose((int) $this->findByNameOrFail($schoolYear)->id, $payload); + } + public function close(int $id, array $payload, ?int $actorId): array { $year = $this->findOrFail($id); @@ -376,6 +407,11 @@ class SchoolYearClosureService }); } + public function closeByYearName(string $schoolYear, array $payload, ?int $actorId): array + { + return $this->close((int) $this->findByNameOrFail($schoolYear)->id, $payload, $actorId); + } + public function reopen(int $id, ?int $actorId): array { $year = $this->findOrFail($id); @@ -411,6 +447,11 @@ class SchoolYearClosureService }); } + public function reopenByYearName(string $schoolYear, ?int $actorId): array + { + return $this->reopen((int) $this->findByNameOrFail($schoolYear)->id, $actorId); + } + public function parentBalances(int $id, ?string $toSchoolYear = null): array { $year = $this->findOrFail($id); @@ -421,6 +462,11 @@ class SchoolYearClosureService ); } + public function parentBalancesByYearName(string $schoolYear, ?string $toSchoolYear = null): array + { + return $this->parentBalances((int) $this->findByNameOrFail($schoolYear)->id, $toSchoolYear); + } + public function promotionPreview(int $id, ?string $toSchoolYear = null): array { $year = $this->findOrFail($id); @@ -431,6 +477,11 @@ class SchoolYearClosureService ); } + public function promotionPreviewByYearName(string $schoolYear, ?string $toSchoolYear = null): array + { + return $this->promotionPreview((int) $this->findByNameOrFail($schoolYear)->id, $toSchoolYear); + } + public function findOrFail(int $id): SchoolYear { $this->resolver->ensureCurrentTracked(); @@ -438,6 +489,13 @@ class SchoolYearClosureService return SchoolYear::query()->findOrFail($id); } + public function findByNameOrFail(string $schoolYear): SchoolYear + { + $this->resolver->ensureCurrentTracked(); + + return SchoolYear::query()->where('name', trim($schoolYear))->firstOrFail(); + } + private function validateNewYearPayload(SchoolYear $currentYear, array $newYear): array { $errors = []; @@ -683,85 +741,110 @@ class SchoolYearClosureService private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear): array { - $invoiceBalances = DB::table('invoices') - ->where('school_year', $fromSchoolYear) - ->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled']) - ->select('parent_id') - ->selectRaw('ROUND(SUM(COALESCE(balance, 0)), 2) as net_balance') - ->selectRaw('COUNT(CASE WHEN COALESCE(balance, 0) > 0.01 THEN 1 END) as unpaid_invoice_count') - ->groupBy('parent_id') - ->havingRaw('ABS(SUM(COALESCE(balance, 0))) > 0.01') - ->get(); + $invoices = $this->eligibleCarryoverInvoiceQuery($fromSchoolYear) + ->whereRaw('ABS(COALESCE(balance, 0)) > 0.01') + ->get(['id', 'parent_id', 'balance']); - $rows = []; - $summary = [ - 'parents_with_non_zero_balance' => 0, - 'parents_with_credit_balance' => 0, - 'existing_transfer_conflicts' => 0, - 'total_old_unpaid_balance' => 0.0, - 'total_credit_balance' => 0.0, - 'net_balance_to_transfer' => 0.0, - ]; - - foreach ($invoiceBalances as $balanceRow) { - $parentId = (int) ($balanceRow->parent_id ?? 0); + $balancesByParent = []; + foreach ($invoices as $invoice) { + $parentId = (int) ($invoice->parent_id ?? 0); if ($parentId <= 0) { continue; } - $net = round((float) ($balanceRow->net_balance ?? 0), 2); - if (abs($net) < 0.01) { + $balance = round((float) ($invoice->balance ?? 0), 2); + $balancesByParent[$parentId] ??= [ + 'positive_unpaid_balance' => 0.0, + 'credit_balance' => 0.0, + 'positive_invoice_ids' => [], + 'credit_invoice_ids' => [], + ]; + + if ($balance > 0.01) { + $balancesByParent[$parentId]['positive_unpaid_balance'] += $balance; + $balancesByParent[$parentId]['positive_invoice_ids'][] = (int) $invoice->id; + } elseif ($balance < -0.01) { + $balancesByParent[$parentId]['credit_balance'] += abs($balance); + $balancesByParent[$parentId]['credit_invoice_ids'][] = (int) $invoice->id; + } + } + + $rows = []; + $summary = [ + 'parents_with_positive_balance' => 0, + 'parents_with_credit_balance' => 0, + 'parents_with_net_non_zero_balance' => 0, + 'existing_transfer_conflicts' => 0, + 'total_positive_unpaid_balance' => 0.0, + 'total_credit_balance' => 0.0, + 'net_balance_impact' => 0.0, + // Deprecated aliases retained for older UI/tests during the migration. + 'parents_with_non_zero_balance' => 0, + 'total_old_unpaid_balance' => 0.0, + 'net_balance_to_transfer' => 0.0, + ]; + + foreach ($balancesByParent as $parentId => $balanceRow) { + $positive = round((float) $balanceRow['positive_unpaid_balance'], 2); + $credit = round((float) $balanceRow['credit_balance'], 2); + $net = round($positive - $credit, 2); + + if ($positive < 0.01 && $credit < 0.01) { continue; } - $existing = ParentBalanceTransfer::query() - ->where('parent_id', $parentId) - ->where('from_school_year', $fromSchoolYear) - ->where('to_school_year', $toSchoolYear) - ->first(); - - $sourceInvoiceIds = DB::table('invoices') - ->where('parent_id', $parentId) - ->where('school_year', $fromSchoolYear) - ->whereRaw('ABS(COALESCE(balance, 0)) > 0.01') - ->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled']) - ->pluck('id') - ->map(fn ($id) => (int) $id) - ->all(); + $existing = $positive > 0.01 + ? ParentBalanceTransfer::query() + ->where('parent_id', $parentId) + ->where('from_school_year', $fromSchoolYear) + ->where('to_school_year', $toSchoolYear) + ->first() + : null; $row = [ 'parent_id' => $parentId, 'parent_name' => $this->resolveParentName($parentId), 'student_names' => $this->resolveStudentNames($parentId, $fromSchoolYear), - 'unpaid_invoice_count' => (int) ($balanceRow->unpaid_invoice_count ?? 0), - 'old_unpaid_balance' => $net > 0 ? $net : 0.0, - 'credit_balance' => $net < 0 ? abs($net) : 0.0, + 'unpaid_invoice_count' => count($balanceRow['positive_invoice_ids']), + 'positive_unpaid_balance' => $positive, + 'old_unpaid_balance' => $positive, + 'credit_balance' => $credit, 'net_balance' => $net, - 'amount_to_transfer' => $net, - 'transfer_status' => $existing?->status ?? ($net > 0 ? 'pending' : 'credit_pending'), + 'amount_to_transfer' => $positive, + 'transfer_status' => $existing?->status ?? ($positive > 0.01 ? 'pending' : 'credit_reported'), 'existing_transfer_id' => $existing?->id, - 'source_invoice_ids' => $sourceInvoiceIds, + 'existing_transfer_conflict' => $existing !== null, + 'positive_invoice_ids' => $balanceRow['positive_invoice_ids'], + 'credit_invoice_ids' => $balanceRow['credit_invoice_ids'], + 'source_invoice_ids' => $balanceRow['positive_invoice_ids'], ]; $rows[] = $row; - $summary['parents_with_non_zero_balance']++; - $summary['net_balance_to_transfer'] += $net; - $summary['total_old_unpaid_balance'] += $row['old_unpaid_balance']; - $summary['total_credit_balance'] += $row['credit_balance']; - if ($row['credit_balance'] > 0) { - $summary['parents_with_credit_balance']++; + if ($positive > 0.01) { + $summary['parents_with_positive_balance']++; + $summary['total_positive_unpaid_balance'] += $positive; } - if ($row['existing_transfer_id']) { + if ($credit > 0.01) { + $summary['parents_with_credit_balance']++; + $summary['total_credit_balance'] += $credit; + } + if (abs($net) > 0.01) { + $summary['parents_with_net_non_zero_balance']++; + } + if ($row['existing_transfer_conflict']) { $summary['existing_transfer_conflicts']++; } } usort($rows, static fn (array $a, array $b) => $b['amount_to_transfer'] <=> $a['amount_to_transfer']); - $summary['net_balance_to_transfer'] = round($summary['net_balance_to_transfer'], 2); - $summary['total_old_unpaid_balance'] = round($summary['total_old_unpaid_balance'], 2); + $summary['total_positive_unpaid_balance'] = round($summary['total_positive_unpaid_balance'], 2); $summary['total_credit_balance'] = round($summary['total_credit_balance'], 2); + $summary['net_balance_impact'] = round($summary['total_positive_unpaid_balance'] - $summary['total_credit_balance'], 2); + $summary['parents_with_non_zero_balance'] = $summary['parents_with_net_non_zero_balance']; + $summary['total_old_unpaid_balance'] = $summary['total_positive_unpaid_balance']; + $summary['net_balance_to_transfer'] = $summary['net_balance_impact']; return [ 'from_school_year' => $fromSchoolYear, @@ -771,6 +854,13 @@ class SchoolYearClosureService ]; } + private function eligibleCarryoverInvoiceQuery(string $schoolYear) + { + return DB::table('invoices') + ->where('school_year', $schoolYear) + ->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled', 'draft', 'pending']); + } + private function resolveTargetSection(?int $targetLevelId, string $schoolYear): array { if ($targetLevelId === null || $targetLevelId <= 0) { @@ -905,7 +995,12 @@ class SchoolYearClosureService foreach ($rows as $row) { $amount = round((float) $row['amount_to_transfer'], 2); - if (abs($amount) < 0.01) { + $credit = round((float) ($row['credit_balance'] ?? 0), 2); + if ($amount < 0.01) { + if ($credit > 0.01) { + $counts['credit_parents']++; + $counts['credit_amount'] += $credit; + } continue; } @@ -924,36 +1019,51 @@ class SchoolYearClosureService )); } - ParentAccount::query()->updateOrCreate( - ['parent_id' => $row['parent_id'], 'school_year' => $fromSchoolYear], - ['opening_balance' => 0, 'current_balance' => $amount] - ); - $newAccount = ParentAccount::query()->firstOrCreate( ['parent_id' => $row['parent_id'], 'school_year' => $toSchoolYear], ['opening_balance' => 0, 'current_balance' => 0] ); + if (Schema::hasColumn('parent_accounts', 'school_year_id') && $newAccount->school_year_id === null) { + $newAccount->school_year_id = $this->schoolYearIdForName($toSchoolYear); + } $newAccount->opening_balance = round((float) $newAccount->opening_balance + $amount, 2); $newAccount->current_balance = round((float) $newAccount->current_balance + $amount, 2); $newAccount->save(); - $transfer = ParentBalanceTransfer::query()->create([ + $transferPayload = [ 'parent_id' => $row['parent_id'], 'from_school_year' => $fromSchoolYear, 'to_school_year' => $toSchoolYear, 'amount' => $amount, 'status' => 'transferred', 'source_summary_json' => [ - 'source_invoice_ids' => $row['source_invoice_ids'], - 'old_unpaid_balance' => $row['old_unpaid_balance'], - 'credit_balance' => $row['credit_balance'], + 'source_invoice_ids' => $row['positive_invoice_ids'] ?? $row['source_invoice_ids'] ?? [], + 'positive_invoice_ids' => $row['positive_invoice_ids'] ?? $row['source_invoice_ids'] ?? [], + 'credit_invoice_ids' => $row['credit_invoice_ids'] ?? [], + 'old_unpaid_balance' => $row['old_unpaid_balance'] ?? $amount, + 'positive_unpaid_balance' => $row['positive_unpaid_balance'] ?? $amount, + 'credit_balance' => $credit, + 'net_balance' => $row['net_balance'] ?? ($amount - $credit), ], 'created_by' => $actorId, - ]); + ]; + if (Schema::hasColumn('parent_balance_transfers', 'source_summary')) { + $transferPayload['source_summary'] = $transferPayload['source_summary_json']; + } + $fromYearId = $this->schoolYearIdForName($fromSchoolYear); + $toYearId = $this->schoolYearIdForName($toSchoolYear); + if (Schema::hasColumn('parent_balance_transfers', 'from_school_year_id')) { + $transferPayload['from_school_year_id'] = $fromYearId; + } + if (Schema::hasColumn('parent_balance_transfers', 'to_school_year_id')) { + $transferPayload['to_school_year_id'] = $toYearId; + } + + $transfer = ParentBalanceTransfer::query()->create($transferPayload); $newInvoiceId = null; if ($amount > 0) { - $newInvoiceId = DB::table('invoices')->insertGetId([ + $invoicePayload = [ 'parent_id' => $row['parent_id'], 'invoice_number' => sprintf('OB-%s-%d', str_replace('-', '', $toSchoolYear), $transfer->id), 'total_amount' => $amount, @@ -970,15 +1080,23 @@ class SchoolYearClosureService 'updated_by' => $actorId, 'semester' => Configuration::getConfig('semester'), 'balance_transfer_id' => $transfer->id, - ]); + ]; + if (Schema::hasColumn('invoices', 'invoice_type')) { + $invoicePayload['invoice_type'] = 'opening_balance'; + } + if (Schema::hasColumn('invoices', 'school_year_id')) { + $invoicePayload['school_year_id'] = $toYearId; + } + $newInvoiceId = DB::table('invoices')->insertGetId($invoicePayload); $transfer->new_invoice_id = $newInvoiceId; $transfer->save(); $counts['transferred_parents']++; $counts['transferred_amount'] += $amount; - } else { - $counts['credit_parents']++; - $counts['credit_amount'] += abs($amount); + if ($credit > 0.01) { + $counts['credit_parents']++; + $counts['credit_amount'] += $credit; + } } $this->logAudit( @@ -1018,6 +1136,17 @@ class SchoolYearClosureService ->count(); } + private function schoolYearIdForName(string $schoolYear): ?int + { + if (! Schema::hasTable('school_years')) { + return null; + } + + $id = SchoolYear::query()->where('name', $schoolYear)->value('id'); + + return $id ? (int) $id : null; + } + private function countFinancialInconsistencies(string $schoolYear): int { return DB::table('invoices') @@ -1114,7 +1243,9 @@ class SchoolYearClosureService 'parents_with_unpaid_balances' => 0, 'total_unpaid_balance_transferred' => 0.0, 'parents_with_credit_balances' => 0, + 'total_credit_carried_or_reported' => 0.0, 'total_credit_transferred' => 0.0, + 'net_balance_impact' => 0.0, ]; if (! Schema::hasTable('parent_balance_transfers')) { @@ -1124,25 +1255,49 @@ class SchoolYearClosureService $rows = DB::table('parent_balance_transfers') ->where('from_school_year', $fromSchoolYear) ->when($toSchoolYear !== null && trim($toSchoolYear) !== '', fn ($query) => $query->where('to_school_year', $toSchoolYear)) - ->get(['amount']); + ->get(['amount', 'source_summary_json', 'source_summary']); foreach ($rows as $row) { $amount = round((float) ($row->amount ?? 0), 2); if ($amount > 0.01) { $totals['parents_with_unpaid_balances']++; $totals['total_unpaid_balance_transferred'] += $amount; - } elseif ($amount < -0.01) { + } + + $sourceSummary = $this->decodeTransferSourceSummary($row); + $credit = round((float) ($sourceSummary['credit_balance'] ?? 0), 2); + if ($credit > 0.01) { $totals['parents_with_credit_balances']++; - $totals['total_credit_transferred'] += abs($amount); + $totals['total_credit_carried_or_reported'] += $credit; } } $totals['total_unpaid_balance_transferred'] = round($totals['total_unpaid_balance_transferred'], 2); - $totals['total_credit_transferred'] = round($totals['total_credit_transferred'], 2); + $totals['total_credit_carried_or_reported'] = round($totals['total_credit_carried_or_reported'], 2); + $totals['total_credit_transferred'] = $totals['total_credit_carried_or_reported']; + $totals['net_balance_impact'] = round( + $totals['total_unpaid_balance_transferred'] - $totals['total_credit_carried_or_reported'], + 2 + ); return $totals; } + private function decodeTransferSourceSummary(object $row): array + { + $raw = $row->source_summary_json ?? $row->source_summary ?? null; + if (is_array($raw)) { + return $raw; + } + if (! is_string($raw) || trim($raw) === '') { + return []; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : []; + } + private function resolveUserName(int $userId): string|int|null { if ($userId <= 0) { @@ -1196,6 +1351,11 @@ class SchoolYearClosureService return $this->presentSchoolYear($year->fresh()); } + public function archiveByYearName(string $schoolYear, ?int $actorId): array + { + return $this->archive((int) $this->findByNameOrFail($schoolYear)->id, $actorId); + } + private function presentSchoolYear(?SchoolYear $year): ?array { if (! $year) { diff --git a/app/Services/SchoolYears/SchoolYearWriteGuard.php b/app/Services/SchoolYears/SchoolYearWriteGuard.php index cd86fafa..ca78564d 100644 --- a/app/Services/SchoolYears/SchoolYearWriteGuard.php +++ b/app/Services/SchoolYears/SchoolYearWriteGuard.php @@ -30,7 +30,7 @@ class SchoolYearWriteGuard foreach ($years as $year) { $row = $statuses->get($year); - if ($row && $row->status !== SchoolYear::STATUS_ACTIVE) { + if ($row && in_array($row->status, [SchoolYear::STATUS_CLOSED, SchoolYear::STATUS_ARCHIVED], true)) { throw new RuntimeException(sprintf( 'School year %s is %s and read-only.', $year, diff --git a/app/Services/SchoolYears/SelectedSchoolYearContext.php b/app/Services/SchoolYears/SelectedSchoolYearContext.php new file mode 100644 index 00000000..e1a13f4e --- /dev/null +++ b/app/Services/SchoolYears/SelectedSchoolYearContext.php @@ -0,0 +1,13 @@ +selectedName($request, $explicit); + + if ($name === '') { + $name = $this->activeSchoolYearName(); + } + + if ($name === '') { + throw new UnprocessableEntityHttpException('A school_year value is required.'); + } + + if (! Schema::hasTable('school_years')) { + throw new UnprocessableEntityHttpException('School-year tracking is unavailable.'); + } + + $this->resolver->ensureCurrentTracked(); + + $year = SchoolYear::query()->where('name', $name)->first(); + if (! $year) { + throw new NotFoundHttpException(sprintf('School year %s was not found.', $name)); + } + + return new SelectedSchoolYearContext( + (int) $year->id, + (string) $year->name, + (string) $year->status, + in_array($year->status, [SchoolYear::STATUS_CLOSED, SchoolYear::STATUS_ARCHIVED], true), + ); + } + + public function fromName(string $schoolYear): SelectedSchoolYearContext + { + $request = Request::create('/', 'GET', ['school_year' => $schoolYear]); + + return $this->fromRequest($request); + } + + private function selectedName(Request $request, bool &$explicit): string + { + $sources = [ + $request->query('school_year'), + $request->headers->get('X-School-Year'), + $request->input('school_year'), + ]; + + foreach ($sources as $value) { + if (is_string($value) && trim($value) !== '') { + $explicit = true; + + return trim($value); + } + } + + return ''; + } + + private function activeSchoolYearName(): string + { + $configured = trim((string) (Configuration::getConfig('school_year') ?? '')); + if ($configured !== '') { + return $configured; + } + + if (! Schema::hasTable('school_years')) { + return ''; + } + + $year = SchoolYear::query()->where('is_current', true)->orderByDesc('id')->first(); + + return $year ? trim((string) $year->name) : ''; + } +} diff --git a/app/Services/Staff/StaffCommandService.php b/app/Services/Staff/StaffCommandService.php index edd3fd4d..dc901e4c 100644 --- a/app/Services/Staff/StaffCommandService.php +++ b/app/Services/Staff/StaffCommandService.php @@ -3,9 +3,11 @@ namespace App\Services\Staff; use App\Models\Staff; +use App\Models\Role; use App\Models\User; use App\Services\System\GlobalConfigService; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use RuntimeException; class StaffCommandService @@ -15,9 +17,11 @@ class StaffCommandService public function create(array $payload): Staff { return DB::transaction(function () use ($payload) { - $userId = $this->resolveUserId($payload); + $role = $this->resolveRole($payload); + $user = $this->resolveOrCreateUser($payload, $role); + $userId = (int) $user->id; - $roleName = (string) ($payload['role_name'] ?? ''); + $roleName = (string) $role->name; $activeRole = strtolower($roleName); if (! empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive') { $activeRole = 'inactive'; @@ -25,8 +29,14 @@ class StaffCommandService $schoolYear = (string) ($payload['school_year'] ?? $this->configService->getSchoolYear() ?? ''); - $staff = Staff::query()->create([ + DB::table('user_roles')->updateOrInsert( + ['user_id' => $userId, 'role_id' => (int) $role->id], + ['created_at' => now(), 'updated_at' => now()] + ); + + $staff = Staff::query()->updateOrCreate([ 'user_id' => $userId, + ], [ 'firstname' => (string) $payload['firstname'], 'lastname' => (string) $payload['lastname'], 'email' => (string) $payload['email'], @@ -34,7 +44,6 @@ class StaffCommandService 'role_name' => $roleName, 'active_role' => $activeRole, 'school_year' => $schoolYear, - 'created_at' => now(), 'updated_at' => now(), ]); @@ -49,15 +58,23 @@ class StaffCommandService public function update(Staff $staff, array $payload): Staff { return DB::transaction(function () use ($staff, $payload) { + $role = array_key_exists('role_id', $payload) || array_key_exists('role_name', $payload) + ? $this->resolveRole($payload) + : null; $updates = []; - foreach (['firstname', 'lastname', 'email', 'phone', 'role_name', 'school_year'] as $field) { + foreach (['firstname', 'lastname', 'email', 'phone', 'school_year'] as $field) { if (array_key_exists($field, $payload) && $payload[$field] !== '') { $updates[$field] = $payload[$field]; } } - if (array_key_exists('role_name', $updates)) { + if ($role) { + $updates['role_name'] = (string) $role->name; $updates['active_role'] = strtolower((string) $updates['role_name']); + DB::table('user_roles')->updateOrInsert( + ['user_id' => (int) $staff->user_id, 'role_id' => (int) $role->id], + ['created_at' => now(), 'updated_at' => now()] + ); } if (! empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive') { $updates['active_role'] = 'inactive'; @@ -68,6 +85,15 @@ class StaffCommandService if (! empty($updates)) { $staff->fill($updates); $staff->save(); + + $userUpdates = array_intersect_key($updates, array_flip(['firstname', 'lastname', 'email', 'phone', 'school_year'])); + if (isset($userUpdates['phone'])) { + $userUpdates['cellphone'] = $userUpdates['phone']; + unset($userUpdates['phone']); + } + if ($userUpdates !== []) { + User::query()->whereKey((int) $staff->user_id)->update($userUpdates + ['updated_at' => now()]); + } } return $staff->fresh(); @@ -79,20 +105,69 @@ class StaffCommandService return (bool) $staff->delete(); } - private function resolveUserId(array $payload): int + private function resolveRole(array $payload): Role { - if (! empty($payload['user_id'])) { - return (int) $payload['user_id']; - } - - $email = $payload['email'] ?? null; - if ($email) { - $userId = User::query()->where('email', $email)->value('id'); - if ($userId) { - return (int) $userId; + if (! empty($payload['role_id'])) { + $role = Role::query()->whereKey((int) $payload['role_id'])->where('is_active', 1)->first(); + if ($role) { + return $role; } } - throw new RuntimeException('A valid user_id or existing email is required.'); + $roleName = trim((string) ($payload['role_name'] ?? '')); + if ($roleName !== '') { + $role = Role::query() + ->where('is_active', 1) + ->where(function ($query) use ($roleName) { + $query->whereRaw('LOWER(name) = ?', [strtolower($roleName)]) + ->orWhereRaw('LOWER(slug) = ?', [strtolower($roleName)]); + }) + ->first(); + if ($role) { + return $role; + } + } + + throw new RuntimeException('A valid active role is required.'); + } + + private function resolveOrCreateUser(array $payload, Role $role): User + { + if (! empty($payload['user_id'])) { + $user = User::query()->find((int) $payload['user_id']); + if (! $user) { + throw new RuntimeException('The selected user was not found.'); + } + } else { + $email = strtolower(trim((string) ($payload['email'] ?? ''))); + if ($email === '') { + throw new RuntimeException('A valid email is required.'); + } + $user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first() ?? new User(); + } + + $isNew = ! $user->exists; + $user->firstname = (string) $payload['firstname']; + $user->lastname = (string) $payload['lastname']; + $user->email = strtolower(trim((string) $payload['email'])); + $user->cellphone = $payload['phone'] ?? $user->cellphone ?? null; + $user->school_year = (string) ($payload['school_year'] ?? $this->configService->getSchoolYear() ?? ''); + $user->semester = $user->semester ?: (string) ($this->configService->getSemester() ?? ''); + $user->status = ! empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive' ? 'Inactive' : 'Active'; + $user->is_verified = 1; + $user->is_suspended = 0; + $user->accept_school_policy = $user->accept_school_policy ?? 1; + $user->school_id = $user->school_id ?: random_int(100000, 999999); + if ($isNew) { + $password = (string) ($payload['password'] ?? ''); + if ($password === '') { + throw new RuntimeException('A temporary password is required for a new staff user.'); + } + $user->password = Hash::make($password); + } + $user->user_type = (string) $role->name; + $user->save(); + + return $user; } } diff --git a/app/Services/Staff/StaffQueryService.php b/app/Services/Staff/StaffQueryService.php index 29db117b..e149c02a 100644 --- a/app/Services/Staff/StaffQueryService.php +++ b/app/Services/Staff/StaffQueryService.php @@ -24,6 +24,29 @@ class StaffQueryService $query->whereRaw('LOWER(active_role) = ?', [$role]); } + $schoolYear = (string) ($filters['school_year'] ?? $this->configService->getSchoolYear() ?? ''); + if ($schoolYear !== '') { + $query->where('school_year', $schoolYear); + } + + if (! empty($filters['status'])) { + $status = strtolower(trim((string) $filters['status'])); + if ($status === 'inactive') { + $query->whereRaw('LOWER(active_role) = ?', ['inactive']); + } elseif ($status === 'active') { + $query->whereRaw('LOWER(active_role) != ?', ['inactive']); + } + } + + if (! empty($filters['search'])) { + $search = '%'.strtolower(trim((string) $filters['search'])).'%'; + $query->where(function ($inner) use ($search) { + $inner->whereRaw('LOWER(firstname) LIKE ?', [$search]) + ->orWhereRaw('LOWER(lastname) LIKE ?', [$search]) + ->orWhereRaw('LOWER(email) LIKE ?', [$search]); + }); + } + $sortBy = $filters['sort_by'] ?? 'created_at'; $sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc'; @@ -31,7 +54,6 @@ class StaffQueryService ->orderBy($sortBy, $sortDir) ->paginate($perPage, ['*'], 'page', $page); - $schoolYear = (string) ($filters['school_year'] ?? $this->configService->getSchoolYear() ?? ''); $semester = (string) ($filters['semester'] ?? $this->configService->getSemester() ?? ''); $assignByTeacher = $this->buildAssignments($schoolYear); diff --git a/app/Services/Whatsapp/WhatsappContextService.php b/app/Services/Whatsapp/WhatsappContextService.php index 35f287ef..f429fae1 100644 --- a/app/Services/Whatsapp/WhatsappContextService.php +++ b/app/Services/Whatsapp/WhatsappContextService.php @@ -16,7 +16,6 @@ class WhatsappContextService $override, request()->query('school_year'), request()->header('X-School-Year'), - request()->header('X-Selected-School-Year'), Configuration::getConfig('school_year'), ] as $candidate) { $value = trim((string) ($candidate ?? '')); diff --git a/docs/alrahma_api_fix_plan_school_year_only_v7.md b/docs/alrahma_api_fix_plan_school_year_only_v7.md new file mode 100644 index 00000000..68f575be --- /dev/null +++ b/docs/alrahma_api_fix_plan_school_year_only_v7.md @@ -0,0 +1,3913 @@ +# Alrahma API: School-Year Page Remediation Plan + +## Scope + +This plan covers the listed frontend pages, their backend API contracts, and the failing CI logs from: + +- `frontend.zip` +- `backend.zip` +- `build-Test (PHPUnit)-973.log` +- `build-Security audit-975.log` + +The corrected URL pattern must use only `school_year=2025-2026`. The old `school_year_id` (deprecated; do not use) query parameter should be removed from frontend routes, API calls, PDF links, exports, imports, and tests. The fix must not be page-by-page guessing. The correct attack is to stabilize one shared contract, then wire every page into it. + +## Executive Diagnosis + +The page list is not the root problem. The pages are symptoms. + +The real failures group into these buckets: + +1. **School-year context is inconsistent** + - Frontend routes must carry only `school_year`. + - Frontend API calls must consistently pass only `school_year`. + - Backend services and middleware must resolve the internal school-year record by the `school_year` name. `school_year_id` (deprecated; do not use) must not be part of the external contract. + - Mutating routes need closed-year protection, but read-only pages must still load archived/closed years. + +2. **Authorization and active-account test setup is broken** + - Many PHPUnit failures are `403`/`401`, which usually means the tests are not creating users that pass active, verified, role, and permission middleware. + - Do not weaken production middleware to satisfy tests. Fix factories and test auth helpers. + +3. **Backend API shape is not uniform** + - Some modules are under `/api/v1/administrator/*`. + - Some are under `/api/v1/admin/*`. + - Some finance features are under `/api/v1/finance/*`. + - Some pages are wrapper pages that call older endpoints from `session.ts`. + - The frontend needs one reliable API layer per domain, not ad-hoc fetch calls sprinkled like confetti after a bad deployment. + +4. **CI failures are systemic** + - Test failures include authorization mismatches, dependency constructor drift, model metadata expectations, SQLite database locks, PDF/export 500s, and missing/imported service classes. + - Security audit dependency check passed, but the secret scan failed because committed files contain environment-style secret values and dummy CI values that match the grep pattern. + +## Non-Negotiable Fix Contract + +Every page and API call must obey this contract: + +```txt +school_year = 2025-2026 +``` + +Backend must resolve a single selected school-year object internally from that string: + +```json +{ + "id": 4, + "school_year": "2025-2026", + "status": "active|draft|closed|archived", + "is_read_only": true +} +``` + +Rules: + +- `school_year` is the only accepted external school-year selector. +- `school_year_id` must be removed from frontend URLs, API requests, headers, request bodies, tests, and documentation. +- backend may use the resolved numeric ID internally, but it must not require the client to send it. +- invalid or unknown `school_year` returns `422` or `404` consistently. +- missing context falls back to current active school year only for pages where that is intentional. Do not silently fall back on pages opened with an invalid explicit year. +- `GET`, `HEAD`, and safe read APIs are allowed for closed/archived years. +- `POST`, `PUT`, `PATCH`, and `DELETE` are blocked for closed/archived years with `409`, not a vague `403`. + +## Phase 1: Backend School-Year Context Foundation + +### 1.1 Add a single resolver service + +Create or update a backend service similar to: + +```php +SelectedSchoolYearContextService +``` + +It must resolve from these sources in order: + +1. Query: `school_year` +2. Header: `X-School-Year` +3. Body: `school_year` +4. Active configured school year fallback only when no explicit school year was supplied + +Do not resolve selected school year from deprecated `school_year_id`, `X-School-Year-Id`, `X-Selected-School-Year-Id`, or request body IDs. Those are legacy inputs and should be removed from the frontend. If the backend still receives them during transition, either ignore them or return a controlled deprecation error, but do not let them override `school_year`. + +It should return a DTO/value object: + +```php +SelectedSchoolYearContext { + public int $id; + public string $name; + public string $status; + public bool $isReadOnly; +} +``` + +### 1.2 Update middleware + +Update `EnsureSchoolYearEditable` so it resolves only from the selected `school_year` string and then uses the internal database ID after resolution. + +Expected behavior: + +- safe methods pass. +- mutation against active/draft editable year passes. +- mutation against closed/archived year returns `409`. +- unknown year returns `404` or `422`, consistently. + +### 1.3 Update controllers and services + +Controllers should not manually parse `request('school_year')` unless absolutely necessary. + +Replace scattered logic with the resolver in: + +- attendance controllers +- assignments/controllers under administrator tools +- family admin +- finance controllers +- discounts/expenses/refunds/reimbursements +- printables/certificates +- grading placement and below-60 email tools +- calendar/events +- progress reports +- inventory +- WhatsApp contacts/invites +- school-year management/reporting pages + +### 1.4 Apply table strategy correctly + +Use the existing school-year table registry strategy: + +- **Year-scoped tables:** exact selected-year filter. +- **Identity tables with year relations:** filter through enrollments/family-student/student-class/etc., not by pretending identity rows are year rows. +- **Global tables:** never apply year filter. +- **Context/audit tables:** filter by year only when user-facing context requires it. + +This matters because filtering global identity tables by `school_year` can hide legitimate parents/staff while also making “closed year” reports lie with confidence, humanity’s favorite database feature. + +## Phase 2: Frontend School-Year Context Foundation + +### 2.1 Centralize selected-year query creation + +Add a shared frontend helper: + +```ts +useSelectedSchoolYearParams() +``` + +It should return: + +```ts +{ + schoolYear: string | null; + query: URLSearchParams; + queryString: string; + headers: { + 'X-School-Year': string; + }; + isReadOnly: boolean; +} +``` + +### 2.2 Make API calls append selected year by default + +Update the API client so GET calls append only: + +```txt +school_year=2025-2026 +``` + +For mutations, include the same `school_year` context in the body or `X-School-Year` header. + +Do not rely only on headers. Some downloads, PDF links, and browser navigation flows need query params. + +### 2.3 Add a read-only page state + +Every selected-year page should receive: + +```ts +selectedSchoolYear.isReadOnly +``` + +Behavior: + +- show a clear banner on closed/archived years. +- disable create/update/delete/import/finalize/send actions. +- keep exports/downloads/preview reads enabled. +- submit buttons should explain why disabled. + +### 2.4 Preserve query params during navigation + +All links inside these sections must preserve: + +```txt +school_year +``` + +Especially: + +- invoice PDF links +- certificate group tabs +- grading student links +- parent profile/family drilldowns +- printables report-card links +- calendar/event links +- school-year reports links + +## Phase 3: Page Inventory and Fix Matrix + +| URL family | Frontend component/file | Main backend API family | Required fix | +|---|---|---|---| +| `/administrator/parent_profiles` | `AdminParentProfilePage`, `AdministratorToolPages.tsx` | parent/admin profile APIs | Apply selected-year query to parent list, balances, children, contact/profile detail APIs. | +| `/whatsapp` | `WhatsappHubPage.tsx` | `/api/v1/whatsapp/*` | Scope contacts, class contacts, invites, membership by selected year. Block invite mutations for read-only years. | +| `/admin/progress` | `AdminProgressPages.tsx` | `/api/v1/admin/progress` | Add selected-year to list/meta filters. Verify no cross-year class/student leakage. | +| `/administrator/calendar` | `AdminCalendarPage`, `AdministratorToolPages.tsx` | `/api/v1/settings/school-calendar/events` | Add selected-year to event reads/writes. Writes blocked for closed years. | +| `/administrator/events` | `AdminCalendarViewPage`, `AdministratorToolPages.tsx` | calendar/events APIs | Same as calendar. Ensure alias preserves URL query params. | +| `/administrator/calendar_view` | `AdminCalendarViewPage`, `AdministratorToolPages.tsx` | calendar/events APIs | Same as calendar. Do not fork behavior from `/administrator/events`. | +| `/administrator/exam-drafts` | `AdminExamDraftsPage`, `AdministratorToolPages.tsx` | `/api/v1/exams/drafts/admin` | Add selected-year filter to drafts, exams, classes, students. | +| `/administrator/family` | `FamilyManagementPage.tsx` | `/api/v1/family-admin` | Filter family display by selected-year relationships, not by global family rows. | +| `/families/import-legacy` | `FamiliesImportLegacyPage.tsx` | `/api/v1/families/import-*` | Require selected-year for import target. Disable import for closed years. | +| `/administrator/discounts` | `DiscountListPage.tsx` | `/api/v1/discounts` | Scope list and mutation target by selected year. | +| `/administrator/expenses` | `ExpensesIndexPage.tsx` | `/api/v1/administrator/expenses` | Scope expenses and reports by selected year. | +| `/administrator/invoice-payment/pdf/:invoiceId` | `InvoicePdfTemplatePage.tsx` | `/api/v1/finance/invoices/{id}/pdf` | Include selected-year in PDF URL. Backend validates invoice belongs to selected year. | +| `/administrator/payment/manual-pay` | `ManualPayPage.tsx` | `/api/v1/finance/payments/manual` and related | Scope invoice/payment lookup by selected year. Block payments for read-only year. | +| `/administrator/paypal_transactions` | `AdminPaypalTransactionsPage`, `AdministratorToolPages.tsx` | `/api/v1/administrator/paypal-transactions` | Scope transaction list and detail by selected year. | +| `/refunds/list` | `RefundsListPage.tsx` | `/api/v1/finance/refunds` | Scope refund list and actions. Block create/approve/reject if year read-only. | +| `/administrator/reimbursements/under-processing` | `ReimbursementsUnderProcessingPage.tsx` | `/api/v1/administrator/reimbursements` | Scope batches/items by selected year. Block processing actions for closed year. | +| `/administrator/tuition-forecast` | `FinancialReportSummaryPage.tsx` | finance report APIs | Scope forecast/report calculations by selected year and selected semester where applicable. | +| `/administrator/printables/badges` | `BadgeFormPage.tsx` | `/api/v1/administrator/printables/badges/*` | Scope student options by selected year. Generate disabled for read-only if it writes logs. | +| `/administrator/certificates` | `CertificatesPage.tsx` | `/api/v1/administrator/certificates/*` | Scope dashboard/form-options/generate/reprint/audit by selected year and group. | +| `/administrator/class-prep` | `ClassPrepListPage.tsx` | class prep APIs | Scope class prep data by selected year. | +| `/admin/print-requests` | `AdminPrintRequestsPage.tsx` | print request APIs | Scope request list and actions by selected year. | +| `/administrator/printables/report-cards` | `ReportCardManagementPage.tsx` | `/api/v1/administrator/printables/report-card/*` | Scope metadata/students/report card generation by selected year. | +| `/printables_reports/stickers` | `StickerFormPage.tsx` | `/api/v1/reports/stickers` and printables stickers | Scope options and generated output by selected year. | +| `/admin/school-years` | `SchoolYearsManagementPage.tsx` | `/api/v1/school-years` | Ensure selected-year manager can load context without requiring selected year parameter. | +| `/grading/schedule-meeting` | `ScheduleMeetingPage.tsx` | `/api/v1/grading/*meeting*` | Validate student belongs to selected year. Preserve semester and selected year. | +| `/grading/below-60/email-editor` | `BelowSixtyEmailEditorPage.tsx` | `/api/v1/grading/below-sixty/*` | Scope below-60 lookup and email draft/send by year and semester. Disable sends for read-only year if policy requires. | +| `/administrator/parent-email-extractor` | `ParentEmailExtractorPage.tsx` | `/api/v1/administrator/email-extractor/*` | Scope extracted contacts by selected year enrollment/family links. | +| `/staff` | `StaffIndexPage.tsx` | `/api/v1/administrator/staff` | Decide whether staff are global or year-associated. If year-associated, filter through assignment tables, not only staff row year. | +| `/administrator/teacher_class_assignment` | `AdminTeacherClassAssignmentPage`, `AdministratorToolPages.tsx` | `/api/v1/administrator/teacher-class/*` | Scope classes/sections/teacher assignments by selected year. Block assignment writes for read-only year. | +| `/administrator/daily_attendance` | `AdminDailyAttendancePage`, `AdministratorToolPages.tsx` | `/api/v1/attendance/admin/daily` | Scope attendance date/class/student list by selected year. | +| `/attendance/early-dismissals/new` | `EarlyDismissalsAddPage.tsx` | `/api/v1/attendance/early-dismissals/*` | Scope student options and created dismissal by selected year. | +| `/administrator/flags/management` | `FlagsManagementPage.tsx` | `/api/v1/administrator/flags/*` | Scope flag dashboard/items by selected year. | +| `/grading/placement` | `PlacementSectionPage.tsx` | `/api/v1/grading/placement/*` | Scope placement batches/students/sections by selected year. Fix missing batch behavior. | +| `/administrator/student_class_assignment` | `AdminStudentClassAssignmentPage`, `AdministratorToolPages.tsx` | `/api/v1/students/*class*` | Scope student/class options and assignments by selected year. | +| `/administrator/absence` | `AdministratorAbsencePage.tsx` | `/api/v1/administrator/absence` | Scope absences, teacher/student options, and submissions by selected year. | + +## Inventory Pages + +Inventory routes must be included, not hand-waved into oblivion: + +| Frontend inventory routes | Backend API family | Required fix | +|---|---|---| +| `/administrator/inventory`, `/inventory` | `/api/v1/inventory/*` | Add selected-year scope only where inventory is school-year-specific. Global item catalog should remain global. | +| `/inventory/low-stock` | `/api/v1/inventory/low-stock` | Confirm whether low-stock is global or school-year scoped. | +| `/inventory/reorder` | `/api/v1/inventory/reorder` | Keep stock movement actions disabled for closed years only if stock is yearly. | +| `/inventory/stock-status` | `/api/v1/inventory/stock-status` | Include selected-year if stock snapshots are yearly. | +| `/inventory/book`, `/inventory/classroom`, `/inventory/kitchen`, `/inventory/office` | category inventory APIs | Avoid filtering shared catalog rows by year unless movements are yearly. | +| `/inventory/items`, `/inventory/summary`, `/inventory/movements` | inventory item/movement APIs | Scope movements, not necessarily item definitions. | + +## Phase 4: Backend Test Log Fixes + +### 4.1 Fix auth and permissions test foundation first + +Symptoms in the log include large numbers of `403` and `401` mismatches. Fix the foundation before touching route logic. + +Actions: + +- Update `UserFactory` defaults: + - `status = Active` + - `is_verified = 1` + - `is_suspended = 0` + - valid `school_id` + - valid default `school_year` +- Add test helpers: + - `actingAsActiveUser()` + - `actingAsAdmin()` + - `actingAsWithPermission(string $permission)` + - `actingAsParent()` + - `actingAsTeacher()` +- Seed required roles and permissions in feature tests. +- Keep middleware strict in production. + +### 4.2 Fix dependency constructor drift + +Update tests and service construction for services whose constructors changed: + +- `EnrollmentEventService` +- `PaymentEventService` +- `RegistrationService` +- `TeacherAbsenceService` + +The correct fix is to inject/stub the new dependencies in tests, not to make production constructors sloppy just because tests are old. + +### 4.3 Fix missing imports/classes + +`RoleAssignmentServiceTest` references `RoleStaffService` as if it lives in the test namespace. Add the correct import: + +```php +use App\Services\Roles\RoleStaffService; +``` + +or resolve it from the Laravel container. + +### 4.4 Fix model metadata/fillable tests + +There are many failures where arrays are expected to be identical. Decide the source of truth: + +- If migrations are correct, update model `$fillable`, casts, timestamps, and relationships. +- If models are correct, update migrations/tests. +- Do not blindly update tests to match broken models. That is just sweeping glass under the carpet and calling it interior design. + +### 4.5 Fix SQLite database locking + +The log shows `SQLSTATE[HY000]: General error: 5 database is locked`. + +Actions: + +- Stop sharing `database/database.sqlite` between parallel or repeated test phases. +- Use one SQLite DB per process: + - `database/test-${TOKEN}.sqlite` + - or `:memory:` where possible. +- Disable parallelism for tests that depend on file exports/PDFs/shared storage. +- Ensure transactions are closed around jobs/events/listeners. +- Clear queue/storage state between suites. + +### 4.6 Fix PDF/export failures + +Failing areas include invoice PDF, financial report PDF, and long-running export routes. + +Actions: + +- Ensure PDF package/dependency is installed in CI. +- Make PDF routes return controlled JSON in test mode when renderer is unavailable. +- Pass selected-year context into PDF queries. +- Validate invoice/report belongs to selected year. +- Add tests for: + - authorized admin gets 200/download response. + - unauthorized user gets 403. + - cross-year invoice returns 404 or 403 consistently. + - invalid PDF parameters return 422, not 500. + +### 4.7 Fix placement finalization behavior + +The log shows placement finalization using `findOrFail(1)` on a missing `SectionPlacementBatch`. + +Actions: + +- Scope placement batch lookup by selected year and owner/permission. +- Return controlled `404` when batch does not exist. +- Do not log expected missing-batch contract tests as production errors. +- Add a test for missing batch response. + +### 4.8 Fix schema mismatch + +The log includes `student_class has no column named school_id`. + +Actions: + +- Check migration and model expectations. +- Either: + - add `school_id` to `student_class` if multi-school filtering belongs there, + - or update services/tests to filter via related student/class/school tables. +- Do not use a column that does not exist. Apparently databases remain annoyingly literal. + +### 4.9 Fix `email_templates.code` NOT NULL failures + +Actions: + +- Update factory/seeder to always provide `code`. +- Add unique test-safe codes. +- Avoid creating email templates from partial arrays in tests. + +## Phase 5: Security Audit Fixes + +### 5.1 Keep composer audit + +Composer audit reported no advisories and no abandoned packages. Keep it. + +### 5.2 Fix secret detection failures + +The secret scan fails because committed files contain environment-style values matching the scanner. + +Actions: + +- Remove real/dev secret values from: + - `env.dev` + - `.env.docker.dev` + - `docs/envfile.txt` + - CI YAML files where dummy values are written like real secrets +- Replace with `.example` files: + - `APP_KEY=base64:REPLACE_ME` + - `DB_PASSWORD=REPLACE_ME` + - `JWT_SECRET=REPLACE_ME` + - `MAIL_PASSWORD=REPLACE_ME` +- Move CI-only values to runner secrets or generate them inside CI at runtime. +- Adjust grep to avoid false positives from the grep pattern itself and test assertions, but do not whitelist real committed secret files. +- Rotate any secret-like values that were committed, even if they were “dev.” The internet has never respected “just dev.” + +## Phase 6: Frontend Implementation Sequence + +### 6.1 API client + +Update `api/http.ts`: + +- always attach `X-School-Year` when context exists. +- for GET requests, append only `school_year` unless explicitly disabled. +- for file/PDF URLs, expose a helper: + +```ts +withSelectedSchoolYearUrl(url: string): string +``` + +### 6.2 Page wrapper + +Create: + +```tsx + +``` + +It should: + +- wait until selected year context is loaded. +- render loading state. +- render invalid/mismatch state. +- render read-only banner. +- provide selected-year params to children. + +### 6.3 Replace page-local parsing + +Replace page-local `new URLSearchParams(location.search)` logic with the shared hook wherever possible. + +Priority pages: + +1. Finance/payment/PDF/refunds/reimbursements +2. Attendance/daily attendance/absence/early dismissal +3. Grading/placement/below-60/schedule meeting +4. Assignments/staff/family/parent profiles +5. Printables/certificates/report cards/stickers +6. Calendar/events/WhatsApp/inventory + +### 6.4 Mutation behavior + +Every mutating button must check: + +```ts +selectedSchoolYear.isReadOnly +``` + +Blocked actions: + +- manual payments +- refunds approve/reject/create +- reimbursement processing +- import legacy families +- class assignment changes +- teacher assignment changes +- attendance submission +- early dismissal creation +- flag updates +- certificate/report-card generation if it writes audit/log rows +- WhatsApp invite sending +- placement finalization + +Allowed reads: + +- lists +- reports +- PDFs +- previews +- audit logs +- downloads where no write occurs + +## Phase 7: Backend Route and Permission Audit + +For every listed API family, verify: + +- route exists. +- route has auth middleware. +- route has proper role/permission middleware. +- GET routes do not require editable year. +- mutations require editable year. +- route uses selected-year resolver. +- route blocks cross-year record access. +- route returns consistent error shape. + +Expected status codes: + +| Case | Status | +|---|---:| +| unauthenticated | 401 | +| authenticated but missing permission | 403 | +| invalid school-year input | 422 | +| deprecated `school_year_id` supplied | 422 or ignored consistently during transition | +| selected year not found | 404 | +| mutation against closed/archived year | 409 | +| cross-year private record | 404 or 403 consistently | +| valid read | 200 | +| valid create | 201 | +| valid validation failure | 422 | + +## Phase 8: Test Plan + +### 8.1 Backend feature contract tests + +Create a test class for the listed page API families: + +```php +SelectedSchoolYearSurfaceContractTest +``` + +For each API family: + +- as active admin, request with `school_year=2025-2026`. +- assert `200`. +- create records in another year. +- assert cross-year records are not returned. +- mutation on active year succeeds. +- mutation on closed year returns `409`. +- deprecated `school_year_id` input does not affect resolution and should be rejected or ignored consistently during transition. + +### 8.2 Frontend route tests + +Add frontend tests for: + +- each listed route renders selected-year context. +- each route preserves selected-year params. +- API calls include `school_year` as the query parameter and `X-School-Year` as the header where headers are available. +- read-only school year disables mutating controls. + +### 8.3 CI test stability + +- Run backend tests in one deterministic DB mode. +- Run PDF/export tests in isolated storage. +- Run auth/permission tests after factory fix. +- Run secret scan after sanitizing committed files. + +## Phase 9: Acceptance Checklist + +The fix is complete only when all of this is true: + +- Every listed frontend route renders with `school_year=2025-2026` only. +- Every page’s API calls include selected year name only. +- Backend resolves the same internal year record from `school_year` in query/header/body. +- GET routes load closed/archived years as read-only. +- Mutations against closed/archived years return `409`. +- No cross-year private data leaks. +- Parent balances, invoices, refunds, reimbursements, progress, attendance, and grading are year-scoped. +- Inventory behavior is explicitly classified as global, yearly, or movement-scoped. +- PHPUnit no longer fails because of auth fixtures, constructor drift, SQLite locks, or PDF 500s. +- Security audit passes without committed environment secrets. +- Frontend build/test/lint passes. +- Backend test/security jobs pass. + +## Suggested Branch Order + +1. `fix/ci-auth-test-foundation` +2. `fix/backend-selected-school-year-context` +3. `fix/frontend-selected-school-year-context` +4. `fix/page-api-scope-finance-attendance-grading` +5. `fix/page-api-scope-family-printables-inventory` +6. `fix/pdf-export-and-placement-contracts` +7. `fix/security-audit-secret-scan` + +## Final Warning + +Do not “fix” each page by manually appending `school_year=2025-2026` in random components. That will work for about twelve minutes, then fail on PDF links, exports, imports, and closed-year mutations. Build the shared context contract once, then make every route obey it. + +## Addendum: School-Year Management Preview and Parent Balance Carryover Fix + +This addendum specifically targets the broken school-year management screen, especially the close-preview and parent balance carryover behavior. + +### Reviewed files + +Backend: + +- `app/Http/Controllers/Api/SchoolYears/SchoolYearController.php` +- `app/Services/SchoolYears/SchoolYearClosureService.php` +- `routes/api.php` +- `app/Http/Requests/SchoolYears/PreviewCloseSchoolYearRequest.php` +- `app/Http/Requests/SchoolYears/CloseSchoolYearRequest.php` +- `tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php` + +Frontend: + +- `src/pages/administrator/SchoolYearsManagementPage.tsx` +- `src/pages/administrator/SchoolYearDetailPage.tsx` +- `src/pages/administrator/SchoolYearReportsPage.tsx` +- `src/api/schoolYears.ts` + +### Root problems found + +1. **The management page still uses numeric year IDs externally.** + - `SchoolYearsManagementPage.tsx` reads `year` from the query string and stores `selectedYearId`. + - It writes `year=` back into the URL when selecting a year. + - It calls APIs such as `/api/v1/school-years/{id}/summary`, `/preview-close`, `/parent-balances`, and `/promotion-preview`. + - This violates the corrected contract: the page URL and API contract must use only `school_year=2025-2026`. + +2. **The page has two separate selected-year concepts that can disagree.** + - The header renders `SchoolYearSelector`, which is the global selected-school-year control. + - The management page ignores that value and independently selects by numeric `year`. + - Result: a user can see one school year in the URL/global selector while the preview APIs run against another internal numeric ID. This explains why the preview feels random instead of merely disappointing in the usual frontend way. + +3. **Close preview uses the active year, not necessarily the inspected/selected year.** + - `handleValidateClose`, `handlePreviewClose`, and `handleClose` use `activeYear.id`. + - The inspection widgets use `selectedYear.id`. + - That means “Load parent balances” and “Preview close” can run against different source years. The UI does not make that distinction clear enough. + +4. **Backend school-year routes are still ID-based.** + - `routes/api.php` defines `/api/v1/school-years/{schoolYear}` with `whereNumber('schoolYear')`. + - `SchoolYearController` methods accept `int $schoolYear`. + - This makes the backend contract depend on a numeric database ID even though the frontend must now use only the school-year name. + +5. **Parent carryover currently nets all invoice balances together.** + - `SchoolYearClosureService::buildParentBalanceRows()` groups invoices by parent and calculates `SUM(balance)` as `net_balance`. + - It then uses that net amount as `amount_to_transfer`. + - This is not the same as “carry over parents with balance > 0.” A parent with one unpaid invoice of `200` and one credit invoice of `-200` becomes net `0` and disappears, even though they still have an unpaid invoice. Humanity invented accounting, then apparently decided subtraction was a business rule. + +6. **The summary names are ambiguous.** + - `parents_with_non_zero_balance` includes credit-only parents. + - The UI labels this as “Parents with balances,” which users reasonably interpret as parents owing money. + - For closure carryover, unpaid parents and credit parents must be counted separately. + +7. **Source invoice IDs are not separated by positive unpaid vs credit.** + - `source_invoice_ids` currently includes all non-zero balance invoices. + - For unpaid carryover, the source list should include only positive outstanding invoices. + - Credit source invoices should be listed separately. + +8. **Status is not enough to decide carryover.** + - The current query excludes void/cancelled invoices, but still permits stale statuses. + - Correct behavior must be based on numeric outstanding balance first. + - A parent with `balance = 0` must not appear just because an invoice status says `overdue`. + - A parent with `balance > 0` must appear even if the label is `partial`, `unpaid`, or `overdue`, provided the invoice is not void/cancelled/draft/pending and has no financial inconsistency. + +9. **Old-year parent account mutation is dangerous.** + - `applyBalanceTransfers()` calls `ParentAccount::updateOrCreate()` for the old school year and sets `current_balance` to the transfer amount. + - Closure should not rewrite the old-year parent account as a side effect of preview/transfer. The old year should be locked/read-only after closure. The new year should receive the opening balance entry. + +10. **Transfer records do not fully use the newer school-year columns.** + - Migrations added `from_school_year_id`, `to_school_year_id`, `school_year_id`, `invoice_type`, and `old_balance_invoice_id`-style fields. + - The closure service mostly writes string year fields only. Internally it should populate IDs after resolving `school_year`, while still keeping the external contract name-based. + +### Correct behavior definition + +The school-year management page must obey this behavior: + +- URL uses only `school_year=2025-2026`. +- The selected year shown in the page, the global selector, summary cards, inspection preview, close preview, closing report links, and API calls all resolve to the same school year. +- The close wizard is shown only when the selected year is the active/current year, or it clearly says it will close the current active year and not the inspected year. +- “Parent unpaid carryover” means parents with eligible invoice balances greater than `0.01` in the source school year. +- Credit balances are reported separately. Do not mix them into the unpaid carryover count. +- Net financial impact may be shown as a secondary metric, but it must not replace the unpaid carryover amount. +- Parents with `balance = 0` never appear in unpaid carryover merely because their invoice status is stale. +- Parents with at least one eligible invoice where `balance > 0` appear in unpaid carryover even if they also have credits. +- Closing must be idempotent: an existing `parent_balance_transfers` row for the same parent/from/to year must block duplicate transfer and display as a conflict in preview. + +### Backend fix plan + +#### 1. Replace ID-based management endpoints with school-year-name endpoints + +Keep old ID routes only temporarily if needed for backward compatibility during migration, but frontend must not call them. + +Preferred new API shape: + +```txt +GET /api/v1/school-years +GET /api/v1/school-years/summary?school_year=2025-2026 +GET /api/v1/school-years/promotion-preview?school_year=2025-2026&to_school_year=2026-2027 +GET /api/v1/school-years/parent-balances?school_year=2025-2026&to_school_year=2026-2027 +POST /api/v1/school-years/validate-close?school_year=2025-2026 +POST /api/v1/school-years/preview-close?school_year=2025-2026 +POST /api/v1/school-years/close?school_year=2025-2026 +POST /api/v1/school-years/reopen?school_year=2025-2026 +GET /api/v1/school-years/reports/closing?school_year=2025-2026 +GET /api/v1/school-years/reports/balance-transfers?school_year=2025-2026 +``` + +Controller methods should resolve the source year using the selected-year resolver: + +```php +$selected = $this->selectedSchoolYearContext->fromRequest($request); +$year = $selected->schoolYear; +``` + +Then call service methods by `SchoolYear $year` or `$year->name`, not raw numeric request IDs. + +#### 2. Change closure service method signatures + +Current style: + +```php +summary(int $id) +previewClose(int $id, array $payload) +parentBalances(int $id, ?string $toSchoolYear = null) +``` + +Target style: + +```php +summaryByYearName(string $schoolYear): array +previewCloseByYearName(string $schoolYear, array $payload): array +parentBalancesByYearName(string $schoolYear, ?string $toSchoolYear = null): array +closeByYearName(string $schoolYear, array $payload, ?int $actorId): array +``` + +Internally, resolve the `SchoolYear` model once and pass it through. Avoid re-querying and avoid accepting IDs from the request. + +#### 3. Split unpaid balances from credits + +Replace the net-only query in `buildParentBalanceRows()` with a query that produces separate positive and credit totals. + +Required row shape: + +```json +{ + "parent_id": 10, + "parent_name": "Parent User", + "student_names": ["Sara Student"], + "positive_unpaid_balance": 200.00, + "credit_balance": 50.00, + "net_balance": 150.00, + "amount_to_transfer": 200.00, + "positive_invoice_ids": [500], + "credit_invoice_ids": [501], + "existing_transfer_conflict": false, + "transfer_status": "pending" +} +``` + +Required summary shape: + +```json +{ + "parents_with_positive_balance": 1, + "parents_with_credit_balance": 1, + "parents_with_net_non_zero_balance": 1, + "total_positive_unpaid_balance": 200.00, + "total_credit_balance": 50.00, + "net_balance_impact": 150.00, + "existing_transfer_conflicts": 0 +} +``` + +Keep temporary aliases only if existing UI needs them during migration: + +```json +{ + "parents_with_non_zero_balance": "deprecated alias of parents_with_net_non_zero_balance", + "total_old_unpaid_balance": "deprecated alias of total_positive_unpaid_balance", + "net_balance_to_transfer": "deprecated alias of net_balance_impact" +} +``` + +But the frontend should be updated to use the explicit names. + +#### 4. Eligibility rules for invoices + +Create one private method or dedicated service, not scattered `whereRaw` strings: + +```php +eligibleCarryoverInvoiceQuery(string $schoolYear) +``` + +Rules: + +- Include only the selected source `school_year`. +- Exclude void, voided, cancelled, canceled. +- Exclude draft/pending invoices from transfer; report them as closure blockers. +- Include positive carryover only when `balance > 0.01`. +- Track credits only when `balance < -0.01`. +- Do not use invoice status alone to infer money owed. +- Treat stale paid/overdue statuses with `balance = 0` as zero, not carryover. +- If `total_amount`, `paid_amount`, and `balance` are inconsistent, block closure and surface the invoice IDs in validation instead of silently transferring garbage. + +#### 5. Do not overwrite old-year parent accounts during transfer + +Remove this behavior from `applyBalanceTransfers()`: + +```php +ParentAccount::query()->updateOrCreate( + ['parent_id' => $row['parent_id'], 'school_year' => $fromSchoolYear], + ['opening_balance' => 0, 'current_balance' => $amount] +); +``` + +Instead: + +- read old-year parent account only for reconciliation warnings. +- create/update only the new-year parent account. +- set new-year `opening_balance += positive_unpaid_balance`. +- set new-year `current_balance += positive_unpaid_balance`. +- if credits are carried forward, record them separately and subtract from new-year account only if that is the explicit finance rule. +- never mutate the closed source year’s account just to make the UI look tidy. That is not accounting; that is sweeping glass under a rug. + +#### 6. Opening balance invoice creation + +When creating the new-year opening balance invoice: + +- create it only for `positive_unpaid_balance > 0`. +- use `invoice_type = 'opening_balance'` if the column exists. +- set `school_year` and `school_year_id` if the column exists. +- set `balance_transfer_id`. +- source summary must store only positive invoice IDs for unpaid transfer. +- use deterministic invoice numbers and protect uniqueness. +- do not create opening balance invoices for zero or credit-only parents. + +#### 7. Existing transfer conflict handling + +Preview should not just hide already-transferred parents. + +For every parent with eligible unpaid balance: + +- if a transfer already exists for same parent/from/to, show the row with `existing_transfer_conflict = true`. +- include `existing_transfer_id` and `transfer_status`. +- validation should block close if conflicts exist. +- close should still lock and re-check conflicts inside the transaction. + +#### 8. Closing report correction + +Update `closingReport()` and `transferTotalsForClosedYear()` so report totals align with the new summary fields: + +- `parents_with_unpaid_balances` +- `total_unpaid_balance_transferred` +- `parents_with_credit_balances` +- `total_credit_carried_or_reported` +- `net_balance_impact` + +Do not reconstruct closed totals from live invoice rows if audit/transfer rows exist. Use the transfer table as the closure source of truth. + +### Frontend fix plan + +#### 1. Remove numeric `year` selection from school-year management + +Replace: + +```ts +const selectedYearId = Number(searchParams.get('year') ?? 0) || null +``` + +With: + +```ts +const selectedSchoolYearName = searchParams.get('school_year') +``` + +Selection should write: + +```txt +school_year=2025-2026 +``` + +not: + +```txt +year=4 +school_year_id=4 +``` + +#### 2. Make `SchoolYearSelector` and page selection share the same source + +The management page should use the same selected-year hook/context used by the rest of the app. + +Required behavior: + +- selecting a year in the list updates global selected school year. +- selecting from `SchoolYearSelector` updates the inspected year. +- URL stays `?school_year=2025-2026`. +- no hidden `year` query remains. + +#### 3. Rewrite `src/api/schoolYears.ts` to accept names, not IDs + +Replace API helpers like: + +```ts +fetchSchoolYearSummary(schoolYearId: number) +previewSchoolYearClose(schoolYearId: number, payload) +fetchSchoolYearParentBalances(schoolYearId: number, toSchoolYear?: string) +``` + +With: + +```ts +fetchSchoolYearSummary(schoolYear: string) +previewSchoolYearClose(schoolYear: string, payload) +fetchSchoolYearParentBalances(schoolYear: string, toSchoolYear?: string) +``` + +Build URLs with query params: + +```ts +const query = new URLSearchParams({ school_year: schoolYear }) +``` + +#### 4. Fix management-page action targeting + +- Summary cards use selected `school_year`. +- “Load promotion preview” uses selected `school_year`. +- “Load parent balances” uses selected `school_year`. +- “Validate close” uses selected `school_year` only if selected year is active/current. +- “Preview close” uses selected `school_year` only if selected year is active/current. +- “Close” uses selected `school_year` only if selected year is active/current. +- If selected year is not active, hide or disable the close wizard and show: `Only the current active school year can be closed.` + +Do not silently close `activeYear` while the user is inspecting a different year. That is how admins become superstitious. + +#### 5. Fix report/detail links + +Replace ID-based links: + +```tsx +/app/admin/school-years/${selectedYear.id}/summary +/app/admin/school-years/${selectedYear.id}/reports/closing +/app/admin/school-years/${selectedYear.id}/reports/balance-transfers +``` + +With name/query-based links: + +```tsx +/app/admin/school-years/summary?school_year=2025-2026 +/app/admin/school-years/reports/closing?school_year=2025-2026 +/app/admin/school-years/reports/balance-transfers?school_year=2025-2026 +``` + +Update `SchoolYearDetailPage` and `SchoolYearReportsPage` so they read `school_year` from query params instead of `yearId` from route params. + +#### 6. Update the parent balance table labels and columns + +Show explicit finance columns: + +| Column | Meaning | +|---|---| +| Parent | Parent display name | +| Students | Students enrolled in the source year | +| Unpaid carryover | Sum of eligible positive invoice balances | +| Credits | Sum of credit balances | +| Net impact | Unpaid minus credits | +| Source invoices | Positive invoice IDs | +| Conflict | Existing transfer conflict badge | + +Use the new summary fields: + +- `parents_with_positive_balance` +- `total_positive_unpaid_balance` +- `parents_with_credit_balance` +- `total_credit_balance` +- `net_balance_impact` +- `existing_transfer_conflicts` + +Stop showing `parents_with_non_zero_balance` as the main carryover count. + +#### 7. Make preview impossible to misread + +The preview panel must display this source/target header: + +```txt +Source year: 2025-2026 +Target year: 2026-2027 +Transfer mode: unpaid balances only +``` + +If credits are only reported and not transferred, say so. + +If credits are also carried into the new year, label it explicitly. + +### Required tests to add + +#### Backend feature tests + +Add these to `SchoolYearControllerTest` or a new `SchoolYearCarryoverPreviewTest`: + +1. `test_parent_with_balance_greater_than_zero_appears_in_carryover_preview` + - Invoice balance: `200` + - Expected: parent appears, unpaid total `200`. + +2. `test_zero_balance_parent_with_overdue_status_does_not_appear` + - Invoice status: `overdue` + - Invoice balance: `0` + - Parent account current balance may be stale positive. + - Expected: parent does not appear in unpaid carryover. + +3. `test_positive_balance_is_not_hidden_by_credit_balance` + - Same parent has invoice balance `200` and credit balance `-200`. + - Expected: parent appears in unpaid carryover with `positive_unpaid_balance = 200`, `credit_balance = 200`, `net_balance = 0`. + +4. `test_credit_only_parent_is_reported_as_credit_not_unpaid_carryover` + - Invoice balance: `-50`. + - Expected: no unpaid carryover invoice is created; credit appears in credit summary. + +5. `test_pending_or_draft_invoice_blocks_close_but_is_not_transferred` + - Draft invoice balance: `100`. + - Expected: validation error includes invoice issue; transfer preview excludes it from eligible carryover. + +6. `test_close_creates_opening_balance_invoice_only_for_positive_unpaid_balance` + - Positive parent creates opening balance invoice. + - Credit-only and zero-balance parents do not. + +7. `test_close_does_not_mutate_old_year_parent_account` + - Seed old-year `parent_accounts.current_balance`. + - Close year. + - Assert old-year account is unchanged. + +8. `test_school_year_management_endpoints_accept_school_year_name_only` + - Request `?school_year=2025-2026` succeeds. + - Request with only ID path is either unsupported or temporary deprecated. + - Request with `school_year_id` does not affect selected context. + +9. `test_existing_balance_transfer_conflict_is_visible_in_preview_and_blocks_close` + - Seed `parent_balance_transfers` for same parent/from/to. + - Preview shows conflict. + - Close returns validation error. + +#### Frontend tests + +Add tests for `SchoolYearsManagementPage` and `schoolYears.ts`: + +1. Opening `/app/admin/school-years?school_year=2025-2026` selects `2025-2026`. +2. Selecting a year writes only `school_year=` to the URL. +3. No API call contains `/api/v1/school-years/{id}`. +4. No API call contains `school_year_id`. +5. Summary, promotion preview, parent balance preview, validate close, preview close, and close all send the same `school_year`. +6. Close wizard is disabled when selected year is not active/current. +7. Parent balance preview renders unpaid, credit, net, source invoice IDs, and conflict status. + +### Acceptance criteria for this addendum + +This page is fixed only when: + +- `http://localhost:5173/app/admin/school-years?school_year=2025-2026` works without `year` or `school_year_id`. +- The backend receives `school_year=2025-2026` for every management/preview/report request. +- Parent with invoice `balance > 0` appears in unpaid carryover. +- Parent with invoice `balance = 0` does not appear even if status is `overdue` or parent account is stale. +- Positive unpaid balances are not netted away by credits. +- Credits are reported separately. +- Existing transfers are shown as conflicts. +- Closing creates opening balance invoices only for positive unpaid carryover. +- Old-year parent account rows are not rewritten during closure. +- The UI clearly displays source year, target year, unpaid total, credit total, and net impact. + +--- + +## Addendum: Staff Management page implementation + +Target page: + +```txt +/app/staff?school_year=2025-2026 +``` + +### Current implementation problems found + +The staff page is not broken because of one missing query parameter. It is broken because the frontend and backend are speaking two different dialects and pretending it is architecture. + +#### 1. Frontend API contract does not match backend validation + +Current frontend file: + +```txt +frontend/src/api/staff.ts +``` + +Current frontend create payload: + +```ts +{ + firstname, + lastname, + email, + password, + role_id, +} +``` + +Current backend request expects: + +```php +'user_id' => nullable integer, +'firstname' => required, +'lastname' => required, +'email' => required, +'phone' => nullable, +'role_name' => required, +'school_year' => nullable, +'status' => nullable, +``` + +So `StaffCreatePage` submits `role_id` and `password`, but `StaffStoreRequest` requires `role_name` and `StaffCommandService` refuses to create unless a matching user already exists. The create form is therefore not a real create flow. + +Fix: define one contract and make both sides use it. + +Recommended create contract: + +```json +{ + "firstname": "Amina", + "lastname": "Teacher", + "email": "amina@example.com", + "phone": "5555555555", + "password": "temporary-password", + "role_id": 3, + "status": "active", + "school_year": "2025-2026" +} +``` + +Backend resolves `role_id` to role name inside the command service. The frontend must not send `role_name` as a trusted source of truth. + +#### 2. Staff page still has duplicate selected-year state + +Current page: + +```txt +frontend/src/pages/staff/StaffIndexPage.tsx +``` + +The page reads `school_year` from the URL and separately uses `CiAcademicFilter`. This duplicates the global school-year context and makes it easy for `/app/staff?school_year_id=4&school_year=2025-2026` to keep the stale ID around. + +Fix: use the global `useSchoolYear()` context only, and write only `school_year=` to the URL. Do not read, write, or preserve `school_year_id`. + +#### 3. Staff API client uses `/administrator/staff`, while backend tests and duplicate routes also expose `/staff` + +Current frontend API base: + +```ts +const BASE = '/api/v1/administrator/staff' +``` + +Current backend routes define staff twice: + +```php +/api/v1/administrator/staff +/api/v1/staff +``` + +Fix: choose one canonical route: + +```txt +/api/v1/staff +``` + +Remove the duplicate route group or keep a temporary alias only if a legacy screen still needs it. All React code must call only `/api/v1/staff`. + +#### 4. Staff index is not correctly school-year scoped + +`StaffQueryService::paginate()` accepts `school_year`, but it does not filter staff rows by school year. It only uses the selected year to look up teacher-class assignments. + +Current behavior: + +- Staff list can show staff from unrelated years. +- `issues_count` only counts missing teacher assignments on the current page, not the full filtered result set. +- A staff member’s assignment status can say “No class assigned” even when the selected year was not cleanly resolved. + +Fix: make the selected `school_year` explicit and authoritative: + +```php +$schoolYear = $context->currentSchoolYear($filters['school_year'] ?? null); +``` + +Then use it consistently for: + +- staff roster status for that year, +- teacher class assignments, +- staff attendance links, +- create/update write guards, +- response metadata. + +#### 5. The current staff schema cannot represent staff history per school year + +The `staff` table has both: + +```sql +UNIQUE KEY email +UNIQUE KEY user_id +school_year varchar(9) +``` + +That means one staff member can only have one staff row globally, even though the row also contains a school year. That is internally confused. Either staff is global, or it is year-scoped. Right now it is pretending to be both, because apparently the database wanted to cosplay as a spreadsheet. + +Good implementation: + +Use `staff` as the global staff profile keyed by `user_id`. + +Add a new year-scoped table for the staff roster/status: + +```sql +staff_school_years +- id +- staff_id +- user_id +- school_year +- role_id +- role_name_snapshot +- status enum('active','inactive','leave','archived') +- employment_type nullable +- start_date nullable +- end_date nullable +- notes nullable +- created_at +- updated_at + +unique(staff_id, school_year) +index(school_year, status) +index(user_id, school_year) +``` + +This solves the real problem: + +- the person is global, +- the role/status can differ by school year, +- old school years stay readable, +- new year rollover can copy active staff cleanly, +- staff attendance and class assignments can use the same selected year. + +If you want a smaller first patch, keep `staff` global and use its `school_year` only as `created_school_year`, but do not pretend it is a roster filter. The cleaner version is the `staff_school_years` table. + +#### 6. Create/update does not keep `users`, `user_roles`, and `staff` synchronized + +Current `StaffCommandService::create()` only creates a `staff` row if a user already exists. It does not create the user, does not hash the submitted password, and does not assign the selected role. + +Fix: make create/update transactional: + +Create flow: + +1. Validate `school_year` by name only. +2. Validate `role_id` exists and is active. +3. Create or reuse `users` by email. +4. Hash `password` when creating a new user. +5. Set user fields: firstname, lastname, email, cellphone/phone, status, school_year. +6. Attach role in `user_roles`, replacing staff-manageable primary role when needed. +7. Upsert `staff` global profile by `user_id`. +8. Upsert `staff_school_years` row for selected school year. +9. Return one normalized resource. + +Update flow: + +1. Update profile fields on both `users` and `staff`. +2. If `role_id` changed, update `user_roles` and `staff_school_years.role_id`. +3. If `status` changed, update `staff_school_years.status` for the selected year. +4. Never overwrite old-year roster rows while editing a different selected year. + +Delete/deactivate flow: + +Do not hard-delete staff by default. Use year-scoped deactivation: + +```json +{ + "status": "inactive", + "school_year": "2025-2026" +} +``` + +Hard delete should be admin-only and blocked if there are attendance, teacher assignments, messages, audit logs, print requests, reimbursements, or expenses linked to the user. + +#### 7. Backend route parameter validation is unsafe + +The PHPUnit log shows `StaffController::show()`, `update()`, and `destroy()` receiving a string route parameter and throwing a `TypeError` instead of returning a controlled 404/422. That happens because the route accepts arbitrary `{id}` and the controller type-hints `int`. + +Fix routes: + +```php +Route::prefix('staff') + ->middleware(['multi.auth', 'admin.access']) + ->group(function () { + Route::get('/', [StaffController::class, 'index']); + Route::get('form-options', [StaffController::class, 'formOptions']); + Route::post('/', [StaffController::class, 'store'])->middleware('school_year.editable'); + Route::get('{staff}', [StaffController::class, 'show'])->whereNumber('staff'); + Route::patch('{staff}', [StaffController::class, 'update'])->whereNumber('staff')->middleware('school_year.editable'); + Route::delete('{staff}', [StaffController::class, 'destroy'])->whereNumber('staff')->middleware('school_year.editable'); + }); +``` + +Prefer route-model binding: + +```php +public function show(Staff $staff): JsonResponse +public function update(StaffUpdateRequest $request, Staff $staff): JsonResponse +public function destroy(Staff $staff): JsonResponse +``` + +#### 8. Form options are too weak + +Current `formOptions()` only returns roles. The frontend needs enough metadata to render correctly and avoid guessing. + +Required response: + +```json +{ + "roles": [ + { "id": 3, "name": "teacher", "label": "Teacher" } + ], + "statuses": [ + { "value": "active", "label": "Active" }, + { "value": "inactive", "label": "Inactive" }, + { "value": "leave", "label": "Leave" } + ], + "school_year": "2025-2026", + "is_read_only_year": false +} +``` + +For closed or archived years, create/update/deactivate buttons must be disabled. + +#### 9. Staff resource is missing useful normalized fields + +Return a predictable API shape: + +```json +{ + "id": 10, + "user_id": 55, + "school_id": 10055, + "firstname": "Amina", + "lastname": "Teacher", + "full_name": "Amina Teacher", + "email": "amina@example.com", + "phone": "5555555555", + "role": { + "id": 3, + "name": "teacher", + "label": "Teacher" + }, + "status": "active", + "school_year": "2025-2026", + "class_assignments": [ + { + "class_section_id": 12, + "class_section_name": "Quran 3A", + "position": "main", + "semester": "Fall" + } + ], + "assignment_summary": "Quran 3A (main)", + "has_assignment_issue": false, + "is_read_only_year": false, + "created_at": "2026-07-06 12:00:00", + "updated_at": "2026-07-06 12:00:00" +} +``` + +Do not make the frontend reconstruct assignment state from random string fields. + +### Frontend implementation plan + +#### 1. Replace `frontend/src/api/staff.ts` + +Use a single typed API client: + +```ts +const BASE = '/api/v1/staff' +``` + +Functions: + +```ts +fetchStaffIndex({ school_year, semester, role, status, search, page, per_page }) +fetchStaffFormOptions({ school_year }) +fetchStaffMember(id, { school_year }) +createStaff(payload) +updateStaff(id, payload) +deactivateStaff(id, { school_year }) +``` + +Every request must include `school_year` as a query param or body field. No `school_year_id`. No selected-year ID header. + +#### 2. Rewrite `StaffIndexPage` + +Required UI behavior: + +- Use `useSchoolYear()`. +- Read `school_year` from selected context/name. +- Display `SchoolYearSelector`, not a second unrelated `CiAcademicFilter` year dropdown. +- Optional semester filter can remain local, but must not reset the school year. +- Add search by name/email/school ID. +- Add filters: role and status. +- Show pagination from backend meta. +- Add a visible read-only banner for closed/archived years. +- Show `Add Staff` button only when selected year is editable. +- Preserve `school_year=` when navigating to create/edit. + +Edit links must include the selected year: + +```tsx + +``` + +Create link: + +```tsx + +``` + +#### 3. Rewrite `StaffCreatePage` + +Required fields: + +- First name +- Last name +- Email +- Phone +- Temporary password +- Role +- Status +- Selected school year context, shown read-only + +Submit payload must match backend: + +```ts +{ + firstname, + lastname, + email, + phone, + password, + role_id, + status, + school_year: selectedSchoolYearName, +} +``` + +After create, navigate back to: + +```txt +/app/staff?school_year=2025-2026 +``` + +#### 4. Rewrite `StaffEditPage` + +Required behavior: + +- Load staff using `id` and `school_year`. +- Show profile fields. +- Show role/status for the selected school year. +- Disable edits for closed/archived school year. +- Allow deactivation for selected school year instead of deleting the global person. +- Preserve `school_year` when navigating back. + +### Backend implementation plan + +#### 1. Create a staff domain service boundary + +Recommended services: + +```txt +app/Services/Staff/StaffRosterQueryService.php +app/Services/Staff/StaffProfileCommandService.php +app/Services/Staff/StaffRosterCommandService.php +``` + +Responsibilities: + +- `StaffRosterQueryService`: list staff for selected year, join role/status, class assignments, issues. +- `StaffProfileCommandService`: create/update user and staff global profile. +- `StaffRosterCommandService`: create/update/deactivate year-specific roster row. + +Do not keep one command service doing everything by accident. + +#### 2. Add migration for `staff_school_years` + +Migration must backfill from current `staff` rows: + +```php +foreach (Staff::query()->cursor() as $staff) { + DB::table('staff_school_years')->updateOrInsert( + ['staff_id' => $staff->id, 'school_year' => $staff->school_year ?: $currentYear], + [ + 'user_id' => $staff->user_id, + 'role_name_snapshot' => $staff->role_name, + 'status' => strtolower($staff->active_role) === 'inactive' ? 'inactive' : 'active', + 'created_at' => now(), + 'updated_at' => now(), + ] + ); +} +``` + +Then treat `staff.school_year` as legacy/profile metadata only. + +#### 3. Staff index query + +The query must return staff for the selected school year and assignment state for that same year. + +Pseudo-query: + +```php +$schoolYear = $context->currentSchoolYear($filters['school_year'] ?? null); + +$query = Staff::query() + ->join('staff_school_years as ssy', 'ssy.staff_id', '=', 'staff.id') + ->leftJoin('users', 'users.id', '=', 'staff.user_id') + ->leftJoin('roles', 'roles.id', '=', 'ssy.role_id') + ->where('ssy.school_year', $schoolYear) + ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('ssy.status', $status)) + ->when($filters['role_id'] ?? null, fn ($q, $roleId) => $q->where('ssy.role_id', $roleId)) + ->when($filters['search'] ?? null, function ($q, $search) { + $q->where(function ($inner) use ($search) { + $inner->where('staff.firstname', 'like', "%{$search}%") + ->orWhere('staff.lastname', 'like', "%{$search}%") + ->orWhere('staff.email', 'like', "%{$search}%") + ->orWhere('users.school_id', 'like', "%{$search}%"); + }); + }); +``` + +Assignment issue count must be computed against the full filtered result, not just the current page. + +Teacher roles requiring class assignment: + +```txt +teacher +teacher_assistant +ta +``` + +For each selected school year/semester, load assignments from `teacher_class` and join `classSection` safely. + +#### 4. Staff create transaction + +Required behavior: + +```php +DB::transaction(function () use ($payload, $schoolYear) { + $role = Role::query()->whereKey($payload['role_id'])->where('is_active', 1)->firstOrFail(); + + $user = User::query()->firstOrNew(['email' => strtolower($payload['email'])]); + $user->fill([...]); + if (! $user->exists) { + $user->password = Hash::make($payload['password']); + } + $user->save(); + + $user->roles()->syncWithoutDetaching([$role->id]); + + $staff = Staff::query()->updateOrCreate( + ['user_id' => $user->id], + [...profile fields...] + ); + + StaffSchoolYear::query()->updateOrCreate( + ['staff_id' => $staff->id, 'school_year' => $schoolYear], + [ + 'user_id' => $user->id, + 'role_id' => $role->id, + 'role_name_snapshot' => $role->name, + 'status' => $payload['status'] ?? 'active', + ] + ); + + return $staff->fresh(); +}); +``` + +Do not store plain passwords. Do not trust role names from the client. Do not create duplicate users by email case differences. + +#### 5. Staff update transaction + +- Validate selected `school_year`. +- Update user profile. +- Update staff profile. +- Update roster row for selected school year only. +- If role changes, update `user_roles` carefully. Do not remove unrelated roles such as admin permissions unless the UI explicitly supports multi-role management. + +#### 6. Deactivation instead of deletion + +Add route: + +```php +Route::post('{staff}/deactivate', [StaffController::class, 'deactivate']) + ->whereNumber('staff') + ->middleware('school_year.editable'); +``` + +Payload: + +```json +{ + "school_year": "2025-2026", + "reason": "No longer teaching this year" +} +``` + +This updates only `staff_school_years.status` for that year. + +#### 7. School-year write guard + +All mutating staff routes must use `school_year.editable` and must resolve the selected year from `school_year` only. Closed or archived years are read-only. + +### Required tests to add + +#### Backend feature tests + +Add or update `tests/Feature/Api/V1/Staff/StaffControllerTest.php`: + +1. `test_staff_index_requires_admin` +2. `test_staff_index_accepts_school_year_name_only` +3. `test_staff_index_rejects_school_year_id_as_context` +4. `test_staff_index_returns_only_selected_year_roster_rows` +5. `test_staff_assignment_issue_count_uses_full_filtered_result_not_current_page` +6. `test_staff_create_creates_user_staff_role_and_roster_row_in_one_transaction` +7. `test_staff_create_hashes_password` +8. `test_staff_create_reuses_existing_user_by_email_case_insensitive` +9. `test_staff_update_changes_selected_year_roster_without_mutating_other_years` +10. `test_staff_deactivate_only_deactivates_selected_school_year` +11. `test_staff_mutation_rejected_for_closed_school_year` +12. `test_staff_show_update_delete_non_numeric_id_returns_404_not_type_error` +13. `test_teacher_without_assignment_in_selected_year_is_flagged` +14. `test_teacher_with_assignment_in_other_year_is_still_flagged_for_selected_year` +15. `test_teacher_with_assignment_in_selected_year_has_assignment_summary` + +#### Backend unit tests + +Add tests for: + +- `StaffRosterQueryService` +- `StaffProfileCommandService` +- `StaffRosterCommandService` +- `StaffResource` +- `StaffSchoolYear` model + +#### Frontend tests + +Add tests for: + +1. `/app/staff?school_year=2025-2026` calls `/api/v1/staff?school_year=2025-2026`. +2. No staff API call contains `school_year_id`. +3. Staff page uses global selected school year and does not create a second selected year state. +4. Create page submits `role_id`, `password`, and `school_year`. +5. Edit page preserves `school_year` on load and back navigation. +6. Closed/archived year disables create, update, and deactivate buttons. +7. Assignment issue badge appears only for selected year. +8. Search, role, status, and semester filters preserve `school_year`. + +### Acceptance criteria for Staff Management + +The staff management page is fixed only when: + +- `http://localhost:5173/app/staff?school_year=2025-2026` works without `school_year_id`. +- The frontend calls only `/api/v1/staff`, not `/api/v1/administrator/staff`. +- Every request sends `school_year=2025-2026` by name only. +- Create staff creates or reuses the `users` row, assigns the role, creates/updates `staff`, and creates a selected-year roster row. +- Update staff does not corrupt another school year. +- Deactivate staff affects only the selected year. +- Staff with teacher/assistant roles are flagged if they lack a class assignment in the selected year. +- Teachers assigned in a different year are still flagged for the selected year. +- Closed/archived years are read-only. +- Invalid staff IDs return 404/422 JSON, not PHP `TypeError` stack traces. +- Backend and frontend tests prove no staff request uses `school_year_id`. + +--- + +## Addendum: Parent Profiles Management Fix + +Target page: + +```txt +/app/administrator/parent_profiles?school_year=2025-2026 +``` + +Current broken URL to remove everywhere: + +```txt +/app/administrator/parent_profiles?school_year_id=4&school_year=2025-2026 +``` + +This page is currently not a real parent-profile management implementation. It is a thin page called `AdminParentProfilePage` that calls the generic `family-admin` endpoint, lists guardians from `searchGuardians`, and then renders limited family details. That is why it behaves inconsistently. It is not a strong parent profile page; it is a family lookup page wearing a parent-profile costume. Very convincing, if nobody clicks anything. + +### Current defects found + +#### 1. Frontend page ignores selected school-year changes + +`AdminParentProfilePage` loads once on mount: + +```ts +useEffect(() => { + const data = await fetchFamilyAdminIndex() +}, []) +``` + +That means the page does not reload when the URL or global school-year context changes. If the user moves from `2024-2025` to `2025-2026`, the UI can continue showing stale families/guardians. + +#### 2. Frontend does not pass `school_year` explicitly + +`fetchFamilyAdminIndex()` only sends: + +```ts +student_id +guardian_id +``` + +It does not send: + +```txt +school_year=2025-2026 +``` + +Relying on stored headers is not enough for this project. The URL is the selected context and the API request must carry that same explicit context. + +#### 3. Page uses the wrong shape for students + +The backend `FamilyStudentResource` returns: + +```json +{ + "id": 89, + "firstname": "...", + "lastname": "...", + "grade": "..." +} +``` + +But `AdminParentProfilePage` displays: + +```ts +student.class_section_name +``` + +So class/grade can be missing even when the backend returned it correctly. Pick one canonical key. Use `class_section_name` everywhere, or support both `class_section_name` and `grade` during the transition. + +#### 4. Guardian lookup resolves only one child + +Backend `FamilyQueryService::adminIndexData()` does this: + +```php +if ($studentId <= 0 && $guardianId > 0) { + $resolvedStudentId = $this->resolveStudentIdByGuardian($guardianId); + $studentId = $resolvedStudentId; +} +``` + +That is wrong for parent profiles. A parent can have multiple students, multiple family rows, and children across different years. Selecting a guardian must return all selected-year families/students for that guardian, not one arbitrary first student sorted by name. + +#### 5. Search lists are not scoped to selected school year + +`searchStudents` queries all students. `searchGuardians` queries all guardians. `searchSuggestions()` also ignores school year. + +That means the page can show guardians and students from old years, inactive years, or unrelated historical records while the URL claims `school_year=2025-2026`. + +#### 6. Family student rows are not filtered to selected school year + +`studentsForFamily()` loads every student linked through `family_students`, then tries to compute class section for the selected year. If the student is not actually enrolled in the selected year, they can still appear with an empty class/grade. + +The selected-year roster should be based on actual selected-year enrollment/class context, not the global permanent family link alone. + +#### 7. Family finance ignores school year + +`FamilyFinanceService::loadFinancialsForParents()` loads all invoices and payments for parent IDs: + +```php +DB::table('invoices')->whereIn('parent_id', $parentIds) +DB::table('payments')->whereIn('parent_id', $parentIds) +``` + +It does not filter by `school_year`. That makes balances, invoice counts, and payment history wrong on parent profile pages. This directly overlaps with the carryover bug: parent balance displays must not mix years. + +#### 8. Emergency contacts ignore school year + +`emergencyContactsForParents()` loads contacts for parent IDs without filtering by `school_year`. Parent profiles for `2025-2026` can display contacts from another year. + +#### 9. Compose email endpoint contract is broken + +Frontend sends: + +```ts +/api/v1/family-admin/compose-email/send +{ + to, + subject, + html, + return_url +} +``` + +Backend defines: + +```php +POST /api/v1/family-admin/compose-email +``` + +and expects: + +```json +{ + "recipient": "parent@example.com", + "subject": "...", + "html_message": "..." +} +``` + +So the compose flow from parent/family profile links is contract-drifted. Naturally, even email has decided to participate in the chaos. + +### Required backend implementation + +#### 1. Create a dedicated parent-profile admin endpoint + +Add a canonical endpoint instead of making the page pretend `family-admin` is enough: + +```php +Route::middleware('admin.access')->prefix('parent-profiles')->group(function () { + Route::get('/', [ParentProfileAdminController::class, 'index']); + Route::get('search', [ParentProfileAdminController::class, 'search']); + Route::get('{parent}', [ParentProfileAdminController::class, 'show'])->whereNumber('parent'); + Route::patch('{parent}', [ParentProfileAdminController::class, 'update']) + ->whereNumber('parent') + ->middleware('school_year.editable'); +}); +``` + +Canonical frontend calls: + +```txt +GET /api/v1/parent-profiles?school_year=2025-2026&q=&page=1&per_page=25 +GET /api/v1/parent-profiles/search?school_year=2025-2026&q=smith +GET /api/v1/parent-profiles/{parent_id}?school_year=2025-2026 +PATCH /api/v1/parent-profiles/{parent_id} +``` + +Do not use `school_year_id` in any route, query, header, payload, or test. + +#### 2. Add `ParentProfileAdminQueryService` + +Create a service that returns a real profile payload, not a mixed family page payload. + +The index response should include: + +```json +{ + "data": { + "school_year": "2025-2026", + "read_only": false, + "parents": [ + { + "id": 12, + "firstname": "...", + "lastname": "...", + "email": "...", + "cellphone": "...", + "is_active": true, + "families_count": 1, + "students_count": 2, + "selected_year_students_count": 2, + "balance": 125.00, + "positive_unpaid_balance": 125.00, + "credit_balance": 0.00, + "primary_family": { + "id": 9, + "household_name": "..." + } + } + ], + "pagination": { + "page": 1, + "per_page": 25, + "total": 100 + } + } +} +``` + +The show response should include: + +```json +{ + "data": { + "school_year": "2025-2026", + "read_only": false, + "parent": { + "id": 12, + "firstname": "...", + "lastname": "...", + "email": "...", + "cellphone": "...", + "address_street": "...", + "city": "...", + "state": "...", + "zip": "..." + }, + "families": [], + "students": [], + "emergency_contacts": [], + "invoices": [], + "payments": [], + "finance_summary": { + "invoices_count": 0, + "total_amount": 0, + "paid_amount": 0, + "balance": 0, + "positive_unpaid_balance": 0, + "credit_balance": 0 + } + } +} +``` + +#### 3. Scope every query by selected school year + +The parent profile service must resolve the selected school year once, then pass it into every query. + +Must be scoped: + +- students shown under the parent +- family cards shown under the parent +- class/section display +- invoices +- payments +- emergency contacts +- search results +- balance summary +- counts/badges + +Finance queries must use: + +```php +->where('school_year', $schoolYear) +``` + +on invoices and school-year-aware financial tables. Payments should be joined to selected-year invoices when the payments table itself is not reliably school-year-scoped: + +```php +DB::table('payments as p') + ->join('invoices as i', 'i.id', '=', 'p.invoice_id') + ->where('i.school_year', $schoolYear) +``` + +#### 4. Fix guardian search to return selected-year parents only + +Search should not list every historical guardian. It should list parents connected to at least one selected-year student or selected-year invoice/account row. + +Preferred rule: + +```txt +A parent appears in parent profile search for a school year if they have at least one of: +1. a selected-year active/enrolled student through family/student links, +2. a selected-year invoice, +3. a selected-year parent account/opening balance, +4. a selected-year emergency contact record. +``` + +That prevents old parents from disappearing when they only have finance carryover, while still preventing irrelevant historical noise. + +#### 5. Fix guardian selection to return all selected-year children/families + +Replace `resolveStudentIdByGuardian()` behavior for parent profiles. A guardian profile should load by parent ID and return all related selected-year students/families. + +Do not reduce a parent to one student. That is how siblings vanish, which is not a feature unless the school is run by a database with commitment issues. + +#### 6. Standardize student class fields + +Return both during migration: + +```json +{ + "id": 89, + "firstname": "...", + "lastname": "...", + "class_section_name": "3A", + "grade": "3A" +} +``` + +Long term, keep `class_section_name` as the canonical frontend field. + +#### 7. Fix family finance service signature + +Change: + +```php +loadFinancialsForParents(array $parentIds, int $paymentLimit = 10) +``` + +To: + +```php +loadFinancialsForParents(array $parentIds, string $schoolYear, int $paymentLimit = 10) +``` + +Update every caller. No finance summary on a school-year-aware page may omit the selected year. + +#### 8. Fix emergency contacts signature + +Change: + +```php +emergencyContactsForParents(array $guardians) +``` + +To: + +```php +emergencyContactsForParents(array $guardians, string $schoolYear) +``` + +and filter: + +```php +->where('school_year', $schoolYear) +``` + +If legacy rows have null school year, support a temporary fallback only behind an explicit migration flag. Do not silently mix null-year rows forever. + +#### 9. Fix compose email route and payload + +Choose one contract. Preferred backend-compatible frontend change: + +```ts +return apiFetch('/api/v1/family-admin/compose-email', { + method: 'POST', + body: JSON.stringify({ + recipient: toField.trim(), + subject: subject.trim(), + html_message: html, + return_url: safeReturn, + }), +}) +``` + +Also update `FamilyComposeEmailRequest` to allow `return_url` if the frontend keeps sending it: + +```php +'return_url' => ['nullable', 'string', 'max:2048'], +``` + +Do not create a second `/send` route unless there is a compatibility reason. There is not, because the project is still being fixed and backward compatibility is not the hill to die on today. + +### Required frontend implementation + +#### 1. Replace `AdminParentProfilePage` with a real parent-profile management page + +Create: + +```txt +src/pages/administrator/ParentProfilesManagementPage.tsx +src/api/parentProfiles.ts +``` + +Or rename the existing page, but do not leave it as a weak wrapper around `fetchFamilyAdminIndex()`. + +#### 2. Use global selected school year by name only + +The page must read: + +```ts +const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear() +``` + +Then call: + +```ts +fetchParentProfiles({ schoolYear: selectedSchoolYearName, q, page, perPage }) +``` + +All generated links must preserve only: + +```txt +school_year=2025-2026 +``` + +No `school_year_id`. + +#### 3. Add proper page UI sections + +The page should include: + +- selected school-year badge +- read-only warning when selected year is closed/archived +- search box for parent name/email/phone/student +- paginated parent list +- parent detail drawer or right panel +- parent profile fields +- linked families +- selected-year students and class sections +- selected-year finance summary +- selected-year invoices and payments +- selected-year emergency contacts +- email action using the fixed compose route + +#### 4. Stop loading all guardians into the left rail + +Current UI loads every guardian and renders a huge list. That will age badly as data grows. Use server-side search/pagination. + +Good default behavior: + +- load first 25 selected-year parents +- user searches when needed +- clicking parent loads detail via `/api/v1/parent-profiles/{id}?school_year=...` + +#### 5. Preserve school year in navigation + +When opening parent details, family details, compose email, invoice PDF, or payment pages, preserve: + +```txt +school_year=2025-2026 +``` + +Do not reintroduce: + +```txt +school_year_id=4 +``` + +#### 6. Support read-only closed years + +For closed or archived years: + +- profile detail is viewable +- email action can remain allowed if policy permits communication for historical year +- parent profile editing disabled +- guardian/family mutation disabled +- finance mutation links disabled +- emergency contact mutation disabled + +### Required backend tests + +Add `tests/Feature/Api/V1/ParentProfiles/ParentProfileAdminControllerTest.php`: + +1. `test_parent_profiles_index_requires_admin` +2. `test_parent_profiles_index_accepts_school_year_name_only` +3. `test_parent_profiles_index_rejects_school_year_id_as_context` +4. `test_parent_profiles_index_lists_only_selected_year_relevant_parents` +5. `test_parent_profiles_search_is_scoped_to_selected_year` +6. `test_parent_profiles_show_returns_all_selected_year_students_for_parent` +7. `test_parent_profiles_show_does_not_reduce_parent_to_first_child` +8. `test_parent_profiles_show_excludes_students_only_linked_to_other_years` +9. `test_parent_profiles_finance_summary_filters_invoices_by_school_year` +10. `test_parent_profiles_payments_are_joined_to_selected_year_invoices` +11. `test_parent_profiles_emergency_contacts_filter_by_school_year` +12. `test_parent_profiles_positive_unpaid_balance_is_separate_from_credit_balance` +13. `test_parent_profiles_closed_year_update_is_rejected_with_409` +14. `test_parent_profiles_invalid_parent_id_returns_404_json` +15. `test_family_compose_email_uses_canonical_route_and_payload` + +### Required frontend tests + +Add tests for: + +1. `/app/administrator/parent_profiles?school_year=2025-2026` calls `/api/v1/parent-profiles?school_year=2025-2026`. +2. No parent-profile request contains `school_year_id`. +3. Changing global selected school year reloads parent list. +4. Search preserves `school_year`. +5. Parent click loads details with the same `school_year`. +6. Finance summary displays only selected-year invoices/payments. +7. Siblings in the same selected year are all shown. +8. Students from other years are not shown in selected-year student list. +9. Closed/archived year disables edit/mutation controls. +10. Compose email posts to `/api/v1/family-admin/compose-email` with `recipient` and `html_message`. + +### Acceptance criteria for Parent Profiles + +Parent Profiles is fixed only when: + +- `http://localhost:5173/app/administrator/parent_profiles?school_year=2025-2026` works without `school_year_id`. +- The page uses a dedicated parent-profile API, not the generic family-admin index as its main data source. +- Search, list, detail, finance, emergency contacts, and student/class data are all scoped to the selected `school_year`. +- Selecting one parent shows all selected-year children, not only the first child. +- Parent balance matches the selected year and does not mix historical invoices. +- Positive unpaid balance and credit balance are separated. +- Old-year students and old-year guardians do not pollute the selected-year page. +- Closed/archived years are readable but not mutable. +- Email compose route and payload match backend validation. +- Backend and frontend tests prove no parent profile request uses `school_year_id`. + +--- + +## Addendum 4: Fix School Calendar / Calendar View + +Broken page: + +```txt +http://localhost:5173/app/administrator/calendar_view?school_year=2025-2026&school_year_id=4 +``` + +Canonical page after the fix: + +```txt +http://localhost:5173/app/administrator/calendar_view?school_year=2025-2026 +``` + +`school_year_id` must be ignored only during a temporary redirect cleanup, then removed completely. The permanent frontend/backend contract remains: + +```txt +school_year=2025-2026 +``` + +### Current implementation problems found + +#### 1. The route aliases two different calendar experiences + +Frontend routing maps both pages into the same calendar-management chunk, but the behavior is inconsistent: + +```txt +/app/administrator/calendar +/app/administrator/calendar_view +/app/administrator/events +/app/administrator/calendar_add_event +/app/administrator/calendar_edit/:eventId +``` + +Actual behavior: + +- `/administrator/calendar_view` uses the event list page. +- `/administrator/events` uses the same event list page. +- `/administrator/calendar` uses a browse/calendar card page. +- The browse page calls the calendar API with no selected `school_year`. +- The browse link from the event list does not preserve selected `school_year`. +- Edit and delete actions do not preserve selected `school_year`. + +That means the user can enter the page with `school_year=2025-2026`, then click once and silently fall back to backend defaults. Very sophisticated, in the way a trapdoor is sophisticated. + +#### 2. Add event ignores the selected year from the URL + +`AdminCalendarAddEventPage` loads backend options and uses backend default values. It does not read the page query string. + +Bad outcome: + +```txt +/app/administrator/calendar_add_event?school_year=2025-2026 +``` + +can still create an event under the configured/default year instead of the URL-selected year. + +Fix: + +- read `school_year` from `useSearchParams()` +- initialize the form from the query `school_year` +- use backend defaults only when no valid query year exists +- after save, redirect back to: + +```txt +/app/administrator/calendar_view?school_year=2025-2026 +``` + +#### 3. Edit/delete are ID-only and not school-year guarded + +Backend currently exposes: + +```txt +GET /api/v1/settings/school-calendar/events/{eventId} +PATCH /api/v1/settings/school-calendar/events/{eventId} +DELETE /api/v1/settings/school-calendar/events/{eventId} +``` + +The controller finds events by ID only. That is not acceptable for school-year-scoped management. + +Fix all detail/mutation routes to require selected school year: + +```txt +GET /api/v1/settings/school-calendar/events/{eventId}?school_year=2025-2026 +PATCH /api/v1/settings/school-calendar/events/{eventId}?school_year=2025-2026 +DELETE /api/v1/settings/school-calendar/events/{eventId}?school_year=2025-2026 +``` + +Then backend must check: + +```php +$event->school_year === $selectedSchoolYear->school_year +``` + +If not, return 404, not the other-year event. Cross-year event editing by numeric ID is exactly how small accounting disasters grow teeth. + +#### 4. Backend allows nullable school year on calendar mutations + +`SchoolCalendarStoreRequest` and `SchoolCalendarUpdateRequest` allow nullable `school_year`. For this project that is wrong. + +Fix: + +- `school_year` is required for admin calendar store/update/delete/show. +- backend validates it against real school years using the central `SchoolYearResolver`. +- backend rejects `school_year_id` as context. +- backend never falls back to `Configuration::getConfig('school_year')` for admin management mutations. + +Acceptable fallback: + +- only legacy teacher/parent calendar endpoints may temporarily default to current school year. +- all administrator pages must send explicit `school_year`. + +#### 5. Calendar browser page does not respect selected year + +`AdminCalendarPage` currently calls the list endpoint without filters. + +Fix: + +- read `school_year` from the URL +- if missing, redirect to canonical current-year URL: + +```txt +/app/administrator/calendar?school_year= +``` + +- call: + +```txt +GET /api/v1/settings/school-calendar/events?school_year=2025-2026&audience=admin&include_meetings=1 +``` + +- preserve `school_year` when linking to event list: + +```txt +/app/administrator/calendar_view?school_year=2025-2026 +``` + +#### 6. Calendar list controls are uncontrolled + +The current school-year and semester filters use `defaultValue`. After async current-year loading or URL replacement, the select may display stale values. + +Fix: + +- make filters controlled with `value` +- derive state only from URL search params +- when the global school year changes, update the URL first, then reload events from that URL +- never keep a separate page-local selected year that can disagree with global selection + +#### 7. Event type is exposed but not actually stored + +The `CalendarEvent` model and resources expose `event_type`, and the frontend has an event type field. But the `calendar_events` migration does not create an `event_type` column. + +Current workaround: + +```php +CalendarEvent::supportsEventType() +``` + +silently drops `event_type` if the column is missing. + +Fix: + +Add a migration: + +```php +Schema::table('calendar_events', function (Blueprint $table) { + if (! Schema::hasColumn('calendar_events', 'event_type')) { + $table->string('event_type', 100)->nullable()->after('description'); + } +}); +``` + +Then remove the silent `supportsEventType()` behavior from the mutation flow. If the UI asks users for event type, the database must store it. Revolutionary stuff. + +#### 8. Meeting fallback leaks meetings from other years + +`SchoolCalendarMeetingService::listMeetings()` first queries by selected year, but if no rows exist it falls back to all scheduled meetings regardless of year. + +This is a direct school-year isolation bug. + +Fix: + +- remove the fallback query completely +- return an empty array when no meetings exist for selected `school_year` +- parent meeting lookup must filter family/student links by selected year too + +Required query behavior: + +```php +ParentMeetingSchedule::query() + ->where('school_year', $schoolYear) + ->whereScheduledOnly() +``` + +No fallback. No “helpful” historical leakage. Computers already help too much. + +#### 9. Parent/teacher/admin email recipients are not school-year scoped + +`SchoolCalendarNotificationService` currently collects broad recipients: + +- all parents +- all teachers +- all admins + +For a selected school year calendar event, notifications must be scoped. + +Fix: + +- parents: only guardians with active/enrolled students in selected `school_year` +- teachers: only staff/teachers assigned or active in selected `school_year` +- admins: active admin users only; admins may remain global unless a future admin-year table exists +- pass `school_year` into notification recipient collection +- include `school_year` in email links: + +```txt +/app/administrator/calendar_view?school_year=2025-2026 +``` + +#### 10. Closed/archived years are not protected + +Calendar events are historical school-year records. If the selected school year is closed or archived: + +Allowed: + +- list events +- browse calendar +- show event details + +Blocked: + +- create event +- update event +- delete event +- send notification from edit/create + +Fix: + +- route mutations through the central school-year write guard +- return 409 JSON for closed/archived selected year +- frontend disables Add/Edit/Delete buttons and shows a read-only banner + +### Backend implementation plan + +#### 1. Centralize selected school year resolution + +Inject the project-level school-year resolver into calendar services/controllers. + +Expected behavior: + +```php +$selectedYear = $schoolYearResolver->resolveFromRequest($request); +``` + +Rules: + +- accept only `school_year` +- reject or ignore `school_year_id` depending on the global compatibility policy, but never use it +- validate that the school year exists +- expose `is_closed` / `is_archived` / `status` for write guarding + +#### 2. Update `SchoolCalendarIndexRequest` + +Rules: + +```php +'school_year' => ['required', 'string', 'max:20', 'exists:school_years,school_year'], +'semester' => ['nullable', 'string', Rule::in(['Fall', 'Spring'])], +'audience' => ['nullable', 'string', Rule::in(['parent', 'teacher', 'admin'])], +'include_meetings' => ['nullable', 'boolean'], +'sort_dir' => ['nullable', 'string', Rule::in(['asc', 'desc'])], +``` + +For legacy teacher calendar only, create a separate request or controller branch if defaulting is still needed. Do not weaken admin calendar validation to support legacy behavior. + +#### 3. Update store/update validation + +Store request: + +```php +'school_year' => ['required', 'string', 'max:20', 'exists:school_years,school_year'], +'semester' => ['required', 'string', Rule::in(['Fall', 'Spring'])], +``` + +Update request: + +```php +'school_year' => ['required', 'string', 'max:20', 'exists:school_years,school_year'], +'semester' => ['sometimes', 'string', Rule::in(['Fall', 'Spring'])], +``` + +Do not allow update to move an existing event to another year unless a separate explicit move endpoint is added later. + +#### 4. Add scoped find methods + +In `SchoolCalendarQueryService`: + +```php +public function findEventForYear(int $id, string $schoolYear): ?CalendarEvent +{ + return CalendarEvent::query() + ->whereKey($id) + ->where('school_year', $schoolYear) + ->first(); +} +``` + +Use this in `show`, `update`, and `destroy`. + +#### 5. Add calendar table indexes + +Migration: + +```php +Schema::table('calendar_events', function (Blueprint $table) { + $table->index(['school_year', 'date'], 'calendar_events_school_year_date_idx'); + $table->index(['school_year', 'semester', 'date'], 'calendar_events_school_year_semester_date_idx'); + $table->index(['school_year', 'notify_parent', 'date'], 'calendar_events_parent_year_date_idx'); + $table->index(['school_year', 'notify_teacher', 'date'], 'calendar_events_teacher_year_date_idx'); +}); +``` + +#### 6. Normalize response shape + +Every calendar event response must include: + +```json +{ + "id": 123, + "title": "No School", + "start": "2026-01-10", + "description": "...", + "backgroundColor": "#ffc107", + "extendedProps": { + "school_year": "2025-2026", + "semester": "Fall", + "event_type": "Break", + "notify_parent": true, + "notify_teacher": true, + "notify_admin": true, + "no_school": true, + "source": "calendar_event" + } +} +``` + +Meeting rows should use: + +```json +{ + "id": "meeting-123", + "extendedProps": { + "source": "parent_meeting", + "school_year": "2025-2026" + } +} +``` + +Frontend should not guess from ID prefixes like `pm-`. Add an explicit `source` field. + +#### 7. Fix notification links + +`ApplicationUrlService::spaAdministratorCalendarViewUrl()` must accept `school_year`: + +```php +spaAdministratorCalendarViewUrl(string $schoolYear): string +``` + +Return: + +```txt +/app/administrator/calendar_view?school_year=2025-2026 +``` + +### Frontend implementation plan + +#### 1. Canonicalize URLs + +When page loads with: + +```txt +/app/administrator/calendar_view?school_year=2025-2026&school_year_id=4 +``` + +replace it with: + +```txt +/app/administrator/calendar_view?school_year=2025-2026 +``` + +Do not keep `school_year_id` in links, API calls, redirects, or local state. + +#### 2. Replace duplicate calendar pages with one module + +Create one real calendar management module: + +```txt +src/pages/administrator/school-calendar/ + SchoolCalendarPage.tsx + SchoolCalendarList.tsx + SchoolCalendarBrowse.tsx + SchoolCalendarEventForm.tsx + schoolCalendarApi.ts +``` + +Route behavior: + +```txt +/app/administrator/calendar_view?school_year=2025-2026 -> main management page +/app/administrator/events?school_year=2025-2026 -> redirect to calendar_view +/app/administrator/calendar?school_year=2025-2026 -> browse mode or tab on same page +/app/administrator/calendar_add_event?school_year=... -> form with selected year locked +/app/administrator/calendar_edit/:id?school_year=... -> form scoped to selected year +``` + +#### 3. API client functions + +Use these frontend API calls only: + +```ts +listSchoolCalendarEvents({ schoolYear, semester, audience, includeMeetings }) +getSchoolCalendarEvent(eventId, schoolYear) +createSchoolCalendarEvent(payloadWithSchoolYear) +updateSchoolCalendarEvent(eventId, schoolYear, payload) +deleteSchoolCalendarEvent(eventId, schoolYear) +getSchoolCalendarOptions() +``` + +Every request except options must include `school_year`. + +#### 4. Fix selected-year state + +Use global selected year or URL, not both. + +Rules: + +- URL is the source of truth for page reload/share. +- global selector updates URL. +- page reads from URL. +- API receives URL value. +- form receives URL value. + +No internal `selectedYearId`. No `year=4`. No `school_year_id=4`. We have suffered enough numeric-year cosplay. + +#### 5. Add read-only UI for closed years + +When selected year is closed/archived: + +- show banner: `This school year is closed. Calendar is read-only.` +- hide or disable Add Event +- disable Edit/Delete +- keep Browse/List visible +- prevent form submission even if a user reaches the route directly + +#### 6. Improve event list UI + +The list should show: + +```txt +Date | Title | Type | Semester | Audience | No School | Source | Actions +``` + +Audience column should derive from: + +```txt +notify_parent / notify_teacher / notify_admin +``` + +Source column: + +```txt +Calendar Event +Parent Meeting +System Generated +``` + +Meeting rows should not show Edit/Delete. + +#### 7. Improve form behavior + +Form fields: + +```txt +School Year: read-only selected value +Semester: Fall/Spring +Date: required +Title: required +Event Type: required for normal events +Description: optional +No School: checkbox +Audience: Parents / Teachers / Admins +Send email now: Parents / Teachers / Admins +``` + +When `No School` is checked: + +- default audiences to Parents + Teachers + Admins +- title can default to `No School` +- event type can default to `Break` + +### Required backend tests + +Add or update `tests/Feature/Api/V1/Settings/SchoolCalendarControllerTest.php`: + +1. `test_admin_calendar_index_requires_school_year_name` +2. `test_admin_calendar_index_rejects_school_year_id_context` +3. `test_admin_calendar_index_returns_only_selected_year_events` +4. `test_admin_calendar_index_filters_by_semester_inside_selected_year` +5. `test_calendar_browse_includes_only_selected_year_meetings` +6. `test_meeting_service_does_not_fallback_to_other_school_year` +7. `test_show_event_requires_matching_school_year` +8. `test_show_event_returns_404_for_event_from_other_year` +9. `test_store_requires_school_year` +10. `test_store_rejects_unknown_school_year` +11. `test_store_blocks_closed_year_with_409` +12. `test_update_requires_school_year` +13. `test_update_does_not_allow_cross_year_event_edit` +14. `test_update_blocks_closed_year_with_409` +15. `test_destroy_requires_school_year` +16. `test_destroy_returns_404_for_other_year_event` +17. `test_destroy_blocks_closed_year_with_409` +18. `test_event_type_is_persisted_and_returned` +19. `test_no_school_event_is_visible_to_all_audiences` +20. `test_parent_audience_does_not_see_teacher_only_event` +21. `test_teacher_audience_does_not_see_parent_only_event` +22. `test_notification_recipients_are_scoped_to_selected_school_year` +23. `test_calendar_email_link_contains_school_year_name` + +### Required frontend tests + +Add tests for: + +1. Loading `/app/administrator/calendar_view?school_year=2025-2026` calls: + +```txt +/api/v1/settings/school-calendar/events?school_year=2025-2026 +``` + +2. Loading with `school_year_id=4` canonicalizes the URL and removes it. +3. No calendar API request includes `school_year_id`. +4. Browse page preserves `school_year`. +5. Events page redirects or preserves `school_year`. +6. Add event form reads `school_year` from URL, not backend defaults. +7. Add event submit includes `school_year`. +8. Edit event fetch includes `school_year`. +9. Edit event submit includes `school_year`. +10. Delete event request includes `school_year`. +11. Changing the selected school year reloads events from the new URL. +12. Closed-year selection disables Add/Edit/Delete. +13. Meeting rows display but do not show mutation actions. +14. No-school event defaults audience checkboxes correctly. + +### Acceptance criteria for School Calendar + +School Calendar is fixed only when: + +- `http://localhost:5173/app/administrator/calendar_view?school_year=2025-2026` works without `school_year_id`. +- Opening with `school_year_id=4` redirects/replaces to a URL without it. +- `/administrator/calendar`, `/administrator/events`, add, edit, browse, and delete all preserve `school_year`. +- Backend list/show/store/update/delete all resolve by `school_year`, not ID context. +- Show/update/delete cannot access events from a different school year by numeric event ID. +- Add event uses selected URL year, not backend default year. +- Closed/archived years are read-only. +- Meetings never fall back to another school year. +- Event type is actually stored in the database and returned to the frontend. +- Calendar email recipients and links are scoped to the selected year. +- Backend and frontend tests prove no calendar request uses `school_year_id`. + +--- + +## Addendum V6: Full Teacher Pages Audit and Implementation Plan + +### Scope + +Teacher-facing pages must be treated as one portal, not a pile of legacy route aliases with a React costume. Audit and fix every page below: + +```txt +/app/teacher_dashboard +/app/teacher/no-classes +/app/teacher/select-semester +/app/teacher/class-view +/app/teacher/showupdate-attendance +/app/teacher/absence-vacation +/app/teacher/calendar +/app/teacher/inventory/book-distribute +/app/teacher/print-requests +/app/teacher/competition-scores +/app/teacher/competition-scores/:competitionId +/app/teacher/scores +/app/teacher/homework-list +/app/teacher/add-homework +/app/teacher/add-quiz +/app/teacher/add-quiz-column-form +/app/teacher/add-midterm-exam +/app/teacher/add-final-exam +/app/teacher/add-participation +/app/teacher/add-project +/app/teacher/teacher-assignment +/app/teacher/exam-drafts +/app/teacher/class-progress-submit +/app/teacher/class-progress-history +/app/teacher/class-progress-view +/app/teacher/inbox +/app/teacher/sent +/app/teacher/drafts +/app/teacher/trash +/app/teacher/messages/:messageId +/app/teacher/teacher-contactus +/app/teacher/teacher-support +``` + +The required external context contract remains: + +```txt +school_year=2025-2026 +``` + +Do not use `school_year_id` in teacher URLs, teacher API requests, headers, local storage context, tests, redirects, or generated links. Backend may resolve the internal school year row by `school_year` after request validation. + +### Current teacher-page findings + +1. `MainLayout` teacher navigation links do not preserve the selected `school_year`. Switching between teacher pages can silently fall back to whatever year is stored globally or configured on the backend. + +2. `TeacherDashboardPage` skips the dashboard API unless `VITE_TEACHER_DASHBOARD_API=1`, but the backend does not expose a real `/api/v1/teacher/dashboard` route. The dashboard is therefore mostly a static route directory instead of a teacher status page. + +3. `useTeacherResource()` calls `fetchTeacherJson(path)`, and `fetchTeacherJson()` only prepends `/api/v1/teacher`. It does not append the current URL `school_year`, so many pages never send the selected year in the query string. + +4. `selectedSchoolYearHeaders()` still emits `X-School-Year-Id` and `X-Selected-School-Year-Id`. Teacher pages must not depend on those headers. Since the project decision is “only `school_year`,” these headers must be removed from the teacher request path and eventually from the global request helper. + +5. Backend teacher services often use `Configuration::getConfig('school_year')` through `TeacherConfigService`, `ScoreTermService`, `InventoryContextService`, or `AttendanceService`. That makes teacher pages load the configured current year instead of the selected URL year. + +6. Teacher message pages are wired to nonexistent teacher-prefixed endpoints: + +```txt +GET /api/v1/teacher/inbox +GET /api/v1/teacher/sent +GET /api/v1/teacher/trash +GET /api/v1/teacher/messages/{id} +``` + +The backend currently has `/api/v1/messages/inbox`, `/api/v1/messages/sent`, `/api/v1/messages/trash`, and `/api/v1/messages/{id}`. The frontend/backend contract is simply wrong, which is a bold way to implement messaging. + +7. `TeacherDraftsPage` is labelled as “Draft messages” but calls `/api/v1/teacher/drafts`. Backend maps `teacher/drafts` to exam drafts, not message drafts. This mixes two unrelated concepts: message drafts and exam drafts. + +8. Several teacher pages are `TeacherApiDumpPage` wrappers that dump raw JSON instead of implementing usable screens: + +```txt +add-final-exam +add-homework +add-midterm-exam +add-participation +add-project +add-quiz +add-quiz-column-form +class-progress-view +teacher-assignment +teacher-contactus +teacher-support +competition-scores/:competitionId +``` + +Raw JSON is not a UI. It is what happens when a developer loses an argument with time. + +9. `TeacherClass::getClassAssignmentsByUserId($userId, $schoolYear, $semester)` accepts `$semester` but does not filter `teacher_class.semester`. It also does not select the actual row semester. Teacher pages can show assignments from the wrong semester while pretending the requested semester was applied. + +10. `StudentClass::getStudentsByClassSectionIds()` does not accept or apply `school_year` or `semester`. Teacher class view and teacher inventory can show students from other years if the same `class_section_id` was reused. + +11. `TeacherDashboardService::classView()` resolves teacher assignments by global school year, then loads students without selected-year scoping. It also loads assigned teacher names filtered by `school_year` but not by `semester`. + +12. `TeacherAttendanceApiController::form()` accepts only `class_section_id`; it ignores `school_year` and `semester`. `AttendanceQueryService::teacherAttendanceFormData()` uses current configured year/semester, not the selected URL year/semester. + +13. `TeacherAttendanceApiController::submit()` writes using `AttendanceService::currentSchoolYear()` and `currentSemester()`, again ignoring selected `school_year` unless the service is changed. Attendance can be saved into the wrong school year. Cute, if the goal is historical vandalism. + +14. `TeacherAbsenceService` loads and saves time-off using configured year/semester. The request does not accept `school_year`, and allowed absence dates are computed from the configured year. + +15. `ExamDraftTeacherService::listForTeacher()` uses configured year and does not filter teacher-owned drafts by `school_year`. Legacy exam draft rows are also not filtered by school year. `store()` checks teacher assignment without `school_year` or `semester`, so a teacher assigned in another year can submit against the selected class section. + +16. Teacher score pages mostly pass `school_year` when the URL contains it, but navigation does not consistently preserve the value. Score save/lock requests are safer than many pages because they include `school_year` in the body, but the page still needs canonical URL propagation and closed-year read-only enforcement. + +17. `ClassProgressIndexRequest` still allows `school_year_id`. Teacher progress endpoints must remove it. Teacher progress history/list must resolve only by `school_year`. + +18. `ClassProgressQueryService` uses `TeacherClass::distinctSectionIdsForTeacher()` for non-admin access, which explicitly ignores school year/semester. That lets teachers see progress reports for sections they taught in another year. For a school-year archive feature, that is not “legacy compatibility”; that is cross-year leakage. + +19. `ClassProgressController::legacySubmitForm()` uses configured year/semester instead of request-selected year/semester. + +20. `ClassProgressQueryService::weeklyReports()` fetches all reports for a section and week without adding `school_year` or `semester` filters. If two years share the same Sunday date and section code, reports can bleed together. + +21. Teacher calendar calls `fetchAdministratorCalendarEvents({ audience: 'teacher' })` without passing the selected `school_year`, so it falls back to backend defaults. + +22. Teacher print requests do not carry `school_year`. The `print_requests` table migration has no `school_year` column, and `PrintRequestsPortalService::teacherPrintRequests()` returns all requests for the teacher across all years. + +23. Teacher print request default class selection uses `defaultClassSectionIdForTeacher()` without `school_year` or `semester`, so the default class can come from an old assignment. + +24. Teacher inventory distribution uses `InventoryContextService`, which reads configured school year. It also depends on `StudentClass::getStudentsByClassSectionIds()` with no year scoping, so the student list can include the wrong year. + +25. Teacher routes sit inside a broad `multi.auth` group. Some services check teacher roles manually, some do not. Teacher portal endpoints need one consistent role gate. + +### Target teacher API contract + +Create a real teacher portal API under one explicit route group: + +```txt +/api/v1/teacher/* +``` + +Every GET endpoint must accept: + +```txt +school_year=2025-2026 +semester=Fall|Spring optional, only where relevant +class_section_id=... optional, only where relevant +``` + +Every POST/PATCH/DELETE endpoint must accept `school_year` in the JSON body or multipart form data. Query string is allowed for reads; mutation body is preferred for writes. + +All teacher endpoints must run through: + +```txt +multi.auth +teacher.portal.auth +teacher.access +school_year.context:school_year +school_year.editable only for writes +``` + +Add a `teacher.access` middleware if necessary. It should allow: + +```txt +teacher +teacher_assistant +administrator/admin/principal only when explicitly intended +``` + +The middleware should reject parent-only users with 403 before service logic runs. + +### Backend implementation plan + +#### 1. Add request-scoped teacher school-year context + +Create one service used by all teacher endpoints: + +```php +TeacherPortalContextService +``` + +Responsibilities: + +```txt +resolve school_year from request query/body/form-data +reject school_year_id +validate school_year exists by name +return selected SchoolYear model/name/status +resolve semester from request or Configuration fallback +expose isReadOnly for closed/archived school years +resolve authenticated teacher user +resolve teacher roles +``` + +Do not let individual services call `Configuration::getConfig('school_year')` directly when handling teacher requests. + +#### 2. Fix teacher route group + +Replace scattered legacy aliases with a canonical group: + +```php +Route::middleware(['multi.auth', 'teacher.portal.auth', 'teacher.access']) + ->prefix('teacher') + ->group(function () { + Route::get('dashboard', [TeacherPortalController::class, 'dashboard']); + Route::get('classes', [TeacherPortalController::class, 'classes']); + Route::get('class-view', [TeacherPortalController::class, 'classView']); + Route::get('select-semester', [TeacherPortalController::class, 'selectSemester']); + + Route::get('attendance', [TeacherAttendanceApiController::class, 'form']); + Route::get('showupdate-attendance', [TeacherAttendanceApiController::class, 'form']); + Route::post('attendance/submit', [TeacherAttendanceApiController::class, 'submit']) + ->middleware('school_year.editable'); + + Route::get('absence-vacation', [TeacherController::class, 'absenceForm']); + Route::post('absence-vacation', [TeacherController::class, 'submitAbsence']) + ->middleware('school_year.editable'); + + Route::get('calendar', [TeacherCalendarController::class, 'index']); + + Route::prefix('messages')->group(function () { + Route::get('inbox', [MessagesController::class, 'inbox']); + Route::get('sent', [MessagesController::class, 'sent']); + Route::get('drafts', [MessagesController::class, 'drafts']); + Route::get('trash', [MessagesController::class, 'trash']); + Route::get('{id}', [MessagesController::class, 'show'])->whereNumber('id'); + Route::post('/', [MessagesController::class, 'store'])->middleware('school_year.editable'); + Route::patch('{id}', [MessagesController::class, 'update'])->whereNumber('id')->middleware('school_year.editable'); + Route::delete('{id}', [MessagesController::class, 'destroy'])->whereNumber('id')->middleware('school_year.editable'); + }); + + Route::get('scores', [ScoreController::class, 'overview']); + Route::post('scores/comments', [ScoreController::class, 'comments'])->middleware('school_year.editable'); + Route::post('scores/lock', [ScoreController::class, 'lock'])->middleware('school_year.editable'); + + Route::get('homework-list', [TeacherScoreEntryController::class, 'homeworkList']); + Route::get('add-homework', [TeacherScoreEntryController::class, 'homeworkForm']); + Route::post('add-homework', [TeacherScoreEntryController::class, 'saveHomework'])->middleware('school_year.editable'); + Route::get('add-quiz', [TeacherScoreEntryController::class, 'quizForm']); + Route::post('add-quiz', [TeacherScoreEntryController::class, 'saveQuiz'])->middleware('school_year.editable'); + Route::get('add-quiz-column-form', [TeacherScoreEntryController::class, 'quizColumnForm']); + Route::post('add-quiz-column-form', [TeacherScoreEntryController::class, 'addQuizColumn'])->middleware('school_year.editable'); + Route::get('add-midterm-exam', [TeacherScoreEntryController::class, 'midtermForm']); + Route::post('add-midterm-exam', [TeacherScoreEntryController::class, 'saveMidterm'])->middleware('school_year.editable'); + Route::get('add-final-exam', [TeacherScoreEntryController::class, 'finalForm']); + Route::post('add-final-exam', [TeacherScoreEntryController::class, 'saveFinal'])->middleware('school_year.editable'); + Route::get('add-participation', [TeacherScoreEntryController::class, 'participationForm']); + Route::post('add-participation', [TeacherScoreEntryController::class, 'saveParticipation'])->middleware('school_year.editable'); + Route::get('add-project', [TeacherScoreEntryController::class, 'projectForm']); + Route::post('add-project', [TeacherScoreEntryController::class, 'saveProject'])->middleware('school_year.editable'); + + Route::get('exam-drafts', [ExamDraftController::class, 'teacherIndex']); + Route::post('exam-drafts', [ExamDraftController::class, 'teacherStore'])->middleware('school_year.editable'); + + Route::get('class-progress-submit', [ClassProgressController::class, 'legacySubmitForm']); + Route::post('class-progress-submit', [ClassProgressController::class, 'store'])->middleware('school_year.editable'); + Route::get('class-progress-history', [ClassProgressController::class, 'index']); + Route::get('class-progress-view', [ClassProgressController::class, 'index']); + + Route::get('competition-scores', [CompetitionScoresController::class, 'index']); + Route::get('competition-scores/{competition}', [CompetitionScoresController::class, 'show'])->whereNumber('competition'); + Route::post('competition-scores/{competition}', [CompetitionScoresController::class, 'store'])->whereNumber('competition')->middleware('school_year.editable'); + + Route::get('inventory/books/distribute', [InventoryController::class, 'teacherDistribution']); + Route::post('inventory/books/distribute', [InventoryController::class, 'teacherDistributionStore'])->middleware('school_year.editable'); + + Route::get('print-requests/context', [PrintRequestsController::class, 'teacher']); + Route::post('print-requests', [PrintRequestsController::class, 'store'])->middleware('school_year.editable'); + Route::patch('print-requests/{id}', [PrintRequestsController::class, 'teacherUpdate'])->whereNumber('id')->middleware('school_year.editable'); + Route::delete('print-requests/{id}', [PrintRequestsController::class, 'destroy'])->whereNumber('id')->middleware('school_year.editable'); + + Route::get('assignment', [TeacherClassAssignmentController::class, 'teacherIndex']); + Route::get('support', [ApiSupportController::class, 'teacher']); + Route::post('support', [ApiSupportController::class, 'store'])->middleware('school_year.editable'); + Route::get('contact-us', [ContactController::class, 'teacherForm']); + Route::post('contact-us', [ContactController::class, 'store'])->middleware('school_year.editable'); + }); +``` + +Keep old URL aliases only as frontend redirects, not as separate backend behavior. + +#### 3. Fix teacher assignment model methods + +Update `TeacherClass::getClassAssignmentsByUserId()`: + +```txt +- add WHERE tc.school_year = selected school_year +- add WHERE tc.semester = selected semester when provided +- select tc.semester from the table +- return actual row semester, not the caller-provided value +- join classSection with matching school_year where column exists +- exclude soft-deleted or inactive class sections where supported +``` + +Add methods: + +```php +TeacherClass::assignedSectionIdsForTeacherYearSemester(int $teacherId, string $schoolYear, ?string $semester): array +TeacherClass::teacherCanAccessSection(int $teacherId, int $classSectionId, string $schoolYear, ?string $semester): bool +``` + +Stop using `distinctSectionIdsForTeacher()` for access control in teacher pages. That method can remain only for legacy migration/report repair tools. + +#### 4. Fix student roster scoping + +Replace `StudentClass::getStudentsByClassSectionIds(array $classSectionIds)` with a scoped version: + +```php +StudentClass::getStudentsByClassSectionIds(array $classSectionIds, string $schoolYear, ?string $semester = null): array +``` + +Apply: + +```txt +sc.school_year = selected school_year +sc.semester = selected semester when the page is semester-specific +sc.is_event_only = 0 unless the page explicitly supports event-only rosters +students.is_active = 1 +``` + +Update all teacher consumers: + +```txt +TeacherDashboardService::classView +AttendanceQueryService::teacherAttendanceFormData +InventoryTeacherDistributionService::buildTeacherClassContext +ScoreDashboardService::overview if roster helpers are used later +ClassProgress teacher meta/submit context +``` + +#### 5. Implement real teacher dashboard API + +Add: + +```txt +GET /api/v1/teacher/dashboard?school_year=2025-2026 +``` + +Response: + +```json +{ + "ok": true, + "school_year": "2025-2026", + "semester": "Fall", + "teacher": { "id": 12, "name": "...", "role": "teacher" }, + "assignments": [ + { "class_section_id": 36, "class_section_name": "3A", "position": "main", "semester": "Fall" } + ], + "counts": { + "classes": 1, + "students": 22, + "unsubmitted_attendance_days": 1, + "pending_exam_drafts": 2, + "pending_print_requests": 1, + "progress_reports_this_term": 8 + }, + "read_only": false +} +``` + +Frontend should always call it. Remove the `VITE_TEACHER_DASHBOARD_API` gate. + +#### 6. Fix teacher class view + +Backend: + +```txt +GET /api/v1/teacher/class-view?school_year=2025-2026&semester=Fall&class_section_id=36 +``` + +Must: + +```txt +- require selected school_year +- only return teacher assignments for that selected year and semester +- reject class_section_id not assigned to current teacher in selected year/semester with 403 or 404 +- return students only from student_class for selected year/semester +- return assigned teacher/TA names for selected year/semester +- include class_section_name and class_section_id consistently +``` + +Frontend: + +```txt +- preserve school_year when switching class pills +- include semester when selected +- remove fallback to user.class_section_id when URL selected year has different assignments +- show a proper “No class assigned for 2025-2026 Fall” empty state +``` + +#### 7. Fix teacher attendance + +Backend reads: + +```txt +GET /api/v1/teacher/attendance?school_year=2025-2026&semester=Fall&class_section_id=36 +GET /api/v1/teacher/showupdate-attendance?school_year=2025-2026&semester=Fall&class_section_id=36 +``` + +Backend writes: + +```txt +POST /api/v1/teacher/attendance/submit +{ + "school_year": "2025-2026", + "semester": "Fall", + "class_section_id": 36, + "date": "2025-10-05", + "attendance": [...], + "teachers": {...} +} +``` + +Fixes: + +```txt +- TeacherAttendanceApiController::form must pass school_year and semester into AttendanceQueryService +- AttendanceQueryService::teacherAttendanceFormData must stop reading configured current year for teacher portal requests +- TeacherAttendanceSubmissionService must use payload school_year/semester, not closures that return configured current values +- validate teacher can access class_section_id in selected school_year/semester +- validate all submitted student_ids belong to that class/year/semester +- include read_only flag in GET response +- block POST for closed/archived years with 409 +``` + +#### 8. Fix teacher absence/vacation + +Backend: + +```txt +GET /api/v1/teacher/absence-vacation?school_year=2025-2026&semester=Fall +POST /api/v1/teacher/absence-vacation +``` + +POST body: + +```json +{ + "school_year": "2025-2026", + "semester": "Fall", + "dates": ["2025-10-05"], + "reason_type": "vacation", + "reason": "Family travel" +} +``` + +Fixes: + +```txt +- TeacherAbsenceSubmitRequest must accept school_year and semester +- TeacherAbsenceService::formData must accept resolved context +- TeacherAbsenceService::submit must save into selected year +- allowedAbsenceDates must be based on selected year +- notifications must include selected school_year in action links +- closed/archived years block POST +``` + +#### 9. Fix teacher calendar + +Frontend must call: + +```txt +fetchAdministratorCalendarEvents({ audience: 'teacher', schoolYear: selectedSchoolYear }) +``` + +or better: + +```txt +GET /api/v1/teacher/calendar?school_year=2025-2026 +``` + +Backend must: + +```txt +- return only teacher-visible events for selected year +- include no-school days +- include meeting events only from selected school_year +- never fall back to another year +``` + +#### 10. Fix teacher messages + +Choose one contract and implement it consistently. Preferred teacher contract: + +```txt +GET /api/v1/teacher/messages/inbox?school_year=2025-2026 +GET /api/v1/teacher/messages/sent?school_year=2025-2026 +GET /api/v1/teacher/messages/drafts?school_year=2025-2026 +GET /api/v1/teacher/messages/trash?school_year=2025-2026 +GET /api/v1/teacher/messages/{id}?school_year=2025-2026 +POST /api/v1/teacher/messages +PATCH /api/v1/teacher/messages/{id} +DELETE /api/v1/teacher/messages/{id} +``` + +Frontend changes: + +```txt +- TeacherInboxPage path becomes /messages/inbox +- TeacherSentPage path becomes /messages/sent +- TeacherTrashPage path becomes /messages/trash +- TeacherDraftsPage path becomes /messages/drafts +- TeacherMessagePage path becomes /messages/{messageId} +- render message cards/table from backend `messages`, not old `receivedMessages`/`sentMessages` shapes +``` + +Backend changes: + +```txt +- messages table must have school_year if messages are year-scoped +- if messages are global, response must explicitly say school_year: null and frontend must not pretend they are selected-year data +- recipient lists for teacher messaging must be scoped to selected school_year +- message show/update/delete must verify sender/recipient access +``` + +#### 11. Fix teacher score/coursework pages + +Replace JSON dump pages with real forms backed by explicit endpoints. + +Pages: + +```txt +/homework-list +/add-homework +/add-quiz +/add-quiz-column-form +/add-midterm-exam +/add-final-exam +/add-participation +/add-project +/scores +``` + +Backend must provide consistent response shape: + +```json +{ + "ok": true, + "school_year": "2025-2026", + "semester": "Fall", + "class_section_id": 36, + "class_section_name": "3A", + "read_only": false, + "scores_locked": false, + "students": [], + "columns": [], + "scores": {} +} +``` + +Rules: + +```txt +- every score endpoint requires school_year +- every write validates teacher assignment for selected year/semester/class +- locked semester returns 409 on write +- closed/archived year returns 409 on write +- frontend score links preserve school_year and semester +- no route uses school_year_id +``` + +#### 12. Fix teacher exam drafts + +Backend: + +```txt +GET /api/v1/teacher/exam-drafts?school_year=2025-2026&semester=Fall +POST /api/v1/teacher/exam-drafts +``` + +Fix `ExamDraftTeacherService`: + +```txt +- listForTeacher(int $teacherId, string $schoolYear, ?string $semester) +- filter teacher-owned drafts by school_year and semester +- filter legacy_exams by school_year and semester +- return assignments from selected year/semester only +- store() validates teacher assignment in selected year/semester +- store() writes selected school_year/semester, not configured values +- uploaded file metadata includes selected school_year +``` + +Frontend: + +```txt +- preserve school_year when changing class_section_id +- include school_year and semester in FormData +- disable upload when selected year is closed/archived +``` + +#### 13. Fix teacher class progress pages + +Backend: + +```txt +GET /api/v1/teacher/class-progress-submit?school_year=2025-2026&semester=Fall +POST /api/v1/teacher/class-progress-submit +GET /api/v1/teacher/class-progress-history?school_year=2025-2026&semester=Fall +GET /api/v1/teacher/class-progress-view?school_year=2025-2026&semester=Fall +``` + +Fixes: + +```txt +- remove school_year_id from ClassProgressIndexRequest +- legacySubmitForm must use request school_year/semester +- teacherAssignments must filter by selected year/semester +- listReports must not use distinctSectionIdsForTeacher for teacher access +- weeklyReports must filter by school_year and semester +- store must validate teacher assignment for selected year/semester/class_section_id +- closed/archived years block POST/PATCH/DELETE +``` + +Frontend: + +```txt +- class progress submit/historical filters preserve school_year +- class progress view must be a real viewer, not JSON dump +- attachment download links include school_year or validate by report access server-side +``` + +#### 14. Fix teacher competition scores + +Backend: + +```txt +GET /api/v1/teacher/competition-scores?school_year=2025-2026&semester=Fall +GET /api/v1/teacher/competition-scores/{competition}?school_year=2025-2026&semester=Fall +POST /api/v1/teacher/competition-scores/{competition} +``` + +Rules: + +```txt +- index shows only competitions assigned to teacher class sections in selected year/semester +- detail page must render a score-entry table, not JSON dump +- show/store validate the competition belongs to selected year and teacher section +- locked competitions are read-only +- closed/archived years block score writes +``` + +#### 15. Fix teacher inventory book distribution + +Backend: + +```txt +GET /api/v1/teacher/inventory/books/distribute?school_year=2025-2026&class_section_id=36&item_id=5 +POST /api/v1/teacher/inventory/books/distribute +``` + +Fixes: + +```txt +- InventoryContextService must accept request context; do not read only Configuration +- buildTeacherClassContext must use selected school_year and semester +- StudentClass roster must be selected-year scoped +- distribution POST must include school_year +- InventoryMovement rows already have school_year; write selected value only +- validate selected students belong to selected class/year +- closed/archived years block distribution +``` + +#### 16. Fix teacher print requests + +Add migration: + +```txt +ALTER TABLE print_requests ADD school_year VARCHAR(20) NULL; +ALTER TABLE print_requests ADD semester VARCHAR(20) NULL; +CREATE INDEX print_requests_teacher_year_idx ON print_requests(teacher_id, school_year, semester); +``` + +Backfill: + +```txt +- infer from classSection.school_year where possible +- otherwise leave null and treat as legacy only in admin repair view, not normal selected-year teacher list +``` + +Backend fixes: + +```txt +- teacherBootstrap(int $teacherId, string $schoolYear, ?string $semester) +- teacherPrintRequests filters by selected school_year +- defaultClassSectionIdForTeacher filters by selected school_year/semester +- storeTeacherUpload writes selected school_year/semester +- storeHandCopy writes selected school_year/semester +- copyFromExisting must copy only if source belongs to selected school_year or explicitly create new selected-year copy +- update/delete/download verify teacher owns request and request school_year matches selected school_year +- admin print queue can filter by selected school_year +``` + +Frontend fixes: + +```txt +- fetchTeacherPrintRequestsContext sends school_year +- submit/edit/copy/delete sends school_year +- closed/archived years disable create/edit/delete/copy +``` + +#### 17. Fix teacher assignment page + +The teacher-facing assignment page must not call the admin assignment management API as a JSON dump. + +Implement: + +```txt +GET /api/v1/teacher/assignment?school_year=2025-2026&semester=Fall +``` + +Response: + +```json +{ + "ok": true, + "school_year": "2025-2026", + "semester": "Fall", + "assignments": [ + { "class_section_id": 36, "class_section_name": "3A", "position": "main" } + ] +} +``` + +This page is read-only for teachers. Admin assignment editing remains under administrator routes. + +#### 18. Fix teacher support/contact pages + +Replace raw JSON dumps with real forms: + +```txt +GET /api/v1/teacher/support?school_year=2025-2026 +POST /api/v1/teacher/support +GET /api/v1/teacher/contact-us?school_year=2025-2026 +POST /api/v1/teacher/contact-us +``` + +Payload must include: + +```txt +school_year +teacher_id from auth, not request body +subject/message +category where supported +``` + +Store `school_year` on support/contact log tables where columns exist. Add columns if missing. + +### Frontend implementation plan + +#### 1. Add a teacher URL/query helper + +Create: + +```ts +src/pages/teacher/teacherSchoolYear.ts +``` + +Exports: + +```ts +getTeacherSchoolYear(search: URLSearchParams): string +withTeacherSchoolYear(path: string, schoolYear: string, extra?: Record): string +useTeacherSchoolYear(): { schoolYear: string; semester: string | null; readOnly: boolean } +``` + +Rules: + +```txt +- read school_year from URL first +- fallback to stored selected school year name only to canonicalize the URL +- never read school_year_id +- if school_year_id exists, remove it and replace URL with only school_year +``` + +#### 2. Fix `useTeacherResource` + +Change signature: + +```ts +useTeacherResource(path: string, options?: { includeSchoolYear?: boolean; includeSemester?: boolean }) +``` + +Behavior: + +```txt +- append school_year from current URL/stored selected name +- append semester when requested or present in URL +- reject paths that already contain school_year_id in development +- reload when school_year or semester changes +``` + +#### 3. Fix `fetchTeacherJson`, `postTeacherJson`, and `postTeacherMultipart` + +All teacher API helpers must support selected school year: + +```ts +fetchTeacherJson(path, { schoolYear, semester }) +postTeacherJson(path, body, { schoolYear, semester }) +postTeacherMultipart(path, formData, { schoolYear, semester }) +``` + +Mutation helpers must add `school_year` into JSON/FormData if missing. + +#### 4. Fix teacher navigation + +`MainLayout` must preserve `school_year` on every teacher nav item: + +```txt +/app/teacher_dashboard?school_year=2025-2026 +/app/teacher/class-view?school_year=2025-2026 +/app/teacher/showupdate-attendance?school_year=2025-2026 +... +``` + +Also fix: + +```txt +TeacherDashboardPage group links +Teacher class-switch links +Teacher score/action links +Teacher exam draft class filter links +Teacher class progress links +Teacher competition detail links +Teacher message thread links +Teacher print request links +Inventory teacher links +``` + +#### 5. Remove teacher JSON dump pages + +Replace every `TeacherApiDumpPage` usage with a real component or an intentional read-only summary. A teacher page is accepted only when it renders usable controls/tables and handles loading, empty, read-only, and validation states. + +### Backend tests to add/update + +Add teacher portal feature tests: + +```txt +tests/Feature/Api/V1/Teacher/TeacherPortalSchoolYearContextTest.php +tests/Feature/Api/V1/Teacher/TeacherDashboardTest.php +tests/Feature/Api/V1/Teacher/TeacherClassViewTest.php +tests/Feature/Api/V1/Teacher/TeacherAttendanceTest.php +tests/Feature/Api/V1/Teacher/TeacherAbsenceTest.php +tests/Feature/Api/V1/Teacher/TeacherMessagesTest.php +tests/Feature/Api/V1/Teacher/TeacherScoresTest.php +tests/Feature/Api/V1/Teacher/TeacherExamDraftsTest.php +tests/Feature/Api/V1/Teacher/TeacherClassProgressTest.php +tests/Feature/Api/V1/Teacher/TeacherCompetitionScoresTest.php +tests/Feature/Api/V1/Teacher/TeacherInventoryDistributionTest.php +tests/Feature/Api/V1/Teacher/TeacherPrintRequestsTest.php +``` + +Required cases: + +1. Every teacher GET rejects or ignores `school_year_id` and resolves only `school_year`. +2. Every teacher mutation rejects `school_year_id` and requires body/form `school_year`. +3. Teacher with assignment in 2024-2025 cannot see 2025-2026 class roster. +4. Teacher with assignment in Fall cannot see Spring-only class unless assigned. +5. Class view returns only selected-year students. +6. Attendance form returns only selected-year students and prior attendance. +7. Attendance submit writes selected `school_year` and `semester`. +8. Attendance submit rejects student IDs outside selected class/year. +9. Absence form returns dates from selected school year. +10. Absence submit writes selected school year and blocks closed year. +11. Calendar returns only selected-year teacher events. +12. Messages use correct teacher endpoints and do not collide with exam drafts. +13. Exam draft list filters selected school year. +14. Exam draft upload writes selected school year and validates assignment in selected year. +15. Class progress history does not show reports from sections taught only in another year. +16. Class progress weekly detail filters school year/semester. +17. Score overview returns selected-year roster/scores. +18. Score comments/lock block closed year. +19. Inventory distribution uses selected-year roster and movement rows. +20. Print request list filters selected year. +21. Print request create writes selected year. +22. Teacher route group rejects parent-only users. +23. Admin users can access teacher route only where explicitly allowed. +24. No teacher test uses `school_year_id` except rejection tests. + +### Frontend tests to add/update + +Add teacher portal tests: + +```txt +src/pages/teacher/__tests__/TeacherNavigationSchoolYear.test.tsx +src/pages/teacher/__tests__/TeacherDashboardPage.test.tsx +src/pages/teacher/__tests__/TeacherClassViewPage.test.tsx +src/pages/teacher/__tests__/TeacherAttendancePage.test.tsx +src/pages/teacher/__tests__/TeacherAbsenceVacationPage.test.tsx +src/pages/teacher/__tests__/TeacherMessagesPages.test.tsx +src/pages/teacher/__tests__/TeacherScoresPage.test.tsx +src/pages/teacher/__tests__/TeacherExamDraftsPage.test.tsx +src/pages/teacher/__tests__/TeacherClassProgressPages.test.tsx +src/pages/teacher/__tests__/TeacherInventoryDistributionPage.test.tsx +src/pages/printRequests/__tests__/TeacherPrintRequestsPage.test.tsx +``` + +Required frontend assertions: + +1. Opening any teacher page with `school_year_id=4&school_year=2025-2026` canonicalizes to `school_year=2025-2026` only. +2. Main teacher nav links preserve `school_year`. +3. `useTeacherResource` appends `school_year` to teacher API calls. +4. No teacher API request contains `school_year_id`. +5. Teacher class switch links preserve `school_year`. +6. Attendance submit includes `school_year`. +7. Absence submit includes `school_year`. +8. Calendar load includes `school_year`. +9. Exam draft upload FormData includes `school_year`. +10. Print request FormData includes `school_year`. +11. Closed-year banner disables teacher mutations. +12. Message drafts page calls message drafts endpoint, not exam drafts endpoint. +13. Raw JSON dump is not rendered for the core teacher pages. + +### Acceptance criteria for teacher pages + +Teacher pages are fixed only when: + +- Every teacher URL works with `?school_year=2025-2026` and without `school_year_id`. +- Opening an old URL with `school_year_id` removes it. +- Every teacher API request sends `school_year` in query or body/FormData. +- No teacher request sends `school_year_id` or `X-School-Year-Id`. +- Backend never falls back to configured current year when request selected year is present. +- Class rosters, attendance, scores, progress, inventory, exam drafts, calendar, and print requests are all scoped to the selected year. +- Teacher assignment checks include selected school year and semester. +- Closed/archived school years are read-only across the entire teacher portal. +- Message drafts and exam drafts are separate pages/endpoints. +- Teacher dashboard is backed by a real API. +- JSON dump placeholder pages are replaced with actual UI. +- Backend and frontend tests prove the above, instead of hoping the bug gets bored and leaves. + +--- + +## Global UI Requirement: Sortable Tables and Alphabetical Dropdowns + +This requirement applies to every page in this plan, including administrator pages, finance pages, printables, grading, staff management, parent profiles, school calendar, teacher pages, inventory pages, reports, imports, exports, and any future page added to the school-year workflow. + +### Problem + +Many pages return data in whatever order the database happens to emit, and several dropdowns rely on unsorted API responses or local arrays. That creates inconsistent UI behavior between refreshes, browsers, and environments. It also makes testing harder because row order becomes accidental instead of intentional. Naturally, humans then assume the data is wrong, because random ordering is indistinguishable from broken ordering when money, students, parents, and school years are involved. + +### Required table behavior + +Every data table must support sorting. + +Required implementation rules: + +1. Every table must define a default sort column and direction. +2. Every visible sortable column must expose a clear sort control in the table header. +3. Sorting must be stable and deterministic. +4. Server-backed tables must send sort parameters to the backend instead of sorting only the current page of results. +5. Small fully-loaded client tables may sort in the frontend, but the sort behavior must still be deterministic. +6. Sort state must survive filtering, pagination, and refresh when the page already preserves query parameters. +7. Sorting must not remove or rewrite `school_year`. +8. Sorting must never reintroduce `school_year_id`. +9. Numeric columns must sort numerically, not lexicographically. +10. Date columns must sort by actual date value, not formatted display text. +11. Currency/balance columns must sort by raw numeric amount. +12. Name columns must use normalized case-insensitive sorting. +13. Nullable values must have a consistent policy, preferably empty values last unless a specific business rule says otherwise. +14. Tables that show closed/archived school-year records must still allow sorting, even when mutations are disabled. + +Standard frontend query shape for backend-backed tables: + +```txt +?school_year=2025-2026&sort=last_name&direction=asc&page=1&per_page=25 +``` + +Allowed sort fields must be whitelisted per endpoint. Do not pass raw request field names into `orderBy`. That is not flexibility; that is SQL injection wearing a fake mustache. + +### Required backend behavior for table sorting + +Each list endpoint that backs a table must support: + +```txt +sort= +direction=asc|desc +``` + +Backend requirements: + +1. Validate `sort` against a controller/request-level allowlist. +2. Validate `direction` as `asc` or `desc`; default to `asc` unless the endpoint has a documented reason to default to `desc`. +3. Always add a stable tie-breaker, usually primary key ascending. +4. Preserve selected `school_year` filtering before sorting. +5. For joined tables, sort using explicit selected columns or aliases, not ambiguous column names. +6. For parent/student/staff names, prefer sorting by last name, then first name, then ID. +7. For finance tables, sort by raw decimal values and transaction dates. +8. For calendar/event tables, sort by `start_date`, then `start_time`, then title. +9. For teacher rosters, sort by student last name, first name, then student ID. +10. For dropdown-source endpoints, provide an alphabetized order from the backend unless the frontend owns the option list completely. + +Example backend allowlist pattern: + +```php +$allowedSorts = [ + 'name' => 'families.family_name', + 'parent_name' => 'parents.last_name', + 'student_name' => 'students.last_name', + 'balance' => 'balance_amount', + 'created_at' => 'created_at', +]; + +$sort = $request->input('sort', 'name'); +$direction = $request->input('direction', 'asc'); + +abort_unless(array_key_exists($sort, $allowedSorts), 422, 'Invalid sort field.'); +abort_unless(in_array($direction, ['asc', 'desc'], true), 422, 'Invalid sort direction.'); + +$query->orderBy($allowedSorts[$sort], $direction) + ->orderBy('id', 'asc'); +``` + +### Required frontend behavior for table sorting + +Create or standardize one table sorting helper/hook instead of reimplementing sort state on every page. + +Recommended frontend helper: + +```txt +src/hooks/useTableSort.ts +src/components/table/SortableHeader.tsx +``` + +Frontend requirements: + +1. `useTableSort` reads/writes `sort` and `direction` query parameters. +2. `useTableSort` preserves `school_year`. +3. `useTableSort` removes `school_year_id` if present in legacy URLs. +4. `SortableHeader` shows current sort state clearly. +5. Clicking the active column toggles `asc`/`desc`. +6. Clicking a different column starts with that column's default direction. +7. Reset filters must not reset school year unless the user explicitly changes year. +8. Export/PDF buttons should use the same sort/filter state as the visible table when that makes sense. +9. Client-side sorting must use shared comparators for string, numeric, date, and currency fields. +10. Empty table states must not break when sort parameters are present. + +### Required dropdown behavior + +Every dropdown list must be alphabetically ordered unless the business meaning requires a different order. + +Alphabetical ordering applies to: + +```txt +parents +families +students +staff +teachers +classes/class sections +groups +grades, where not using grade progression order +subjects +semesters, only if not using academic order +school years, only if not using chronological order +event types +payment methods +invoice categories +discount types +expense categories +print request types +inventory items +certificate groups +attendance reasons +absence/vacation reasons +flags +roles/permissions where displayed to users +``` + +Important exceptions: + +1. School years should normally sort chronologically, newest first or oldest first based on the page purpose, not alphabetically. +2. Semesters should normally sort by academic sequence, such as Fall before Spring, not alphabetically. +3. Grades may sort by academic grade order when applicable, not raw alphabetic text. +4. Status dropdowns may sort by workflow order if that is clearer than alphabetic order. +5. Calendar month/day selectors must keep calendar order. + +Dropdown rules: + +1. Backend option endpoints should return alphabetized options by default. +2. Frontend must not assume backend order blindly if the option list is local or merged from multiple sources. +3. Sorting must be case-insensitive. +4. Sorting must ignore leading/trailing spaces. +5. Duplicate display names must be disambiguated with secondary labels, not silently collapsed. +6. Disabled/inactive options should appear after active options unless a page explicitly includes archived data. +7. Dropdowns must preserve selected value after sorting. +8. Dropdown labels and values must not depend on `school_year_id`. +9. Year-scoped dropdowns must filter by `school_year` before sorting. +10. Searchable dropdowns must show alphabetized filtered results. + +### Required pages to verify + +At minimum, verify sortable tables and alphabetical dropdowns on these areas: + +```txt +Parent Profiles +Family Management +Staff Management +Teacher Class Assignment +Student Class Assignment +Daily Attendance +Absence Management +Early Dismissals +Flags Management +Grading Placement +Progress Admin +Teacher Portal pages +School Calendar / Calendar View +Events +Exam Drafts +Discounts +Expenses +Manual Payments +PayPal Transactions +Refunds +Reimbursements +Tuition Forecast +Inventory pages +Print Requests +Badges +Certificates +Report Cards +Stickers +Parent Email Extractor +WhatsApp +School Year Management +School Year Closing Preview +School Year Parent Balances Preview +``` + +### Backend tests to add/update + +Add feature tests for every major list endpoint proving: + +1. Default ordering is deterministic. +2. `sort` and `direction` are accepted for allowed fields. +3. Invalid sort fields return `422`. +4. Invalid sort directions return `422`. +5. Sorting does not leak data across `school_year`. +6. Sorting does not require or accept `school_year_id`. +7. Numeric balances sort by numeric amount. +8. Dates sort by actual date. +9. Dropdown option endpoints return expected alphabetic order. +10. Academic-order exceptions are explicitly tested. + +Suggested backend test file pattern: + +```txt +tests/Feature/Api/V1/Sorting/*SortTest.php +tests/Feature/Api/V1/Dropdowns/*DropdownOrderTest.php +``` + +### Frontend tests to add/update + +Add frontend tests proving: + +1. Table header click toggles sort direction. +2. Sort query parameters are added while preserving `school_year`. +3. `school_year_id` is removed from legacy URLs during sort navigation. +4. Table renders rows in sorted order when using mocked API data. +5. API-backed tables send `sort` and `direction` to the backend. +6. Dropdown options render alphabetically. +7. Dropdowns keep selected value after options are sorted. +8. Academic-order exceptions remain in academic order. +9. Closed-year read-only pages still allow sorting and dropdown viewing. +10. Export/PDF links preserve table sort state where applicable. + +Suggested frontend test file pattern: + +```txt +src/components/table/__tests__/SortableHeader.test.tsx +src/hooks/__tests__/useTableSort.test.tsx +src/components/forms/__tests__/DropdownAlphabeticalOrder.test.tsx +src/pages/**/__tests__/*Sorting.test.tsx +``` + +### Acceptance criteria + +This requirement is complete only when: + +- Every table has a default deterministic sort. +- Every user-facing table supports sorting where the data is tabular. +- Backend-backed tables sort server-side across the full result set, not just the current page. +- Every dropdown is alphabetized unless a documented business order applies. +- All sort/dropdown behavior preserves `school_year` and never reintroduces `school_year_id`. +- Tests exist for generic table sorting, generic dropdown ordering, and the major page-specific endpoints. +- Financial, roster, calendar, parent, staff, and teacher pages all pass sort/order tests under `school_year=2025-2026`. diff --git a/routes/api.php b/routes/api.php index 88166e5d..e9f798e2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -43,6 +43,7 @@ use App\Http\Controllers\Api\Expenses\ExpenseController; use App\Http\Controllers\Api\ExtraCharges\ExtraChargesController; use App\Http\Controllers\Api\Family\FamilyAdminController; use App\Http\Controllers\Api\Family\FamilyController; +use App\Http\Controllers\Api\Family\ParentProfileAdminController; use App\Http\Controllers\Api\Finance\BalanceCarryforwardController; use App\Http\Controllers\Api\Finance\ChargeController; use App\Http\Controllers\Api\Finance\EventChargeController; @@ -384,10 +385,10 @@ Route::prefix('v1')->group(function () { Route::prefix('staff')->group(function () { Route::get('/', [StaffController::class, 'index']); Route::get('form-options', [StaffController::class, 'formOptions']); - Route::get('{id}', [StaffController::class, 'show']); - Route::post('/', [StaffController::class, 'store']); - Route::patch('{id}', [StaffController::class, 'update']); - Route::delete('{id}', [StaffController::class, 'destroy']); + Route::get('{id}', [StaffController::class, 'show'])->whereNumber('id'); + Route::post('/', [StaffController::class, 'store'])->middleware('school_year.editable'); + Route::patch('{id}', [StaffController::class, 'update'])->whereNumber('id')->middleware('school_year.editable'); + Route::delete('{id}', [StaffController::class, 'destroy'])->whereNumber('id')->middleware('school_year.editable'); }); Route::prefix('flags')->group(function () { @@ -509,10 +510,20 @@ Route::prefix('v1')->group(function () { }); Route::middleware(['multi.auth', 'account.active'])->prefix('school-years')->group(function () { - Route::get('/', [SchoolYearController::class, 'index'])->middleware('perm:school_year.view'); + Route::get('/', [SchoolYearController::class, 'index']); Route::get('current', [SchoolYearController::class, 'current']); Route::get('options', [SchoolYearController::class, 'options']); + Route::get('summary', [SchoolYearController::class, 'summarySelected'])->middleware('perm:school_year.view'); + Route::get('reports/closing', [SchoolYearController::class, 'closingReportSelected'])->middleware('perm:school_year.view'); + Route::post('validate-close', [SchoolYearController::class, 'validateCloseSelected'])->middleware('perm:school_year.close'); + Route::post('preview-close', [SchoolYearController::class, 'previewCloseSelected'])->middleware('perm:school_year.close'); + Route::post('close', [SchoolYearController::class, 'closeSelected'])->middleware('perm:school_year.close'); + Route::post('reopen', [SchoolYearController::class, 'reopenSelected'])->middleware('perm:school_year.reopen'); + Route::post('archive', [SchoolYearController::class, 'archiveSelected'])->middleware('perm:school_year.archive'); + Route::get('parent-balances', [SchoolYearController::class, 'parentBalancesSelected'])->middleware('perm:finance.balance_transfer.view'); + Route::get('promotion-preview', [SchoolYearController::class, 'promotionPreviewSelected'])->middleware('perm:student_enrollment.promote'); Route::post('/', [SchoolYearController::class, 'store'])->middleware('perm:school_year.create'); + Route::patch('selected', [SchoolYearController::class, 'updateSelected'])->middleware('perm:school_year.manage'); Route::patch('{schoolYear}', [SchoolYearController::class, 'update'])->middleware('perm:school_year.manage')->whereNumber('schoolYear'); Route::get('{schoolYear}/summary', [SchoolYearController::class, 'summary'])->middleware('perm:school_year.view')->whereNumber('schoolYear'); Route::get('{schoolYear}/reports/closing', [SchoolYearController::class, 'closingReport'])->middleware('perm:school_year.view')->whereNumber('schoolYear'); @@ -685,10 +696,10 @@ Route::prefix('v1')->group(function () { Route::prefix('staff')->group(function () { Route::get('/', [StaffController::class, 'index']); Route::get('form-options', [StaffController::class, 'formOptions']); - Route::get('{id}', [StaffController::class, 'show']); - Route::post('/', [StaffController::class, 'store']); - Route::patch('{id}', [StaffController::class, 'update']); - Route::delete('{id}', [StaffController::class, 'destroy']); + Route::get('{id}', [StaffController::class, 'show'])->whereNumber('id'); + Route::post('/', [StaffController::class, 'store'])->middleware('school_year.editable'); + Route::patch('{id}', [StaffController::class, 'update'])->whereNumber('id')->middleware('school_year.editable'); + Route::delete('{id}', [StaffController::class, 'destroy'])->whereNumber('id')->middleware('school_year.editable'); }); Route::prefix('messages')->group(function () { @@ -976,6 +987,15 @@ Route::prefix('v1')->group(function () { Route::post('compose-email', [FamilyAdminController::class, 'composeEmail']); }); + Route::middleware('admin.access')->prefix('parent-profiles')->group(function () { + Route::get('/', [ParentProfileAdminController::class, 'index']); + Route::get('search', [ParentProfileAdminController::class, 'search']); + Route::get('{parent}', [ParentProfileAdminController::class, 'show'])->whereNumber('parent'); + Route::patch('{parent}', [ParentProfileAdminController::class, 'update']) + ->whereNumber('parent') + ->middleware('school_year.editable'); + }); + Route::prefix('families')->group(function () { Route::get('by-student/{studentId}', [FamilyController::class, 'familiesByStudent']); Route::get('{familyId}/guardians', [FamilyController::class, 'guardiansByFamily']); diff --git a/tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php b/tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php index 3752d4b6..53c0ee71 100644 --- a/tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php +++ b/tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php @@ -45,7 +45,7 @@ class ClassProgressControllerTest extends TestCase $this->assertCount(2, $response->json('data.items')); } - public function test_admin_grouped_index_accepts_school_year_id_and_includes_legacy_rows(): void + public function test_admin_grouped_index_uses_school_year_name_and_includes_legacy_rows(): void { $admin = $this->createUser('admin@example.com'); $this->assignRole($admin->id, 'administrator'); @@ -54,8 +54,9 @@ class ClassProgressControllerTest extends TestCase 'semester' => '', 'school_year' => null, ]); - $schoolYearId = DB::table('school_years')->insertGetId([ + DB::table('school_years')->updateOrInsert([ 'name' => '2025-2026', + ], [ 'start_date' => '2025-08-01', 'end_date' => '2026-07-31', 'status' => 'active', @@ -74,21 +75,30 @@ class ClassProgressControllerTest extends TestCase Sanctum::actingAs($admin); - $response = $this->getJson('/api/v1/class-progress?school_year_id='.$schoolYearId.'&group_by_week=1'); + $response = $this->getJson('/api/v1/class-progress?school_year=2025-2026&group_by_week=1'); $response->assertOk(); $response->assertJsonPath('status', true); $this->assertCount(1, $response->json('data.items')); - $administratorAlias = $this->getJson('/api/v1/administrator/progress?school_year_id='.$schoolYearId.'&group_by_week=1'); + $administratorAlias = $this->getJson('/api/v1/administrator/progress?school_year=2025-2026&group_by_week=1'); $administratorAlias->assertOk(); $this->assertCount(1, $administratorAlias->json('data.items')); - $adminAlias = $this->getJson('/api/v1/admin/progress?school_year_id='.$schoolYearId.'&group_by_week=1'); + $adminAlias = $this->getJson('/api/v1/admin/progress?school_year=2025-2026&group_by_week=1'); $adminAlias->assertOk(); $this->assertCount(1, $adminAlias->json('data.items')); } + public function test_index_rejects_school_year_id_context(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $this->getJson('/api/v1/class-progress?school_year_id=1') + ->assertStatus(422); + } + public function test_parent_progress_returns_reports_for_enrolled_sections(): void { $parent = $this->createUser('parent@example.com'); @@ -196,6 +206,7 @@ class ClassProgressControllerTest extends TestCase 'unit_islamic' => ['Unit 1'], 'chapter_islamic' => ['Chapter A'], 'flags' => ['needs_support'], + 'school_year' => '2025-2026', ]; $response = $this->postJson('/api/v1/class-progress', $payload); @@ -246,6 +257,7 @@ class ClassProgressControllerTest extends TestCase $response = $this->patchJson('/api/v1/class-progress/'.$report->id, [ 'status' => 'behind', + 'school_year' => '2025-2026', ]); $response->assertOk(); @@ -266,7 +278,7 @@ class ClassProgressControllerTest extends TestCase Sanctum::actingAs($user); - $response = $this->deleteJson('/api/v1/class-progress/'.$report->id); + $response = $this->deleteJson('/api/v1/class-progress/'.$report->id.'?school_year=2025-2026'); $response->assertOk(); $this->assertDatabaseMissing('class_progress_reports', [ @@ -328,6 +340,17 @@ class ClassProgressControllerTest extends TestCase private function seedClassSection(): void { + DB::table('school_years')->updateOrInsert([ + 'name' => '2025-2026', + ], [ + 'start_date' => '2025-08-01', + 'end_date' => '2026-07-31', + 'status' => 'active', + 'is_current' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('classSection')->insert([ 'id' => 1, 'class_id' => 1, diff --git a/tests/Feature/Api/V1/Family/FamilyAdminControllerTest.php b/tests/Feature/Api/V1/Family/FamilyAdminControllerTest.php index 472794fc..1bfb56e7 100644 --- a/tests/Feature/Api/V1/Family/FamilyAdminControllerTest.php +++ b/tests/Feature/Api/V1/Family/FamilyAdminControllerTest.php @@ -118,6 +118,15 @@ class FamilyAdminControllerTest extends TestCase private function seedUser(int $id): User { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'admin', + 'slug' => 'admin', + 'priority' => 1, + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('users')->insert([ 'id' => $id, 'firstname' => 'Parent', @@ -134,6 +143,13 @@ class FamilyAdminControllerTest extends TestCase 'semester' => 'Fall', 'school_year' => '2025-2026', 'status' => 'Active', + 'is_verified' => 1, + 'is_suspended' => 0, + ]); + + DB::table('user_roles')->insert([ + 'user_id' => $id, + 'role_id' => $roleId, ]); return User::query()->findOrFail($id); @@ -219,6 +235,7 @@ class FamilyAdminControllerTest extends TestCase 'total_amount' => 100, 'paid_amount' => 0, 'balance' => 100, + 'school_year' => '2025-2026', 'issue_date' => now()->toDateString(), 'due_date' => now()->addDays(10)->toDateString(), 'created_at' => now(), @@ -238,6 +255,7 @@ class FamilyAdminControllerTest extends TestCase 'payment_method' => 'cash', 'payment_date' => now()->toDateString(), 'status' => 'completed', + 'school_year' => '2025-2026', 'created_at' => now(), 'updated_at' => now(), ]); diff --git a/tests/Feature/Api/V1/ParentProfiles/ParentProfileAdminControllerTest.php b/tests/Feature/Api/V1/ParentProfiles/ParentProfileAdminControllerTest.php new file mode 100644 index 00000000..e259fe87 --- /dev/null +++ b/tests/Feature/Api/V1/ParentProfiles/ParentProfileAdminControllerTest.php @@ -0,0 +1,199 @@ +createUser('teacher')); + + $this->getJson('/api/v1/parent-profiles?school_year=2025-2026') + ->assertForbidden(); + } + + public function test_parent_profiles_rejects_school_year_id_context(): void + { + Sanctum::actingAs($this->createUser('admin')); + + $this->getJson('/api/v1/parent-profiles?school_year_id=1') + ->assertStatus(422); + } + + public function test_parent_profiles_index_lists_only_selected_year_relevant_parents(): void + { + Sanctum::actingAs($this->createUser('admin')); + $selectedParent = $this->createUser('parent', 'selected@example.com'); + $otherParent = $this->createUser('parent', 'other@example.com'); + $this->createStudentFamily($selectedParent->id, 'Amina', 'Selected', '2025-2026'); + $this->createStudentFamily($otherParent->id, 'Omar', 'Other', '2024-2025'); + + $response = $this->getJson('/api/v1/parent-profiles?school_year=2025-2026'); + + $response->assertOk(); + $this->assertSame(['selected@example.com'], array_column($response->json('data.parents'), 'email')); + } + + public function test_parent_profile_show_returns_selected_year_siblings_and_finance_only(): void + { + Sanctum::actingAs($this->createUser('admin')); + $parent = $this->createUser('parent', 'parent@example.com'); + $this->createStudentFamily($parent->id, 'Amina', 'Sibling', '2025-2026'); + $this->createStudentFamily($parent->id, 'Yusuf', 'Sibling', '2025-2026'); + $this->createStudentFamily($parent->id, 'Old', 'Student', '2024-2025'); + + $selectedInvoice = DB::table('invoices')->insertGetId([ + 'parent_id' => $parent->id, + 'invoice_number' => 'INV-2025', + 'status' => 'open', + 'total_amount' => 100, + 'paid_amount' => 25, + 'balance' => 75, + 'school_year' => '2025-2026', + 'issue_date' => now()->toDateString(), + 'due_date' => now()->addDays(5)->toDateString(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('invoices')->insert([ + 'parent_id' => $parent->id, + 'invoice_number' => 'INV-2024', + 'status' => 'open', + 'total_amount' => 80, + 'paid_amount' => 0, + 'balance' => 80, + 'school_year' => '2024-2025', + 'issue_date' => now()->subYear()->toDateString(), + 'due_date' => now()->subYear()->addDays(5)->toDateString(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('payments')->insert([ + 'parent_id' => $parent->id, + 'invoice_id' => $selectedInvoice, + 'total_amount' => 100, + 'paid_amount' => 25, + 'balance' => 75, + 'number_of_installments' => 1, + 'payment_method' => 'cash', + 'payment_date' => now()->toDateString(), + 'status' => 'completed', + 'school_year' => '2024-2025', + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('emergency_contacts')->insert([ + 'parent_id' => $parent->id, + 'emergency_contact_name' => 'Emergency One', + 'relation' => 'uncle', + 'cellphone' => '5555555555', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->getJson('/api/v1/parent-profiles/'.$parent->id.'?school_year=2025-2026'); + + $response->assertOk(); + $this->assertSame(['Amina', 'Yusuf'], array_column($response->json('data.students'), 'firstname')); + $this->assertSame(['INV-2025'], array_column($response->json('data.invoices'), 'invoice_number')); + $this->assertEquals(75.0, $response->json('data.finance_summary.positive_unpaid_balance')); + $this->assertCount(1, $response->json('data.payments')); + $this->assertCount(1, $response->json('data.emergency_contacts')); + } + + private function createUser(string $roleName, ?string $email = null): User + { + DB::table('school_years')->updateOrInsert( + ['name' => '2025-2026'], + [ + 'start_date' => '2025-09-01', + 'end_date' => '2026-06-30', + 'status' => 'active', + 'is_current' => 1, + 'updated_at' => now(), + 'created_at' => now(), + ], + ); + + $roleId = DB::table('roles')->insertGetId([ + 'name' => $roleName, + 'priority' => 1, + 'is_active' => 1, + ]); + + $user = User::query()->create([ + 'firstname' => ucfirst($roleName), + 'lastname' => 'User', + 'email' => $email ?? ($roleName.uniqid().'@example.com'), + 'cellphone' => '5555555555', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'status' => 'Active', + 'is_verified' => 1, + 'is_suspended' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('user_roles')->insert([ + 'user_id' => $user->id, + 'role_id' => $roleId, + ]); + + return $user; + } + + private function createStudentFamily(int $parentId, string $firstname, string $lastname, string $schoolYear): void + { + $familyId = DB::table('families')->insertGetId([ + 'family_code' => uniqid('fam_', true), + 'household_name' => $lastname.' Household', + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $studentId = DB::table('students')->insertGetId([ + 'firstname' => $firstname, + 'lastname' => $lastname, + 'is_active' => 1, + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('family_guardians')->insert([ + 'family_id' => $familyId, + 'user_id' => $parentId, + 'relation' => 'parent', + 'is_primary' => 1, + 'receive_emails' => 1, + 'receive_sms' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('family_students')->insert([ + 'family_id' => $familyId, + 'student_id' => $studentId, + 'is_primary_home' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php b/tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php index 08d42b12..d377e974 100644 --- a/tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php +++ b/tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php @@ -41,6 +41,33 @@ class SchoolYearControllerTest extends TestCase $this->assertSame('promote', $response->json('data.promotion_preview.rows.0.promotion_action')); } + public function test_name_based_school_year_management_routes_use_school_year_query(): void + { + $this->seedClosureData(); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $this->getJson('/api/v1/school-years/summary?school_year=2025-2026') + ->assertOk() + ->assertJsonPath('data.school_year.name', '2025-2026'); + + $this->getJson('/api/v1/school-years/promotion-preview?school_year=2025-2026&to_school_year=2026-2027') + ->assertOk() + ->assertJsonPath('data.current_school_year', '2025-2026') + ->assertJsonPath('data.target_school_year', '2026-2027'); + + $this->postJson('/api/v1/school-years/preview-close?school_year=2025-2026', [ + 'new_school_year' => [ + 'name' => '2026-2027', + 'start_date' => '2026-09-01', + 'end_date' => '2027-06-30', + ], + 'transfer_unpaid_balances' => true, + ])->assertOk() + ->assertJsonPath('data.school_year.name', '2025-2026') + ->assertJsonPath('data.parent_balances.summary.total_old_unpaid_balance', 200); + } + public function test_preview_close_excludes_zero_invoice_balance_parent_even_when_parent_account_is_stale(): void { $this->seedClosureData(); @@ -73,6 +100,146 @@ class SchoolYearControllerTest extends TestCase ); } + public function test_positive_balance_is_not_hidden_by_credit_balance(): void + { + $this->seedClosureData(); + + DB::table('invoices')->insert([ + 'id' => 502, + 'parent_id' => 10, + 'invoice_number' => 'INV-502-CREDIT', + 'total_amount' => 0, + 'balance' => -200, + 'paid_amount' => 200, + 'has_discount' => 0, + 'issue_date' => '2026-05-11', + 'due_date' => '2026-06-10', + 'status' => 'credit', + 'description' => 'Credit balance', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + 'updated_by' => 1, + 'semester' => 'Fall', + ]); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $response = $this->postJson('/api/v1/school-years/preview-close?school_year=2025-2026', [ + 'new_school_year' => [ + 'name' => '2026-2027', + 'start_date' => '2026-09-01', + 'end_date' => '2027-06-30', + ], + 'transfer_unpaid_balances' => true, + ]); + + $response->assertOk() + ->assertJsonPath('data.parent_balances.summary.parents_with_positive_balance', 1) + ->assertJsonPath('data.parent_balances.summary.total_positive_unpaid_balance', 200) + ->assertJsonPath('data.parent_balances.summary.parents_with_credit_balance', 1) + ->assertJsonPath('data.parent_balances.summary.total_credit_balance', 200) + ->assertJsonPath('data.parent_balances.summary.net_balance_impact', 0); + + $row = collect($response->json('data.parent_balances.rows')) + ->first(fn (array $row): bool => (int) $row['parent_id'] === 10); + + $this->assertSame(200.0, (float) $row['positive_unpaid_balance']); + $this->assertSame(200.0, (float) $row['credit_balance']); + $this->assertSame(0.0, (float) $row['net_balance']); + $this->assertSame(200.0, (float) $row['amount_to_transfer']); + $this->assertSame([500], $row['positive_invoice_ids']); + $this->assertSame([502], $row['credit_invoice_ids']); + } + + public function test_credit_only_parent_is_reported_as_credit_not_unpaid_carryover(): void + { + $this->seedClosureData(); + $this->seedParent(12, 'Credit', 'Only', 'credit@example.com'); + + DB::table('invoices')->insert([ + 'id' => 503, + 'parent_id' => 12, + 'invoice_number' => 'INV-503-CREDIT', + 'total_amount' => 0, + 'balance' => -50, + 'paid_amount' => 50, + 'has_discount' => 0, + 'issue_date' => '2026-05-11', + 'due_date' => '2026-06-10', + 'status' => 'credit', + 'description' => 'Credit only', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + 'updated_by' => 1, + 'semester' => 'Fall', + ]); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $response = $this->getJson('/api/v1/school-years/parent-balances?school_year=2025-2026&to_school_year=2026-2027'); + + $response->assertOk() + ->assertJsonPath('data.summary.parents_with_positive_balance', 1) + ->assertJsonPath('data.summary.parents_with_credit_balance', 1) + ->assertJsonPath('data.summary.total_positive_unpaid_balance', 200) + ->assertJsonPath('data.summary.total_credit_balance', 50); + + $creditRow = collect($response->json('data.rows')) + ->first(fn (array $row): bool => (int) $row['parent_id'] === 12); + + $this->assertNotNull($creditRow); + $this->assertSame(0.0, (float) $creditRow['positive_unpaid_balance']); + $this->assertSame(50.0, (float) $creditRow['credit_balance']); + $this->assertSame(0.0, (float) $creditRow['amount_to_transfer']); + $this->assertSame([503], $creditRow['credit_invoice_ids']); + } + + public function test_pending_or_draft_invoice_blocks_close_but_is_not_transferred(): void + { + $this->seedClosureData(); + + DB::table('invoices')->insert([ + 'id' => 504, + 'parent_id' => 10, + 'invoice_number' => 'INV-504-DRAFT', + 'total_amount' => 100, + 'balance' => 100, + 'paid_amount' => 0, + 'has_discount' => 0, + 'issue_date' => '2026-05-11', + 'due_date' => '2026-06-10', + 'status' => 'draft', + 'description' => 'Draft invoice', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + 'updated_by' => 1, + 'semester' => 'Fall', + ]); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $response = $this->postJson('/api/v1/school-years/preview-close?school_year=2025-2026', [ + 'new_school_year' => [ + 'name' => '2026-2027', + 'start_date' => '2026-09-01', + 'end_date' => '2027-06-30', + ], + 'transfer_unpaid_balances' => true, + ]); + + $response->assertOk() + ->assertJsonPath('data.validation.can_close', false) + ->assertJsonPath('data.parent_balances.summary.total_positive_unpaid_balance', 200); + + $this->assertStringContainsString( + 'invoices are still in draft status', + implode(' ', $response->json('data.validation.errors')) + ); + } + public function test_close_school_year_does_not_transfer_zero_invoice_balance_parent_even_when_parent_account_is_stale(): void { $this->seedClosureData(); @@ -111,6 +278,79 @@ class SchoolYearControllerTest extends TestCase ]); } + public function test_close_does_not_mutate_old_year_parent_account(): void + { + $this->seedClosureData(); + + DB::table('parent_accounts')->insert([ + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'opening_balance' => 55, + 'current_balance' => 77, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $this->postJson('/api/v1/school-years/close?school_year=2025-2026', [ + 'new_school_year' => [ + 'name' => '2026-2027', + 'start_date' => '2026-09-01', + 'end_date' => '2027-06-30', + ], + 'transfer_unpaid_balances' => true, + 'confirmation' => 'CLOSE 2025-2026', + ])->assertOk(); + + $this->assertDatabaseHas('parent_accounts', [ + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'opening_balance' => 55, + 'current_balance' => 77, + ]); + + $this->assertDatabaseHas('parent_accounts', [ + 'parent_id' => 10, + 'school_year' => '2026-2027', + 'opening_balance' => 200, + 'current_balance' => 200, + ]); + } + + public function test_existing_balance_transfer_conflict_is_visible_in_preview_and_blocks_close(): void + { + $this->seedClosureData(); + + DB::table('parent_balance_transfers')->insert([ + 'parent_id' => 10, + 'from_school_year' => '2025-2026', + 'to_school_year' => '2026-2027', + 'amount' => 200, + 'status' => 'transferred', + 'source_summary_json' => json_encode(['source_invoice_ids' => [500]]), + 'created_by' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $response = $this->postJson('/api/v1/school-years/preview-close?school_year=2025-2026', [ + 'new_school_year' => [ + 'name' => '2026-2027', + 'start_date' => '2026-09-01', + 'end_date' => '2027-06-30', + ], + 'transfer_unpaid_balances' => true, + ]); + + $response->assertOk() + ->assertJsonPath('data.validation.can_close', false) + ->assertJsonPath('data.parent_balances.summary.existing_transfer_conflicts', 1) + ->assertJsonPath('data.parent_balances.rows.0.existing_transfer_conflict', true); + } + public function test_parent_cannot_close_school_year(): void { $this->seedClosureData(); @@ -231,6 +471,41 @@ class SchoolYearControllerTest extends TestCase ]); } + public function test_admin_can_update_school_year_by_name_via_api(): void + { + $this->seedClosureData(); + DB::table('school_years')->insert([ + 'id' => 2, + 'name' => '2027-2028', + 'start_date' => '2027-09-01', + 'end_date' => '2028-06-30', + 'status' => 'draft', + 'is_current' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $response = $this->patchJson('/api/v1/school-years/selected?school_year=2027-2028', [ + 'name' => '2028-2029', + 'start_date' => '2028-09-01', + 'end_date' => '2029-06-30', + 'school_year' => '2027-2028', + ]); + + $response->assertOk() + ->assertJsonPath('data.name', '2028-2029') + ->assertJsonPath('data.start_date', '2028-09-01'); + + $this->assertDatabaseHas('school_years', [ + 'id' => 2, + 'name' => '2028-2029', + 'start_date' => '2028-09-01 00:00:00', + 'end_date' => '2029-06-30 00:00:00', + ]); + } + public function test_close_school_year_creates_new_active_year_enrollment_and_balance_transfer(): void { $this->seedClosureData(); @@ -379,6 +654,35 @@ class SchoolYearControllerTest extends TestCase ]); } + private function seedParent(int $id, string $firstname, string $lastname, string $email): void + { + DB::table('users')->insert([ + 'id' => $id, + 'school_id' => $id, + 'firstname' => $firstname, + 'lastname' => $lastname, + 'cellphone' => '5555555555', + 'email' => $email, + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('user_roles')->insert([ + 'user_id' => $id, + 'role_id' => 2, + ]); + } + private function seedClosureData(): void { DB::table('configuration')->insert([ diff --git a/tests/Feature/Api/V1/Staff/StaffControllerTest.php b/tests/Feature/Api/V1/Staff/StaffControllerTest.php index d21063e9..7c9330c5 100644 --- a/tests/Feature/Api/V1/Staff/StaffControllerTest.php +++ b/tests/Feature/Api/V1/Staff/StaffControllerTest.php @@ -99,7 +99,7 @@ class StaffControllerTest extends TestCase 'school_year' => '2025-2026', ]); - $response = $this->patchJson('/api/v1/staff/'.$staff->id, [ + $response = $this->patchJson('/api/v1/staff/'.$staff->id.'?school_year=2025-2026', [ 'firstname' => 'Updated', ]); @@ -127,7 +127,7 @@ class StaffControllerTest extends TestCase 'school_year' => '2025-2026', ]); - $response = $this->deleteJson('/api/v1/staff/'.$staff->id); + $response = $this->deleteJson('/api/v1/staff/'.$staff->id.'?school_year=2025-2026'); $response->assertOk(); $this->assertDatabaseMissing('staff', [ @@ -137,6 +137,18 @@ class StaffControllerTest extends TestCase private function createUser(string $roleName, ?string $email = null): User { + DB::table('school_years')->updateOrInsert( + ['name' => '2025-2026'], + [ + 'start_date' => '2025-09-01', + 'end_date' => '2026-06-30', + 'status' => 'active', + 'is_current' => 1, + 'updated_at' => now(), + 'created_at' => now(), + ], + ); + $roleId = DB::table('roles')->insertGetId([ 'name' => $roleName, 'priority' => 1, @@ -154,6 +166,8 @@ class StaffControllerTest extends TestCase 'zip' => '12345', 'accept_school_policy' => 1, 'status' => 'Active', + 'is_verified' => 1, + 'is_suspended' => 0, 'password' => bcrypt('secret'), 'semester' => 'Fall', 'school_year' => '2025-2026', diff --git a/tests/Unit/Services/Families/FamilyFinanceServiceTest.php b/tests/Unit/Services/Families/FamilyFinanceServiceTest.php index eb03d91e..3f97f896 100644 --- a/tests/Unit/Services/Families/FamilyFinanceServiceTest.php +++ b/tests/Unit/Services/Families/FamilyFinanceServiceTest.php @@ -20,6 +20,7 @@ class FamilyFinanceServiceTest extends TestCase 'total_amount' => 100, 'paid_amount' => 20, 'balance' => 80, + 'school_year' => '2025-2026', 'issue_date' => now()->toDateString(), 'due_date' => now()->addDays(5)->toDateString(), 'created_at' => now(), @@ -36,16 +37,90 @@ class FamilyFinanceServiceTest extends TestCase 'payment_method' => 'cash', 'payment_date' => now()->toDateString(), 'status' => 'completed', + 'school_year' => '2024-2025', 'created_at' => now(), 'updated_at' => now(), ]); $service = new FamilyFinanceService; - $result = $service->loadFinancialsForParents([1]); + $result = $service->loadFinancialsForParents([1], '2025-2026'); $this->assertSame(1, $result['summary']['invoices_count']); $this->assertSame(100.0, $result['summary']['total_amount']); + $this->assertSame(80.0, $result['summary']['positive_unpaid_balance']); + $this->assertSame(0.0, $result['summary']['credit_balance']); $this->assertNotEmpty($result['invoices']); $this->assertNotEmpty($result['payments']); } + + public function test_load_financials_filters_invoices_and_payments_by_selected_year(): void + { + DB::table('invoices')->insert([ + [ + 'id' => 10, + 'parent_id' => 1, + 'invoice_number' => 'INV-2025', + 'status' => 'open', + 'total_amount' => 100, + 'paid_amount' => 0, + 'balance' => 100, + 'school_year' => '2025-2026', + 'issue_date' => now()->toDateString(), + 'due_date' => now()->addDays(5)->toDateString(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => 11, + 'parent_id' => 1, + 'invoice_number' => 'INV-2024', + 'status' => 'open', + 'total_amount' => 75, + 'paid_amount' => 0, + 'balance' => -25, + 'school_year' => '2024-2025', + 'issue_date' => now()->subYear()->toDateString(), + 'due_date' => now()->subYear()->addDays(5)->toDateString(), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + DB::table('payments')->insert([ + [ + 'parent_id' => 1, + 'invoice_id' => 10, + 'total_amount' => 100, + 'paid_amount' => 50, + 'balance' => 50, + 'number_of_installments' => 1, + 'payment_method' => 'cash', + 'payment_date' => now()->toDateString(), + 'status' => 'completed', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'parent_id' => 1, + 'invoice_id' => 11, + 'total_amount' => 75, + 'paid_amount' => 75, + 'balance' => -25, + 'number_of_installments' => 1, + 'payment_method' => 'cash', + 'payment_date' => now()->subYear()->toDateString(), + 'status' => 'completed', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $service = new FamilyFinanceService; + $result = $service->loadFinancialsForParents([1], '2025-2026'); + + $this->assertSame(['INV-2025'], array_column($result['invoices'], 'invoice_number')); + $this->assertSame([10], array_column($result['payments'], 'invoice_id')); + } } diff --git a/tests/Unit/Services/Staff/StaffCommandServiceTest.php b/tests/Unit/Services/Staff/StaffCommandServiceTest.php index 523c1993..a12dd9c4 100644 --- a/tests/Unit/Services/Staff/StaffCommandServiceTest.php +++ b/tests/Unit/Services/Staff/StaffCommandServiceTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\Staff\StaffCommandService; use App\Services\System\GlobalConfigService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Tests\TestCase; class StaffCommandServiceTest extends TestCase @@ -15,6 +16,7 @@ class StaffCommandServiceTest extends TestCase public function test_create_requires_user(): void { + $this->seedRole('teacher'); $service = new StaffCommandService(new GlobalConfigService); $this->expectException(\RuntimeException::class); @@ -28,6 +30,7 @@ class StaffCommandServiceTest extends TestCase public function test_create_with_user_id(): void { + $this->seedRole('teacher'); $user = User::query()->create([ 'firstname' => 'Test', 'lastname' => 'User', @@ -57,4 +60,18 @@ class StaffCommandServiceTest extends TestCase $this->assertInstanceOf(Staff::class, $staff); $this->assertSame($user->id, $staff->user_id); } + + private function seedRole(string $name): void + { + DB::table('roles')->updateOrInsert( + ['name' => $name], + [ + 'slug' => $name, + 'priority' => 1, + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } }