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

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,46 @@
<?php
namespace Database\Factories;
use App\Models\Member;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Member>
*/
class MemberFactory extends Factory
{
protected $model = Member::class;
public function definition(): array
{
$jabatan = $this->faker->randomElement([
'Khidmat Pengurusan', 'Kewangan', 'Perancangan', 'Kejuruteraan',
'Komuniti & Ekonomi', 'Teknologi Maklumat', 'Undang-undang', 'Audit Dalam',
'Landskap', 'Penguatkuasaan',
]);
$bahagian = $this->faker->randomElement([
'Unit A', 'Unit B', 'Unit C', 'Pentadbiran', 'Operasi', 'Sokongan',
]);
// No KP Malaysia 12 digit: YYMMDD-PB-###G
$yy = str_pad((string) $this->faker->numberBetween(60, 99), 2, '0', STR_PAD_LEFT);
$mm = str_pad((string) $this->faker->numberBetween(1, 12), 2, '0', STR_PAD_LEFT);
$dd = str_pad((string) $this->faker->numberBetween(1, 28), 2, '0', STR_PAD_LEFT);
$pb = str_pad((string) $this->faker->numberBetween(1, 14), 2, '0', STR_PAD_LEFT);
$serial = str_pad((string) $this->faker->numberBetween(0, 9999), 4, '0', STR_PAD_LEFT);
$noKp = "{$yy}{$mm}{$dd}-{$pb}-{$serial}";
return [
'no_anggota' => 'A' . str_pad((string) $this->faker->unique()->numberBetween(1, 99999), 5, '0', STR_PAD_LEFT),
'no_pekerja' => 'MBIP' . str_pad((string) $this->faker->unique()->numberBetween(1, 99999), 5, '0', STR_PAD_LEFT),
'no_kp' => $noKp,
'nama' => $this->faker->name(),
'jabatan' => $jabatan,
'bahagian' => $bahagian,
'telefon' => '01' . $this->faker->numberBetween(0, 9) . '-' . $this->faker->numerify('#######'),
'status_aktif' => $this->faker->boolean(95),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('members', function (Blueprint $table) {
$table->id();
$table->string('no_anggota')->nullable()->index();
$table->string('no_pekerja')->nullable()->unique();
$table->string('no_kp')->nullable()->unique();
$table->string('nama')->index();
$table->string('jabatan')->nullable();
$table->string('bahagian')->nullable();
$table->string('telefon')->nullable();
$table->boolean('status_aktif')->default(true);
$table->timestamps();
$table->index(['jabatan', 'bahagian']);
});
}
public function down(): void
{
Schema::dropIfExists('members');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('attendance_records', function (Blueprint $table) {
$table->id();
// Satu rekod kehadiran sahaja bagi setiap anggota (elak double attendance)
$table->foreignId('member_id')->unique()->constrained('members')->cascadeOnDelete();
$table->timestamp('attended_at')->useCurrent();
$table->foreignId('recorded_by')->nullable()->constrained('users')->nullOnDelete();
$table->string('ip_address', 45)->nullable();
$table->string('user_agent')->nullable();
$table->timestamps();
$table->index('attended_at');
});
}
public function down(): void
{
Schema::dropIfExists('attendance_records');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('prizes', function (Blueprint $table) {
$table->id();
$table->string('kod_hadiah')->nullable()->index();
$table->string('nama_hadiah');
$table->string('kategori')->nullable();
$table->decimal('nilai_anggaran', 12, 2)->nullable();
$table->unsignedInteger('draw_order')->default(0)->index();
$table->unsignedInteger('item_index')->default(1); // #1, #2 ... bagi kuantiti > 1
$table->string('batch_kod')->nullable()->index(); // kumpulkan item dari satu baris import
// belum_dicabut, sedang_dicabut, disahkan, redraw_required
$table->string('status')->default('belum_dicabut')->index();
$table->unsignedInteger('redraw_count')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('prizes');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('draw_sessions', function (Blueprint $table) {
$table->id();
$table->string('nama');
$table->boolean('is_active')->default(true);
// Setting: masukkan semula peserta yang dibatalkan ke pool? Default: ya
$table->boolean('return_cancelled_to_pool')->default(true);
$table->foreignId('started_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('draw_sessions');
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('draw_results', function (Blueprint $table) {
$table->id();
$table->foreignId('draw_session_id')->constrained('draw_sessions')->cascadeOnDelete();
$table->foreignId('prize_id')->constrained('prizes')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('members')->cascadeOnDelete();
// pending_confirmation, confirmed, cancelled_absent, redrawn
$table->string('status')->default('pending_confirmation')->index();
$table->unsignedInteger('attempt_number')->default(1);
$table->timestamp('spun_at')->useCurrent();
$table->timestamp('confirmed_at')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->foreignId('confirmed_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('cancelled_by')->nullable()->constrained('users')->nullOnDelete();
$table->string('sebab_batal')->nullable();
// Diset oleh aplikasi (dalam transaction confirm) hanya bila status = confirmed,
// selainnya NULL. Unique index kuatkuasakan di peringkat DB:
// - seorang anggota hanya boleh ada SATU pengesahan (tak boleh menang lagi)
// - sesuatu hadiah hanya boleh ada SATU pemenang disahkan
$table->unsignedBigInteger('confirmed_member_id')->nullable();
$table->unsignedBigInteger('confirmed_prize_id')->nullable();
$table->timestamps();
$table->index(['prize_id', 'status']);
$table->index(['member_id', 'status']);
$table->unique('confirmed_member_id', 'uq_confirmed_member');
$table->unique('confirmed_prize_id', 'uq_confirmed_prize');
});
}
public function down(): void
{
Schema::dropIfExists('draw_results');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('import_logs', function (Blueprint $table) {
$table->id();
$table->string('type'); // members | prizes
$table->string('filename');
$table->unsignedInteger('total_rows')->default(0);
$table->unsignedInteger('success_count')->default(0);
$table->unsignedInteger('duplicate_count')->default(0);
$table->unsignedInteger('failed_count')->default(0);
$table->json('errors')->nullable();
$table->foreignId('imported_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('import_logs');
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('action')->index();
$table->string('description')->nullable();
$table->nullableMorphs('subject');
$table->string('ip_address', 45)->nullable();
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};

View File

@@ -0,0 +1,137 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id
$table->string('name');
$table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name');
$table->string('guard_name');
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
Schema::dropIfExists($tableNames['role_has_permissions']);
Schema::dropIfExists($tableNames['model_has_roles']);
Schema::dropIfExists($tableNames['model_has_permissions']);
Schema::dropIfExists($tableNames['roles']);
Schema::dropIfExists($tableNames['permissions']);
}
};

View File

@@ -0,0 +1,65 @@
<?php
namespace Database\Seeders;
use App\Models\DrawSession;
use App\Models\Member;
use App\Models\Prize;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call(RoleUserSeeder::class);
// Sesi cabutan aktif untuk event
DrawSession::firstOrCreate(
['is_active' => true],
['nama' => 'Mesyuarat Agung Tahunan KOIPB ' . date('Y')]
);
// 200 anggota dummy
if (Member::count() === 0) {
Member::factory()->count(200)->create();
}
$this->seedPrizes();
}
private function seedPrizes(): void
{
if (Prize::count() > 0) {
return;
}
// Definisi hadiah (kuantiti > 1 dipecah jadi item berasingan)
$definitions = [
['H01', 'Cabutan Utama - Umrah', 'Utama', 12000, 1, 1],
['H02', 'Motosikal', 'Utama', 6000, 2, 1],
['H03', 'Peti Sejuk 2 Pintu', 'Elektrik', 2500, 3, 1],
['H04', 'Mesin Basuh', 'Elektrik', 1800, 4, 1],
['H05', 'Smart TV 55"', 'Elektrik', 3200, 5, 1],
['H06', 'Telefon Pintar', 'Gajet', 2200, 6, 2],
['H07', 'Tablet', 'Gajet', 1500, 7, 2],
['H08', 'Basikal', 'Sukan', 900, 8, 2],
['H09', 'Hamper Premium', 'Hamper', 350, 9, 5],
['H10', 'Baucar Tunai RM200', 'Baucar', 200, 10, 5],
];
foreach ($definitions as [$kod, $nama, $kategori, $nilai, $drawOrder, $kuantiti]) {
for ($n = 1; $n <= $kuantiti; $n++) {
Prize::create([
'kod_hadiah' => $kod,
'nama_hadiah' => $kuantiti > 1 ? "{$nama} #{$n}" : $nama,
'kategori' => $kategori,
'nilai_anggaran' => $nilai,
'draw_order' => $drawOrder,
'item_index' => $n,
'batch_kod' => $kod,
'status' => Prize::STATUS_BELUM,
]);
}
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
class RoleUserSeeder extends Seeder
{
public function run(): void
{
$roles = [
User::ROLE_ADMIN,
User::ROLE_KAUNTER,
User::ROLE_CABUTAN,
];
foreach ($roles as $role) {
Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
}
$users = [
['Admin KOIPB', 'admin@koipb.test', User::ROLE_ADMIN],
['Petugas Kaunter', 'kaunter@koipb.test', User::ROLE_KAUNTER],
['Petugas Cabutan', 'cabutan@koipb.test', User::ROLE_CABUTAN],
];
foreach ($users as [$name, $email, $role]) {
$user = User::firstOrCreate(
['email' => $email],
['name' => $name, 'password' => Hash::make('password')]
);
$user->syncRoles([$role]);
}
}
}