add school year model

This commit is contained in:
root
2026-06-07 00:52:01 -04:00
parent a192ed433d
commit 6866aedf42
36 changed files with 4771 additions and 88 deletions
@@ -16,9 +16,9 @@ class AdministratorTeacherSubmissionController extends Controller
) {
}
public function index(): JsonResponse
public function index(Request $request): JsonResponse
{
return response()->json($this->service->report());
return response()->json($this->service->report($request->query()));
}
public function notify(Request $request): JsonResponse
@@ -23,6 +23,8 @@ class EmailExtractorController extends BaseApiController
return response()->json([
'ok' => true,
'users' => $emails['users'] ?? [],
'parents' => $emails['parents'] ?? [],
'emails' => new EmailExtractorEmailsResource($emails),
]);
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Api\SchoolYears;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\SchoolYears\CloseSchoolYearRequest;
use App\Http\Requests\SchoolYears\PreviewCloseSchoolYearRequest;
use App\Http\Requests\SchoolYears\SaveSchoolYearRequest;
use App\Services\SchoolYears\SchoolYearClosureService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
class SchoolYearController extends BaseApiController
{
public function __construct(private SchoolYearClosureService $service)
{
parent::__construct();
}
public function index(): JsonResponse
{
return response()->json(['ok' => true, 'data' => $this->service->list()]);
}
public function store(SaveSchoolYearRequest $request): JsonResponse
{
return response()->json([
'ok' => true,
'data' => $this->service->createYear($request->validated(), $this->getCurrentUserId()),
], Response::HTTP_CREATED);
}
public function update(SaveSchoolYearRequest $request, int $schoolYear): JsonResponse
{
return response()->json([
'ok' => true,
'data' => $this->service->updateYear($schoolYear, $request->validated(), $this->getCurrentUserId()),
]);
}
public function summary(int $schoolYear): JsonResponse
{
return response()->json(['ok' => true, 'data' => $this->service->summary($schoolYear)]);
}
public function validateClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse
{
return response()->json([
'ok' => true,
'validation' => $this->service->validateClose($schoolYear, $request->validated()),
]);
}
public function previewClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse
{
return response()->json([
'ok' => true,
'data' => $this->service->previewClose($schoolYear, $request->validated()),
]);
}
public function close(CloseSchoolYearRequest $request, int $schoolYear): JsonResponse
{
try {
$result = $this->service->close($schoolYear, $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([
'ok' => true,
'data' => $this->service->reopen($schoolYear, $this->getCurrentUserId()),
]);
}
public function parentBalances(Request $request, int $schoolYear): JsonResponse
{
return response()->json([
'ok' => true,
'data' => $this->service->parentBalances($schoolYear, $request->query('to_school_year')),
]);
}
public function promotionPreview(Request $request, int $schoolYear): JsonResponse
{
return response()->json([
'ok' => true,
'data' => $this->service->promotionPreview($schoolYear, $request->query('to_school_year')),
]);
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\SchoolYears\SchoolYearClosureService;
use App\Services\SchoolYears\SchoolYearResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
class SchoolYearAdminPageController extends Controller
{
public function __construct(
private SchoolYearClosureService $service,
private SchoolYearResolver $resolver
) {
}
public function show(Request $request): View
{
$years = $this->service->list();
$selectedId = (int) ($request->query('year') ?? 0);
$activeYear = collect($years)->firstWhere('is_current', true);
$defaultNext = $activeYear
? ($this->resolver->suggestNextName((string) $activeYear['name']) ?? '')
: '';
[$defaultStart, $defaultEnd] = $defaultNext !== ''
? $this->resolver->inferDatesFromName($defaultNext)
: ['', ''];
return view('admin.school-years', [
'pageConfig' => [
'jwt' => JWTAuth::fromUser($request->user()),
'selected_year_id' => $selectedId,
'docs_url' => url('/docs'),
'default_draft' => [
'name' => $defaultNext,
'start_date' => $defaultStart,
'end_date' => $defaultEnd,
],
'api' => [
'index' => url('/api/v1/school-years'),
'store' => url('/api/v1/school-years'),
'update' => url('/api/v1/school-years/__ID__'),
'summary' => url('/api/v1/school-years/__ID__/summary'),
'validate_close' => url('/api/v1/school-years/__ID__/validate-close'),
'preview_close' => url('/api/v1/school-years/__ID__/preview-close'),
'close' => url('/api/v1/school-years/__ID__/close'),
'reopen' => url('/api/v1/school-years/__ID__/reopen'),
'parent_balances' => url('/api/v1/school-years/__ID__/parent-balances'),
'promotion_preview' => url('/api/v1/school-years/__ID__/promotion-preview'),
],
],
]);
}
public function store(Request $request): RedirectResponse
{
$payload = $request->validate([
'name' => ['required', 'string', 'max:50'],
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after:start_date'],
]);
try {
$year = $this->service->createYear($payload, optional($request->user())->id);
} catch (ValidationException $e) {
throw $e;
}
return redirect()
->route('admin.school-years', ['year' => $year['id']])
->with('status', sprintf('Draft school year %s created.', $year['name']));
}
public function update(Request $request, int $schoolYear): RedirectResponse
{
$payload = $request->validate([
'name' => ['required', 'string', 'max:50'],
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after:start_date'],
]);
$updated = $this->service->updateYear($schoolYear, $payload, optional($request->user())->id);
return redirect()
->route('admin.school-years', ['year' => $updated['id']])
->with('status', sprintf('School year %s updated.', $updated['name']));
}
public function validateClose(Request $request, int $schoolYear): JsonResponse
{
return response()->json([
'ok' => true,
'validation' => $this->service->validateClose($schoolYear, $this->validatedClosePayload($request)),
]);
}
public function previewClose(Request $request, int $schoolYear): JsonResponse
{
return response()->json([
'ok' => true,
'data' => $this->service->previewClose($schoolYear, $this->validatedClosePayload($request)),
]);
}
public function close(Request $request, int $schoolYear): RedirectResponse
{
$payload = $this->validatedClosePayload($request);
$payload['confirmation'] = (string) $request->validate([
'confirmation' => ['required', 'string', 'max:80'],
])['confirmation'];
$result = $this->service->close($schoolYear, $payload, optional($request->user())->id);
return redirect()
->route('admin.school-years', ['year' => $result['new_school_year']['id']])
->with('status', sprintf(
'Closed %s and opened %s.',
$result['closed_school_year']['name'],
$result['new_school_year']['name']
));
}
public function reopen(Request $request, int $schoolYear): RedirectResponse
{
$year = $this->service->reopen($schoolYear, optional($request->user())->id);
return redirect()
->route('admin.school-years', ['year' => $year['id']])
->with('status', sprintf('School year %s is now active.', $year['name']));
}
private function validatedClosePayload(Request $request): array
{
return $request->validate([
'new_school_year' => ['required', 'array'],
'new_school_year.name' => ['required', 'string', 'max:50'],
'new_school_year.start_date' => ['required', 'date'],
'new_school_year.end_date' => ['required', 'date', 'after:new_school_year.start_date'],
'transfer_unpaid_balances' => ['nullable', 'boolean'],
]);
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Middleware;
use App\Models\Configuration;
use App\Services\SchoolYears\SchoolYearWriteGuard;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
class EnsureSchoolYearEditable
{
public function __construct(private SchoolYearWriteGuard $guard)
{
}
public function handle(Request $request, Closure $next): Response
{
if (!in_array(strtoupper($request->method()), ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
return $next($request);
}
try {
$this->guard->assertEditable($this->resolveSchoolYears($request));
} catch (RuntimeException $e) {
return response()->json([
'ok' => false,
'message' => $e->getMessage(),
], 409);
}
return $next($request);
}
private function resolveSchoolYears(Request $request): array
{
$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);
}
}
$routeYears = [
['param' => 'invoiceId', 'table' => 'invoices', 'column' => 'school_year'],
['param' => 'invoice', 'table' => 'invoices', 'column' => 'school_year'],
['param' => 'paymentId', 'table' => 'payments', 'column' => 'school_year'],
['param' => 'payment', 'table' => 'payments', 'column' => 'school_year'],
['param' => 'refundId', 'table' => 'refunds', 'column' => 'school_year'],
['param' => 'refund', 'table' => 'refunds', 'column' => 'school_year'],
['param' => 'promotionId', 'table' => 'student_promotion_records', 'column' => 'current_school_year'],
['param' => 'promotion', 'table' => 'student_promotion_records', 'column' => 'current_school_year'],
['param' => 'eventCharge', 'table' => 'event_charges', 'column' => 'school_year'],
['param' => 'eventId', 'table' => 'events', 'column' => 'school_year'],
['param' => 'classSection', 'table' => 'classSection', 'column' => 'school_year'],
['param' => 'classSectionId', 'table' => 'classSection', 'column' => 'school_year'],
];
foreach ($routeYears as $mapping) {
$value = $request->route($mapping['param']);
if ($value === null || $value === '') {
continue;
}
$resolved = $this->lookupYear((string) $value, $mapping['table'], $mapping['column']);
if ($resolved !== null) {
$years[] = $resolved;
}
}
if ($years === []) {
$current = trim((string) (Configuration::getConfig('school_year') ?? ''));
if ($current !== '') {
$years[] = $current;
}
}
return $years;
}
private function lookupYear(string $id, string $table, string $column): ?string
{
if (!ctype_digit($id) || !DB::getSchemaBuilder()->hasTable($table)) {
return null;
}
$primaryKey = match ($table) {
'classSection' => 'class_section_id',
'student_promotion_records' => 'promotion_id',
default => 'id',
};
$value = DB::table($table)->where($primaryKey, (int) $id)->value($column);
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\SchoolYears;
class CloseSchoolYearRequest extends PreviewCloseSchoolYearRequest
{
public function rules(): array
{
return array_merge(parent::rules(), [
'confirmation' => ['required', 'string', 'max:80'],
]);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\SchoolYears;
use Illuminate\Foundation\Http\FormRequest;
class PreviewCloseSchoolYearRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'new_school_year' => ['required', 'array'],
'new_school_year.name' => ['required', 'string', 'max:50'],
'new_school_year.start_date' => ['required', 'date'],
'new_school_year.end_date' => ['required', 'date', 'after:new_school_year.start_date'],
'transfer_unpaid_balances' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\SchoolYears;
use Illuminate\Foundation\Http\FormRequest;
class SaveSchoolYearRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50'],
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after:start_date'],
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
class AuditLog extends BaseModel
{
public $timestamps = false;
protected $table = 'audit_logs';
protected $fillable = [
'user_id',
'action',
'table_name',
'record_id',
'old_value',
'new_value',
'metadata',
'created_at',
];
protected $casts = [
'user_id' => 'integer',
'record_id' => 'integer',
'old_value' => 'array',
'new_value' => 'array',
'metadata' => 'array',
'created_at' => 'datetime',
];
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
class ParentAccount extends BaseModel
{
protected $table = 'parent_accounts';
protected $fillable = [
'parent_id',
'school_year',
'opening_balance',
'current_balance',
];
protected $casts = [
'parent_id' => 'integer',
'opening_balance' => 'decimal:2',
'current_balance' => 'decimal:2',
];
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
class ParentBalanceTransfer extends BaseModel
{
protected $table = 'parent_balance_transfers';
protected $fillable = [
'parent_id',
'from_school_year',
'to_school_year',
'amount',
'status',
'source_summary_json',
'new_invoice_id',
'created_by',
];
protected $casts = [
'parent_id' => 'integer',
'amount' => 'decimal:2',
'source_summary_json' => 'array',
'new_invoice_id' => 'integer',
'created_by' => 'integer',
];
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
class SchoolYear extends BaseModel
{
public const STATUS_DRAFT = 'draft';
public const STATUS_ACTIVE = 'active';
public const STATUS_CLOSED = 'closed';
public const STATUS_ARCHIVED = 'archived';
protected $table = 'school_years';
protected $fillable = [
'name',
'start_date',
'end_date',
'status',
'is_current',
'closed_at',
'closed_by',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'is_current' => 'boolean',
'closed_at' => 'datetime',
'closed_by' => 'integer',
];
}
@@ -12,14 +12,18 @@ class AdministratorSharedService
) {
}
public function getSemester(): string
public function getSemester(?string $override = null): string
{
return (string) ($this->configuration->getConfig('semester') ?? '');
$value = trim((string) $override);
return $value !== '' ? $value : (string) ($this->configuration->getConfig('semester') ?? '');
}
public function getSchoolYear(): string
public function getSchoolYear(?string $override = null): string
{
return (string) ($this->configuration->getConfig('school_year') ?? '');
$value = trim((string) $override);
return $value !== '' ? $value : (string) ($this->configuration->getConfig('school_year') ?? '');
}
public function getPreviousSchoolYear(string $schoolYear): string
@@ -146,4 +150,4 @@ class AdministratorSharedService
return count(array_unique($ids));
}
}
}
@@ -12,13 +12,13 @@ class AdministratorTeacherSubmissionService
) {
}
public function report(): array
public function report(array $filters = []): array
{
return $this->reportService->report();
return $this->reportService->report($filters);
}
public function sendNotifications(Request $request, int $adminId): array
{
return $this->notificationService->send($request, $adminId);
}
}
}
@@ -32,7 +32,9 @@ class TeacherSubmissionNotificationService
}
$missingItemsPayload = $request->input('missing_items', []);
$targets = $this->extractTargets($notify);
$schoolYear = $this->shared->getSchoolYear($request->input('school_year'));
$semester = $this->shared->getSemester($request->input('semester'));
$targets = $this->extractTargets($notify, $schoolYear, $semester);
if (empty($targets)) {
return [
@@ -72,8 +74,7 @@ class TeacherSubmissionNotificationService
$sectionName = $classSections->get($classSectionId)?->class_section_name ?? "Section {$classSectionId}";
$teacherName = trim(($teacher->firstname ?? '') . ' ' . ($teacher->lastname ?? '')) ?: 'Teacher';
$missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? '';
$missingItems = $this->support->parseMissingItemsPayload((string) $missingPayload);
$missingItems = $this->resolveMissingItems($missingItemsPayload, $classSectionId, $teacherId);
$missingNote = !empty($missingItems)
? '<p>Outstanding items: ' . e($this->support->formatMissingItemsText($missingItems)) . '.</p>'
@@ -111,8 +112,8 @@ class TeacherSubmissionNotificationService
'notification_category' => 'teacher_submissions',
'message' => $this->support->truncateNotificationMessage($body),
'status' => $status,
'school_year' => $this->shared->getSchoolYear(),
'semester' => $this->shared->getSemester(),
'school_year' => $schoolYear,
'semester' => $semester,
'sent_at' => now(),
]);
}
@@ -134,8 +135,31 @@ class TeacherSubmissionNotificationService
];
}
protected function extractTargets(array $notify): array
protected function extractTargets(array $notify, string $schoolYear, string $semester): array
{
if ($this->isFlatTeacherIdList($notify)) {
$teacherIds = array_values(array_unique(array_map('intval', array_filter($notify, static fn ($value) => (int) $value > 0))));
if (empty($teacherIds)) {
return [];
}
return ClassSection::query()
->select('teacher_class.class_section_id', 'teacher_class.teacher_id')
->join('teacher_class', 'teacher_class.class_section_id', '=', 'classSection.class_section_id')
->whereIn('teacher_class.teacher_id', $teacherIds)
->where('teacher_class.school_year', $schoolYear)
->where('teacher_class.semester', $semester)
->orderBy('teacher_class.class_section_id')
->get()
->map(static fn ($row): array => [
'class_section_id' => (int) $row->class_section_id,
'teacher_id' => (int) $row->teacher_id,
])
->filter(static fn (array $row): bool => $row['class_section_id'] > 0 && $row['teacher_id'] > 0)
->values()
->all();
}
$targets = [];
foreach ($notify as $sectionIdRaw => $teachers) {
@@ -159,4 +183,39 @@ class TeacherSubmissionNotificationService
return array_values($targets);
}
protected function isFlatTeacherIdList(array $notify): bool
{
foreach ($notify as $value) {
if (is_array($value)) {
return false;
}
}
return true;
}
protected function resolveMissingItems($payload, int $classSectionId, int $teacherId): array
{
if (!is_array($payload)) {
return [];
}
if (isset($payload[$classSectionId]) && is_array($payload[$classSectionId])) {
$sectionPayload = $payload[$classSectionId];
if (array_key_exists($teacherId, $sectionPayload) || array_key_exists((string) $teacherId, $sectionPayload)) {
$value = $sectionPayload[$teacherId] ?? $sectionPayload[(string) $teacherId] ?? null;
return $this->support->parseMissingItemsPayload($value);
}
}
if (array_key_exists($teacherId, $payload) || array_key_exists((string) $teacherId, $payload)) {
$value = $payload[$teacherId] ?? $payload[(string) $teacherId] ?? null;
return $this->support->parseMissingItemsPayload($value);
}
return [];
}
}
@@ -17,10 +17,10 @@ class TeacherSubmissionReportService
) {
}
public function report(): array
public function report(array $filters = []): array
{
$semester = $this->shared->getSemester();
$schoolYear = $this->shared->getSchoolYear();
$semester = $this->shared->getSemester($filters['semester'] ?? null);
$schoolYear = $this->shared->getSchoolYear($filters['school_year'] ?? null);
$assignmentRows = DB::table('teacher_class as tc')
->select([
@@ -230,6 +230,8 @@ class TeacherSubmissionReportService
'rows' => $rows,
'semester' => $semester,
'schoolYear' => $schoolYear,
'currentSemester' => $this->shared->getSemester(),
'currentSchoolYear' => $this->shared->getSchoolYear(),
'notificationHistory' => $historyMap,
'summary' => $summary,
];
@@ -83,8 +83,21 @@ class TeacherSubmissionSupportService
: mb_substr($text, 0, $limit) . '…';
}
public function parseMissingItemsPayload(string $payload): array
public function parseMissingItemsPayload($payload): array
{
if (is_array($payload)) {
$items = [];
foreach ($payload as $item) {
$item = trim((string) $item);
if ($item !== '') {
$items[] = $item;
}
}
return array_values(array_unique($items));
}
$payload = trim((string) $payload);
if ($payload === '') {
return [];
}
+7 -1
View File
@@ -2,6 +2,8 @@
namespace App\Services;
use Illuminate\Support\Facades\Route;
/**
* Laravel named routes (`route()`), cookie-session URLs, SPA paths, and absolute URLs for `public/` paths.
*/
@@ -48,7 +50,11 @@ final class ApplicationUrlService
/** Named route `docs.home` (GET `/`; usually redirects to the docs client). */
public function docsHomeUrl(bool $absolute = true): string
{
return route('docs.home', [], $absolute);
if (Route::has('docs.home')) {
return route('docs.home', [], $absolute);
}
return $absolute ? url('/app/home') : '/app/home';
}
/** Cookie-session SPA paths from `routes/web.php`. Named route `login`. */
@@ -48,18 +48,39 @@ class AttendanceManagementService
});
}
$rows = $query->orderByDesc('follow_up_required')
$schoolYear = trim((string) ($filters['school_year'] ?? ''));
$semester = $this->normalizeSemester($filters['semester'] ?? null);
$limit = max(1, (int) ($filters['limit'] ?? 250));
$rowCollection = $query->orderByDesc('follow_up_required')
->orderBy('follow_up_completed')
->orderBy('person_name')
->limit((int) ($filters['limit'] ?? 250))
->get()
->map(fn ($r) => (array) $r)
->map(fn ($r) => $this->hydrateAcademicContext((array) $r))
->filter(function (array $row) use ($schoolYear, $semester): bool {
if ($schoolYear !== '' && (string) ($row['school_year'] ?? '') !== $schoolYear) {
return false;
}
if ($semester !== '' && (string) ($row['semester'] ?? '') !== $semester) {
return false;
}
return true;
});
$rows = $rowCollection
->take($limit)
->values()
->all();
return [
'date' => $date,
'summary' => $this->summaryForDate($date),
'filters' => $this->availableFilters(),
'school_year' => $schoolYear,
'semester' => $semester,
'current_year' => $this->currentSchoolYear(),
'current_semester' => $this->currentSemester(),
'summary' => $this->summaryForDate($date, $rowCollection->values()->all(), $schoolYear, $semester),
'filters' => $this->availableFilters($rows),
'rows' => $rows,
];
}
@@ -75,6 +96,7 @@ class AttendanceManagementService
$status = $isLate ? self::STATUS_LATE : self::STATUS_PRESENT;
$counts = $this->countsFor($person['type'], $person['id'], $entryAt->toDateString(), $status);
$badgeExceptionCount = $this->badgeExceptionCount($person['type'], $person['id'], $entryAt->toDateString()) + 1;
$academicContext = $this->academicContextForDate($entryAt);
$row = [
'person_type' => $person['type'],
@@ -83,6 +105,8 @@ class AttendanceManagementService
'role_grade' => $person['role_grade'],
'badge_id' => $person['badge_id'],
'event_date' => $entryAt->toDateString(),
'school_year' => $academicContext['school_year'],
'semester' => $academicContext['semester'],
'attendance_status' => $status,
'report_status' => $reportStatus,
'entry_time' => $entryAt,
@@ -144,6 +168,7 @@ class AttendanceManagementService
$status = $entryAt->gt($this->schoolStartFor($entryAt)) ? self::STATUS_LATE : self::STATUS_PRESENT;
$counts = $this->countsFor($person['type'], $person['id'], $entryAt->toDateString(), $status);
$academicContext = $this->academicContextForDate($entryAt);
$row = [
'person_type' => $person['type'],
'person_id' => $person['id'],
@@ -151,6 +176,8 @@ class AttendanceManagementService
'role_grade' => $person['role_grade'],
'badge_id' => $badge,
'event_date' => $entryAt->toDateString(),
'school_year' => $academicContext['school_year'],
'semester' => $academicContext['semester'],
'attendance_status' => $status,
'report_status' => $reportStatus,
'entry_time' => $entryAt,
@@ -189,6 +216,7 @@ class AttendanceManagementService
$reportStatus = $authorized ? 'reported' : $this->normalizeReportStatus($data['report_status'] ?? null);
$counts = $this->countsFor($person['type'], $person['id'], $exitAt->toDateString(), self::STATUS_EARLY_DISMISSAL);
$badgeExceptions = $this->badgeExceptionCount($person['type'], $person['id'], $exitAt->toDateString()) + ((($data['exit_method'] ?? '') === 'manual_exit') ? 1 : 0);
$academicContext = $this->academicContextForDate($exitAt);
$row = [
'person_type' => $person['type'],
'person_id' => $person['id'],
@@ -196,6 +224,8 @@ class AttendanceManagementService
'role_grade' => $person['role_grade'],
'badge_id' => $person['badge_id'],
'event_date' => $exitAt->toDateString(),
'school_year' => $academicContext['school_year'],
'semester' => $academicContext['semester'],
'attendance_status' => self::STATUS_EARLY_DISMISSAL,
'report_status' => $reportStatus,
'exit_time' => $exitAt,
@@ -233,6 +263,7 @@ class AttendanceManagementService
$date = $this->dateOnly($data['date'] ?? now()->toDateString());
$reportStatus = $this->normalizeReportStatus($data['report_status'] ?? null);
$counts = $this->countsFor($person['type'], $person['id'], $date, self::STATUS_ABSENT);
$academicContext = $this->academicContextForDate(Carbon::parse($date));
$row = [
'person_type' => $person['type'],
'person_id' => $person['id'],
@@ -240,6 +271,8 @@ class AttendanceManagementService
'role_grade' => $person['role_grade'],
'badge_id' => $person['badge_id'],
'event_date' => $date,
'school_year' => $academicContext['school_year'],
'semester' => $academicContext['semester'],
'attendance_status' => self::STATUS_ABSENT,
'report_status' => $reportStatus,
'reason' => $data['reason'] ?? null,
@@ -290,6 +323,10 @@ class AttendanceManagementService
throw new \RuntimeException('Attendance management event not found.');
}
$count = (int) DB::table('late_slip_reprints')->where('attendance_management_event_id', $eventId)->count() + 1;
$academicContext = [
'school_year' => (string) ($event['school_year'] ?? $this->currentSchoolYear()),
'semester' => (string) ($event['semester'] ?? $this->currentSemester()),
];
$row = [
'attendance_management_event_id' => $eventId,
'late_slip_log_id' => $data['late_slip_log_id'] ?? null,
@@ -297,6 +334,8 @@ class AttendanceManagementService
'student_name' => $event['person_name'] ?? null,
'slip_number' => $data['slip_number'] ?? ('AME-'.$eventId),
'reprinted_at' => now(),
'school_year' => $academicContext['school_year'],
'semester' => $academicContext['semester'],
'reprinted_by' => $actor?->id,
'reason' => $data['reason'] ?? 'Reprint requested',
'reprint_count' => $count,
@@ -370,8 +409,8 @@ class AttendanceManagementService
private function printLateSlip(array $event, ?User $actor): array
{
$payload = [
'school_year' => Configuration::getConfigValueByKey('school_year') ?? '',
'semester' => Configuration::getConfigValueByKey('semester') ?? '',
'school_year' => (string) ($event['school_year'] ?? $this->currentSchoolYear()),
'semester' => (string) ($event['semester'] ?? $this->currentSemester()),
'student_name' => $event['person_name'] ?? '',
'slip_date' => $event['event_date'] ?? now()->toDateString(),
'time_in' => Carbon::parse($event['official_entry_time'] ?? now())->format('H:i:s'),
@@ -389,12 +428,15 @@ class AttendanceManagementService
private function recordBadgeException(int $eventId, array $person, Carbon $date, string $reason, int $count, ?User $actor, ?string $notes): void
{
$academicContext = $this->academicContextForDate($date);
DB::table('badge_exceptions')->insert([
'attendance_management_event_id' => $eventId,
'person_type' => $person['type'],
'person_id' => $person['id'],
'person_name' => $person['name'],
'exception_date' => $date->toDateString(),
'school_year' => $academicContext['school_year'],
'semester' => $academicContext['semester'],
'exception_type' => 'manual_entry',
'badge_status' => $reason,
'exception_count' => $count,
@@ -431,28 +473,55 @@ class AttendanceManagementService
->count();
}
private function summaryForDate(string $date): array
private function summaryForDate(string $date, array $rows = [], string $schoolYear = '', string $semester = ''): array
{
$base = DB::table('attendance_management_events')->whereDate('event_date', $date);
$badgeExceptions = DB::table('badge_exceptions')->whereDate('exception_date', $date);
$this->applyAcademicFilters($badgeExceptions, $schoolYear, $semester);
$lateSlipReprints = DB::table('late_slip_reprints')->whereDate('reprinted_at', $date);
$this->applyAcademicFilters($lateSlipReprints, $schoolYear, $semester);
return [
'present' => (clone $base)->where('attendance_status', self::STATUS_PRESENT)->count(),
'absent' => (clone $base)->where('attendance_status', self::STATUS_ABSENT)->count(),
'late' => (clone $base)->where('attendance_status', self::STATUS_LATE)->count(),
'early_dismissal' => (clone $base)->where('attendance_status', self::STATUS_EARLY_DISMISSAL)->count(),
'not_reported' => (clone $base)->where('report_status', 'not_reported')->count(),
'follow_up_required' => (clone $base)->where('follow_up_required', true)->where('follow_up_completed', false)->count(),
'badge_exceptions' => DB::table('badge_exceptions')->whereDate('exception_date', $date)->count(),
'late_slip_reprints' => DB::table('late_slip_reprints')->whereDate('reprinted_at', $date)->count(),
'present' => $this->countRowsByStatus($rows, self::STATUS_PRESENT),
'absent' => $this->countRowsByStatus($rows, self::STATUS_ABSENT),
'late' => $this->countRowsByStatus($rows, self::STATUS_LATE),
'early_dismissal' => $this->countRowsByStatus($rows, self::STATUS_EARLY_DISMISSAL),
'not_reported' => $this->countRowsByReportStatus($rows, 'not_reported'),
'follow_up_required' => $this->countRowsRequiringFollowUp($rows),
'badge_exceptions' => $badgeExceptions->count(),
'late_slip_reprints' => $lateSlipReprints->count(),
];
}
private function availableFilters(): array
private function availableFilters(array $rows = []): array
{
$schoolYears = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['school_year'] ?? '')),
$rows,
))));
rsort($schoolYears);
$currentYear = $this->currentSchoolYear();
if ($currentYear !== '' && ! in_array($currentYear, $schoolYears, true)) {
array_unshift($schoolYears, $currentYear);
}
$semesters = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['semester'] ?? '')),
$rows,
))));
$currentSemester = $this->currentSemester();
if ($currentSemester !== '' && ! in_array($currentSemester, $semesters, true)) {
array_unshift($semesters, $currentSemester);
}
return [
'attendance_status' => [self::STATUS_PRESENT, self::STATUS_ABSENT, self::STATUS_ABSENT_PENDING, self::STATUS_LATE, self::STATUS_LATE_WITHOUT_SLIP, self::STATUS_EARLY_DISMISSAL],
'report_status' => ['reported', 'not_reported', 'pending_clarification'],
'risk_level' => ['low', 'medium', 'high', 'very_high', 'critical', 'severe'],
'person_type' => ['student', 'staff', 'contractor', 'visitor'],
'school_years' => $schoolYears,
'semesters' => $semesters,
];
}
@@ -462,6 +531,91 @@ class AttendanceManagementService
return Carbon::parse($date->toDateString().' '.$configured, $date->timezone);
}
private function hydrateAcademicContext(array $row): array
{
$date = trim((string) ($row['event_date'] ?? ''));
if ($date === '') {
return $row;
}
$context = $this->academicContextForDate(Carbon::parse($date));
if (trim((string) ($row['school_year'] ?? '')) === '') {
$row['school_year'] = $context['school_year'];
}
if (trim((string) ($row['semester'] ?? '')) === '') {
$row['semester'] = $context['semester'];
}
return $row;
}
private function academicContextForDate(Carbon $date): array
{
$derivedYear = $this->deriveSchoolYearForDate($date);
$derivedSemester = $this->deriveSemesterForDate($date);
if ($date->isSameDay(now())) {
return [
'school_year' => $this->currentSchoolYear() ?: $derivedYear,
'semester' => $this->currentSemester() ?: $derivedSemester,
];
}
return [
'school_year' => $derivedYear ?: $this->currentSchoolYear(),
'semester' => $derivedSemester ?: $this->currentSemester(),
];
}
private function deriveSchoolYearForDate(Carbon $date): string
{
$startYear = $date->month >= 8 ? $date->year : ($date->year - 1);
return sprintf('%d-%d', $startYear, $startYear + 1);
}
private function deriveSemesterForDate(Carbon $date): string
{
return ($date->month >= 8 || $date->month === 1) ? 'Fall' : 'Spring';
}
private function currentSchoolYear(): string
{
return trim((string) (Configuration::getConfigValueByKey('school_year') ?? ''));
}
private function currentSemester(): string
{
return $this->normalizeSemester(Configuration::getConfig('semester'));
}
private function applyAcademicFilters($query, string $schoolYear = '', string $semester = ''): void
{
if ($schoolYear !== '') {
$query->where('school_year', $schoolYear);
}
if ($semester !== '') {
$query->where('semester', $semester);
}
}
private function countRowsByStatus(array $rows, string $status): int
{
return count(array_filter($rows, static fn (array $row): bool => (string) ($row['attendance_status'] ?? '') === $status));
}
private function countRowsByReportStatus(array $rows, string $status): int
{
return count(array_filter($rows, static fn (array $row): bool => (string) ($row['report_status'] ?? '') === $status));
}
private function countRowsRequiringFollowUp(array $rows): int
{
return count(array_filter($rows, static function (array $row): bool {
return (bool) ($row['follow_up_required'] ?? false) && ! (bool) ($row['follow_up_completed'] ?? false);
}));
}
private function dateTime($value): Carbon { return $value instanceof Carbon ? $value : Carbon::parse((string) $value); }
private function dateOnly($value): string { return Carbon::parse((string) $value)->toDateString(); }
@@ -471,6 +625,19 @@ class AttendanceManagementService
return in_array($v, ['reported', 'not_reported', 'pending_clarification'], true) ? $v : 'pending_clarification';
}
private function normalizeSemester($value): string
{
$semester = strtolower(trim((string) $value));
if ($semester === 'fall') {
return 'Fall';
}
if ($semester === 'spring') {
return 'Spring';
}
return '';
}
private function combinationCode(int $abs, int $late, int $ed, int $badge): string
{
$parts = [];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
<?php
namespace App\Services\SchoolYears;
use App\Models\Configuration;
use App\Models\SchoolYear;
class SchoolYearResolver
{
public function ensureCurrentTracked(): ?SchoolYear
{
$current = trim((string) (Configuration::getConfig('school_year') ?? ''));
if ($current === '') {
return SchoolYear::query()->where('is_current', true)->orderByDesc('id')->first();
}
$existing = SchoolYear::query()->where('name', $current)->first();
if ($existing) {
if ($existing->status !== SchoolYear::STATUS_ACTIVE || !$existing->is_current) {
SchoolYear::query()->where('id', '!=', $existing->id)->where('is_current', true)->update([
'is_current' => false,
'updated_at' => now(),
]);
$existing->status = SchoolYear::STATUS_ACTIVE;
$existing->is_current = true;
$existing->save();
}
return $existing->refresh();
}
[$startDate, $endDate] = $this->inferDatesFromName($current);
SchoolYear::query()->where('is_current', true)->update([
'is_current' => false,
'updated_at' => now(),
]);
return SchoolYear::query()->create([
'name' => $current,
'start_date' => $startDate,
'end_date' => $endDate,
'status' => SchoolYear::STATUS_ACTIVE,
'is_current' => true,
]);
}
public function inferDatesFromName(string $schoolYear): array
{
if (preg_match('/^(\\d{4})-(\\d{4})$/', trim($schoolYear), $matches) === 1) {
return [
sprintf('%s-09-01', $matches[1]),
sprintf('%s-06-30', $matches[2]),
];
}
$year = (int) date('Y');
return [
sprintf('%d-09-01', $year),
sprintf('%d-06-30', $year + 1),
];
}
public function suggestNextName(string $schoolYear): ?string
{
if (preg_match('/^(\\d{4})-(\\d{4})$/', trim($schoolYear), $matches) !== 1) {
return null;
}
return sprintf('%d-%d', (int) $matches[1] + 1, (int) $matches[2] + 1);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Services\SchoolYears;
use App\Models\SchoolYear;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class SchoolYearWriteGuard
{
public function assertEditable(array $schoolYears): void
{
if (!Schema::hasTable('school_years')) {
return;
}
$years = array_values(array_unique(array_filter(array_map(
static fn ($year) => is_string($year) ? trim($year) : null,
$schoolYears
))));
if ($years === []) {
return;
}
$statuses = SchoolYear::query()
->whereIn('name', $years)
->get(['name', 'status'])
->keyBy('name');
foreach ($years as $year) {
$row = $statuses->get($year);
if ($row && $row->status !== SchoolYear::STATUS_ACTIVE) {
throw new RuntimeException(sprintf(
'School year %s is %s and read-only.',
$year,
$row->status
));
}
}
}
}
+1
View File
@@ -35,6 +35,7 @@ return Application::configure(basePath: dirname(__DIR__))
'parent.progress' => \App\Http\Middleware\EnsureParentProgressAccess::class,
'teacher.progress' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class,
'class_progress.teacher' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class,
'school_year.editable' => \App\Http\Middleware\EnsureSchoolYearEditable::class,
'print_requests.teacher' => \App\Http\Middleware\EnsurePrintRequestsTeacherAccess::class,
'print_requests.admin' => \App\Http\Middleware\EnsurePrintRequestsAdminAccess::class,
'badge_scan.logs' => \App\Http\Middleware\EnsureBadgeScanLogsAccess::class,
@@ -0,0 +1,146 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('school_years')) {
Schema::create('school_years', function (Blueprint $table) {
$table->id();
$table->string('name', 50)->unique();
$table->date('start_date');
$table->date('end_date');
$table->string('status', 20)->default('draft')->index();
$table->boolean('is_current')->default(false)->index();
$table->timestamp('closed_at')->nullable();
$table->unsignedBigInteger('closed_by')->nullable()->index();
$table->timestamps();
});
}
if (!Schema::hasTable('parent_accounts')) {
Schema::create('parent_accounts', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->index();
$table->string('school_year', 50)->index();
$table->decimal('opening_balance', 12, 2)->default(0);
$table->decimal('current_balance', 12, 2)->default(0);
$table->timestamps();
$table->unique(['parent_id', 'school_year'], 'parent_accounts_parent_year_unique');
});
}
if (!Schema::hasTable('parent_balance_transfers')) {
Schema::create('parent_balance_transfers', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->index();
$table->string('from_school_year', 50)->index();
$table->string('to_school_year', 50)->index();
$table->decimal('amount', 12, 2)->default(0);
$table->string('status', 30)->default('pending')->index();
$table->json('source_summary_json')->nullable();
$table->unsignedBigInteger('new_invoice_id')->nullable()->index();
$table->unsignedBigInteger('created_by')->nullable()->index();
$table->timestamps();
$table->unique(['parent_id', 'from_school_year', 'to_school_year'], 'parent_balance_transfers_parent_year_unique');
});
}
if (!Schema::hasTable('audit_logs')) {
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->string('action', 100)->index();
$table->string('table_name', 100)->nullable()->index();
$table->unsignedBigInteger('record_id')->nullable()->index();
$table->json('old_value')->nullable();
$table->json('new_value')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
});
}
if (Schema::hasTable('invoices') && !Schema::hasColumn('invoices', 'balance_transfer_id')) {
Schema::table('invoices', function (Blueprint $table) {
$table->unsignedBigInteger('balance_transfer_id')->nullable()->index()->after('semester');
});
}
$this->seedCurrentSchoolYear();
}
public function down(): void
{
if (Schema::hasTable('invoices') && Schema::hasColumn('invoices', 'balance_transfer_id')) {
Schema::table('invoices', function (Blueprint $table) {
$table->dropColumn('balance_transfer_id');
});
}
Schema::dropIfExists('audit_logs');
Schema::dropIfExists('parent_balance_transfers');
Schema::dropIfExists('parent_accounts');
Schema::dropIfExists('school_years');
}
private function seedCurrentSchoolYear(): void
{
if (!Schema::hasTable('configuration') || !Schema::hasTable('school_years')) {
return;
}
$current = DB::table('configuration')
->where('config_key', 'school_year')
->orderByDesc('id')
->value('config_value');
$current = is_string($current) ? trim($current) : '';
if ($current === '') {
return;
}
$exists = DB::table('school_years')->where('name', $current)->exists();
if ($exists) {
DB::table('school_years')->where('name', $current)->update([
'status' => 'active',
'is_current' => true,
'updated_at' => now(),
]);
return;
}
[$startDate, $endDate] = $this->inferDates($current);
DB::table('school_years')->insert([
'name' => $current,
'start_date' => $startDate,
'end_date' => $endDate,
'status' => 'active',
'is_current' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function inferDates(string $schoolYear): array
{
if (preg_match('/^(\\d{4})-(\\d{4})$/', $schoolYear, $matches) === 1) {
return [
sprintf('%s-09-01', $matches[1]),
sprintf('%s-06-30', $matches[2]),
];
}
$year = (int) date('Y');
return [
sprintf('%d-09-01', $year),
sprintf('%d-06-30', $year + 1),
];
}
};
@@ -0,0 +1,165 @@
<?php
use Carbon\CarbonImmutable;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('attendance_management_events')) {
Schema::table('attendance_management_events', function (Blueprint $table) {
if (! Schema::hasColumn('attendance_management_events', 'school_year')) {
$table->string('school_year', 20)->nullable()->after('event_date')->index();
}
if (! Schema::hasColumn('attendance_management_events', 'semester')) {
$table->string('semester', 20)->nullable()->after('school_year')->index();
}
});
DB::table('attendance_management_events')
->select('id', 'event_date', 'school_year', 'semester')
->orderBy('id')
->chunkById(200, function ($rows): void {
foreach ($rows as $row) {
$schoolYear = trim((string) ($row->school_year ?? ''));
$semester = trim((string) ($row->semester ?? ''));
if ($schoolYear !== '' && $semester !== '') {
continue;
}
$date = CarbonImmutable::parse((string) $row->event_date);
DB::table('attendance_management_events')
->where('id', $row->id)
->update([
'school_year' => $schoolYear !== '' ? $schoolYear : $this->deriveSchoolYearForDate($date),
'semester' => $semester !== '' ? $semester : $this->deriveSemesterForDate($date),
]);
}
});
}
if (Schema::hasTable('badge_exceptions')) {
Schema::table('badge_exceptions', function (Blueprint $table) {
if (! Schema::hasColumn('badge_exceptions', 'school_year')) {
$table->string('school_year', 20)->nullable()->after('exception_date')->index();
}
if (! Schema::hasColumn('badge_exceptions', 'semester')) {
$table->string('semester', 20)->nullable()->after('school_year')->index();
}
});
DB::table('badge_exceptions')
->select('id', 'exception_date', 'school_year', 'semester')
->orderBy('id')
->chunkById(200, function ($rows): void {
foreach ($rows as $row) {
$schoolYear = trim((string) ($row->school_year ?? ''));
$semester = trim((string) ($row->semester ?? ''));
if ($schoolYear !== '' && $semester !== '') {
continue;
}
$date = CarbonImmutable::parse((string) $row->exception_date);
DB::table('badge_exceptions')
->where('id', $row->id)
->update([
'school_year' => $schoolYear !== '' ? $schoolYear : $this->deriveSchoolYearForDate($date),
'semester' => $semester !== '' ? $semester : $this->deriveSemesterForDate($date),
]);
}
});
}
if (Schema::hasTable('late_slip_reprints')) {
Schema::table('late_slip_reprints', function (Blueprint $table) {
if (! Schema::hasColumn('late_slip_reprints', 'school_year')) {
$table->string('school_year', 20)->nullable()->after('reprinted_at')->index();
}
if (! Schema::hasColumn('late_slip_reprints', 'semester')) {
$table->string('semester', 20)->nullable()->after('school_year')->index();
}
});
DB::table('late_slip_reprints')
->leftJoin('attendance_management_events as ame', 'ame.id', '=', 'late_slip_reprints.attendance_management_event_id')
->select(
'late_slip_reprints.id',
'late_slip_reprints.reprinted_at',
'late_slip_reprints.school_year',
'late_slip_reprints.semester',
'ame.school_year as event_school_year',
'ame.semester as event_semester',
)
->orderBy('late_slip_reprints.id')
->chunkById(200, function ($rows): void {
foreach ($rows as $row) {
$schoolYear = trim((string) ($row->school_year ?? ''));
$semester = trim((string) ($row->semester ?? ''));
if ($schoolYear !== '' && $semester !== '') {
continue;
}
$date = CarbonImmutable::parse((string) $row->reprinted_at);
DB::table('late_slip_reprints')
->where('id', $row->id)
->update([
'school_year' => $schoolYear !== '' ? $schoolYear : (trim((string) ($row->event_school_year ?? '')) ?: $this->deriveSchoolYearForDate($date)),
'semester' => $semester !== '' ? $semester : (trim((string) ($row->event_semester ?? '')) ?: $this->deriveSemesterForDate($date)),
]);
}
}, 'late_slip_reprints.id', 'id');
}
}
public function down(): void
{
if (Schema::hasTable('late_slip_reprints')) {
Schema::table('late_slip_reprints', function (Blueprint $table) {
if (Schema::hasColumn('late_slip_reprints', 'semester')) {
$table->dropColumn('semester');
}
if (Schema::hasColumn('late_slip_reprints', 'school_year')) {
$table->dropColumn('school_year');
}
});
}
if (Schema::hasTable('badge_exceptions')) {
Schema::table('badge_exceptions', function (Blueprint $table) {
if (Schema::hasColumn('badge_exceptions', 'semester')) {
$table->dropColumn('semester');
}
if (Schema::hasColumn('badge_exceptions', 'school_year')) {
$table->dropColumn('school_year');
}
});
}
if (Schema::hasTable('attendance_management_events')) {
Schema::table('attendance_management_events', function (Blueprint $table) {
if (Schema::hasColumn('attendance_management_events', 'semester')) {
$table->dropColumn('semester');
}
if (Schema::hasColumn('attendance_management_events', 'school_year')) {
$table->dropColumn('school_year');
}
});
}
}
private function deriveSchoolYearForDate(CarbonImmutable $date): string
{
$startYear = $date->month >= 8 ? $date->year : ($date->year - 1);
return sprintf('%d-%d', $startYear, $startYear + 1);
}
private function deriveSemesterForDate(CarbonImmutable $date): string
{
return ($date->month >= 8 || $date->month === 1) ? 'Fall' : 'Spring';
}
};
+42
View File
@@ -0,0 +1,42 @@
http://localhost:5173/app/administrator/attendance/badge-scans
http://localhost:5173/app/administrator/sections/auto-distribute
http://localhost:5173/app/administrator/sections/promotion-totals
http://localhost:5173/app/administrator/parent-email-extractor
http://localhost:5173/app/administrator/parent_profiles
http://localhost:5173/app/administrator/student_profiles
http://localhost:5173/app/whatsapp?school_year=2025-2026
http://localhost:5173/app/competition-winners
http://localhost:5173/app/admin/progress
http://localhost:5173/app/administrator/subject-curriculum
http://localhost:5173/app/administrator/teacher-submissions
http://localhost:5173/app/administrator/calendar
http://localhost:5173/app/administrator/events?school_year=2025-2026
http://localhost:5173/app/administrator/exam-drafts
http://localhost:5173/app/administrator/family
http://localhost:5173/app/families/import-legacy
http://localhost:5173/app/inventory/book
http://localhost:5173/app/inventory
http://localhost:5173/app/inventory/kitchen
http://localhost:5173/app/inventory/office
http://localhost:5173/app/inventory/summary-all
http://localhost:5173/app/inventory/movements
http://localhost:5173/app/administrator/notifications_alerts
http://localhost:5173/app/administrator/certificates?school_year=2025-2026
http://localhost:5173/app/administrator/class-prep
http://localhost:5173/app/admin/print-requests
http://localhost:5173/app/administrator/printables/report-cards
http://localhost:5173/app/grading/decisions?school_year=2025-2026
http://localhost:5173/app/grading/below-60/decisions?school_year=2025-2026
http://localhost:5173/app/grading?school_year=2025-2026&semester=Fall
http://localhost:5173/app/staff
http://localhost:5173/app/administrator/teacher_class_assignment
http://localhost:5173/app/administrator/emergency-contacts
http://localhost:5173/app/administrator/enroll-withdraw/enrollment-withdrawal
http://localhost:5173/app/grading/placement
http://localhost:5173/app/administrator/removed_students
http://localhost:5173/app/administrator/calendar_view?school_year=2025-2026
http://localhost:5173/app/administrator/student_class_assignment
http://localhost:5173/app/administrator/student_profiles
http://localhost:5173/app/administrator/absence
http://localhost:5173/app/administrator/ip_bans
http://localhost:5173/app/user/user-list
File diff suppressed because it is too large Load Diff
+36 -18
View File
@@ -126,6 +126,7 @@ use App\Http\Controllers\Api\Documentation\DocsCatalogController;
use App\Http\Controllers\Api\Parents\AuthorizedUserInviteController;
use App\Http\Controllers\Api\Administrator\AdministratorPromotionController;
use App\Http\Controllers\Api\Parents\ParentPromotionController;
use App\Http\Controllers\Api\SchoolYears\SchoolYearController;
use App\Http\Controllers\Api\Public\PublicWinnersController;
use App\Http\Controllers\Api\ScannerController;
use App\Http\Controllers\Api\Staff\TimeOffNotificationController;
@@ -154,6 +155,10 @@ Route::middleware('auth.docs')->group(function () {
Route::get('docs/public', [ApiDocsAdminController::class, 'public'])->name('api-docs.public');
// Legacy email extractor endpoints still used by the SPA parity page.
Route::middleware('auth:api')->get('emails', [EmailExtractorController::class, 'emails']);
Route::middleware('auth:api')->post('compare', [EmailExtractorController::class, 'compare']);
Route::get('access_denied', [AccessDeniedController::class, 'accessDenied'])->name('access_denied');
Route::get('certificates/verify/{token}', [CertificateController::class, 'verify'])
@@ -302,13 +307,13 @@ Route::prefix('v1')->group(function () {
->name('api.v1.administrator.attendance-templates.bootstrap');
Route::get('absence', [AdministratorAbsenceController::class, 'index']);
Route::post('absence', [AdministratorAbsenceController::class, 'store']);
Route::post('absence', [AdministratorAbsenceController::class, 'store'])->middleware('school_year.editable');
Route::get('dashboard/metrics', [AdministratorDashboardController::class, 'metrics']);
Route::get('dashboard/user-search', [AdministratorDashboardController::class, 'userSearch']);
Route::get('teacher-submissions', [AdministratorTeacherSubmissionController::class, 'index']);
Route::post('teacher-submissions/notify', [AdministratorTeacherSubmissionController::class, 'notify']);
Route::post('teacher-submissions/notify', [AdministratorTeacherSubmissionController::class, 'notify'])->middleware('school_year.editable');
Route::prefix('trophy')->group(function () {
Route::get('/', [AdministratorTrophyController::class, 'index']);
@@ -317,22 +322,22 @@ Route::prefix('v1')->group(function () {
});
Route::get('teacher-class/assignments', [TeacherClassAssignmentController::class, 'index']);
Route::post('teacher-class/assign', [TeacherClassAssignmentController::class, 'store']);
Route::post('teacher-class/delete', [TeacherClassAssignmentController::class, 'destroy']);
Route::post('teacher-class/assign', [TeacherClassAssignmentController::class, 'store'])->middleware('school_year.editable');
Route::post('teacher-class/delete', [TeacherClassAssignmentController::class, 'destroy'])->middleware('school_year.editable');
Route::get('notifications/alerts', [AdministratorNotificationController::class, 'alerts']);
Route::post('notifications/alerts', [AdministratorNotificationController::class, 'saveAlerts']);
Route::post('notifications/alerts', [AdministratorNotificationController::class, 'saveAlerts'])->middleware('school_year.editable');
Route::get('notifications/print-recipients', [AdministratorNotificationController::class, 'printRecipients']);
Route::post('notifications/print-recipients', [AdministratorNotificationController::class, 'savePrintRecipients']);
Route::post('notifications/print-recipients', [AdministratorNotificationController::class, 'savePrintRecipients'])->middleware('school_year.editable');
Route::get('enrollment-withdrawal', [AdministratorEnrollmentController::class, 'index']);
Route::get('enrollment-withdrawal/new-students', [AdministratorEnrollmentController::class, 'newStudents']);
Route::post('enrollment-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses']);
Route::post('enrollment-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses'])->middleware('school_year.editable');
Route::get('enroll-withdrawal', [AdministratorEnrollmentController::class, 'index']);
Route::get('enroll-withdrawal/new-students', [AdministratorEnrollmentController::class, 'newStudents']);
Route::post('enroll-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses']);
Route::post('enroll-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses'])->middleware('school_year.editable');
Route::prefix('promotions')->group(function () {
Route::prefix('promotions')->middleware('school_year.editable')->group(function () {
Route::get('/', [AdministratorPromotionController::class, 'index']);
Route::get('summary', [AdministratorPromotionController::class, 'summary']);
Route::post('evaluate', [AdministratorPromotionController::class, 'evaluate']);
@@ -392,9 +397,9 @@ Route::prefix('v1')->group(function () {
Route::get('/', [ExpenseController::class, 'index']);
Route::get('options', [ExpenseController::class, 'options']);
Route::get('{id}', [ExpenseController::class, 'show']);
Route::post('/', [ExpenseController::class, 'store']);
Route::put('{id}', [ExpenseController::class, 'update']);
Route::post('{id}/status', [ExpenseController::class, 'updateStatus']);
Route::post('/', [ExpenseController::class, 'store'])->middleware('school_year.editable');
Route::put('{id}', [ExpenseController::class, 'update'])->middleware('school_year.editable');
Route::post('{id}/status', [ExpenseController::class, 'updateStatus'])->middleware('school_year.editable');
});
Route::prefix('reports')->group(function () {
@@ -471,7 +476,20 @@ Route::prefix('v1')->group(function () {
});
});
Route::middleware('auth:api')->prefix('attendance')->group(function () {
Route::middleware(['auth:api', 'admin.access'])->prefix('school-years')->group(function () {
Route::get('/', [SchoolYearController::class, 'index']);
Route::post('/', [SchoolYearController::class, 'store']);
Route::patch('{schoolYear}', [SchoolYearController::class, 'update'])->whereNumber('schoolYear');
Route::get('{schoolYear}/summary', [SchoolYearController::class, 'summary'])->whereNumber('schoolYear');
Route::post('{schoolYear}/validate-close', [SchoolYearController::class, 'validateClose'])->whereNumber('schoolYear');
Route::post('{schoolYear}/preview-close', [SchoolYearController::class, 'previewClose'])->whereNumber('schoolYear');
Route::post('{schoolYear}/close', [SchoolYearController::class, 'close'])->whereNumber('schoolYear');
Route::post('{schoolYear}/reopen', [SchoolYearController::class, 'reopen'])->whereNumber('schoolYear');
Route::get('{schoolYear}/parent-balances', [SchoolYearController::class, 'parentBalances'])->whereNumber('schoolYear');
Route::get('{schoolYear}/promotion-preview', [SchoolYearController::class, 'promotionPreview'])->whereNumber('schoolYear');
});
Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance')->group(function () {
// Teacher
Route::get('/teacher/grid', [TeacherAttendanceApiController::class, 'grid']);
Route::get('/teacher/form', [TeacherAttendanceApiController::class, 'form']);
@@ -513,7 +531,7 @@ Route::prefix('v1')->group(function () {
});
});
Route::middleware('auth:api')->prefix('attendance-tracking')->group(function () {
Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance-tracking')->group(function () {
Route::get('/pending-violations', [AttendanceTrackingController::class, 'pendingViolations']);
Route::get('/notified-violations', [AttendanceTrackingController::class, 'notifiedViolations']);
Route::get('/student-case/{studentId}', [AttendanceTrackingController::class, 'studentCase']);
@@ -525,7 +543,7 @@ Route::prefix('v1')->group(function () {
Route::post('/save-notification-note', [AttendanceTrackingController::class, 'saveNotificationNote']);
});
Route::middleware('auth:api')->prefix('attendance-comment-templates')->group(function () {
Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance-comment-templates')->group(function () {
Route::get('/', [AttendanceCommentTemplateController::class, 'index'])->name('api.v1.attendance-comment-templates.index');
Route::get('list-data', [AttendanceCommentTemplateController::class, 'listData'])
->name('api.v1.attendance-comment-templates.list-data');
@@ -538,19 +556,19 @@ Route::prefix('v1')->group(function () {
/*
| Legacy attendance template list/save/delete endpoints (auth:api).
*/
Route::middleware('auth:api')->prefix('attendance-templates')->group(function () {
Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance-templates')->group(function () {
Route::get('/', [AttendanceCommentTemplateController::class, 'listData'])->name('api.v1.attendance-templates.legacy');
Route::post('save', [AttendanceCommentTemplateController::class, 'legacySave']);
Route::post('delete', [AttendanceCommentTemplateController::class, 'legacyDelete']);
});
Route::middleware('auth:api')->prefix('assignments')->group(function () {
Route::middleware(['auth:api', 'school_year.editable'])->prefix('assignments')->group(function () {
Route::get('/', [AssignmentApiController::class, 'index']);
Route::post('/', [AssignmentApiController::class, 'store']);
Route::get('/class-assignment-data', [AssignmentApiController::class, 'classAssignmentData']);
});
Route::middleware('auth:api')->group(function () {
Route::middleware(['auth:api', 'school_year.editable'])->group(function () {
// Legacy alias: same handler as GET /api/v1/scores/homework
Route::get('teacher/homework-list', [HomeworkScoreController::class, 'index']);
// Legacy alias: teacher add-homework page and save action
+22
View File
@@ -3,6 +3,7 @@
use App\Http\Controllers\Api\Auth\AuthSessionController;
use App\Http\Controllers\Api\Auth\SessionTimeoutController;
use App\Http\Controllers\Api\System\DashboardRedirectController;
use App\Http\Controllers\Web\SchoolYearAdminPageController;
use App\Http\Controllers\Web\TeacherCalendarPageController;
use Illuminate\Support\Facades\Route;
@@ -80,6 +81,27 @@ Route::middleware('web')->group(function () {
->name('teacher.calendar');
Route::middleware('auth')->get('app/teacher/calendar', [TeacherCalendarPageController::class, 'show'])
->name('app.teacher.calendar');
Route::middleware(['auth', 'admin.access'])->get('administrator/school-years', [SchoolYearAdminPageController::class, 'show'])
->name('admin.school-years');
Route::middleware(['auth', 'admin.access'])->get('app/administrator/school-years', [SchoolYearAdminPageController::class, 'show'])
->name('app.admin.school-years');
Route::middleware(['auth', 'admin.access'])->post('administrator/school-years', [SchoolYearAdminPageController::class, 'store'])
->name('admin.school-years.store');
Route::middleware(['auth', 'admin.access'])->patch('administrator/school-years/{schoolYear}', [SchoolYearAdminPageController::class, 'update'])
->whereNumber('schoolYear')
->name('admin.school-years.update');
Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/validate-close', [SchoolYearAdminPageController::class, 'validateClose'])
->whereNumber('schoolYear')
->name('admin.school-years.validate-close');
Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/preview-close', [SchoolYearAdminPageController::class, 'previewClose'])
->whereNumber('schoolYear')
->name('admin.school-years.preview-close');
Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/close', [SchoolYearAdminPageController::class, 'close'])
->whereNumber('schoolYear')
->name('admin.school-years.close');
Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/reopen', [SchoolYearAdminPageController::class, 'reopen'])
->whereNumber('schoolYear')
->name('admin.school-years.reopen');
Route::middleware('auth')->get('teacher/absence-vacation', function () {
return redirect('/app/teacher/absence-vacation');
})->name('teacher.absence-vacation');
@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\Administrator;
use App\Models\User;
use App\Services\Administrator\TeacherSubmissionNotificationService;
use App\Services\Administrator\TeacherSubmissionReportService;
use App\Http\Middleware\EnsurePrintRequestsAdminAccess;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Mockery;
@@ -22,12 +23,14 @@ class AdministratorTeacherSubmissionControllerTest extends TestCase
public function test_can_get_teacher_submission_report(): void
{
$this->withoutMiddleware(EnsurePrintRequestsAdminAccess::class);
$user = User::factory()->create();
Sanctum::actingAs($user);
Sanctum::actingAs($user, [], 'api');
$mock = Mockery::mock(TeacherSubmissionReportService::class);
$mock->shouldReceive('report')
->once()
->with([])
->andReturn([
'rows' => [],
'semester' => 'Fall',
@@ -55,8 +58,9 @@ class AdministratorTeacherSubmissionControllerTest extends TestCase
public function test_notify_requires_notify_payload(): void
{
$this->withoutMiddleware(EnsurePrintRequestsAdminAccess::class);
$user = User::factory()->create();
Sanctum::actingAs($user);
Sanctum::actingAs($user, [], 'api');
$response = $this->postJson('/api/v1/administrator/teacher-submissions/notify', [
'missing_items' => [],
@@ -68,8 +72,9 @@ class AdministratorTeacherSubmissionControllerTest extends TestCase
public function test_notify_calls_service(): void
{
$this->withoutMiddleware(EnsurePrintRequestsAdminAccess::class);
$user = User::factory()->create();
Sanctum::actingAs($user);
Sanctum::actingAs($user, [], 'api');
$payload = [
'notify' => [
@@ -105,4 +110,4 @@ class AdministratorTeacherSubmissionControllerTest extends TestCase
'failed' => 0,
]);
}
}
}
@@ -32,8 +32,8 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase
public function test_notify_endpoint_requires_notify_payload(): void
{
$admin = User::factory()->create();
Sanctum::actingAs($admin);
$admin = $this->seedAdminUser('admin.notify.required@example.com');
Sanctum::actingAs($admin, [], 'api');
$response = $this->postJson('/api/v1/administrator/teacher-submissions/notify', [
'missing_items' => [],
@@ -47,13 +47,8 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase
{
Mail::fake();
$admin = User::factory()->create([
'id' => 900,
'firstname' => 'Admin',
'lastname' => 'One',
'email' => 'admin@test.com',
]);
Sanctum::actingAs($admin);
$admin = $this->seedAdminUser('admin@test.com', 900, 'Admin', 'One');
Sanctum::actingAs($admin, [], 'api');
User::factory()->create([
'id' => 100,
@@ -117,12 +112,8 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase
{
Mail::fake();
$admin = User::factory()->create([
'id' => 901,
'firstname' => 'Admin',
'lastname' => 'Two',
]);
Sanctum::actingAs($admin);
$admin = $this->seedAdminUser('admin.two@test.com', 901, 'Admin', 'Two');
Sanctum::actingAs($admin, [], 'api');
User::factory()->create([
'id' => 101,
@@ -169,4 +160,94 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase
'semester' => 'Fall',
]);
}
public function test_notify_endpoint_accepts_flat_teacher_ids_from_spa_payload(): void
{
Mail::fake();
$admin = $this->seedAdminUser('admin3@test.com', 902, 'Admin', 'Three');
Sanctum::actingAs($admin, [], 'api');
User::factory()->create([
'id' => 102,
'firstname' => 'Flat',
'lastname' => 'Teacher',
'email' => 'flat@test.com',
]);
DB::table('classSection')->insert([
[
'class_section_id' => 12,
'class_section_name' => 'Grade 7A',
'class_id' => 7,
'semester' => 'Fall',
'school_year' => '2025-2026',
],
]);
DB::table('teacher_class')->insert([
'class_section_id' => 12,
'teacher_id' => 102,
'position' => 'main',
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
$payload = [
'notify' => [102],
'missing_items' => [
102 => ['midterm scores', 'attendance'],
],
];
$response = $this->postJson('/api/v1/administrator/teacher-submissions/notify', $payload);
$response->assertOk()
->assertJson([
'sent' => 1,
'failed' => 0,
]);
$this->assertDatabaseHas('teacher_submission_notification_history', [
'teacher_id' => 102,
'class_section_id' => 12,
'admin_id' => 902,
'notification_category' => 'teacher_submissions',
'status' => 'sent',
]);
}
private function seedAdminUser(string $email, ?int $id = null, string $firstname = 'Admin', string $lastname = 'User'): User
{
$roleId = DB::table('roles')->insertGetId([
'name' => 'administrator',
'slug' => 'administrator',
'is_active' => 1,
]);
$userId = DB::table('users')->insertGetId(array_filter([
'id' => $id,
'firstname' => $firstname,
'lastname' => $lastname,
'cellphone' => '5555555555',
'email' => $email,
'address_street' => '123 Main',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('secret'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
], static fn ($value) => $value !== null));
DB::table('user_roles')->insert([
'user_id' => $userId,
'role_id' => $roleId,
]);
return User::query()->findOrFail($userId);
}
}
@@ -4,7 +4,6 @@ namespace Tests\Feature\Api\Administrator;
use App\Models\AttendanceDay;
use App\Models\Configuration;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\TeacherSubmissionNotificationHistory;
use App\Models\User;
@@ -35,8 +34,8 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
public function test_teacher_submission_report_endpoint_returns_rows_summary_and_history(): void
{
$admin = User::factory()->create(['id' => 900, 'firstname' => 'Admin', 'lastname' => 'One']);
Sanctum::actingAs($admin);
$admin = $this->seedAdminUser('admin.report.one@test.com', 900, 'Admin', 'One');
Sanctum::actingAs($admin, [], 'api');
User::factory()->create([
'id' => 100,
@@ -114,8 +113,7 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
'participation_score' => '9',
]);
ScoreComment::unguard();
ScoreComment::query()->create([
DB::table('score_comments')->insert([
'student_id' => 1,
'class_section_id' => 10,
'school_year' => '2025-2026',
@@ -123,7 +121,7 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
'score_type' => 'midterm',
'comment' => 'Good progress',
]);
ScoreComment::query()->create([
DB::table('score_comments')->insert([
'student_id' => 1,
'class_section_id' => 10,
'school_year' => '2025-2026',
@@ -199,8 +197,8 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
public function test_teacher_submission_report_returns_no_students_status_for_empty_section(): void
{
$admin = User::factory()->create();
Sanctum::actingAs($admin);
$admin = $this->seedAdminUser('admin.report.empty@test.com');
Sanctum::actingAs($admin, [], 'api');
User::factory()->create([
'id' => 200,
@@ -246,8 +244,8 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
public function test_teacher_submission_report_limits_history_to_three_entries(): void
{
$admin = User::factory()->create(['id' => 901, 'firstname' => 'Admin', 'lastname' => 'Two']);
Sanctum::actingAs($admin);
$admin = $this->seedAdminUser('admin.report.two@test.com', 901, 'Admin', 'Two');
Sanctum::actingAs($admin, [], 'api');
User::factory()->create([
'id' => 300,
@@ -298,4 +296,82 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
$history = $response->json('notificationHistory.12.300');
$this->assertCount(3, $history);
}
public function test_teacher_submission_report_honors_school_year_and_semester_query_filters(): void
{
$admin = $this->seedAdminUser('admin.report.filters@test.com');
Sanctum::actingAs($admin, [], 'api');
User::factory()->create([
'id' => 400,
'firstname' => 'Yearly',
'lastname' => 'Teacher',
'email' => 'yearly@test.com',
]);
DB::table('classSection')->insert([
[
'class_section_id' => 20,
'class_section_name' => 'Grade 8A',
'class_id' => 8,
'semester' => 'Spring',
'school_year' => '2024-2025',
],
]);
DB::table('teacher_class')->insert([
[
'class_section_id' => 20,
'teacher_id' => 400,
'position' => 'main',
'school_year' => '2024-2025',
'semester' => 'Spring',
],
]);
$response = $this->getJson('/api/v1/administrator/teacher-submissions?school_year=2024-2025&semester=Spring');
$response->assertOk()
->assertJson([
'semester' => 'Spring',
'schoolYear' => '2024-2025',
'currentSemester' => 'Fall',
'currentSchoolYear' => '2025-2026',
])
->assertJsonCount(1, 'rows');
}
private function seedAdminUser(string $email, ?int $id = null, string $firstname = 'Admin', string $lastname = 'User'): User
{
$roleId = DB::table('roles')->insertGetId([
'name' => 'administrator',
'slug' => 'administrator',
'is_active' => 1,
]);
$userId = DB::table('users')->insertGetId(array_filter([
'id' => $id,
'firstname' => $firstname,
'lastname' => $lastname,
'cellphone' => '5555555555',
'email' => $email,
'address_street' => '123 Main',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('secret'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
], static fn ($value) => $value !== null));
DB::table('user_roles')->insert([
'user_id' => $userId,
'role_id' => $roleId,
]);
return User::query()->findOrFail($userId);
}
}
@@ -15,7 +15,7 @@ class EmailExtractorControllerTest extends TestCase
public function test_emails_endpoint_returns_users_and_parents(): void
{
$user = $this->createUser();
Sanctum::actingAs($user);
Sanctum::actingAs($user, [], 'api');
DB::table('parents')->insert([
'secondparent_firstname' => 'Parent',
@@ -31,13 +31,38 @@ class EmailExtractorControllerTest extends TestCase
$response->assertOk();
$response->assertJsonPath('ok', true);
$this->assertContains('admin@example.com', $response->json('users'));
$this->assertContains('parent2@example.com', $response->json('parents'));
$this->assertContains('parent2@example.com', $response->json('emails.parents'));
}
public function test_legacy_emails_endpoint_returns_top_level_arrays(): void
{
$user = $this->createUser();
Sanctum::actingAs($user, [], 'api');
DB::table('parents')->insert([
'secondparent_firstname' => 'Parent',
'secondparent_lastname' => 'Legacy',
'secondparent_email' => 'legacy-parent@example.com',
'secondparent_phone' => '5555555555',
'firstparent_id' => 1,
'secondparent_id' => 3,
'school_year' => '2025-2026',
]);
$response = $this->getJson('/api/emails');
$response->assertOk();
$response->assertJsonPath('ok', true);
$this->assertContains('admin@example.com', $response->json('users'));
$this->assertContains('legacy-parent@example.com', $response->json('parents'));
}
public function test_compare_endpoint_returns_comparison(): void
{
$user = $this->createUser();
Sanctum::actingAs($user);
Sanctum::actingAs($user, [], 'api');
DB::table('parents')->insert([
'secondparent_firstname' => 'Parent',
@@ -0,0 +1,343 @@
<?php
namespace Tests\Feature\Api\V1\SchoolYears;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class SchoolYearControllerTest extends TestCase
{
use RefreshDatabase;
public function test_preview_close_returns_promotion_and_balance_preview(): void
{
$this->seedClosureData();
$this->actingAs(User::query()->findOrFail(1), 'api');
$response = $this->postJson('/api/v1/school-years/1/preview-close', [
'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', true)
->assertJsonPath('data.promotion_preview.summary.promote', 1)
->assertJsonPath('data.parent_balances.summary.total_old_unpaid_balance', 200);
$this->assertSame(200.0, (float) $response->json('data.parent_balances.rows.0.amount_to_transfer'));
$this->assertSame('promote', $response->json('data.promotion_preview.rows.0.promotion_action'));
}
public function test_admin_can_create_draft_school_year_via_api(): void
{
$this->seedClosureData();
$this->actingAs(User::query()->findOrFail(1), 'api');
$response = $this->postJson('/api/v1/school-years', [
'name' => '2026-2027',
'start_date' => '2026-09-01',
'end_date' => '2027-06-30',
]);
$response->assertCreated()
->assertJsonPath('data.name', '2026-2027')
->assertJsonPath('data.status', 'draft');
$this->assertDatabaseHas('school_years', [
'name' => '2026-2027',
'status' => 'draft',
'is_current' => 0,
]);
}
public function test_admin_can_update_draft_school_year_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/2', [
'name' => '2028-2029',
'start_date' => '2028-09-01',
'end_date' => '2029-06-30',
]);
$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();
$this->actingAs(User::query()->findOrFail(1), 'api');
$response = $this->postJson('/api/v1/school-years/1/close', [
'new_school_year' => [
'name' => '2026-2027',
'start_date' => '2026-09-01',
'end_date' => '2027-06-30',
],
'transfer_unpaid_balances' => true,
'confirmation' => 'CLOSE 2025-2026',
]);
$response->assertOk()
->assertJsonPath('data.closed_school_year.status', 'closed')
->assertJsonPath('data.new_school_year.status', 'active');
$this->assertDatabaseHas('school_years', [
'name' => '2025-2026',
'status' => 'closed',
'is_current' => 0,
]);
$this->assertDatabaseHas('school_years', [
'name' => '2026-2027',
'status' => 'active',
'is_current' => 1,
]);
$this->assertDatabaseHas('configuration', [
'config_key' => 'school_year',
'config_value' => '2026-2027',
]);
$this->assertDatabaseHas('enrollments', [
'student_id' => 100,
'parent_id' => 10,
'school_year' => '2026-2027',
'enrollment_status' => 'enrolled',
]);
$this->assertDatabaseHas('parent_balance_transfers', [
'parent_id' => 10,
'from_school_year' => '2025-2026',
'to_school_year' => '2026-2027',
'amount' => 200.00,
'status' => 'transferred',
]);
$this->assertDatabaseHas('invoices', [
'parent_id' => 10,
'school_year' => '2026-2027',
'total_amount' => 200.00,
'balance' => 200.00,
'description' => 'Opening balance transferred from 2025-2026',
]);
}
public function test_closed_school_year_blocks_legacy_invoice_generation(): void
{
$this->seedClosureData();
$this->actingAs(User::query()->findOrFail(1), 'api');
$this->postJson('/api/v1/school-years/1/close', [
'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();
$response = $this->postJson('/api/v1/finance/invoices/generate', [
'parent_id' => 10,
'school_year' => '2025-2026',
]);
$response->assertStatus(409)
->assertJsonPath('message', 'School year 2025-2026 is closed and read-only.');
}
private function seedClosureData(): void
{
DB::table('configuration')->insert([
['config_key' => 'school_year', 'config_value' => '2025-2026'],
['config_key' => 'semester', 'config_value' => 'Fall'],
]);
DB::table('roles')->insert([
['id' => 1, 'name' => 'administrator', 'slug' => 'administrator', 'is_active' => 1],
['id' => 2, 'name' => 'parent', 'slug' => 'parent', 'is_active' => 1],
]);
DB::table('users')->insert([
[
'id' => 1,
'school_id' => 1,
'firstname' => 'Admin',
'lastname' => 'User',
'cellphone' => '5555555555',
'email' => 'admin@example.com',
'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',
],
[
'id' => 10,
'school_id' => 1,
'firstname' => 'Parent',
'lastname' => 'User',
'cellphone' => '5555555555',
'email' => 'parent@example.com',
'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' => 1, 'role_id' => 1],
['user_id' => 10, 'role_id' => 2],
]);
DB::table('school_years')->insert([
'id' => 1,
'name' => '2025-2026',
'start_date' => '2025-09-01',
'end_date' => '2026-06-30',
'status' => 'active',
'is_current' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('students')->insert([
'id' => 100,
'parent_id' => 10,
'firstname' => 'Sara',
'lastname' => 'Student',
'school_id' => 'S-100',
'age' => 9,
'gender' => 'Female',
'is_active' => 1,
'photo_consent' => 1,
'year_of_registration' => '2025',
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
DB::table('classes')->insert([
['id' => 3, 'class_name' => 'Grade 3', 'schedule' => 'Sunday 9:00', 'school_year' => '2025-2026', 'semester' => 'Fall', 'capacity' => 30],
['id' => 4, 'class_name' => 'Grade 4', 'schedule' => 'Sunday 10:00', 'school_year' => '2026-2027', 'semester' => 'Fall', 'capacity' => 30],
]);
DB::table('classSection')->insert([
[
'class_section_id' => 301,
'class_section_name' => '3A',
'class_id' => 3,
'semester' => 'Fall',
'school_year' => '2025-2026',
],
[
'class_section_id' => 401,
'class_section_name' => '4A',
'class_id' => 4,
'semester' => 'Fall',
'school_year' => '2026-2027',
],
]);
DB::table('student_class')->insert([
'student_id' => 100,
'class_section_id' => 301,
'school_year' => '2025-2026',
'semester' => 'Fall',
'is_event_only' => 0,
]);
DB::table('enrollments')->insert([
'student_id' => 100,
'class_section_id' => 301,
'parent_id' => 10,
'enrollment_date' => '2025-09-05',
'enrollment_status' => 'enrolled',
'withdrawal_date' => null,
'is_withdrawn' => 0,
'admission_status' => 'accepted',
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('student_decisions')->insert([
'student_id' => 100,
'school_year' => '2025-2026',
'class_section_name' => '3A',
'year_score' => 85,
'decision' => 'promoted',
'source' => 'manual',
'notes' => 'Ready for promotion',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('invoices')->insert([
'id' => 500,
'parent_id' => 10,
'invoice_number' => 'INV-500',
'total_amount' => 500,
'balance' => 200,
'paid_amount' => 300,
'has_discount' => 0,
'issue_date' => '2026-05-10',
'due_date' => '2026-06-10',
'status' => 'unpaid',
'description' => 'Tuition balance',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
'updated_by' => 1,
'semester' => 'Fall',
]);
}
}
@@ -0,0 +1,117 @@
<?php
namespace Tests\Feature\Web;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class SchoolYearAdminPageTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$compiledPath = storage_path('framework/views');
if (!is_dir($compiledPath)) {
mkdir($compiledPath, 0777, true);
}
config([
'app.key' => 'base64:' . base64_encode(random_bytes(32)),
'app.cipher' => 'AES-256-CBC',
'jwt.secret' => bin2hex(random_bytes(32)),
'view.compiled' => $compiledPath,
]);
}
public function test_admin_can_view_school_year_page(): void
{
$this->seedAdminContext();
$response = $this->actingAs(User::query()->findOrFail(1))
->get('/administrator/school-years');
$response->assertOk();
$response->assertSee('School Years Management');
$response->assertSee('JWT Authorization: Bearer');
$response->assertSee('/api/v1/school-years');
}
public function test_admin_can_create_draft_school_year_from_page(): void
{
$this->seedAdminContext();
$response = $this->actingAs(User::query()->findOrFail(1))
->post('/administrator/school-years', [
'name' => '2026-2027',
'start_date' => '2026-09-01',
'end_date' => '2027-06-30',
]);
$response->assertRedirect();
$this->assertDatabaseHas('school_years', [
'name' => '2026-2027',
'status' => 'draft',
'is_current' => 0,
]);
}
private function seedAdminContext(): void
{
DB::table('configuration')->insert([
['config_key' => 'school_year', 'config_value' => '2025-2026'],
['config_key' => 'semester', 'config_value' => 'Fall'],
]);
DB::table('roles')->insert([
'id' => 1,
'name' => 'administrator',
'slug' => 'administrator',
'is_active' => 1,
]);
DB::table('users')->insert([
'id' => 1,
'school_id' => 1,
'firstname' => 'Admin',
'lastname' => 'User',
'gender' => 'Female',
'cellphone' => '5555555555',
'email' => 'admin@example.com',
'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',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('user_roles')->insert([
'user_id' => 1,
'role_id' => 1,
]);
DB::table('school_years')->insert([
'id' => 1,
'name' => '2025-2026',
'start_date' => '2025-09-01',
'end_date' => '2026-06-30',
'status' => 'active',
'is_current' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,103 @@
<?php
namespace Tests\Unit\Services\AttendanceManagement;
use App\Services\AttendanceManagement\AttendanceManagementService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class AttendanceManagementServiceTest extends TestCase
{
use RefreshDatabase;
public function test_badge_scan_persists_school_year_and_semester_for_the_event_date(): void
{
$this->seedConfiguration();
$this->seedUserWithBadge('RFID-100');
$service = new AttendanceManagementService();
$row = $service->badgeScan([
'badge_scan' => 'RFID-100',
'scan_time' => '2026-02-10 08:30:00',
]);
$this->assertSame('2025-2026', $row['school_year']);
$this->assertSame('Spring', $row['semester']);
$this->assertDatabaseHas('attendance_management_events', [
'id' => $row['id'],
'badge_id' => 'RFID-100',
'school_year' => '2025-2026',
'semester' => 'Spring',
]);
}
public function test_dashboard_derives_academic_context_for_legacy_rows_and_applies_filters(): void
{
DB::table('attendance_management_events')->insert([
'person_type' => 'student',
'person_id' => 10,
'person_name' => 'Legacy Student',
'event_date' => '2026-02-10',
'attendance_status' => AttendanceManagementService::STATUS_PRESENT,
'report_status' => 'reported',
'created_at' => now(),
'updated_at' => now(),
]);
$service = new AttendanceManagementService();
$data = $service->dashboard([
'date' => '2026-02-10',
'school_year' => '2025-2026',
'semester' => 'Spring',
]);
$this->assertCount(1, $data['rows']);
$this->assertSame('2025-2026', $data['rows'][0]['school_year']);
$this->assertSame('Spring', $data['rows'][0]['semester']);
$this->assertSame(1, $data['summary']['present']);
$empty = $service->dashboard([
'date' => '2026-02-10',
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
$this->assertCount(0, $empty['rows']);
}
private function seedConfiguration(): void
{
DB::table('configuration')->insert([
['config_key' => 'school_year', 'config_value' => '2025-2026'],
['config_key' => 'semester', 'config_value' => 'Fall'],
]);
}
private function seedUserWithBadge(string $rfidTag): void
{
DB::table('users')->insert([
'id' => 100,
'school_id' => 1,
'firstname' => 'Badge',
'lastname' => 'User',
'cellphone' => '5555555555',
'email' => 'badge-user@example.com',
'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',
'rfid_tag' => $rfidTag,
]);
}
}