feat: API wali murid (Sanctum) + verifikasi admin
- Pasang laravel/sanctum, routes/api.php (prefix api/v1, 10 endpoint) - Migrasi: parent_name di students, parent_students (pivot wali↔anak), parent_devices (token FCM), permission parents-verify - Auth wali: registrasi NISN+HP (auto-verify jika HP cocok data siswa, pending jika tidak) + login token Sanctum - Endpoint: me, logout, students, link anak, violations, sp-letters, notifications, register device - Halaman admin Verifikasi Wali (approve/reject) + menu sidebar - Sync kesiswaan: tarik data orang tua (student_parents) ke students - 8 feature test baru (ParentApiTest) — total suite 39 test hijau
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ParentStudent;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ParentVerificationController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$pending = ParentStudent::query()
|
||||
->where('status', 'pending')
|
||||
->with(['user:id,name,username,phone,created_at', 'student:id,nisn,full_name,class_name,department_name'])
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
$recent = ParentStudent::query()
|
||||
->where('status', '!=', 'pending')
|
||||
->with(['user:id,name,username', 'student:id,nisn,full_name,class_name'])
|
||||
->orderByDesc('updated_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return view('parents.verification', compact('pending', 'recent'));
|
||||
}
|
||||
|
||||
public function approve(Request $request, int $linkId): RedirectResponse
|
||||
{
|
||||
$link = ParentStudent::findOrFail($linkId);
|
||||
|
||||
if ($link->status !== 'pending') {
|
||||
return back()->with('error', 'Tautan sudah diproses sebelumnya.');
|
||||
}
|
||||
|
||||
$link->update([
|
||||
'status' => 'active',
|
||||
'verified_at' => now(),
|
||||
'verified_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
// Isi data kontak wali ke siswa bila masih kosong
|
||||
if ($link->student && empty($link->student->parent_phone)) {
|
||||
$link->student->update([
|
||||
'parent_name' => $link->user->name,
|
||||
'parent_phone' => $link->user->phone ?: ($link->student->parent_phone),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Akun wali "' . $link->user->name . '" untuk ' . $link->student->full_name . ' disetujui.');
|
||||
}
|
||||
|
||||
public function reject(Request $request, int $linkId): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$link = ParentStudent::findOrFail($linkId);
|
||||
|
||||
if ($link->status !== 'pending') {
|
||||
return back()->with('error', 'Tautan sudah diproses sebelumnya.');
|
||||
}
|
||||
|
||||
$link->update([
|
||||
'status' => 'rejected',
|
||||
'verified_at' => now(),
|
||||
'verified_by' => $request->user()->id,
|
||||
'rejection_reason' => $validated['reason'] ?? null,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Tautan ditolak.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ParentStudent;
|
||||
use App\Models\Student;
|
||||
use App\Models\User;
|
||||
use App\Support\PhoneHelper;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ParentAuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* Registrasi wali murid.
|
||||
*
|
||||
* Wali memasukkan NISN anak + nomor HP sendiri. Jika nomor HP cocok dengan
|
||||
* data orang tua siswa → langsung aktif. Jika tidak/belum ada data →
|
||||
* status pending menunggu verifikasi admin.
|
||||
*/
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nisn' => ['required', 'string', 'max:30'],
|
||||
'parent_phone' => ['required', 'string', 'min:9', 'max:20'],
|
||||
'name' => ['required', 'string', 'min:3', 'max:150'],
|
||||
'password' => ['required', 'string', 'min:6', 'max:100'],
|
||||
'relation' => ['nullable', Rule::in(['father', 'mother', 'guardian'])],
|
||||
]);
|
||||
|
||||
$nisn = trim($validated['nisn']);
|
||||
$phone = trim($validated['parent_phone']);
|
||||
|
||||
$student = Student::query()
|
||||
->where('nisn', $nisn)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $student) {
|
||||
return response()->json([
|
||||
'message' => 'NISN tidak ditemukan. Periksa kembali NISN anak Anda.',
|
||||
'errors' => ['nisn' => ['NISN tidak ditemukan atau siswa tidak aktif.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Akun wali memakai NISN sebagai username (satu akun per siswa pertama,
|
||||
// anak berikutnya ditautkan lewat endpoint link).
|
||||
$user = User::where('username', $nisn)->first();
|
||||
|
||||
if ($user && ! $user->isParent()) {
|
||||
return response()->json([
|
||||
'message' => 'NISN ini sudah terdaftar sebagai akun lain.',
|
||||
'errors' => ['nisn' => ['NISN sudah digunakan akun lain.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
$already = ParentStudent::where('user_id', $user->id)
|
||||
->where('student_id', $student->id)
|
||||
->exists();
|
||||
|
||||
if ($already) {
|
||||
return response()->json([
|
||||
'message' => 'Akun wali untuk siswa ini sudah terdaftar. Silakan login.',
|
||||
'errors' => ['nisn' => ['Sudah terdaftar.']],
|
||||
], 422);
|
||||
}
|
||||
} else {
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
'username' => $nisn,
|
||||
'email' => $nisn . '@parent.local',
|
||||
'password' => $validated['password'],
|
||||
'role' => 'parent',
|
||||
'roles' => ['parent'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Verifikasi otomatis jika HP cocok dengan data orang tua di database
|
||||
$autoVerified = PhoneHelper::matches($phone, $student->parent_phone);
|
||||
|
||||
$link = ParentStudent::create([
|
||||
'user_id' => $user->id,
|
||||
'student_id' => $student->id,
|
||||
'relation' => $validated['relation'] ?? null,
|
||||
'status' => $autoVerified ? 'active' : 'pending',
|
||||
'verified_at' => $autoVerified ? now() : null,
|
||||
]);
|
||||
|
||||
$token = $user->createToken('parent-mobile')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'message' => $autoVerified
|
||||
? 'Registrasi berhasil. Data anak Anda sudah aktif.'
|
||||
: 'Registrasi berhasil. Akun menunggu verifikasi pihak sekolah (biasanya 1×24 jam).',
|
||||
'token' => $token,
|
||||
'user' => $this->userPayload($user),
|
||||
'students' => $this->studentPayloads($user),
|
||||
'needs_verification' => ! $autoVerified,
|
||||
'link_id' => $link->id,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login wali murid (username = NISN anak, password = milik akun wali).
|
||||
*/
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'username' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$user = User::where('username', trim($validated['username']))->first();
|
||||
|
||||
if (! $user || ! $user->isParent() || ! Hash::check($validated['password'], $user->password)) {
|
||||
return response()->json([
|
||||
'message' => 'NISN atau password salah.',
|
||||
'errors' => ['username' => ['Kredensial tidak valid.']],
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (! $user->is_active) {
|
||||
return response()->json([
|
||||
'message' => 'Akun Anda dinonaktifkan. Hubungi pihak sekolah.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$token = $user->createToken('parent-mobile')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Login berhasil.',
|
||||
'token' => $token,
|
||||
'user' => $this->userPayload($user),
|
||||
'students' => $this->studentPayloads($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userPayload($user),
|
||||
'students' => $this->studentPayloads($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return response()->json(['message' => 'Logout berhasil.']);
|
||||
}
|
||||
|
||||
protected function userPayload(User $user): array
|
||||
{
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
'phone' => $user->phone,
|
||||
'role' => 'parent',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Daftar anak + status tautan (active/pending) + ringkasan poin & SP.
|
||||
*/
|
||||
protected function studentPayloads(User $user): array
|
||||
{
|
||||
return $user->parentStudents()
|
||||
->with('student.class')
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(function (ParentStudent $link) {
|
||||
$student = $link->student;
|
||||
$active = $link->status === 'active';
|
||||
|
||||
$payload = [
|
||||
'link_id' => $link->id,
|
||||
'link_status' => $link->status,
|
||||
'relation' => $link->relation,
|
||||
'student' => [
|
||||
'id' => $student->id,
|
||||
'nisn' => $student->nisn,
|
||||
'student_number' => $student->student_number,
|
||||
'full_name' => $student->full_name,
|
||||
'gender' => $student->gender,
|
||||
'class_name' => $student->class_name,
|
||||
'class_level' => $student->class_level,
|
||||
'department_name' => $student->department_name,
|
||||
'photo_url' => $student->photo_path ? url($student->photo_path) : null,
|
||||
],
|
||||
];
|
||||
|
||||
if ($active) {
|
||||
$latestSp = $student->spLetters()
|
||||
->where('status', '!=', 'draft')
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
|
||||
$payload['summary'] = [
|
||||
'total_points' => $student->total_points,
|
||||
'latest_sp' => $latestSp ? [
|
||||
'letter_number' => $latestSp->letter_number,
|
||||
'title' => $latestSp->title,
|
||||
'status' => $latestSp->status,
|
||||
'total_points_at_time' => $latestSp->total_points_at_time,
|
||||
'date' => $latestSp->created_at?->toDateString(),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
})
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ParentDevice;
|
||||
use App\Models\ParentStudent;
|
||||
use App\Models\ViolationNotification;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ParentNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Notifikasi untuk anak-anak wali (SP baru, dll).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$studentIds = ParentStudent::where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->pluck('student_id')
|
||||
->all();
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return response()->json(['notifications' => []]);
|
||||
}
|
||||
|
||||
$notifications = ViolationNotification::query()
|
||||
->whereIn('student_id', $studentIds)
|
||||
->with('student:id,full_name,class_name')
|
||||
->orderByDesc('created_at')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
$items = $notifications->map(function (ViolationNotification $n) {
|
||||
return [
|
||||
'id' => $n->id,
|
||||
'student_name' => $n->student?->full_name,
|
||||
'class_name' => $n->student?->class_name,
|
||||
'channel' => $n->channel,
|
||||
'recipient' => $n->recipient,
|
||||
'message' => $n->message,
|
||||
'status' => $n->status,
|
||||
'created_at' => $n->created_at?->toISOString(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'notifications' => $items,
|
||||
'unread_count' => $notifications->where('status', '!=', 'sent')->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daftarkan perangkat (token FCM) untuk push notification.
|
||||
*/
|
||||
public function registerDevice(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'platform' => ['required', Rule::in(['android', 'ios'])],
|
||||
'fcm_token' => ['required', 'string', 'max:255'],
|
||||
'device_name' => ['nullable', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
$device = ParentDevice::updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'fcm_token' => $validated['fcm_token'],
|
||||
],
|
||||
[
|
||||
'platform' => $validated['platform'],
|
||||
'device_name' => $validated['device_name'] ?? null,
|
||||
'last_active_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Perangkat terdaftar.',
|
||||
'device_id' => $device->id,
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ParentStudent;
|
||||
use App\Models\SpLetter;
|
||||
use App\Models\Student;
|
||||
use App\Models\Violation;
|
||||
use App\Support\PhoneHelper;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ParentStudentController extends Controller
|
||||
{
|
||||
/**
|
||||
* Daftar anak milik wali (termasuk yang statusnya pending verifikasi).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$payload = app(ParentAuthController::class)->studentPayloads($request->user());
|
||||
|
||||
return response()->json(['students' => $payload]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tautkan anak tambahan (kakak/adik) ke akun wali.
|
||||
*/
|
||||
public function link(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nisn' => ['required', 'string', 'max:30'],
|
||||
'parent_phone' => ['required', 'string', 'min:9', 'max:20'],
|
||||
'relation' => ['nullable', Rule::in(['father', 'mother', 'guardian'])],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$nisn = trim($validated['nisn']);
|
||||
|
||||
$student = Student::query()
|
||||
->where('nisn', $nisn)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $student) {
|
||||
return response()->json([
|
||||
'message' => 'NISN tidak ditemukan. Periksa kembali NISN anak Anda.',
|
||||
'errors' => ['nisn' => ['NISN tidak ditemukan atau siswa tidak aktif.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$exists = ParentStudent::where('user_id', $user->id)
|
||||
->where('student_id', $student->id)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return response()->json([
|
||||
'message' => 'Siswa ini sudah tertaut ke akun Anda.',
|
||||
'errors' => ['nisn' => ['Sudah tertaut.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$autoVerified = PhoneHelper::matches(trim($validated['parent_phone']), $student->parent_phone);
|
||||
|
||||
$link = ParentStudent::create([
|
||||
'user_id' => $user->id,
|
||||
'student_id' => $student->id,
|
||||
'relation' => $validated['relation'] ?? null,
|
||||
'status' => $autoVerified ? 'active' : 'pending',
|
||||
'verified_at' => $autoVerified ? now() : null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => $autoVerified
|
||||
? 'Siswa berhasil ditautkan dan langsung aktif.'
|
||||
: 'Siswa ditautkan, menunggu verifikasi pihak sekolah.',
|
||||
'link_id' => $link->id,
|
||||
'link_status' => $link->status,
|
||||
'needs_verification' => ! $autoVerified,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Riwayat pelanggaran anak (hanya untuk tautan aktif).
|
||||
*/
|
||||
public function violations(Request $request, int $student): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$student = $this->assertActiveChild($user, $student);
|
||||
if ($student instanceof JsonResponse) {
|
||||
return $student;
|
||||
}
|
||||
|
||||
$violations = Violation::query()
|
||||
->where('student_id', $student->id)
|
||||
->whereNull('deleted_at')
|
||||
->with(['violationType.category', 'evidences'])
|
||||
->orderByDesc('violation_date')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
$items = collect($violations->items())->map(function (Violation $v) {
|
||||
return [
|
||||
'id' => $v->id,
|
||||
'violation_date' => $v->violation_date?->toDateString(),
|
||||
'violation_time' => $v->violation_time?->format('H:i'),
|
||||
'description' => $v->description,
|
||||
'location' => $v->location,
|
||||
'points' => $v->points,
|
||||
'sanction' => $v->sanction,
|
||||
'handling_status' => $v->handling_status,
|
||||
'is_verified' => (bool) $v->is_verified,
|
||||
'type' => $v->violationType ? [
|
||||
'name' => $v->violationType->name,
|
||||
'category' => $v->violationType->category?->name,
|
||||
] : null,
|
||||
'evidences' => $v->evidences->map(fn ($e) => [
|
||||
'id' => $e->id,
|
||||
'url' => url($e->file_path),
|
||||
'mime_type' => $e->mime_type,
|
||||
])->all(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'student' => $this->studentBrief($student),
|
||||
'total_points' => $student->total_points,
|
||||
'violations' => $items,
|
||||
'pagination' => [
|
||||
'current_page' => $violations->currentPage(),
|
||||
'last_page' => $violations->lastPage(),
|
||||
'per_page' => $violations->perPage(),
|
||||
'total' => $violations->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daftar surat SP anak (selain draft).
|
||||
*/
|
||||
public function spLetters(Request $request, int $student): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$student = $this->assertActiveChild($user, $student);
|
||||
if ($student instanceof JsonResponse) {
|
||||
return $student;
|
||||
}
|
||||
|
||||
$letters = SpLetter::query()
|
||||
->where('student_id', $student->id)
|
||||
->where('status', '!=', 'draft')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
$items = $letters->map(function (SpLetter $sp) {
|
||||
return [
|
||||
'id' => $sp->id,
|
||||
'letter_number' => $sp->letter_number,
|
||||
'title' => $sp->title,
|
||||
'body' => $sp->body,
|
||||
'status' => $sp->status,
|
||||
'total_points_at_time' => $sp->total_points_at_time,
|
||||
'violations_included' => $sp->violations_included,
|
||||
'file_url' => $sp->file_path ? url($sp->file_path) : null,
|
||||
'created_at' => $sp->created_at?->toISOString(),
|
||||
'delivered_at' => $sp->delivered_at?->toISOString(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'student' => $this->studentBrief($student),
|
||||
'sp_letters' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pastikan siswa adalah anak aktif dari user. Return model atau JsonResponse 403.
|
||||
*/
|
||||
protected function assertActiveChild($user, int $studentId): Student|JsonResponse
|
||||
{
|
||||
$link = ParentStudent::where('user_id', $user->id)
|
||||
->where('student_id', $studentId)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if (! $link) {
|
||||
return response()->json([
|
||||
'message' => 'Akses ditolak. Siswa tidak tertaut atau masih menunggu verifikasi.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $link->student;
|
||||
}
|
||||
|
||||
protected function studentBrief(Student $student): array
|
||||
{
|
||||
return [
|
||||
'id' => $student->id,
|
||||
'nisn' => $student->nisn,
|
||||
'full_name' => $student->full_name,
|
||||
'class_name' => $student->class_name,
|
||||
'department_name' => $student->department_name,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ParentDevice extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id', 'platform', 'fcm_token', 'device_name', 'last_active_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'last_active_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ParentStudent extends Model
|
||||
{
|
||||
protected $table = 'parent_students';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'student_id', 'relation', 'status', 'verified_at', 'verified_by', 'rejection_reason',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'verified_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function student()
|
||||
{
|
||||
return $this->belongsTo(Student::class, 'student_id');
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ class Student extends Model
|
||||
protected $fillable = [
|
||||
'source_id', 'nisn', 'student_number', 'full_name',
|
||||
'gender', 'place_of_birth', 'date_of_birth', 'address',
|
||||
'phone_number', 'parent_phone', 'email', 'class_name', 'class_level',
|
||||
'phone_number', 'parent_phone', 'parent_name', 'email', 'class_name', 'class_level',
|
||||
'department_code', 'department_name', 'academic_year_name',
|
||||
'status', 'photo_path', 'class_id', 'is_active',
|
||||
'metadata', 'synced_at',
|
||||
@@ -41,6 +41,11 @@ class Student extends Model
|
||||
return $this->belongsTo(Classes::class, 'class_id');
|
||||
}
|
||||
|
||||
public function parentLinks()
|
||||
{
|
||||
return $this->hasMany(\App\Models\ParentStudent::class, 'student_id');
|
||||
}
|
||||
|
||||
public function getTotalPointsAttribute(): int
|
||||
{
|
||||
return $this->violations()
|
||||
|
||||
+23
-2
@@ -5,12 +5,13 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
public const ROLES = ['admin', 'bk', 'wali_kelas', 'staff', 'other', 'kepala_sekolah', 'waka_kesiswaan', 'ketua_tim'];
|
||||
public const ROLES = ['admin', 'bk', 'wali_kelas', 'staff', 'other', 'kepala_sekolah', 'waka_kesiswaan', 'ketua_tim', 'parent'];
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'username', 'email', 'phone', 'password', 'role', 'roles', 'is_active', 'active_session_token',
|
||||
@@ -66,6 +67,7 @@ class User extends Authenticatable
|
||||
public function isKepalaSekolah(): bool { return $this->hasRole('kepala_sekolah'); }
|
||||
public function isWakaKesiswaan(): bool { return $this->hasRole('waka_kesiswaan'); }
|
||||
public function isKetuaTim(): bool { return $this->hasRole('ketua_tim'); }
|
||||
public function isParent(): bool { return $this->hasRole('parent'); }
|
||||
|
||||
/**
|
||||
* Role dengan visibilitas data GLOBAL (semua kelas/siswa).
|
||||
@@ -136,6 +138,25 @@ class User extends Authenticatable
|
||||
return $this->hasMany(Violation::class, 'recorded_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi akun wali murid → anak (pivot parent_students).
|
||||
*/
|
||||
public function parentStudents()
|
||||
{
|
||||
return $this->hasMany(\App\Models\ParentStudent::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Anak-anak (siswa) milik akun wali, hanya yang statusnya aktif.
|
||||
*/
|
||||
public function children()
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\Student::class, 'parent_students', 'user_id', 'student_id')
|
||||
->withPivot(['relation', 'status', 'verified_at'])
|
||||
->withTimestamps()
|
||||
->wherePivot('status', 'active');
|
||||
}
|
||||
|
||||
public function appNotifications()
|
||||
{
|
||||
return $this->hasMany(AppNotification::class, 'user_id');
|
||||
|
||||
@@ -45,6 +45,13 @@ class StudentSyncService
|
||||
continue;
|
||||
}
|
||||
|
||||
// Data wali/orang tua: pilih kontak utama (guardian > father > mother)
|
||||
$parents = collect($item['parents'] ?? []);
|
||||
$primaryParent = $parents->first(fn ($p) => ($p['relation'] ?? '') === 'guardian')
|
||||
?? $parents->first(fn ($p) => ($p['relation'] ?? '') === 'father')
|
||||
?? $parents->first(fn ($p) => ($p['relation'] ?? '') === 'mother')
|
||||
?? $parents->first();
|
||||
|
||||
// Use NISN as primary key, fallback to student_number if NISN is null
|
||||
$nisn = $item['nisn'] ?? null;
|
||||
if (!$nisn) {
|
||||
@@ -89,6 +96,8 @@ class StudentSyncService
|
||||
'date_of_birth' => $item['date_of_birth'] ?: null,
|
||||
'address' => $item['address'],
|
||||
'phone_number' => $item['phone_number'],
|
||||
'parent_name' => $primaryParent ? trim((string) $primaryParent['name']) : null,
|
||||
'parent_phone' => $primaryParent ? ($primaryParent['phone_number'] ?: null) : null,
|
||||
'email' => $item['email'],
|
||||
'class_name' => $className,
|
||||
'class_level' => $classData['level'] ?? $item['class_level'] ?? 'X',
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Helper normalisasi nomor HP Indonesia.
|
||||
*/
|
||||
class PhoneHelper
|
||||
{
|
||||
/**
|
||||
* Normalisasi: buang semua non-digit, konversi 62 → 0.
|
||||
* Contoh: "+62 812-3456-7890" → "081234567890"
|
||||
*/
|
||||
public static function normalize(?string $phone): string
|
||||
{
|
||||
if ($phone === null) {
|
||||
return '';
|
||||
}
|
||||
$digits = preg_replace('/\D+/', '', (string) $phone);
|
||||
if (str_starts_with($digits, '62')) {
|
||||
$digits = '0' . substr($digits, 2);
|
||||
}
|
||||
return $digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apakah dua nomor HP merujuk ke nomor yang sama (toleran terhadap 0/62 & format)?
|
||||
*/
|
||||
public static function matches(?string $a, ?string $b): bool
|
||||
{
|
||||
$na = self::normalize($a);
|
||||
$nb = self::normalize($b);
|
||||
|
||||
if ($na === '' || $nb === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $na === $nb;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user