This commit is contained in:
Saufi
2026-06-24 18:30:00 +08:00
commit c0c3d7c09c
122 changed files with 16321 additions and 0 deletions

View 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'));
}
}

View 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);
}
}

View 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);
}
}

View 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.');
}
}

View 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'));
}
}

View 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');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View 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'),
];
}
}

View 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'));
}
}

View 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(),
];
}
}

View 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(),
);
}
}