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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user