DONE: layout letter with letterhead and footer, notification for newly...

This commit is contained in:
ISMAIL MASSERAN
2026-06-28 02:16:49 +00:00
parent 94ecbe5887
commit 034ecf947f
400 changed files with 29802 additions and 5446 deletions
@@ -0,0 +1,5 @@
<?php
return [
'name' => 'MembershipApplication',
];
@@ -0,0 +1,33 @@
<?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('membership_applications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('application_number');
$table->foreignUuid('user_id')->constrained('users')->onDelete('cascade')->nullable();
$table->string('status');
$table->string('board_result');
$table->string('submitted_at');
$table->string('completed_at');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('membership_applications');
}
};
@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('membership_application_applicants', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('membership_application_id')->constrained('membership_applications')->onDelete('cascade');
$table->string('name');
$table->string('email');
$table->string('ic_number');
$table->string('birth_date');
$table->string('birth_place');
$table->string('marriage_status')->comment('Belum Berkahwin, Berkahwin, Bercerai, Balu, Duda');
// contact information
$table->text('address');
$table->string('phone_number');
$table->string('office_number')->nullable();
$table->string('postcode');
// Employment
$table->string('employer_name');
$table->text('employer_address');
$table->string('current_position');
$table->date('start_work_date');
// contribution
$table->decimal('stock_monthly_contribution', 10, 2);
$table->decimal('fee_monthly_contribution', 10, 2);
$table->timestamps();
});
DB::statement('
ALTER TABLE membership_application_applicants
ADD COLUMN gender user_gender_enum NULL
');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('membership_application_applicants');
}
};
@@ -0,0 +1,32 @@
<?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('membership_application_heirs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('membership_application_id')->constrained('membership_applications')->onDelete('cascade');
$table->string('name');
$table->string('ic_number');
$table->string('relationship')->comment('Isteri, Anak, Orang Tua, Saudara, Lain-lain');
$table->string('phone_number');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('membership_application_heirs');
}
};
@@ -0,0 +1,42 @@
<?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('membership_application_references', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('membership_application_id')
->constrained('membership_applications')
->cascadeOnDelete();
$table->string('reference_type')->comment('PROPOSER, SUPPORTER');
// Applicant may select at submit; admin may fill in later
$table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete();
// Set only when admin assigns or updates this reference
$table->foreignUuid('assigned_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('assigned_at')->nullable();
$table->timestamps();
$table->unique(['membership_application_id', 'reference_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('membership_application_references');
}
};
@@ -0,0 +1,40 @@
<?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('membership_application_reviews', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('membership_application_id')
->constrained('membership_applications')
->cascadeOnDelete();
$table->string('stage')->comment('MANAGEMENT, BOARD');
$table->string('decision')->comment('MANAGEMENT: APPROVED, REJECTED | BOARD: PASS, FAIL');
$table->text('remarks')->nullable();
$table->foreignUuid('reviewer_id')->constrained('users')->restrictOnDelete();
$table->timestamp('reviewed_at');
$table->timestamps();
$table->unique(['membership_application_id', 'stage']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('membership_application_reviews');
}
};
@@ -0,0 +1,16 @@
<?php
namespace Modules\MembershipApplication\Database\Seeders;
use Illuminate\Database\Seeder;
class MembershipApplicationDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// $this->call([]);
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\MembershipApplication\Emails;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationFailedNotification extends Notification
{
use Queueable;
public function __construct(
protected MembershipApplication $application
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$this->application->loadMissing('applicant');
return (new MailMessage)
->subject('Keputusan Permohonan Keahlian')
->markdown('membershipapplication::emails.failed', [
'name' => $this->application->applicant->name,
'applicationNumber' => $this->application->application_number,
'logoPath' => public_path('images/logo-kopkb.svg'),
]);
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\MembershipApplication\Emails;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationPassedNotification extends Notification
{
use Queueable;
public function __construct(
protected MembershipApplication $application
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$this->application->loadMissing('applicant');
return (new MailMessage)
->subject('Permohonan Keahlian Diluluskan')
->markdown('membershipapplication::emails.passed', [
'name' => $this->application->applicant->name,
'applicationNumber' => $this->application->application_number,
'logoPath' => public_path('images/logo-kopkb.svg'),
]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace Modules\MembershipApplication\Emails;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationSubmittedNotification extends Notification
{
use Queueable;
public function __construct(
protected MembershipApplication $application
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$this->application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']);
return (new MailMessage)
->subject('Pengesahan Permohonan Keahlian')
->markdown('membershipapplication::emails.submitted', [
'application' => $this->application,
'applicant' => $this->application->applicant,
'heirs' => $this->application->heirs,
'documents' => $this->application->documents,
'documentLabels' => [
'ic_copy' => 'Salinan Kad Pengenalan',
'photo' => 'Gambar Passport',
'salary_slip' => 'Slip Gaji',
'employer_letter' => 'Surat Pengesahan Majikan',
],
'logoPath' => public_path('images/logo-kopkb.svg'),
]);
}
}
@@ -0,0 +1,68 @@
<?php
namespace Modules\MembershipApplication\Entities;
use App\Traits\HasDocuments;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Modules\Auth\Entities\User;
use Modules\MembershipApplication\Enums\ApplicationStatus;
class MembershipApplication extends Model
{
use HasDocuments, HasUuids;
protected $table = 'membership_applications';
/**
* @var list<string>
*/
protected $fillable = [
'application_number',
'user_id',
'status',
'board_result',
'submitted_at',
'completed_at',
];
/**
* @var list<string>
*/
protected $hidden = [
];
protected function casts(): array
{
return [
'status' => ApplicationStatus::class,
];
}
public function applicant(): HasOne
{
return $this->hasOne(MembershipApplicationApplicant::class);
}
public function heirs(): HasMany
{
return $this->hasMany(MembershipApplicationHeir::class);
}
public function references(): HasMany
{
return $this->hasMany(MembershipApplicationReference::class);
}
public function reviews(): HasMany
{
return $this->hasMany(MembershipApplicationReview::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,58 @@
<?php
namespace Modules\MembershipApplication\Entities;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MembershipApplicationApplicant extends Model
{
use HasUuids;
protected $table = 'membership_application_applicants';
/**
* @var list<string>
*/
protected $fillable = [
'membership_application_id',
'name',
'email',
'ic_number',
'birth_date',
'birth_place',
'gender',
'marriage_status',
'address',
'phone_number',
'office_number',
'postcode',
'employer_name',
'employer_address',
'current_position',
'start_work_date',
'stock_monthly_contribution',
'fee_monthly_contribution',
];
/**
* @var list<string>
*/
protected $hidden = [
];
protected function casts(): array
{
return [
'start_work_date' => 'date',
'stock_monthly_contribution' => 'decimal:2',
'fee_monthly_contribution' => 'decimal:2',
];
}
public function membershipApplication(): BelongsTo
{
return $this->belongsTo(MembershipApplication::class);
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\MembershipApplication\Entities;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MembershipApplicationHeir extends Model
{
use HasUuids;
protected $table = 'membership_application_heirs';
/**
* @var list<string>
*/
protected $fillable = [
'membership_application_id',
'name',
'ic_number',
'relationship',
'phone_number',
];
/**
* @var list<string>
*/
protected $hidden = [
];
protected function casts(): array
{
return [
'name' => 'string',
'ic_number' => 'string',
'relationship' => 'string',
'phone_number' => 'string',
];
}
public function membershipApplication(): BelongsTo
{
return $this->belongsTo(MembershipApplication::class);
}
}
@@ -0,0 +1,56 @@
<?php
namespace Modules\MembershipApplication\Entities;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Auth\Entities\User;
use Modules\MembershipApplication\Enums\ReferenceType;
class MembershipApplicationReference extends Model
{
use HasUuids;
protected $table = 'membership_application_references';
/**
* @var list<string>
*/
protected $fillable = [
'membership_application_id',
'reference_type',
'user_id',
'assigned_by',
'assigned_at',
];
/**
* @var list<string>
*/
protected $hidden = [
];
protected function casts(): array
{
return [
'reference_type' => ReferenceType::class,
'assigned_at' => 'datetime',
];
}
public function membershipApplication(): BelongsTo
{
return $this->belongsTo(MembershipApplication::class);
}
public function member(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function assignedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_by');
}
}
@@ -0,0 +1,72 @@
<?php
namespace Modules\MembershipApplication\Entities;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Auth\Entities\User;
use Modules\MembershipApplication\Enums\BoardDecision;
use Modules\MembershipApplication\Enums\ManagementDecision;
use Modules\MembershipApplication\Enums\ReviewStage;
class MembershipApplicationReview extends Model
{
use HasUuids;
protected $table = 'membership_application_reviews';
/**
* @var list<string>
*/
protected $fillable = [
'membership_application_id',
'stage',
'decision',
'remarks',
'reviewer_id',
'reviewed_at',
];
/**
* @var list<string>
*/
protected $hidden = [
];
protected function casts(): array
{
return [
'stage' => ReviewStage::class,
'reviewed_at' => 'datetime',
];
}
public function membershipApplication(): BelongsTo
{
return $this->belongsTo(MembershipApplication::class);
}
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewer_id');
}
public function managementDecision(): ?ManagementDecision
{
if ($this->stage !== ReviewStage::Management) {
return null;
}
return ManagementDecision::tryFrom($this->decision);
}
public function boardDecision(): ?BoardDecision
{
if ($this->stage !== ReviewStage::Board) {
return null;
}
return BoardDecision::tryFrom($this->decision);
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\MembershipApplication\Enums;
enum ApplicationStatus: string
{
case Submitted = 'SUBMITTED';
case PendingBoard = 'PENDING_BOARD';
case ManagementRejected = 'MANAGEMENT_REJECTED';
case PendingNotification = 'PENDING_NOTIFICATION';
case Completed = 'COMPLETED';
}
@@ -0,0 +1,9 @@
<?php
namespace Modules\MembershipApplication\Enums;
enum BoardDecision: string
{
case Pass = 'PASS';
case Fail = 'FAIL';
}
@@ -0,0 +1,9 @@
<?php
namespace Modules\MembershipApplication\Enums;
enum BoardResult: string
{
case Pass = 'PASS';
case Fail = 'FAIL';
}
@@ -0,0 +1,9 @@
<?php
namespace Modules\MembershipApplication\Enums;
enum ManagementDecision: string
{
case Approved = 'APPROVED';
case Rejected = 'REJECTED';
}
@@ -0,0 +1,9 @@
<?php
namespace Modules\MembershipApplication\Enums;
enum ReferenceType: string
{
case Proposer = 'PROPOSER';
case Supporter = 'SUPPORTER';
}
@@ -0,0 +1,9 @@
<?php
namespace Modules\MembershipApplication\Enums;
enum ReviewStage: string
{
case Management = 'MANAGEMENT';
case Board = 'BOARD';
}
@@ -0,0 +1,267 @@
<?php
namespace Modules\MembershipApplication\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Services\DocumentService;
use App\Services\LetterService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Modules\MembershipApplication\Entities\MembershipApplication;
use Modules\MembershipApplication\Enums\ApplicationStatus;
use Modules\MembershipApplication\Enums\BoardDecision;
use Modules\MembershipApplication\Enums\BoardResult;
use Modules\MembershipApplication\Enums\ManagementDecision;
use Modules\MembershipApplication\Http\Requests\AssignReferencesRequest;
use Modules\MembershipApplication\Http\Requests\BatchCompleteRequest;
use Modules\MembershipApplication\Http\Requests\BoardReviewRequest;
use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest;
use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest;
use Modules\MembershipApplication\Http\Requests\UploadMembershipApplicationDocumentRequest;
use Modules\MembershipApplication\Services\MembershipApplicationService;
use Modules\MembershipApplication\Transformers\MembershipApplicationListResource;
use Modules\MembershipApplication\Transformers\MembershipApplicationResource;
use Symfony\Component\HttpFoundation\StreamedResponse;
class MembershipApplicationController extends Controller
{
use AuthorizesRequests;
public function __construct(
protected MembershipApplicationService $membershipApplicationService,
protected DocumentService $documentService,
protected LetterService $letterService,
) {}
public function index(Request $request): JsonResponse
{
$this->authorize('viewAny', MembershipApplication::class);
$perPage = min((int) $request->get('per_page', 10), 100);
$search = (string) ($request->get('search', '') ?? '');
$status = $request->get('status');
$sortBy = (string) ($request->get('sort_by', 'submitted_at') ?? 'submitted_at');
$sortOrder = (string) ($request->get('sort_order', 'desc') ?? 'desc');
$applications = $this->membershipApplicationService->getPaginatedList(
$perPage,
$search,
$status,
$sortBy,
$sortOrder
);
return response()->json([
'success' => true,
'data' => MembershipApplicationListResource::collection($applications->items()),
'pagination' => [
'current_page' => $applications->currentPage(),
'per_page' => $applications->perPage(),
'total' => $applications->total(),
'last_page' => $applications->lastPage(),
'from' => $applications->firstItem(),
'to' => $applications->lastItem(),
'has_more_pages' => $applications->hasMorePages(),
],
]);
}
public function show(MembershipApplication $membershipApplication): JsonResponse
{
$this->authorize('view', $membershipApplication);
$application = $this->membershipApplicationService->getByIdWithRelations($membershipApplication->id);
if (! $application) {
return response()->json([
'success' => false,
'message' => 'Permohonan tidak dijumpai.',
], 404);
}
return response()->json([
'success' => true,
'data' => new MembershipApplicationResource($application),
]);
}
public function update(UpdateMembershipApplicationRequest $request, MembershipApplication $membershipApplication): JsonResponse
{
$this->authorize('update', $membershipApplication);
$application = $this->membershipApplicationService->update(
$membershipApplication,
$request->validated(),
);
return response()->json([
'success' => true,
'message' => 'Permohonan keahlian berjaya dikemaskini.',
'data' => new MembershipApplicationResource($application),
]);
}
public function uploadDocument(UploadMembershipApplicationDocumentRequest $request, MembershipApplication $membershipApplication): JsonResponse
{
$this->authorize('update', $membershipApplication);
$application = $this->membershipApplicationService->uploadDocument(
$membershipApplication,
$request->validated('type'),
$request->file('file'),
);
return response()->json([
'success' => true,
'message' => 'Dokumen berjaya dimuat naik.',
'data' => new MembershipApplicationResource($application),
]);
}
public function managementReview(ManagementReviewRequest $request, MembershipApplication $membershipApplication): JsonResponse {
$this->authorize('managementReview', $membershipApplication);
$application = $this->membershipApplicationService->managementReview(
$membershipApplication,
ManagementDecision::from($request->validated('decision')),
$request->validated('remarks'),
$request->user()
);
$message = $application->status === ApplicationStatus::PendingBoard
? 'Permohonan diluluskan dan dihantar ke lembaga.'
: 'Permohonan ditolak.';
return response()->json([
'success' => true,
'message' => $message,
'data' => new MembershipApplicationResource($application),
]);
}
public function boardReview(BoardReviewRequest $request, MembershipApplication $membershipApplication): JsonResponse {
$this->authorize('boardReview', $membershipApplication);
$application = $this->membershipApplicationService->boardReview(
$membershipApplication,
BoardDecision::from($request->validated('decision')),
$request->validated('remarks'),
$request->user()
);
$message = $application->board_result === BoardResult::Pass->value
? 'Permohonan lulus semakan lembaga.'
: 'Permohonan gagal semakan lembaga.';
return response()->json([
'success' => true,
'message' => $message,
'data' => new MembershipApplicationResource($application),
]);
}
public function assignReferences(AssignReferencesRequest $request, MembershipApplication $membershipApplication): JsonResponse {
$this->authorize('assignReferences', $membershipApplication);
$application = $this->membershipApplicationService->assignReferences(
$membershipApplication,
$request->validated(),
$request->user()
);
return response()->json([
'success' => true,
'message' => 'Pencadang dan penyokong berjaya dikemaskini.',
'data' => new MembershipApplicationResource($application),
]);
}
public function complete(Request $request, MembershipApplication $membershipApplication): JsonResponse
{
$this->authorize('complete', $membershipApplication);
$application = $this->membershipApplicationService->complete(
$membershipApplication,
$request->user()
);
$message = $application->board_result === BoardResult::Pass->value
? 'Permohonan selesai. Akaun ahli telah dicipta dan e-mel dihantar.'
: 'Permohonan selesai. E-mel keputusan dihantar.';
return response()->json([
'success' => true,
'message' => $message,
'data' => new MembershipApplicationResource($application),
]);
}
public function batchComplete(BatchCompleteRequest $request): JsonResponse
{
$this->authorize('completeBatch', MembershipApplication::class);
$result = $this->membershipApplicationService->completeBatch(
$request->validated('application_ids'),
$request->user()
);
$succeededCount = count($result['succeeded']);
$failedCount = count($result['failed']);
if ($succeededCount === 0) {
return response()->json([
'success' => false,
'message' => 'Tiada permohonan berjaya diselesaikan.',
'data' => [
'succeeded' => [],
'failed' => $result['failed'],
],
], 422);
}
$message = $failedCount > 0
? "{$succeededCount} permohonan berjaya, {$failedCount} gagal."
: "{$succeededCount} permohonan berjaya diselesaikan.";
return response()->json([
'success' => true,
'message' => $message,
'data' => [
'succeeded' => MembershipApplicationResource::collection($result['succeeded']),
'failed' => $result['failed'],
],
]);
}
public function downloadDocument(MembershipApplication $membershipApplication, string $documentId): StreamedResponse {
$this->authorize('view', $membershipApplication);
$document = $membershipApplication->documents()->findOrFail($documentId);
return $this->documentService->downloadDocument($document->id);
}
public function approvedLetter(Request $request, MembershipApplication $membershipApplication): Response
{
$this->authorize('view', $membershipApplication);
$application = $this->membershipApplicationService->getByIdWithRelations($membershipApplication->id);
if (! $application) {
abort(404, 'Permohonan tidak dijumpai.');
}
$view = 'membershipapplication::pdf.approved-letter';
$data = ['application' => $application];
$format = $request->query('format', 'html');
$filename = 'surat-kelulusan-'.$application->application_number.'.pdf';
if ($format === 'pdf') {
return $this->letterService->pdfResponse($view, $data, $filename);
}
return $this->letterService->htmlResponse($view, $data);
}
}
@@ -0,0 +1,60 @@
<?php
namespace Modules\MembershipApplication\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Modules\MembershipApplication\Http\Requests\LookupMemberRequest;
use Modules\MembershipApplication\Http\Requests\StoreMembershipApplicationRequest;
use Modules\MembershipApplication\Services\MembershipApplicationService;
use Modules\MembershipApplication\Transformers\MembershipApplicationResource;
class PublicMembershipApplicationController extends Controller
{
public function __construct(
protected MembershipApplicationService $membershipApplicationService
) {}
public function store(StoreMembershipApplicationRequest $request): JsonResponse
{
$documents = collect($request->file('documents', []))
->filter()
->all();
$application = $this->membershipApplicationService->submit(
$request->safe()->except(['documents']),
$documents
);
return response()->json([
'success' => true,
'message' => 'Permohonan keahlian berjaya dihantar.',
'data' => new MembershipApplicationResource($application),
], 201);
}
public function lookupMember(LookupMemberRequest $request): JsonResponse
{
$member = $this->membershipApplicationService->lookupMemberByIcNumber(
$request->validated('ic_number')
);
if (! $member) {
return response()->json([
'success' => false,
'message' => 'Ahli dengan nombor kad pengenalan ini tidak dijumpai.',
'data' => null,
]);
}
return response()->json([
'success' => true,
'message' => 'Ahli dijumpai.',
'data' => [
'id' => $member->id,
'name' => $member->name,
'ic_number' => $member->ic_number,
],
]);
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class AssignReferencesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'proposer_id' => 'nullable|uuid|exists:users,id',
'supporter_id' => 'nullable|uuid|exists:users,id',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'proposer_id.exists' => 'Pencadang tidak dijumpai.',
'supporter_id.exists' => 'Penyokong tidak dijumpai.',
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class BatchCompleteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'application_ids' => 'required|array|min:1|max:100',
'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'application_ids.required' => 'Senarai permohonan diperlukan.',
'application_ids.min' => 'Sekurang-kurangnya satu permohonan diperlukan.',
'application_ids.max' => 'Maksimum 100 permohonan setiap batch.',
'application_ids.*.exists' => 'Salah satu permohonan tidak dijumpai.',
'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.',
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\MembershipApplication\Enums\BoardDecision;
class BoardReviewRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'decision' => ['required', Rule::enum(BoardDecision::class)],
'remarks' => 'nullable|string|max:1000',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'decision.required' => 'Keputusan diperlukan.',
'decision.enum' => 'Keputusan tidak sah. Pilih PASS atau FAIL.',
'remarks.max' => 'Catatan tidak boleh melebihi 1000 aksara.',
];
}
}
@@ -0,0 +1,34 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class LookupMemberRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'ic_number' => 'required|string|max:20',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'ic_number.required' => 'Nombor kad pengenalan diperlukan.',
'ic_number.max' => 'Nombor kad pengenalan tidak sah.',
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\MembershipApplication\Enums\ManagementDecision;
class ManagementReviewRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'decision' => ['required', Rule::enum(ManagementDecision::class)],
'remarks' => 'nullable|string|max:1000',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'decision.required' => 'Keputusan diperlukan.',
'decision.enum' => 'Keputusan tidak sah. Pilih APPROVED atau REJECTED.',
'remarks.max' => 'Catatan tidak boleh melebihi 1000 aksara.',
];
}
}
@@ -0,0 +1,94 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreMembershipApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'applicant.name' => 'required|string|max:255',
'applicant.email' => 'required|email|max:255',
'applicant.ic_number' => 'required|string|max:20',
'applicant.birth_date' => 'required|date',
'applicant.birth_place' => 'required|string|max:255',
'applicant.gender' => ['required', Rule::in(['Lelaki', 'Perempuan'])],
'applicant.marriage_status' => 'required|string|max:255',
'applicant.address' => 'required|string',
'applicant.phone_number' => 'required|string|max:20',
'applicant.office_number' => 'nullable|string|max:20',
'applicant.postcode' => 'required|string|max:10',
'applicant.employer_name' => 'required|string|max:255',
'applicant.employer_address' => 'required|string',
'applicant.current_position' => 'required|string|max:255',
'applicant.start_work_date' => 'required|date',
'applicant.stock_monthly_contribution' => 'required|numeric|min:0',
'applicant.fee_monthly_contribution' => 'required|numeric|min:0',
'heirs' => 'required|array|min:1',
'heirs.*.name' => 'required|string|max:255',
'heirs.*.ic_number' => 'required|string|max:20',
'heirs.*.relationship' => 'required|string|max:255',
'heirs.*.phone_number' => 'required|string|max:20',
'references.proposer_ic_number' => 'nullable|string|max:20',
'references.supporter_ic_number' => 'nullable|string|max:20',
'documents.ic_copy' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
'documents.photo' => 'nullable|file|mimes:jpg,jpeg,png|max:10240',
'documents.salary_slip' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
'documents.employer_letter' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'applicant.name.required' => 'Nama penuh diperlukan.',
'applicant.email.required' => 'Emel diperlukan.',
'applicant.email.email' => 'Emel tidak sah.',
'applicant.ic_number.required' => 'Nombor kad pengenalan diperlukan.',
'applicant.birth_date.required' => 'Tarikh lahir diperlukan.',
'applicant.birth_place.required' => 'Tempat lahir diperlukan.',
'applicant.gender.required' => 'Jantina diperlukan.',
'applicant.gender.in' => 'Jantina tidak sah.',
'applicant.marriage_status.required' => 'Status perkahwinan diperlukan.',
'applicant.address.required' => 'Alamat diperlukan.',
'applicant.phone_number.required' => 'Nombor telefon diperlukan.',
'applicant.postcode.required' => 'Poskod diperlukan.',
'applicant.employer_name.required' => 'Nama majikan diperlukan.',
'applicant.employer_address.required' => 'Alamat majikan diperlukan.',
'applicant.current_position.required' => 'Jawatan semasa diperlukan.',
'applicant.start_work_date.required' => 'Tarikh mula berkhidmat diperlukan.',
'applicant.stock_monthly_contribution.required' => 'Caruman saham bulanan diperlukan.',
'applicant.fee_monthly_contribution.required' => 'Caruman yuran bulanan diperlukan.',
'heirs.required' => 'Maklumat waris diperlukan.',
'heirs.min' => 'Sekurang-kurangnya satu waris diperlukan.',
'heirs.*.name.required' => 'Nama waris diperlukan.',
'heirs.*.ic_number.required' => 'Nombor kad pengenalan waris diperlukan.',
'heirs.*.relationship.required' => 'Hubungan waris diperlukan.',
'heirs.*.phone_number.required' => 'Nombor telefon waris diperlukan.',
'references.proposer_ic_number.max' => 'Nombor kad pengenalan pencadang tidak sah.',
'references.supporter_ic_number.max' => 'Nombor kad pengenalan penyokong tidak sah.',
'documents.ic_copy.required' => 'Salinan kad pengenalan diperlukan.',
'documents.ic_copy.mimes' => 'Salinan kad pengenalan mestilah PDF, JPG, JPEG atau PNG.',
];
}
}
@@ -0,0 +1,95 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateMembershipApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function validationData(): array
{
return $this->except(array_keys($this->route()?->parameters() ?? []));
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'applicant' => 'sometimes|array',
'applicant.name' => 'sometimes|required|string|max:255',
'applicant.email' => 'sometimes|required|email|max:255',
'applicant.ic_number' => 'sometimes|required|string|max:20',
'applicant.birth_date' => 'sometimes|required|date',
'applicant.birth_place' => 'sometimes|required|string|max:255',
'applicant.gender' => ['sometimes', 'required', Rule::in(['Lelaki', 'Perempuan'])],
'applicant.marriage_status' => 'sometimes|required|string|max:255',
'applicant.address' => 'sometimes|required|string',
'applicant.phone_number' => 'sometimes|required|string|max:20',
'applicant.office_number' => 'nullable|string|max:20',
'applicant.postcode' => 'sometimes|required|string|max:10',
'applicant.employer_name' => 'sometimes|required|string|max:255',
'applicant.employer_address' => 'sometimes|required|string',
'applicant.current_position' => 'sometimes|required|string|max:255',
'applicant.start_work_date' => 'sometimes|required|date',
'applicant.stock_monthly_contribution' => 'sometimes|required|numeric|min:0',
'applicant.fee_monthly_contribution' => 'sometimes|required|numeric|min:0',
'heirs' => 'sometimes|array|min:1',
'heirs.*.name' => 'required|string|max:255',
'heirs.*.ic_number' => 'required|string|max:20',
'heirs.*.relationship' => 'required|string|max:255',
'heirs.*.phone_number' => 'required|string|max:20',
'references' => 'sometimes|array',
'references.proposer_ic_number' => 'nullable|string|max:20',
'references.supporter_ic_number' => 'nullable|string|max:20',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'applicant.name.required' => 'Nama penuh diperlukan.',
'applicant.email.required' => 'Emel diperlukan.',
'applicant.email.email' => 'Emel tidak sah.',
'applicant.ic_number.required' => 'Nombor kad pengenalan diperlukan.',
'applicant.birth_date.required' => 'Tarikh lahir diperlukan.',
'applicant.birth_place.required' => 'Tempat lahir diperlukan.',
'applicant.gender.required' => 'Jantina diperlukan.',
'applicant.gender.in' => 'Jantina tidak sah.',
'applicant.marriage_status.required' => 'Status perkahwinan diperlukan.',
'applicant.address.required' => 'Alamat diperlukan.',
'applicant.phone_number.required' => 'Nombor telefon diperlukan.',
'applicant.postcode.required' => 'Poskod diperlukan.',
'applicant.employer_name.required' => 'Nama majikan diperlukan.',
'applicant.employer_address.required' => 'Alamat majikan diperlukan.',
'applicant.current_position.required' => 'Jawatan semasa diperlukan.',
'applicant.start_work_date.required' => 'Tarikh mula berkhidmat diperlukan.',
'applicant.stock_monthly_contribution.required' => 'Caruman saham bulanan diperlukan.',
'applicant.fee_monthly_contribution.required' => 'Caruman yuran bulanan diperlukan.',
'heirs.min' => 'Sekurang-kurangnya satu waris diperlukan.',
'heirs.*.name.required' => 'Nama waris diperlukan.',
'heirs.*.ic_number.required' => 'Nombor kad pengenalan waris diperlukan.',
'heirs.*.relationship.required' => 'Hubungan waris diperlukan.',
'heirs.*.phone_number.required' => 'Nombor telefon waris diperlukan.',
'references.proposer_ic_number.max' => 'Nombor kad pengenalan pencadang tidak sah.',
'references.supporter_ic_number.max' => 'Nombor kad pengenalan penyokong tidak sah.',
];
}
}
@@ -0,0 +1,48 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UploadMembershipApplicationDocumentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'type' => ['required', Rule::in(['ic_copy', 'photo', 'salary_slip', 'employer_letter'])],
'file' => [
'required',
'file',
'max:10240',
Rule::when(
$this->input('type') === 'photo',
'mimes:jpg,jpeg,png',
'mimes:pdf,jpg,jpeg,png',
),
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'type.required' => 'Jenis dokumen diperlukan.',
'type.in' => 'Jenis dokumen tidak sah.',
'file.required' => 'Fail diperlukan.',
'file.mimes' => 'Format fail tidak disokong.',
'file.max' => 'Saiz fail melebihi had maksimum 10MB.',
];
}
}
@@ -0,0 +1,75 @@
<?php
namespace Modules\MembershipApplication\Policies;
use Illuminate\Auth\Access\HandlesAuthorization;
use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*/
public function viewAny($user): bool
{
return $user->hasPermissionTo('lihat permohonan keahlian');
}
/**
* Determine whether the user can view the model.
*/
public function view($user, ?MembershipApplication $membershipApplication = null): bool
{
return $user->hasPermissionTo('lihat permohonan keahlian');
}
/**
* Management officer: approve or reject an application.
*/
public function managementReview($user, ?MembershipApplication $membershipApplication = null): bool
{
return $user->hasPermissionTo('semak permohonan keahlian pentadbiran');
}
/**
* Board member: pass or fail an application.
*/
public function boardReview($user, ?MembershipApplication $membershipApplication = null): bool
{
return $user->hasPermissionTo('semak permohonan keahlian lembaga');
}
/**
* General manager: send result notification and create account if passed.
*/
public function complete($user, ?MembershipApplication $membershipApplication = null): bool
{
return $user->hasPermissionTo('selesaikan permohonan keahlian');
}
/**
* General manager: batch complete multiple applications.
*/
public function completeBatch($user): bool
{
return $user->hasPermissionTo('selesaikan permohonan keahlian');
}
/**
* Admin: assign or update proposer and supporter.
*/
public function assignReferences($user, ?MembershipApplication $membershipApplication = null): bool
{
return $user->hasPermissionTo('tetapkan pencadang penyokong');
}
/**
* Admin: edit application details before completion.
*/
public function update($user, ?MembershipApplication $membershipApplication = null): bool
{
return $user->hasPermissionTo('kemaskini permohonan keahlian');
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\MembershipApplication\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
/**
* The event handler mappings for the application.
*
* @var array<string, array<int, string>>
*/
protected $listen = [];
/**
* Indicates if events should be discovered.
*
* @var bool
*/
protected static $shouldDiscoverEvents = true;
/**
* Configure the proper event listeners for email verification.
*/
protected function configureEmailVerification(): void {}
}
@@ -0,0 +1,186 @@
<?php
namespace Modules\MembershipApplication\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Cache\RateLimiting\Limit;
use Modules\MembershipApplication\Entities\MembershipApplication;
use Modules\MembershipApplication\Policies\MembershipApplicationPolicy;
use Nwidart\Modules\Traits\PathNamespace;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class MembershipApplicationServiceProvider extends ServiceProvider
{
use PathNamespace;
protected string $name = 'MembershipApplication';
protected string $nameLower = 'membershipapplication';
/**
* Boot the application events.
*/
public function boot(): void
{
$this->registerRateLimiters();
$this->registerCommands();
$this->registerCommandSchedules();
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->loadMigrationsFrom(module_path($this->name, 'Database/Migrations'));
$this->registerPolicies();
}
/**
* Register policies.
*/
protected function registerPolicies(): void
{
Gate::policy(MembershipApplication::class, MembershipApplicationPolicy::class);
}
/**
* Register the service provider.
*/
public function register(): void
{
$this->app->register(EventServiceProvider::class);
$this->app->register(RouteServiceProvider::class);
// Register repository binding
$this->app->bind(
\Modules\MembershipApplication\Repositories\Contracts\MembershipApplicationRepositoryInterface::class,
\Modules\MembershipApplication\Repositories\MembershipApplicationRepository::class
);
}
protected function registerRateLimiters(): void
{
RateLimiter::for('membership-application-submit', function ($request) {
return Limit::perHour(10)->by($request->ip());
});
RateLimiter::for('membership-application-lookup', function ($request) {
return Limit::perMinute(30)->by($request->ip());
});
}
/**
* Register commands in the format of Command::class
*/
protected function registerCommands(): void
{
// $this->commands([]);
}
/**
* Register command Schedules.
*/
protected function registerCommandSchedules(): void
{
// $this->app->booted(function () {
// $schedule = $this->app->make(Schedule::class);
// $schedule->command('inspire')->hourly();
// });
}
/**
* Register translations.
*/
public function registerTranslations(): void
{
$langPath = resource_path('lang/modules/'.$this->nameLower);
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, $this->nameLower);
$this->loadJsonTranslationsFrom($langPath);
} else {
$this->loadTranslationsFrom(module_path($this->name, 'Lang'), $this->nameLower);
$this->loadJsonTranslationsFrom(module_path($this->name, 'Lang'));
}
}
/**
* Register config.
*/
protected function registerConfig(): void
{
$configPath = module_path($this->name, config('modules.paths.generator.config.path'));
if (is_dir($configPath)) {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath));
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$config = str_replace($configPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
$config_key = str_replace([DIRECTORY_SEPARATOR, '.php'], ['.', ''], $config);
$segments = explode('.', $this->nameLower.'.'.$config_key);
// Remove duplicated adjacent segments
$normalized = [];
foreach ($segments as $segment) {
if (end($normalized) !== $segment) {
$normalized[] = $segment;
}
}
$key = ($config === 'config.php') ? $this->nameLower : implode('.', $normalized);
$this->publishes([$file->getPathname() => config_path($config)], 'config');
$this->merge_config_from($file->getPathname(), $key);
}
}
}
}
/**
* Merge config from the given path recursively.
*/
protected function merge_config_from(string $path, string $key): void
{
$existing = config($key, []);
$module_config = require $path;
config([$key => array_replace_recursive($existing, $module_config)]);
}
/**
* Register views.
*/
public function registerViews(): void
{
$viewPath = resource_path('views/modules/'.$this->nameLower);
$sourcePath = module_path($this->name, 'Resources/Views');
$this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']);
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->nameLower);
Blade::componentNamespace(config('modules.namespace').'\\' . $this->name . '\\View\\Components', $this->nameLower);
}
/**
* Get the services provided by the provider.
*/
public function provides(): array
{
return [];
}
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (config('view.paths') as $path) {
if (is_dir($path.'/modules/'.$this->nameLower)) {
$paths[] = $path.'/modules/'.$this->nameLower;
}
}
return $paths;
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\MembershipApplication\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
protected string $name = 'MembershipApplication';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*/
public function boot(): void
{
parent::boot();
}
/**
* Define the routes for the application.
*/
public function map(): void
{
$this->mapApiRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*/
protected function mapWebRoutes(): void
{
Route::middleware('web')->group(module_path($this->name, '/Routes/web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*/
protected function mapApiRoutes(): void
{
Route::middleware('api')->name('api.')->group(module_path($this->name, '/Routes/api.php'));
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\MembershipApplication\Repositories\Contracts;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Modules\MembershipApplication\Entities\MembershipApplication;
interface MembershipApplicationRepositoryInterface
{
public function create(array $data): MembershipApplication;
public function findById(string $id): ?MembershipApplication;
public function findByIdWithRelations(string $id): ?MembershipApplication;
public function getAllPaginated(
int $perPage = 10,
string $search = '',
?string $status = null,
string $sortBy = 'submitted_at',
string $sortOrder = 'desc'
): LengthAwarePaginator;
}
@@ -0,0 +1,69 @@
<?php
namespace Modules\MembershipApplication\Repositories;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Modules\MembershipApplication\Entities\MembershipApplication;
use Modules\MembershipApplication\Repositories\Contracts\MembershipApplicationRepositoryInterface;
class MembershipApplicationRepository implements MembershipApplicationRepositoryInterface
{
public function create(array $data): MembershipApplication
{
return MembershipApplication::create($data);
}
public function findById(string $id): ?MembershipApplication
{
return MembershipApplication::find($id);
}
public function findByIdWithRelations(string $id): ?MembershipApplication
{
return MembershipApplication::with([
'applicant',
'heirs',
'references.member:id,name,ic_number',
'documents',
'reviews.reviewer:id,name',
])->find($id);
}
public function getAllPaginated(
int $perPage = 10,
string $search = '',
?string $status = null,
string $sortBy = 'submitted_at',
string $sortOrder = 'desc'
): LengthAwarePaginator {
$allowedSortColumns = [
'application_number',
'status',
'submitted_at',
'created_at',
];
$sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'submitted_at';
$sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
$query = MembershipApplication::query()
->with(['applicant:id,membership_application_id,name,email,ic_number'])
->orderBy($sortBy, $sortOrder);
if ($status !== null && $status !== '') {
$query->where('status', $status);
}
if ($search !== '') {
$query->where(function ($q) use ($search) {
$q->where('application_number', 'ILIKE', "%{$search}%")
->orWhereHas('applicant', function ($applicantQuery) use ($search) {
$applicantQuery->where('name', 'ILIKE', "%{$search}%")
->orWhere('email', 'ILIKE', "%{$search}%")
->orWhere('ic_number', 'ILIKE', "%{$search}%");
});
});
}
return $query->paginate($perPage);
}
}
@@ -0,0 +1,18 @@
@component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath))
<div style="text-align: center; margin-bottom: 24px;">
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
</div>
@endif
# Keputusan Permohonan Keahlian
Assalamualaikum **{{ $name }}**,
Permohonan keahlian anda (**{{ $applicationNumber }}**) **tidak diluluskan** pada peringkat lembaga.
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
Terima kasih,<br>
{{ config('app.name') }}
@endcomponent
@@ -0,0 +1,18 @@
@component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath))
<div style="text-align: center; margin-bottom: 24px;">
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
</div>
@endif
# Keputusan Permohonan Keahlian
Assalamualaikum **{{ $name }}**,
Permohonan keahlian anda (**{{ $applicationNumber }}**) **diluluskan** pada peringkat lembaga.
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
Terima kasih,<br>
{{ config('app.name') }}
@endcomponent
@@ -0,0 +1,79 @@
@component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath))
<div style="text-align: center; margin-bottom: 24px;">
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
</div>
@endif
# Pengesahan Permohonan Keahlian
Assalamualaikum **{{ $applicant->name }}**,
Terima kasih. Permohonan keahlian anda telah berjaya diterima.
**No. Permohonan:** {{ $application->application_number }}
Berikut adalah ringkasan maklumat yang anda hantar:
## Maklumat Peribadi
- **Nama:** {{ $applicant->name }}
- **Emel:** {{ $applicant->email }}
- **No. Kad Pengenalan:** {{ $applicant->ic_number }}
- **Tarikh Lahir:** {{ $applicant->birth_date }}
- **Tempat Lahir:** {{ $applicant->birth_place }}
- **Jantina:** {{ $applicant->gender }}
- **Status Perkahwinan:** {{ $applicant->marriage_status }}
## Hubungan & Alamat
- **Alamat:** {{ $applicant->address }}
- **Poskod:** {{ $applicant->postcode }}
- **No. Telefon:** {{ $applicant->phone_number }}
- **No. Pejabat:** {{ $applicant->office_number ?: '-' }}
## Maklumat Pekerjaan & Caruman
- **Nama Majikan:** {{ $applicant->employer_name }}
- **Alamat Majikan:** {{ $applicant->employer_address }}
- **Jawatan Semasa:** {{ $applicant->current_position }}
- **Tarikh Mula Berkhidmat:** {{ $applicant->start_work_date?->format('d/m/Y') ?? $applicant->start_work_date }}
- **Caruman Saham (RM):** {{ number_format((float) $applicant->stock_monthly_contribution, 2) }}
- **Caruman Yuran (RM):** {{ number_format((float) $applicant->fee_monthly_contribution, 2) }}
## Maklumat Waris
@forelse ($heirs as $index => $heir)
**Waris {{ $index + 1 }}**
- **Nama:** {{ $heir->name }}
- **No. Kad Pengenalan:** {{ $heir->ic_number }}
- **Hubungan:** {{ $heir->relationship }}
- **No. Telefon:** {{ $heir->phone_number }}
@empty
Tiada maklumat waris.
@endforelse
## Pencadang & Penyokong
@php
$proposer = $application->references->first(fn ($ref) => ($ref->reference_type?->value ?? $ref->reference_type) === 'PROPOSER');
$supporter = $application->references->first(fn ($ref) => ($ref->reference_type?->value ?? $ref->reference_type) === 'SUPPORTER');
@endphp
- **Pencadang:** {{ $proposer?->member?->name ? $proposer->member->name . ' (' . $proposer->member->ic_number . ')' : '-' }}
- **Penyokong:** {{ $supporter?->member?->name ? $supporter->member->name . ' (' . $supporter->member->ic_number . ')' : '-' }}
## Dokumen Dimuat Naik
@forelse ($documents as $document)
- **{{ $documentLabels[$document->type] ?? $document->type }}:** {{ $document->name }}
@empty
Tiada dokumen.
@endforelse
Anda akan menerima emel apabila keputusan permohonan tersedia.
Terima kasih,<br>
{{ config('app.name') }}
@endcomponent
@@ -0,0 +1,12 @@
{{--
Approval letter extends the shared base layout.
Fill in sections below when the final design is ready.
--}}
@extends('pdf.layouts.letter')
{{-- @section('letter-reference', $application->application_number) --}}
{{-- @section('letter-date', ...) --}}
{{-- @section('letter-recipient') ... @endsection --}}
{{-- @section('letter-subject') PERKARA: ... @endsection --}}
{{-- @section('letter-body') ... @endsection --}}
{{-- @section('letter-signature') ... @endsection --}}
@@ -0,0 +1,46 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\MembershipApplication\Http\Controllers\MembershipApplicationController;
use Modules\MembershipApplication\Http\Controllers\PublicMembershipApplicationController;
// public routes
Route::prefix('v1/public')->group(function () {
Route::middleware('throttle:membership-application-lookup')->get('membership-applications/member-lookup',[PublicMembershipApplicationController::class, 'lookupMember'])->name('membership-application.public.member-lookup');
Route::middleware('throttle:membership-application-submit')->post('membership-applications',[PublicMembershipApplicationController::class, 'store'])->name('membership-application.public.store');
});
// admin routes
Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () {
// Membership application management routes
Route::get('membership-applications', [MembershipApplicationController::class, 'index'])->name('membership-application.index');
// Membership application detail routes
Route::get('membership-applications/{membershipApplication}', [MembershipApplicationController::class, 'show'])->name('membership-application.show');
// Membership application update routes - admin
Route::patch('membership-applications/{membershipApplication}', [MembershipApplicationController::class, 'update'])->name('membership-application.update');
// Membership application management review routes (level 1)
Route::post('membership-applications/{membershipApplication}/management-review', [MembershipApplicationController::class, 'managementReview'])->name('membership-application.management-review');
// Membership application board review routes (level 2)
Route::post('membership-applications/{membershipApplication}/board-review', [MembershipApplicationController::class, 'boardReview'])->name('membership-application.board-review');
// Membership application assign references routes
Route::put('membership-applications/{membershipApplication}/references', [MembershipApplicationController::class, 'assignReferences'])->name('membership-application.assign-references');
// Membership application batch complete routes (level 3)
Route::post('membership-applications/batch-complete', [MembershipApplicationController::class, 'batchComplete'])->name('membership-application.batch-complete');
// Membership application complete routes (level 3)
Route::post('membership-applications/{membershipApplication}/complete', [MembershipApplicationController::class, 'complete'])->name('membership-application.complete');
// Membership application upload document routes
Route::post('membership-applications/{membershipApplication}/documents', [MembershipApplicationController::class, 'uploadDocument'])->name('membership-application.upload-document');
// download document routes
Route::get('membership-applications/{membershipApplication}/documents/{documentId}/download', [MembershipApplicationController::class, 'downloadDocument'])->name('membership-application.download-document');
// approved letter preview (html or pdf)
Route::get('membership-applications/{membershipApplication}/approved-letter', [MembershipApplicationController::class, 'approvedLetter'])->name('membership-application.approved-letter');
});
@@ -0,0 +1,8 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\MembershipApplication\Http\Controllers\MembershipApplicationController;
Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('membershipapplications', MembershipApplicationController::class)->names('membershipapplication');
});
@@ -0,0 +1,478 @@
<?php
namespace Modules\MembershipApplication\Services;
use App\Services\DocumentService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Modules\Auth\Entities\User;
use Modules\Auth\Services\EmailVerificationOtpService;
use Modules\MembershipApplication\Entities\MembershipApplication;
use Modules\MembershipApplication\Enums\ApplicationStatus;
use Modules\MembershipApplication\Enums\BoardDecision;
use Modules\MembershipApplication\Enums\BoardResult;
use Modules\MembershipApplication\Enums\ManagementDecision;
use Modules\MembershipApplication\Enums\ReferenceType;
use Modules\MembershipApplication\Enums\ReviewStage;
use Modules\MembershipApplication\Emails\MembershipApplicationFailedNotification;
use Modules\MembershipApplication\Emails\MembershipApplicationPassedNotification;
use Modules\MembershipApplication\Emails\MembershipApplicationSubmittedNotification;
use Modules\MembershipApplication\Repositories\Contracts\MembershipApplicationRepositoryInterface;
use Modules\Role\Entities\Role;
class MembershipApplicationService
{
public function __construct(
protected MembershipApplicationRepositoryInterface $repository,
protected DocumentService $documentService,
) {}
/**
* @param array{
* applicant: array<string, mixed>,
* heirs: list<array<string, mixed>>,
* references?: array{proposer_ic_number?: string|null, supporter_ic_number?: string|null}
* } $data
* @param array<string, UploadedFile> $documents
*/
public function submit(array $data, array $documents): MembershipApplication
{
$application = DB::transaction(function () use ($data, $documents) {
// membership_applications table
$application = $this->repository->create([
'application_number' => $this->generateApplicationNumber(),
'status' => ApplicationStatus::Submitted,
'board_result' => '',
'submitted_at' => now()->toDateTimeString(),
'completed_at' => '',
]);
// membership_application_applicants table
$application->applicant()->create($data['applicant']);
// membership_application_heirs table
foreach ($data['heirs'] as $heir) {
$application->heirs()->create($heir);
}
// membership_application_references table
$this->syncReferences(
$application,
$data['references'] ?? [],
(string) ($data['applicant']['ic_number'] ?? ''),
);
// documents table
foreach ($documents as $type => $file) {
$this->documentService->validateFile($file);
$this->documentService->uploadDocument($application, $file, $type);
}
return $this->repository->findByIdWithRelations($application->id);
});
$this->sendSubmissionConfirmation($application);
return $application;
}
public function getPaginatedList(int $perPage, string $search, ?string $status, string $sortBy, string $sortOrder): LengthAwarePaginator
{
return $this->repository->getAllPaginated($perPage, $search, $status, $sortBy, $sortOrder);
}
public function getByIdWithRelations(string $id): ?MembershipApplication
{
return $this->repository->findByIdWithRelations($id);
}
public function update(MembershipApplication $application, array $data): MembershipApplication
{
if ($application->status === ApplicationStatus::Completed) {
throw ValidationException::withMessages([
'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'],
]);
}
return DB::transaction(function () use ($application, $data) {
$application->loadMissing('applicant');
if (isset($data['applicant'])) {
$application->applicant->update($data['applicant']);
}
if (isset($data['heirs'])) {
$application->heirs()->delete();
foreach ($data['heirs'] as $heir) {
$application->heirs()->create($heir);
}
}
if (array_key_exists('references', $data)) {
$this->syncReferences(
$application,
$data['references'] ?? [],
(string) ($data['applicant']['ic_number'] ?? $application->applicant->ic_number),
);
}
return $this->repository->findByIdWithRelations($application->id);
});
}
public function uploadDocument(MembershipApplication $application, string $type, UploadedFile $file): MembershipApplication
{
if ($application->status === ApplicationStatus::Completed) {
throw ValidationException::withMessages([
'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'],
]);
}
$this->documentService->validateFile($file);
$application->documentsOfType($type)->get()->each(
fn ($document) => $this->documentService->deleteDocument($document->id)
);
$this->documentService->uploadDocument($application, $file, $type);
return $this->repository->findByIdWithRelations($application->id);
}
public function managementReview(MembershipApplication $application, ManagementDecision $decision, ?string $remarks, User $reviewer): MembershipApplication {
if ($application->status !== ApplicationStatus::Submitted) {
throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh disemak pada peringkat pentadbiran.'],
]);
}
return DB::transaction(function () use ($application, $decision, $remarks, $reviewer) {
$application->reviews()->create([
'stage' => ReviewStage::Management,
'decision' => $decision->value,
'remarks' => $remarks,
'reviewer_id' => $reviewer->id,
'reviewed_at' => now(),
]);
$application->update([
'status' => $decision === ManagementDecision::Approved
? ApplicationStatus::PendingBoard
: ApplicationStatus::ManagementRejected,
]);
return $this->repository->findByIdWithRelations($application->id);
});
}
public function boardReview(MembershipApplication $application, BoardDecision $decision, ?string $remarks, User $reviewer): MembershipApplication
{
if ($application->status !== ApplicationStatus::PendingBoard) {
throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh disemak pada peringkat lembaga.'],
]);
}
return DB::transaction(function () use ($application, $decision, $remarks, $reviewer) {
$application->reviews()->create([
'stage' => ReviewStage::Board,
'decision' => $decision->value,
'remarks' => $remarks,
'reviewer_id' => $reviewer->id,
'reviewed_at' => now(),
]);
$application->update([
'board_result' => $decision === BoardDecision::Pass
? BoardResult::Pass->value
: BoardResult::Fail->value,
'status' => ApplicationStatus::PendingNotification,
]);
return $this->repository->findByIdWithRelations($application->id);
});
}
/**
* @param array{proposer_id?: string|null, supporter_id?: string|null} $references
*/
public function assignReferences(MembershipApplication $application, array $references, User $assignedBy): MembershipApplication
{
return DB::transaction(function () use ($application, $references, $assignedBy) {
$referenceMap = [
'proposer_id' => ReferenceType::Proposer,
'supporter_id' => ReferenceType::Supporter,
];
foreach ($referenceMap as $key => $type) {
if (! array_key_exists($key, $references)) {
continue;
}
$application->references()->updateOrCreate(
['reference_type' => $type->value],
[
'user_id' => $references[$key],
'assigned_by' => $references[$key] ? $assignedBy->id : null,
'assigned_at' => $references[$key] ? now() : null,
]
);
}
return $this->repository->findByIdWithRelations($application->id);
});
}
public function complete(MembershipApplication $application, User $processedBy): MembershipApplication
{
if ($application->status !== ApplicationStatus::PendingNotification) {
throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'],
]);
}
if (! in_array($application->board_result, [BoardResult::Pass->value, BoardResult::Fail->value], true)) {
throw ValidationException::withMessages([
'board_result' => ['Keputusan lembaga belum ditetapkan.'],
]);
}
return DB::transaction(function () use ($application) {
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
$user = $this->createMemberFromApplication($application);
$application->update(['user_id' => $user->id]);
}
$this->sendResultNotification($application);
$application->update([
'status' => ApplicationStatus::Completed,
'completed_at' => now()->toDateTimeString(),
]);
return $this->repository->findByIdWithRelations($application->id);
});
}
/**
* @param list<string> $applicationIds
* @return array{
* succeeded: list<MembershipApplication>,
* failed: list<array{id: string, application_number: string|null, message: string}>
* }
*/
public function completeBatch(array $applicationIds, User $processedBy): array
{
$succeeded = [];
$failed = [];
foreach ($applicationIds as $id) {
$application = $this->repository->findById($id);
if (! $application) {
$failed[] = [
'id' => $id,
'application_number' => null,
'message' => 'Permohonan tidak dijumpai.',
];
continue;
}
try {
$succeeded[] = $this->complete($application, $processedBy);
} catch (ValidationException $e) {
$failed[] = [
'id' => $id,
'application_number' => $application->application_number,
'message' => collect($e->errors())->flatten()->first() ?? 'Permohonan tidak boleh diselesaikan.',
];
}
}
return [
'succeeded' => $succeeded,
'failed' => $failed,
];
}
protected function createMemberFromApplication(MembershipApplication $application): User
{
$application->loadMissing(['applicant', 'heirs']);
$applicant = $application->applicant;
if (User::where('email', $applicant->email)->exists()) {
throw ValidationException::withMessages([
'email' => ['E-mel pemohon sudah didaftarkan dalam sistem.'],
]);
}
$user = User::create([
'name' => $applicant->name,
'email' => $applicant->email,
'password' => Hash::make(Str::password(16)),
'ic_number' => $applicant->ic_number,
'phone_number' => $applicant->phone_number,
'position' => $applicant->current_position,
'status' => 'active',
'gender' => $applicant->gender,
'marriage_status' => $applicant->marriage_status,
'member_number' => $this->generateMemberNumber(),
'member_type' => 'Anggota',
'join_date' => now()->toDateString(),
'birth_date' => $applicant->birth_date,
'birth_place' => $applicant->birth_place,
]);
$role = Role::where('name', 'Anggota')->first();
if ($role) {
$user->assignRole($role);
}
$user->addresses()->create([
'address_type' => 'home',
'address_line_1' => $applicant->address,
'postcode' => $applicant->postcode,
]);
$user->employments()->create([
'company_name' => $applicant->employer_name,
'job_title' => $applicant->current_position,
'employment_type' => 'Permanent',
'salary' => 0,
'start_date' => $applicant->start_work_date,
'is_current' => true,
]);
foreach ($application->heirs as $index => $heir) {
$user->heirs()->create([
'name' => $heir->name,
'ic_number' => $heir->ic_number,
'relationship' => $heir->relationship,
'phone_number' => $heir->phone_number,
'is_primary' => $index === 0,
]);
}
app(EmailVerificationOtpService::class)->send($user);
return $user;
}
public function lookupMemberByIcNumber(string $icNumber): ?User
{
$icNumber = trim($icNumber);
if ($icNumber === '') {
return null;
}
return User::query()
->where('ic_number', $icNumber)
->where('status', 'active')
->first();
}
protected function sendSubmissionConfirmation(MembershipApplication $application): void
{
$application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']);
Notification::route('mail', $application->applicant->email)
->notify(new MembershipApplicationSubmittedNotification($application));
}
protected function sendResultNotification(MembershipApplication $application): void
{
$application->loadMissing('applicant');
$notification = $application->board_result === BoardResult::Pass->value
? new MembershipApplicationPassedNotification($application)
: new MembershipApplicationFailedNotification($application);
Notification::route('mail', $application->applicant->email)->notify($notification);
}
protected function generateMemberNumber(): int
{
return ((int) User::max('member_number')) + 1;
}
/**
* @param array{proposer_ic_number?: string|null, supporter_ic_number?: string|null} $references
*/
protected function syncReferences(MembershipApplication $application, array $references, string $applicantIcNumber): void
{
$referenceMap = [
'references.proposer_ic_number' => ['field' => 'proposer_ic_number', 'type' => ReferenceType::Proposer],
'references.supporter_ic_number' => ['field' => 'supporter_ic_number', 'type' => ReferenceType::Supporter],
];
$resolvedUserIds = [];
$applicantIcNumber = trim($applicantIcNumber);
foreach ($referenceMap as $errorKey => $config) {
if (! array_key_exists($config['field'], $references)) {
continue;
}
$icNumber = trim((string) ($references[$config['field']] ?? ''));
if ($icNumber === '') {
$application->references()->where('reference_type', $config['type']->value)->delete();
continue;
}
if ($applicantIcNumber !== '' && strcasecmp($icNumber, $applicantIcNumber) === 0) {
throw ValidationException::withMessages([
$errorKey => ['Pencadang/penyokong tidak boleh sama dengan pemohon.'],
]);
}
$member = $this->lookupMemberByIcNumber($icNumber);
if (! $member) {
throw ValidationException::withMessages([
$errorKey => ['Ahli dengan nombor kad pengenalan ini tidak dijumpai.'],
]);
}
if (in_array($member->id, $resolvedUserIds, true)) {
throw ValidationException::withMessages([
$errorKey => ['Pencadang dan penyokong mestilah ahli yang berbeza.'],
]);
}
$resolvedUserIds[] = $member->id;
$application->references()->updateOrCreate(
['reference_type' => $config['type']->value],
['user_id' => $member->id]
);
}
}
protected function generateApplicationNumber(): string
{
$year = now()->year;
$prefix = "APP-{$year}-";
$latestNumber = MembershipApplication::query()
->where('application_number', 'like', "{$prefix}%")
->orderByDesc('application_number')
->value('application_number');
$sequence = 1;
if ($latestNumber && preg_match('/-(\d+)$/', $latestNumber, $matches)) {
$sequence = ((int) $matches[1]) + 1;
}
return sprintf('%s%05d', $prefix, $sequence);
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\MembershipApplication\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MembershipApplicationApplicantResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'ic_number' => $this->ic_number,
'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place,
'gender' => $this->gender,
'marriage_status' => $this->marriage_status,
'address' => $this->address,
'phone_number' => $this->phone_number,
'office_number' => $this->office_number,
'postcode' => $this->postcode,
'employer_name' => $this->employer_name,
'employer_address' => $this->employer_address,
'current_position' => $this->current_position,
'start_work_date' => $this->start_work_date?->toDateString(),
'stock_monthly_contribution' => $this->stock_monthly_contribution,
'fee_monthly_contribution' => $this->fee_monthly_contribution,
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\MembershipApplication\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MembershipApplicationDocumentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'type' => $this->type,
'mime_type' => $this->mime_type,
'file_size' => $this->file_size,
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\MembershipApplication\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MembershipApplicationHeirResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'ic_number' => $this->ic_number,
'relationship' => $this->relationship,
'phone_number' => $this->phone_number,
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace Modules\MembershipApplication\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MembershipApplicationListResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'application_number' => $this->application_number,
'status' => $this->status?->value ?? $this->status,
'board_result' => $this->board_result ?: null,
'submitted_at' => $this->submitted_at,
'applicant' => $this->whenLoaded('applicant', fn () => [
'name' => $this->applicant->name,
'email' => $this->applicant->email,
'ic_number' => $this->applicant->ic_number,
]),
'created_at' => $this->created_at?->toISOString(),
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\MembershipApplication\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MembershipApplicationReferenceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'reference_type' => $this->reference_type?->value ?? $this->reference_type,
'user_id' => $this->user_id,
'member' => $this->whenLoaded('member', fn () => [
'id' => $this->member->id,
'name' => $this->member->name,
'ic_number' => $this->member->ic_number,
]),
'assigned_by' => $this->assigned_by,
'assigned_at' => $this->assigned_at?->toISOString(),
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\MembershipApplication\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MembershipApplicationResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'application_number' => $this->application_number,
'status' => $this->status?->value ?? $this->status,
'board_result' => $this->board_result ?: null,
'submitted_at' => $this->submitted_at,
'completed_at' => $this->completed_at ?: null,
'applicant' => new MembershipApplicationApplicantResource($this->whenLoaded('applicant')),
'heirs' => MembershipApplicationHeirResource::collection($this->whenLoaded('heirs')),
'references' => MembershipApplicationReferenceResource::collection($this->whenLoaded('references')),
'documents' => MembershipApplicationDocumentResource::collection($this->whenLoaded('documents')),
'reviews' => MembershipApplicationReviewResource::collection($this->whenLoaded('reviews')),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\MembershipApplication\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MembershipApplicationReviewResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'stage' => $this->stage?->value ?? $this->stage,
'decision' => $this->decision,
'remarks' => $this->remarks,
'reviewer' => $this->whenLoaded('reviewer', fn () => [
'id' => $this->reviewer->id,
'name' => $this->reviewer->name,
]),
'reviewed_at' => $this->reviewed_at?->toISOString(),
];
}
}
@@ -0,0 +1,30 @@
{
"name": "nwidart/membershipapplication",
"description": "",
"authors": [
{
"name": "Nicolas Widart",
"email": "n.widart@gmail.com"
}
],
"extra": {
"laravel": {
"providers": [],
"aliases": {
}
}
},
"autoload": {
"psr-4": {
"Modules\\MembershipApplication\\": "App",
"Modules\\MembershipApplication\\Database\\Factories\\": "database/factories/",
"Modules\\MembershipApplication\\Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Modules\\MembershipApplication\\Tests\\": "tests/"
}
}
}
@@ -0,0 +1,11 @@
{
"name": "MembershipApplication",
"alias": "membershipapplication",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\MembershipApplication\\Providers\\MembershipApplicationServiceProvider"
],
"files": []
}
@@ -0,0 +1,15 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"axios": "^1.1.2",
"laravel-vite-plugin": "^0.7.5",
"sass": "^1.69.5",
"postcss": "^8.3.7",
"vite": "^4.0.0"
}
}