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