first
This commit is contained in:
26
app/Http/Controllers/Admin/AuditController.php
Normal file
26
app/Http/Controllers/Admin/AuditController.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuditController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$action = $request->query('action');
|
||||
|
||||
$logs = AuditLog::with('user')
|
||||
->when($action, fn ($q) => $q->where('action', $action))
|
||||
->latest()
|
||||
->paginate(40)
|
||||
->withQueryString();
|
||||
|
||||
$actions = AuditLog::query()->distinct()->orderBy('action')->pluck('action');
|
||||
|
||||
return view('admin.audit.index', compact('logs', 'actions', 'action'));
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/Admin/MemberController.php
Normal file
91
app/Http/Controllers/Admin/MemberController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\Member;
|
||||
use App\Services\MemberImportService;
|
||||
use App\Support\Spreadsheet;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$q = $request->query('q');
|
||||
$members = Member::query()
|
||||
->when($q, fn ($query) => $query->search($q))
|
||||
->withCount(['drawResults as confirmed_wins' => fn ($query) => $query->where('status', \App\Models\DrawResult::STATUS_CONFIRMED)])
|
||||
->with('attendance')
|
||||
->orderBy('nama')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
$importLogs = ImportLog::where('type', 'members')->with('importedBy')->latest()->take(5)->get();
|
||||
|
||||
return view('admin.members.index', compact('members', 'q', 'importLogs'));
|
||||
}
|
||||
|
||||
public function edit(Member $member): View
|
||||
{
|
||||
return view('admin.members.edit', compact('member'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Member $member): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'no_anggota' => ['nullable', 'string', 'max:255'],
|
||||
'no_pekerja' => ['nullable', 'string', 'max:255', "unique:members,no_pekerja,{$member->id}"],
|
||||
'no_kp' => ['nullable', 'string', 'max:255', "unique:members,no_kp,{$member->id}"],
|
||||
'nama' => ['required', 'string', 'max:255'],
|
||||
'jabatan' => ['nullable', 'string', 'max:255'],
|
||||
'bahagian' => ['nullable', 'string', 'max:255'],
|
||||
'telefon' => ['nullable', 'string', 'max:255'],
|
||||
'status_aktif' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$member->update($data);
|
||||
AuditLog::record('member.update', "Kemaskini anggota: {$member->nama}", $member);
|
||||
|
||||
return redirect()->route('admin.members.index')->with('success', 'Maklumat anggota dikemaskini.');
|
||||
}
|
||||
|
||||
public function destroy(Member $member): RedirectResponse
|
||||
{
|
||||
$nama = $member->nama;
|
||||
$member->delete();
|
||||
AuditLog::record('member.delete', "Padam anggota: {$nama}");
|
||||
|
||||
return redirect()->route('admin.members.index')->with('success', "Anggota '{$nama}' dipadam.");
|
||||
}
|
||||
|
||||
public function showImport(): View
|
||||
{
|
||||
$logs = ImportLog::where('type', 'members')->with('importedBy')->latest()->take(15)->get();
|
||||
|
||||
return view('admin.members.import', compact('logs'));
|
||||
}
|
||||
|
||||
public function import(Request $request, MemberImportService $service): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => ['required', 'file', 'mimes:csv,txt,xlsx,xls', 'max:10240'],
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$rows = Spreadsheet::read($file->getRealPath(), $file->getClientOriginalExtension());
|
||||
|
||||
$log = $service->import($rows, $file->getClientOriginalName(), $request->user()->id);
|
||||
AuditLog::record('member.import', "Import anggota: {$log->filename}", $log, [
|
||||
'success' => $log->success_count,
|
||||
'duplicate' => $log->duplicate_count,
|
||||
'failed' => $log->failed_count,
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.members.import')->with('import_result', $log->id);
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/Admin/PrizeController.php
Normal file
83
app/Http/Controllers/Admin/PrizeController.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\Prize;
|
||||
use App\Services\PrizeImportService;
|
||||
use App\Support\Spreadsheet;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PrizeController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$prizes = Prize::query()->ordered()->with('drawResults.member')->paginate(30);
|
||||
$importLogs = ImportLog::where('type', 'prizes')->with('importedBy')->latest()->take(5)->get();
|
||||
|
||||
return view('admin.prizes.index', compact('prizes', 'importLogs'));
|
||||
}
|
||||
|
||||
public function edit(Prize $prize): View
|
||||
{
|
||||
return view('admin.prizes.edit', compact('prize'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Prize $prize): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'kod_hadiah' => ['nullable', 'string', 'max:255'],
|
||||
'nama_hadiah' => ['required', 'string', 'max:255'],
|
||||
'kategori' => ['nullable', 'string', 'max:255'],
|
||||
'nilai_anggaran' => ['nullable', 'numeric', 'min:0'],
|
||||
'draw_order' => ['required', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
$prize->update($data);
|
||||
AuditLog::record('prize.update', "Kemaskini hadiah: {$prize->nama_hadiah}", $prize);
|
||||
|
||||
return redirect()->route('admin.prizes.index')->with('success', 'Maklumat hadiah dikemaskini.');
|
||||
}
|
||||
|
||||
public function destroy(Prize $prize): RedirectResponse
|
||||
{
|
||||
if ($prize->status === Prize::STATUS_DISAHKAN) {
|
||||
return back()->with('error', 'Hadiah yang sudah ada pemenang disahkan tidak boleh dipadam.');
|
||||
}
|
||||
|
||||
$nama = $prize->nama_hadiah;
|
||||
$prize->delete();
|
||||
AuditLog::record('prize.delete', "Padam hadiah: {$nama}");
|
||||
|
||||
return redirect()->route('admin.prizes.index')->with('success', "Hadiah '{$nama}' dipadam.");
|
||||
}
|
||||
|
||||
public function showImport(): View
|
||||
{
|
||||
$logs = ImportLog::where('type', 'prizes')->with('importedBy')->latest()->take(15)->get();
|
||||
|
||||
return view('admin.prizes.import', compact('logs'));
|
||||
}
|
||||
|
||||
public function import(Request $request, PrizeImportService $service): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => ['required', 'file', 'mimes:csv,txt,xlsx,xls', 'max:10240'],
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$rows = Spreadsheet::read($file->getRealPath(), $file->getClientOriginalExtension());
|
||||
|
||||
$log = $service->import($rows, $file->getClientOriginalName(), $request->user()->id);
|
||||
AuditLog::record('prize.import', "Import hadiah: {$log->filename}", $log, [
|
||||
'success' => $log->success_count,
|
||||
'failed' => $log->failed_count,
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.prizes.import')->with('import_result', $log->id);
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Admin/SettingController.php
Normal file
60
app/Http/Controllers/Admin/SettingController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AttendanceRecord;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\DrawResult;
|
||||
use App\Models\DrawSession;
|
||||
use App\Models\Prize;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SettingController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$session = DrawSession::active();
|
||||
|
||||
return view('admin.settings.index', compact('session'));
|
||||
}
|
||||
|
||||
public function updateSession(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'nama' => ['required', 'string', 'max:255'],
|
||||
'return_cancelled_to_pool' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$session = DrawSession::active();
|
||||
$session->update($data);
|
||||
AuditLog::record('setting.session', 'Kemaskini tetapan sesi cabutan', $session, $data);
|
||||
|
||||
return back()->with('success', 'Tetapan sesi dikemaskini.');
|
||||
}
|
||||
|
||||
/** Reset data kehadiran & cabutan untuk event baru */
|
||||
public function reset(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'confirm' => ['required', 'in:RESET'],
|
||||
'scope' => ['required', 'in:all,draw'],
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request) {
|
||||
DrawResult::query()->delete();
|
||||
Prize::query()->update(['status' => Prize::STATUS_BELUM, 'redraw_count' => 0]);
|
||||
|
||||
if ($request->scope === 'all') {
|
||||
AttendanceRecord::query()->delete();
|
||||
}
|
||||
});
|
||||
|
||||
AuditLog::record('setting.reset', 'Reset data event', null, ['scope' => $request->scope]);
|
||||
|
||||
return back()->with('success', 'Data event berjaya direset.');
|
||||
}
|
||||
}
|
||||
42
app/Http/Controllers/AttendanceController.php
Normal file
42
app/Http/Controllers/AttendanceController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Member;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AttendanceController extends Controller
|
||||
{
|
||||
/** Senarai kehadiran (hadir / belum hadir) dengan carian & penapis */
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$status = $request->query('status', 'hadir'); // hadir | belum | semua
|
||||
$jabatan = $request->query('jabatan');
|
||||
$q = $request->query('q');
|
||||
|
||||
$members = Member::query()
|
||||
->with('attendance.recordedBy')
|
||||
->when($status === 'hadir', fn ($query) => $query->hadir())
|
||||
->when($status === 'belum', fn ($query) => $query->belumHadir())
|
||||
->when($jabatan, fn ($query) => $query->where('jabatan', $jabatan))
|
||||
->when($q, fn ($query) => $query->search($q))
|
||||
->orderBy('nama')
|
||||
->paginate(30)
|
||||
->withQueryString();
|
||||
|
||||
$jabatanList = Member::query()
|
||||
->whereNotNull('jabatan')
|
||||
->distinct()
|
||||
->orderBy('jabatan')
|
||||
->pluck('jabatan');
|
||||
|
||||
$counts = [
|
||||
'hadir' => Member::hadir()->count(),
|
||||
'belum' => Member::belumHadir()->count(),
|
||||
'semua' => Member::count(),
|
||||
];
|
||||
|
||||
return view('attendance.index', compact('members', 'jabatanList', 'counts', 'status', 'jabatan', 'q'));
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Auth/LoginController.php
Normal file
47
app/Http/Controllers/Auth/LoginController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
return back()
|
||||
->withInput($request->only('email'))
|
||||
->withErrors(['email' => 'E-mel atau kata laluan tidak sah.']);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
AuditLog::record('auth.login', 'Log masuk berjaya');
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
AuditLog::record('auth.logout', 'Log keluar');
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
109
app/Http/Controllers/CounterController.php
Normal file
109
app/Http/Controllers/CounterController.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AttendanceRecord;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Member;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CounterController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('counter.index');
|
||||
}
|
||||
|
||||
/** Live search (AJAX) — laju untuk ~200 peserta */
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
$term = trim((string) $request->query('q', ''));
|
||||
|
||||
if (mb_strlen($term) < 2) {
|
||||
return response()->json(['data' => [], 'message' => 'Taip sekurang-kurangnya 2 aksara.']);
|
||||
}
|
||||
|
||||
$members = Member::query()
|
||||
->search($term)
|
||||
->with('attendance')
|
||||
->orderBy('nama')
|
||||
->limit(25)
|
||||
->get()
|
||||
->map(fn (Member $m) => $this->present($m));
|
||||
|
||||
return response()->json([
|
||||
'data' => $members,
|
||||
'message' => $members->isEmpty()
|
||||
? 'Rekod tidak dijumpai. Individu ini bukan anggota koperasi yang berdaftar.'
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Rekod kehadiran — elak double attendance */
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'member_id' => ['required', 'integer', 'exists:members,id'],
|
||||
]);
|
||||
|
||||
$member = Member::with('attendance')->findOrFail($data['member_id']);
|
||||
|
||||
if ($member->attendance) {
|
||||
return response()->json([
|
||||
'status' => 'already',
|
||||
'message' => 'Anggota ini sudah direkodkan hadir.',
|
||||
'member' => $this->present($member),
|
||||
], 200);
|
||||
}
|
||||
|
||||
try {
|
||||
$attendance = AttendanceRecord::create([
|
||||
'member_id' => $member->id,
|
||||
'attended_at' => now(),
|
||||
'recorded_by' => $request->user()->id,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => substr((string) $request->userAgent(), 0, 255),
|
||||
]);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Race: dua kaunter rekod serentak
|
||||
$member->load('attendance');
|
||||
|
||||
return response()->json([
|
||||
'status' => 'already',
|
||||
'message' => 'Anggota ini sudah direkodkan hadir.',
|
||||
'member' => $this->present($member),
|
||||
], 200);
|
||||
}
|
||||
|
||||
AuditLog::record('attendance.record', "Rekod hadir: {$member->nama}", $attendance, [
|
||||
'member_id' => $member->id,
|
||||
]);
|
||||
|
||||
$member->load('attendance');
|
||||
|
||||
return response()->json([
|
||||
'status' => 'ok',
|
||||
'message' => "Kehadiran {$member->nama} berjaya direkodkan.",
|
||||
'member' => $this->present($member),
|
||||
]);
|
||||
}
|
||||
|
||||
private function present(Member $m): array
|
||||
{
|
||||
return [
|
||||
'id' => $m->id,
|
||||
'nama' => $m->nama,
|
||||
'no_pekerja' => $m->no_pekerja,
|
||||
'no_anggota' => $m->no_anggota,
|
||||
'masked_kp' => $m->maskedKp(),
|
||||
'jabatan' => $m->jabatan,
|
||||
'bahagian' => $m->bahagian,
|
||||
'status_aktif' => (bool) $m->status_aktif,
|
||||
'hadir' => (bool) $m->attendance,
|
||||
'attended_at' => $m->attendance?->attended_at?->format('d/m/Y H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Http/Controllers/DashboardController.php
Normal file
37
app/Http/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AttendanceRecord;
|
||||
use App\Models\DrawResult;
|
||||
use App\Models\Member;
|
||||
use App\Models\Prize;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$totalMembers = Member::count();
|
||||
$hadir = AttendanceRecord::count();
|
||||
|
||||
$stats = [
|
||||
'total_members' => $totalMembers,
|
||||
'hadir' => $hadir,
|
||||
'belum_hadir' => max($totalMembers - $hadir, 0),
|
||||
'total_prizes' => Prize::count(),
|
||||
'prizes_disahkan' => Prize::where('status', Prize::STATUS_DISAHKAN)->count(),
|
||||
'prizes_belum' => Prize::available()->count(),
|
||||
'pemenang_disahkan' => DrawResult::where('status', DrawResult::STATUS_CONFIRMED)->count(),
|
||||
'cabutan_dibatalkan' => DrawResult::where('status', DrawResult::STATUS_CANCELLED)->count(),
|
||||
];
|
||||
|
||||
$recentWinners = DrawResult::with(['member', 'prize'])
|
||||
->where('status', DrawResult::STATUS_CONFIRMED)
|
||||
->latest('confirmed_at')
|
||||
->take(8)
|
||||
->get();
|
||||
|
||||
return view('dashboard', compact('stats', 'recentWinners'));
|
||||
}
|
||||
}
|
||||
173
app/Http/Controllers/DrawController.php
Normal file
173
app/Http/Controllers/DrawController.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AttendanceRecord;
|
||||
use App\Models\DrawResult;
|
||||
use App\Models\DrawSession;
|
||||
use App\Models\Member;
|
||||
use App\Models\Prize;
|
||||
use App\Services\DrawService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;use RuntimeException;
|
||||
|
||||
class DrawController extends Controller
|
||||
{
|
||||
public function __construct(private readonly DrawService $service)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$session = DrawSession::active();
|
||||
|
||||
// Setiap item hadiah diberi Nombor Cabutan 1..N ikut susunan (ascending draw_order).
|
||||
// Cabutan berjalan MENURUN: mula dari nombor terakhir, hadiah utama (#1) dicabut terakhir.
|
||||
$prizes = Prize::query()->ordered()->get()->values()->map(function (Prize $p, int $i) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'seq' => $i + 1,
|
||||
'nama_hadiah' => $p->nama_hadiah,
|
||||
'draw_order' => $p->draw_order,
|
||||
'kategori' => $p->kategori,
|
||||
'status' => $p->status,
|
||||
];
|
||||
});
|
||||
|
||||
$totalPrizes = $prizes->count();
|
||||
$attendance = AttendanceRecord::count();
|
||||
|
||||
// Nombor terakhir (nombor mula cabutan) = jumlah hadiah,
|
||||
// tetapi jika kehadiran < jumlah hadiah, hadkan kepada jumlah kehadiran.
|
||||
$startNumber = min($totalPrizes, $attendance);
|
||||
|
||||
$eligibleCount = $this->service->eligibleMembers($session)->count();
|
||||
|
||||
// Cabutan pending semasa (jika ada) untuk sambung semula
|
||||
$pending = DrawResult::with(['member', 'prize'])
|
||||
->where('status', DrawResult::STATUS_PENDING)
|
||||
->latest('spun_at')
|
||||
->first();
|
||||
|
||||
return view('draw.index', compact(
|
||||
'session', 'prizes', 'totalPrizes', 'attendance', 'startNumber', 'eligibleCount', 'pending'
|
||||
));
|
||||
}
|
||||
|
||||
/** Senarai pool semasa untuk render roda */
|
||||
public function pool(): JsonResponse
|
||||
{
|
||||
$session = DrawSession::active();
|
||||
|
||||
return response()->json([
|
||||
'pool' => $this->poolPayload($session),
|
||||
]);
|
||||
}
|
||||
|
||||
public function spin(Request $request, DrawService $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'prize_id' => ['required', 'integer', 'exists:prizes,id'],
|
||||
]);
|
||||
|
||||
$session = DrawSession::active();
|
||||
$prize = Prize::findOrFail($data['prize_id']);
|
||||
|
||||
try {
|
||||
$result = $service->spin($prize, $session, $request->user());
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
// Pool selepas spin (winner mesti ada dalamnya)
|
||||
$pool = $this->poolPayload($session);
|
||||
$winnerIndex = collect($pool)->search(fn ($p) => $p['id'] === $result->member_id);
|
||||
|
||||
return response()->json([
|
||||
'result_id' => $result->id,
|
||||
'winner_index' => $winnerIndex === false ? 0 : $winnerIndex,
|
||||
'pool' => $pool,
|
||||
'winner' => $this->memberPayload($result->member),
|
||||
'prize' => $this->prizePayload($result->prize),
|
||||
'attempt' => $result->attempt_number,
|
||||
]);
|
||||
}
|
||||
|
||||
public function confirm(Request $request, DrawService $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['result_id' => ['required', 'integer', 'exists:draw_results,id']]);
|
||||
$result = DrawResult::findOrFail($data['result_id']);
|
||||
|
||||
try {
|
||||
$result = $service->confirm($result, $request->user());
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'confirmed',
|
||||
'message' => 'TAHNIAH! Pemenang telah disahkan.',
|
||||
'winner' => $this->memberPayload($result->member),
|
||||
'prize' => $this->prizePayload($result->prize),
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(Request $request, DrawService $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'result_id' => ['required', 'integer', 'exists:draw_results,id'],
|
||||
'sebab' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
$result = DrawResult::findOrFail($data['result_id']);
|
||||
|
||||
try {
|
||||
$result = $service->cancel($result, $request->user(), $data['sebab'] ?? null);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'cancelled',
|
||||
'message' => 'Cabutan dibatalkan. Hadiah boleh dicabut semula.',
|
||||
'prize' => $this->prizePayload($result->prize),
|
||||
]);
|
||||
}
|
||||
|
||||
private function poolPayload(DrawSession $session): array
|
||||
{
|
||||
return $this->service->eligibleMembers($session)
|
||||
->map(fn (Member $m) => [
|
||||
'id' => $m->id,
|
||||
'label' => $m->no_pekerja ?: $m->no_anggota ?: $m->nama,
|
||||
'nama' => $m->nama,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function memberPayload(Member $m): array
|
||||
{
|
||||
return [
|
||||
'id' => $m->id,
|
||||
'nama' => $m->nama,
|
||||
'no_pekerja' => $m->no_pekerja,
|
||||
'no_anggota' => $m->no_anggota,
|
||||
'jabatan' => $m->jabatan,
|
||||
'bahagian' => $m->bahagian,
|
||||
'masked_kp' => $m->maskedKp(),
|
||||
];
|
||||
}
|
||||
|
||||
private function prizePayload(Prize $p): array
|
||||
{
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'nama_hadiah' => $p->nama_hadiah,
|
||||
'draw_order' => $p->draw_order,
|
||||
'kategori' => $p->kategori,
|
||||
'status' => $p->status,
|
||||
'status_label' => $p->statusLabel(),
|
||||
];
|
||||
}
|
||||
}
|
||||
119
app/Http/Controllers/ReportController.php
Normal file
119
app/Http/Controllers/ReportController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DrawResult;
|
||||
use App\Models\Member;
|
||||
use App\Models\Prize;
|
||||
use App\Support\Spreadsheet;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$winners = DrawResult::with(['member', 'prize', 'confirmedBy'])
|
||||
->where('status', DrawResult::STATUS_CONFIRMED)
|
||||
->get()
|
||||
->sortBy(fn ($r) => $r->prize->draw_order)
|
||||
->values();
|
||||
|
||||
$cancelled = DrawResult::with(['member', 'prize', 'cancelledBy'])
|
||||
->where('status', DrawResult::STATUS_CANCELLED)
|
||||
->latest('cancelled_at')
|
||||
->get();
|
||||
|
||||
$undrawn = Prize::available()->ordered()->get();
|
||||
|
||||
return view('reports.index', compact('winners', 'cancelled', 'undrawn'));
|
||||
}
|
||||
|
||||
public function export(Request $request, string $type): Response
|
||||
{
|
||||
$format = $request->query('format', 'xlsx'); // xlsx | pdf
|
||||
|
||||
return match ($type) {
|
||||
'attendance' => $this->exportAttendance($format),
|
||||
'winners' => $this->exportWinners($format),
|
||||
'undrawn' => $this->exportUndrawn($format),
|
||||
'cancelled' => $this->exportCancelled($format),
|
||||
default => abort(404),
|
||||
};
|
||||
}
|
||||
|
||||
private function exportAttendance(string $format): Response
|
||||
{
|
||||
$members = Member::hadir()->with('attendance')->orderBy('nama')->get();
|
||||
$headings = ['Bil', 'No Anggota', 'No Pekerja', 'Nama', 'Jabatan', 'Bahagian', 'Masa Hadir'];
|
||||
$rows = $members->values()->map(fn ($m, $i) => [
|
||||
$i + 1, $m->no_anggota, $m->no_pekerja, $m->nama, $m->jabatan, $m->bahagian,
|
||||
$m->attendance?->attended_at?->format('d/m/Y H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->respond('senarai-kehadiran', 'Senarai Kehadiran MAT KOIPB', $headings, $rows, $format);
|
||||
}
|
||||
|
||||
private function exportWinners(string $format): Response
|
||||
{
|
||||
$winners = DrawResult::with(['member', 'prize'])
|
||||
->where('status', DrawResult::STATUS_CONFIRMED)
|
||||
->get()->sortBy(fn ($r) => $r->prize->draw_order)->values();
|
||||
|
||||
$headings = ['Susunan', 'Hadiah', 'Nama Pemenang', 'No Pekerja', 'Jabatan', 'Masa Disahkan'];
|
||||
$rows = $winners->map(fn ($r) => [
|
||||
$r->prize->draw_order, $r->prize->nama_hadiah, $r->member->nama,
|
||||
$r->member->no_pekerja, $r->member->jabatan,
|
||||
$r->confirmed_at?->format('d/m/Y H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->respond('senarai-pemenang', 'Senarai Pemenang Cabutan Bertuah', $headings, $rows, $format);
|
||||
}
|
||||
|
||||
private function exportUndrawn(string $format): Response
|
||||
{
|
||||
$prizes = Prize::available()->ordered()->get();
|
||||
$headings = ['Susunan', 'Kod', 'Hadiah', 'Kategori', 'Status'];
|
||||
$rows = $prizes->map(fn ($p) => [
|
||||
$p->draw_order, $p->kod_hadiah, $p->nama_hadiah, $p->kategori, $p->statusLabel(),
|
||||
]);
|
||||
|
||||
return $this->respond('hadiah-belum-dicabut', 'Hadiah Belum Dicabut', $headings, $rows, $format);
|
||||
}
|
||||
|
||||
private function exportCancelled(string $format): Response
|
||||
{
|
||||
$cancelled = DrawResult::with(['member', 'prize'])
|
||||
->where('status', DrawResult::STATUS_CANCELLED)
|
||||
->latest('cancelled_at')->get();
|
||||
|
||||
$headings = ['Hadiah', 'Nama', 'No Pekerja', 'Sebab Batal', 'Cubaan', 'Masa Batal'];
|
||||
$rows = $cancelled->map(fn ($r) => [
|
||||
$r->prize->nama_hadiah, $r->member->nama, $r->member->no_pekerja,
|
||||
$r->sebab_batal, $r->attempt_number, $r->cancelled_at?->format('d/m/Y H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->respond('cabutan-dibatalkan', 'Cabutan Dibatalkan', $headings, $rows, $format);
|
||||
}
|
||||
|
||||
private function respond(string $slug, string $title, array $headings, $rows, string $format): Response
|
||||
{
|
||||
if ($format === 'pdf') {
|
||||
$pdf = Pdf::loadView('reports.pdf', [
|
||||
'title' => $title,
|
||||
'headings' => $headings,
|
||||
'rows' => $rows,
|
||||
])->setPaper('a4', 'landscape');
|
||||
|
||||
return $pdf->download($slug . '-' . date('Ymd-His') . '.pdf');
|
||||
}
|
||||
|
||||
return Spreadsheet::download(
|
||||
$slug . '-' . date('Ymd-His') . '.xlsx',
|
||||
$headings,
|
||||
$rows->map(fn ($r) => array_values((array) $r))->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
app/Models/AttendanceRecord.php
Normal file
34
app/Models/AttendanceRecord.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AttendanceRecord extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'attended_at',
|
||||
'recorded_by',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'attended_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
public function recordedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'recorded_by');
|
||||
}
|
||||
}
|
||||
52
app/Models/AuditLog.php
Normal file
52
app/Models/AuditLog.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
|
||||
class AuditLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'action',
|
||||
'description',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'ip_address',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'meta' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function subject(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/** Helper ringkas untuk rekod audit trail tindakan penting */
|
||||
public static function record(string $action, ?string $description = null, ?Model $subject = null, array $meta = []): self
|
||||
{
|
||||
return static::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => $action,
|
||||
'description' => $description,
|
||||
'subject_type' => $subject ? $subject::class : null,
|
||||
'subject_id' => $subject?->getKey(),
|
||||
'ip_address' => Request::ip(),
|
||||
'meta' => $meta ?: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
83
app/Models/DrawResult.php
Normal file
83
app/Models/DrawResult.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DrawResult extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending_confirmation';
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
public const STATUS_CANCELLED = 'cancelled_absent';
|
||||
public const STATUS_REDRAWN = 'redrawn';
|
||||
|
||||
public const STATUS_LABELS = [
|
||||
self::STATUS_PENDING => 'Menunggu Pengesahan',
|
||||
self::STATUS_CONFIRMED => 'Disahkan',
|
||||
self::STATUS_CANCELLED => 'Dibatalkan (Tiada Di Dewan)',
|
||||
self::STATUS_REDRAWN => 'Telah Dicabut Semula',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'draw_session_id',
|
||||
'prize_id',
|
||||
'member_id',
|
||||
'status',
|
||||
'attempt_number',
|
||||
'spun_at',
|
||||
'confirmed_at',
|
||||
'cancelled_at',
|
||||
'confirmed_by',
|
||||
'cancelled_by',
|
||||
'sebab_batal',
|
||||
'confirmed_member_id',
|
||||
'confirmed_prize_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'spun_at' => 'datetime',
|
||||
'confirmed_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
'attempt_number' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function drawSession(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DrawSession::class);
|
||||
}
|
||||
|
||||
public function prize(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Prize::class);
|
||||
}
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
public function confirmedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'confirmed_by');
|
||||
}
|
||||
|
||||
public function cancelledBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'cancelled_by');
|
||||
}
|
||||
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return self::STATUS_LABELS[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function scopeConfirmed(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_CONFIRMED);
|
||||
}
|
||||
}
|
||||
48
app/Models/DrawSession.php
Normal file
48
app/Models/DrawSession.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class DrawSession extends Model
|
||||
{
|
||||
protected $attributes = [
|
||||
'is_active' => true,
|
||||
'return_cancelled_to_pool' => true,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'nama',
|
||||
'is_active',
|
||||
'return_cancelled_to_pool',
|
||||
'started_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'return_cancelled_to_pool' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function results(): HasMany
|
||||
{
|
||||
return $this->hasMany(DrawResult::class);
|
||||
}
|
||||
|
||||
public function startedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'started_by');
|
||||
}
|
||||
|
||||
public static function active(): self
|
||||
{
|
||||
return static::firstOrCreate(
|
||||
['is_active' => true],
|
||||
['nama' => 'Mesyuarat Agung Tahunan KOIPB']
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/Models/ImportLog.php
Normal file
32
app/Models/ImportLog.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ImportLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'filename',
|
||||
'total_rows',
|
||||
'success_count',
|
||||
'duplicate_count',
|
||||
'failed_count',
|
||||
'errors',
|
||||
'imported_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'errors' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function importedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'imported_by');
|
||||
}
|
||||
}
|
||||
110
app/Models/Member.php
Normal file
110
app/Models/Member.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class Member extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'no_anggota',
|
||||
'no_pekerja',
|
||||
'no_kp',
|
||||
'nama',
|
||||
'jabatan',
|
||||
'bahagian',
|
||||
'telefon',
|
||||
'status_aktif',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status_aktif' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function attendance(): HasOne
|
||||
{
|
||||
return $this->hasOne(AttendanceRecord::class);
|
||||
}
|
||||
|
||||
public function drawResults(): HasMany
|
||||
{
|
||||
return $this->hasMany(DrawResult::class);
|
||||
}
|
||||
|
||||
public function hasAttended(): bool
|
||||
{
|
||||
return $this->attendance()->exists();
|
||||
}
|
||||
|
||||
/** Adakah anggota ini sudah menang & disahkan? */
|
||||
public function hasConfirmedWin(): bool
|
||||
{
|
||||
return $this->drawResults()->where('status', DrawResult::STATUS_CONFIRMED)->exists();
|
||||
}
|
||||
|
||||
/** No KP bertopeng untuk paparan biasa: 850101-01-**** */
|
||||
public function maskedKp(): ?string
|
||||
{
|
||||
if (! $this->no_kp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$kp = $this->no_kp;
|
||||
// Format standard 12 digit (boleh ada sempang)
|
||||
$digits = preg_replace('/\D/', '', $kp);
|
||||
if (strlen($digits) === 12) {
|
||||
return substr($digits, 0, 6) . '-' . substr($digits, 6, 2) . '-****';
|
||||
}
|
||||
|
||||
// Fallback: tunjukkan 4 aksara pertama, topeng selebihnya
|
||||
$visible = substr($kp, 0, max(strlen($kp) - 4, 0));
|
||||
return $visible . str_repeat('*', min(4, strlen($kp)));
|
||||
}
|
||||
|
||||
public function scopeAktif(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status_aktif', true);
|
||||
}
|
||||
|
||||
public function scopeHadir(Builder $query): Builder
|
||||
{
|
||||
return $query->whereHas('attendance');
|
||||
}
|
||||
|
||||
public function scopeBelumHadir(Builder $query): Builder
|
||||
{
|
||||
return $query->whereDoesntHave('attendance');
|
||||
}
|
||||
|
||||
public function scopeSearch(Builder $query, ?string $term): Builder
|
||||
{
|
||||
$term = trim((string) $term);
|
||||
if ($term === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$like = '%' . $term . '%';
|
||||
$digits = preg_replace('/\D/', '', $term);
|
||||
|
||||
return $query->where(function (Builder $q) use ($like, $term, $digits) {
|
||||
$q->where('nama', 'like', $like)
|
||||
->orWhere('no_pekerja', 'like', $like)
|
||||
->orWhere('no_anggota', 'like', $like)
|
||||
->orWhere('no_kp', 'like', $like);
|
||||
|
||||
if ($digits !== '') {
|
||||
$q->orWhere('no_kp', 'like', '%' . $digits . '%')
|
||||
->orWhere('no_pekerja', 'like', '%' . $digits . '%');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
91
app/Models/Prize.php
Normal file
91
app/Models/Prize.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Prize extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_BELUM = 'belum_dicabut';
|
||||
public const STATUS_SEDANG = 'sedang_dicabut';
|
||||
public const STATUS_DISAHKAN = 'disahkan';
|
||||
public const STATUS_REDRAW = 'redraw_required';
|
||||
|
||||
public const STATUS_LABELS = [
|
||||
self::STATUS_BELUM => 'Belum Dicabut',
|
||||
self::STATUS_SEDANG => 'Sedang Dicabut',
|
||||
self::STATUS_DISAHKAN => 'Disahkan',
|
||||
self::STATUS_REDRAW => 'Perlu Cabut Semula',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'status' => self::STATUS_BELUM,
|
||||
'item_index' => 1,
|
||||
'draw_order' => 0,
|
||||
'redraw_count' => 0,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'kod_hadiah',
|
||||
'nama_hadiah',
|
||||
'kategori',
|
||||
'nilai_anggaran',
|
||||
'draw_order',
|
||||
'item_index',
|
||||
'batch_kod',
|
||||
'status',
|
||||
'redraw_count',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'nilai_anggaran' => 'decimal:2',
|
||||
'draw_order' => 'integer',
|
||||
'item_index' => 'integer',
|
||||
'redraw_count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function drawResults(): HasMany
|
||||
{
|
||||
return $this->hasMany(DrawResult::class);
|
||||
}
|
||||
|
||||
public function confirmedResult(): HasMany
|
||||
{
|
||||
return $this->hasMany(DrawResult::class)->where('status', DrawResult::STATUS_CONFIRMED);
|
||||
}
|
||||
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return self::STATUS_LABELS[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
/** Nama paparan termasuk nombor item bila kuantiti > 1 cth "Hamper #3" */
|
||||
public function displayName(): string
|
||||
{
|
||||
return $this->nama_hadiah;
|
||||
}
|
||||
|
||||
/** Hadiah yang masih boleh dicabut (belum ada pemenang disahkan) */
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_BELUM, self::STATUS_REDRAW], true);
|
||||
}
|
||||
|
||||
public function scopeAvailable(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_BELUM, self::STATUS_REDRAW]);
|
||||
}
|
||||
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('draw_order')->orderBy('item_index')->orderBy('id');
|
||||
}
|
||||
}
|
||||
38
app/Models/User.php
Normal file
38
app/Models/User.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable, HasRoles;
|
||||
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_KAUNTER = 'petugas_kaunter';
|
||||
public const ROLE_CABUTAN = 'petugas_cabutan';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
180
app/Services/DrawService.php
Normal file
180
app/Services/DrawService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\DrawResult;
|
||||
use App\Models\DrawSession;
|
||||
use App\Models\Member;
|
||||
use App\Models\Prize;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class DrawService
|
||||
{
|
||||
/**
|
||||
* Senarai anggota yang LAYAK masuk cabutan untuk sesi ini:
|
||||
* - sudah hadir
|
||||
* - belum menang & disahkan
|
||||
* - (ikut setting) yang dibatalkan boleh kekal/dikeluarkan dari pool
|
||||
*
|
||||
* @return Collection<int, Member>
|
||||
*/
|
||||
public function eligibleMembers(DrawSession $session): Collection
|
||||
{
|
||||
$query = Member::query()
|
||||
->hadir()
|
||||
->whereDoesntHave('drawResults', function ($q) {
|
||||
$q->where('status', DrawResult::STATUS_CONFIRMED);
|
||||
});
|
||||
|
||||
if (! $session->return_cancelled_to_pool) {
|
||||
// Setting: keluarkan terus peserta yang pernah dibatalkan
|
||||
$query->whereDoesntHave('drawResults', function ($q) {
|
||||
$q->where('status', DrawResult::STATUS_CANCELLED);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->orderBy('id')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pusing roda: pilih seorang anggota layak secara rawak & cipta rekod
|
||||
* pending_confirmation untuk hadiah yang dipilih.
|
||||
*/
|
||||
public function spin(Prize $prize, DrawSession $session, User $user): DrawResult
|
||||
{
|
||||
if (! $prize->isAvailable()) {
|
||||
throw new RuntimeException('Hadiah ini sudah ada pemenang disahkan atau sedang diproses.');
|
||||
}
|
||||
|
||||
$pool = $this->eligibleMembers($session);
|
||||
if ($pool->isEmpty()) {
|
||||
throw new RuntimeException('Tiada peserta layak yang masih ada dalam pool cabutan.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($prize, $session, $user, $pool) {
|
||||
$prize = Prize::whereKey($prize->getKey())->lockForUpdate()->firstOrFail();
|
||||
|
||||
if (! $prize->isAvailable()) {
|
||||
throw new RuntimeException('Hadiah ini sudah ada pemenang disahkan.');
|
||||
}
|
||||
|
||||
// Tandakan sebarang pending sedia ada untuk hadiah ini sebagai redrawn
|
||||
DrawResult::where('prize_id', $prize->id)
|
||||
->where('status', DrawResult::STATUS_PENDING)
|
||||
->update([
|
||||
'status' => DrawResult::STATUS_REDRAWN,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$member = $pool->random();
|
||||
$attempt = DrawResult::where('prize_id', $prize->id)->count() + 1;
|
||||
|
||||
$result = DrawResult::create([
|
||||
'draw_session_id' => $session->id,
|
||||
'prize_id' => $prize->id,
|
||||
'member_id' => $member->id,
|
||||
'status' => DrawResult::STATUS_PENDING,
|
||||
'attempt_number' => $attempt,
|
||||
'spun_at' => now(),
|
||||
]);
|
||||
|
||||
$prize->update(['status' => Prize::STATUS_SEDANG]);
|
||||
|
||||
AuditLog::record('draw.spin', "Spin hadiah '{$prize->nama_hadiah}' -> {$member->nama}", $result, [
|
||||
'prize_id' => $prize->id,
|
||||
'member_id' => $member->id,
|
||||
'attempt' => $attempt,
|
||||
]);
|
||||
|
||||
return $result->load('member', 'prize');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sahkan pemenang. Dilindungi unique index DB supaya tiada
|
||||
* double-confirm (race condition) walaupun dua operator tekan serentak.
|
||||
*/
|
||||
public function confirm(DrawResult $result, User $user): DrawResult
|
||||
{
|
||||
return DB::transaction(function () use ($result, $user) {
|
||||
$result = DrawResult::whereKey($result->getKey())->lockForUpdate()->firstOrFail();
|
||||
|
||||
if ($result->status === DrawResult::STATUS_CONFIRMED) {
|
||||
return $result; // idempotent
|
||||
}
|
||||
|
||||
if ($result->status !== DrawResult::STATUS_PENDING) {
|
||||
throw new RuntimeException('Cabutan ini tidak lagi menunggu pengesahan.');
|
||||
}
|
||||
|
||||
$member = Member::findOrFail($result->member_id);
|
||||
if ($member->hasConfirmedWin()) {
|
||||
throw new RuntimeException("{$member->nama} sudah pun memenangi hadiah lain dan disahkan.");
|
||||
}
|
||||
|
||||
try {
|
||||
$result->update([
|
||||
'status' => DrawResult::STATUS_CONFIRMED,
|
||||
'confirmed_at' => now(),
|
||||
'confirmed_by' => $user->id,
|
||||
'confirmed_member_id' => $result->member_id,
|
||||
'confirmed_prize_id' => $result->prize_id,
|
||||
]);
|
||||
} catch (UniqueConstraintViolationException|QueryException $e) {
|
||||
// Unique index DB menghalang double winner / member menang dua kali
|
||||
throw new RuntimeException('Pengesahan gagal: pemenang atau hadiah ini sudah disahkan oleh operator lain.');
|
||||
}
|
||||
|
||||
Prize::whereKey($result->prize_id)->update(['status' => Prize::STATUS_DISAHKAN]);
|
||||
|
||||
AuditLog::record('draw.confirm', "Sahkan pemenang '{$member->nama}' untuk hadiah #{$result->prize_id}", $result, [
|
||||
'prize_id' => $result->prize_id,
|
||||
'member_id' => $result->member_id,
|
||||
]);
|
||||
|
||||
return $result->fresh(['member', 'prize']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batalkan pemenang (tiada di dewan). Hadiah jadi 'redraw_required'
|
||||
* dan boleh dicabut semula.
|
||||
*/
|
||||
public function cancel(DrawResult $result, User $user, ?string $reason = null): DrawResult
|
||||
{
|
||||
return DB::transaction(function () use ($result, $user, $reason) {
|
||||
$result = DrawResult::whereKey($result->getKey())->lockForUpdate()->firstOrFail();
|
||||
|
||||
if ($result->status !== DrawResult::STATUS_PENDING) {
|
||||
throw new RuntimeException('Hanya cabutan yang menunggu pengesahan boleh dibatalkan.');
|
||||
}
|
||||
|
||||
$result->update([
|
||||
'status' => DrawResult::STATUS_CANCELLED,
|
||||
'cancelled_at' => now(),
|
||||
'cancelled_by' => $user->id,
|
||||
'sebab_batal' => $reason ?: 'Pemenang tiada di dewan',
|
||||
]);
|
||||
|
||||
$prize = Prize::findOrFail($result->prize_id);
|
||||
$prize->update([
|
||||
'status' => Prize::STATUS_REDRAW,
|
||||
'redraw_count' => $prize->redraw_count + 1,
|
||||
]);
|
||||
|
||||
AuditLog::record('draw.cancel', "Batal cabutan hadiah #{$result->prize_id} (member #{$result->member_id})", $result, [
|
||||
'prize_id' => $result->prize_id,
|
||||
'member_id' => $result->member_id,
|
||||
'sebab' => $result->sebab_batal,
|
||||
]);
|
||||
|
||||
return $result->fresh(['member', 'prize']);
|
||||
});
|
||||
}
|
||||
}
|
||||
117
app/Services/MemberImportService.php
Normal file
117
app/Services/MemberImportService.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\Member;
|
||||
|
||||
class MemberImportService
|
||||
{
|
||||
/**
|
||||
* Import baris anggota. Tidak crash bila ada duplicate/ralat —
|
||||
* pulangkan ringkasan + simpan ImportLog.
|
||||
*
|
||||
* @param array<int, array<string, string>> $rows
|
||||
*/
|
||||
public function import(array $rows, string $filename, ?int $userId = null): ImportLog
|
||||
{
|
||||
$success = 0;
|
||||
$duplicate = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
// Set pra-muat untuk kesan duplicate dalam fail itu sendiri
|
||||
$seenKp = [];
|
||||
$seenPekerja = [];
|
||||
|
||||
foreach ($rows as $i => $row) {
|
||||
$line = $i + 2; // baris fail (header = 1)
|
||||
|
||||
$nama = trim($row['nama'] ?? '');
|
||||
$noKp = $this->clean($row['no_kp'] ?? '');
|
||||
$noPekerja = $this->clean($row['no_pekerja'] ?? '');
|
||||
$noAnggota = $this->clean($row['no_anggota'] ?? '');
|
||||
|
||||
if ($nama === '') {
|
||||
$failed++;
|
||||
$errors[] = "Baris {$line}: nama wajib diisi.";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Duplicate dalam fail
|
||||
if ($noKp && isset($seenKp[$noKp])) {
|
||||
$duplicate++;
|
||||
$errors[] = "Baris {$line}: no_kp '{$noKp}' duplicate dalam fail.";
|
||||
continue;
|
||||
}
|
||||
if ($noPekerja && isset($seenPekerja[$noPekerja])) {
|
||||
$duplicate++;
|
||||
$errors[] = "Baris {$line}: no_pekerja '{$noPekerja}' duplicate dalam fail.";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Duplicate dalam DB
|
||||
$exists = Member::query()
|
||||
->when($noKp, fn ($q) => $q->orWhere('no_kp', $noKp))
|
||||
->when($noPekerja, fn ($q) => $q->orWhere('no_pekerja', $noPekerja))
|
||||
->when($noKp || $noPekerja, fn ($q) => $q, fn ($q) => $q->whereRaw('1 = 0'))
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$duplicate++;
|
||||
$errors[] = "Baris {$line}: anggota '{$nama}' sudah wujud (no_kp/no_pekerja sepadan).";
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Member::create([
|
||||
'no_anggota' => $noAnggota ?: null,
|
||||
'no_pekerja' => $noPekerja ?: null,
|
||||
'no_kp' => $noKp ?: null,
|
||||
'nama' => $nama,
|
||||
'jabatan' => $this->clean($row['jabatan'] ?? '') ?: null,
|
||||
'bahagian' => $this->clean($row['bahagian'] ?? '') ?: null,
|
||||
'telefon' => $this->clean($row['telefon'] ?? '') ?: null,
|
||||
'status_aktif' => $this->parseAktif($row['status_aktif'] ?? ''),
|
||||
]);
|
||||
|
||||
if ($noKp) {
|
||||
$seenKp[$noKp] = true;
|
||||
}
|
||||
if ($noPekerja) {
|
||||
$seenPekerja[$noPekerja] = true;
|
||||
}
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$errors[] = "Baris {$line}: {$e->getMessage()}";
|
||||
}
|
||||
}
|
||||
|
||||
return ImportLog::create([
|
||||
'type' => 'members',
|
||||
'filename' => $filename,
|
||||
'total_rows' => count($rows),
|
||||
'success_count' => $success,
|
||||
'duplicate_count' => $duplicate,
|
||||
'failed_count' => $failed,
|
||||
'errors' => $errors ?: null,
|
||||
'imported_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function clean(string $value): string
|
||||
{
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
private function parseAktif(string $value): bool
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
if ($value === '') {
|
||||
return true; // default aktif
|
||||
}
|
||||
|
||||
return in_array($value, ['1', 'aktif', 'active', 'ya', 'yes', 'true', 'y'], true);
|
||||
}
|
||||
}
|
||||
92
app/Services/PrizeImportService.php
Normal file
92
app/Services/PrizeImportService.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\Prize;
|
||||
|
||||
class PrizeImportService
|
||||
{
|
||||
/**
|
||||
* Import baris hadiah. Jika kuantiti > 1, generate beberapa item berasingan
|
||||
* (cth Hamper #1 .. Hamper #5).
|
||||
*
|
||||
* @param array<int, array<string, string>> $rows
|
||||
*/
|
||||
public function import(array $rows, string $filename, ?int $userId = null): ImportLog
|
||||
{
|
||||
$success = 0;
|
||||
$duplicate = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($rows as $i => $row) {
|
||||
$line = $i + 2;
|
||||
|
||||
$nama = trim($row['nama_hadiah'] ?? '');
|
||||
if ($nama === '') {
|
||||
$failed++;
|
||||
$errors[] = "Baris {$line}: nama_hadiah wajib diisi.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$kuantiti = max(1, (int) ($row['kuantiti'] ?? 1));
|
||||
$drawOrder = (int) ($row['susunan_cabutan'] ?? ($row['draw_order'] ?? 0));
|
||||
$kodHadiah = trim($row['kod_hadiah'] ?? '');
|
||||
$batch = $kodHadiah !== '' ? $kodHadiah : 'B' . str_pad((string) ($i + 1), 4, '0', STR_PAD_LEFT);
|
||||
|
||||
try {
|
||||
foreach ($this->expand($nama, $kuantiti) as $index => $itemName) {
|
||||
Prize::create([
|
||||
'kod_hadiah' => $kodHadiah ?: null,
|
||||
'nama_hadiah' => $itemName,
|
||||
'kategori' => trim($row['kategori'] ?? '') ?: null,
|
||||
'nilai_anggaran' => $this->parseDecimal($row['nilai_anggaran'] ?? ''),
|
||||
'draw_order' => $drawOrder,
|
||||
'item_index' => $index,
|
||||
'batch_kod' => $batch,
|
||||
'status' => Prize::STATUS_BELUM,
|
||||
]);
|
||||
$success++;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$errors[] = "Baris {$line}: {$e->getMessage()}";
|
||||
}
|
||||
}
|
||||
|
||||
return ImportLog::create([
|
||||
'type' => 'prizes',
|
||||
'filename' => $filename,
|
||||
'total_rows' => count($rows),
|
||||
'success_count' => $success,
|
||||
'duplicate_count' => $duplicate,
|
||||
'failed_count' => $failed,
|
||||
'errors' => $errors ?: null,
|
||||
'imported_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string> diindeks dari 1
|
||||
*/
|
||||
public function expand(string $nama, int $kuantiti): array
|
||||
{
|
||||
if ($kuantiti <= 1) {
|
||||
return [1 => $nama];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
for ($n = 1; $n <= $kuantiti; $n++) {
|
||||
$items[$n] = "{$nama} #{$n}";
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function parseDecimal(string $value): ?float
|
||||
{
|
||||
$value = trim(str_replace([',', 'RM', 'rm'], '', $value));
|
||||
return $value === '' ? null : (float) $value;
|
||||
}
|
||||
}
|
||||
93
app/Support/Spreadsheet.php
Normal file
93
app/Support/Spreadsheet.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use OpenSpout\Common\Entity\Row as SpoutRow;
|
||||
use OpenSpout\Reader\CSV\Reader as CsvReader;
|
||||
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
|
||||
use OpenSpout\Writer\CSV\Writer as CsvWriter;
|
||||
use OpenSpout\Writer\XLSX\Writer as XlsxWriter;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class Spreadsheet
|
||||
{
|
||||
/**
|
||||
* Baca fail CSV/XLSX menjadi senarai baris bersekutu (keyed by header).
|
||||
* Header dinormalkan: huruf kecil, ruang/sempang -> underscore.
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
public static function read(string $path, ?string $extension = null): array
|
||||
{
|
||||
$extension = strtolower($extension ?? pathinfo($path, PATHINFO_EXTENSION));
|
||||
$reader = $extension === 'csv' ? new CsvReader() : new XlsxReader();
|
||||
|
||||
$reader->open($path);
|
||||
|
||||
$headers = [];
|
||||
$rows = [];
|
||||
|
||||
foreach ($reader->getSheetIterator() as $sheet) {
|
||||
foreach ($sheet->getRowIterator() as $index => $row) {
|
||||
$cells = array_map(
|
||||
static fn ($v) => is_string($v) ? trim($v) : (is_null($v) ? '' : (string) $v),
|
||||
$row->toArray()
|
||||
);
|
||||
|
||||
if ($index === 1) {
|
||||
$headers = array_map(self::normalizeHeader(...), $cells);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Langkau baris kosong sepenuhnya
|
||||
if (count(array_filter($cells, static fn ($v) => $v !== '')) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assoc = [];
|
||||
foreach ($headers as $i => $key) {
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$assoc[$key] = $cells[$i] ?? '';
|
||||
}
|
||||
$rows[] = $assoc;
|
||||
}
|
||||
break; // hanya sheet pertama
|
||||
}
|
||||
|
||||
$reader->close();
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private static function normalizeHeader(string $header): string
|
||||
{
|
||||
$header = strtolower(trim($header));
|
||||
$header = preg_replace('/[\s\-]+/', '_', $header);
|
||||
return preg_replace('/[^a-z0-9_]/', '', $header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hantar muat turun XLSX (atau CSV) terus ke pelayar.
|
||||
*
|
||||
* @param array<int, string> $headings
|
||||
* @param iterable<int, array<int, scalar|null>> $rows
|
||||
*/
|
||||
public static function download(string $filename, array $headings, iterable $rows, string $format = 'xlsx'): StreamedResponse
|
||||
{
|
||||
return response()->streamDownload(function () use ($headings, $rows, $format) {
|
||||
$writer = $format === 'csv' ? new CsvWriter() : new XlsxWriter();
|
||||
$writer->openToFile('php://output');
|
||||
$writer->addRow(SpoutRow::fromValues($headings));
|
||||
foreach ($rows as $row) {
|
||||
$writer->addRow(SpoutRow::fromValues($row));
|
||||
}
|
||||
$writer->close();
|
||||
}, $filename, [
|
||||
'Content-Type' => $format === 'csv'
|
||||
? 'text/csv'
|
||||
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user