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

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

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

View 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
{
//
}
}

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

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

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

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