Dev/v1.1 (#2)
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local> Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace Modules\MembershipApplication\Entities;
|
||||
|
||||
use App\Models\Document;
|
||||
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 Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\MembershipApplication\Enums\ApplicationStatus;
|
||||
|
||||
@@ -14,6 +16,12 @@ class MembershipApplication extends Model
|
||||
{
|
||||
use HasDocuments, HasUuids;
|
||||
|
||||
public const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter';
|
||||
|
||||
public const MINIMUM_SHARE_CAPITAL = 500;
|
||||
|
||||
public const SHARE_INSTALLMENT_MONTHS = 6;
|
||||
|
||||
protected $table = 'membership_applications';
|
||||
|
||||
/**
|
||||
@@ -65,4 +73,10 @@ class MembershipApplication extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function resultLetterDocument(): MorphOne
|
||||
{
|
||||
return $this->morphOne(Document::class, 'documentable')
|
||||
->where('type', self::RESULT_LETTER_DOCUMENT_TYPE);
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -17,10 +17,12 @@ 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\GenerateResultLetterRequest;
|
||||
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\MembershipApplicationDocumentResource;
|
||||
use Modules\MembershipApplication\Transformers\MembershipApplicationListResource;
|
||||
use Modules\MembershipApplication\Transformers\MembershipApplicationResource;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
@@ -243,6 +245,29 @@ class MembershipApplicationController extends Controller
|
||||
return $this->documentService->downloadDocument($document->id);
|
||||
}
|
||||
|
||||
public function generateResultLetter(GenerateResultLetterRequest $request, MembershipApplication $membershipApplication,): JsonResponse {
|
||||
$this->authorize('generateResultLetter', $membershipApplication);
|
||||
|
||||
$application = $this->membershipApplicationService->generateResultLetter(
|
||||
$membershipApplication,
|
||||
$request->validated('board_meeting_reference'),
|
||||
);
|
||||
|
||||
$resultLetter = $application->documents
|
||||
->firstWhere('type', MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Surat keputusan berjaya dijana.',
|
||||
'data' => [
|
||||
'application' => new MembershipApplicationResource($application),
|
||||
'document' => $resultLetter
|
||||
? new MembershipApplicationDocumentResource($resultLetter)
|
||||
: null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function approvedLetter(Request $request, MembershipApplication $membershipApplication): Response
|
||||
{
|
||||
$this->authorize('view', $membershipApplication);
|
||||
@@ -253,10 +278,18 @@ class MembershipApplicationController extends Controller
|
||||
abort(404, 'Permohonan tidak dijumpai.');
|
||||
}
|
||||
|
||||
$boardMeetingReference = (string) $request->query(
|
||||
'board_meeting_reference',
|
||||
'Mesyuarat Lembaga',
|
||||
);
|
||||
|
||||
$view = 'membershipapplication::pdf.approved-letter';
|
||||
$data = ['application' => $application];
|
||||
$data = $this->membershipApplicationService->getResultLetterViewData(
|
||||
$application,
|
||||
$boardMeetingReference,
|
||||
);
|
||||
$format = $request->query('format', 'html');
|
||||
$filename = 'surat-kelulusan-'.$application->application_number.'.pdf';
|
||||
$filename = 'surat-keputusan-'.$application->application_number.'.pdf';
|
||||
|
||||
if ($format === 'pdf') {
|
||||
return $this->letterService->pdfResponse($view, $data, $filename);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MembershipApplication\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GenerateResultLetterRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'board_meeting_reference' => 'required|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
|
||||
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,14 @@ class MembershipApplicationPolicy
|
||||
return $user->hasPermissionTo('tetapkan pencadang penyokong');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the result letter PDF for a completed application.
|
||||
*/
|
||||
public function generateResultLetter($user, ?MembershipApplication $membershipApplication = null): bool
|
||||
{
|
||||
return $user->hasPermissionTo('jana surat keputusan keahlian');
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin: edit application details before completion.
|
||||
*/
|
||||
|
||||
@@ -155,7 +155,7 @@ class MembershipApplicationServiceProvider extends ServiceProvider
|
||||
public function registerViews(): void
|
||||
{
|
||||
$viewPath = resource_path('views/modules/'.$this->nameLower);
|
||||
$sourcePath = module_path($this->name, 'Resources/Views');
|
||||
$sourcePath = module_path($this->name, 'Resources/views');
|
||||
|
||||
$this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']);
|
||||
|
||||
|
||||
@@ -46,7 +46,14 @@ class MembershipApplicationRepository implements MembershipApplicationRepository
|
||||
$sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$query = MembershipApplication::query()
|
||||
->with(['applicant:id,membership_application_id,name,email,ic_number'])
|
||||
->with([
|
||||
'applicant:id,membership_application_id,name,email,ic_number',
|
||||
'resultLetterDocument:id,documentable_type,documentable_id,name,type,mime_type,file_size',
|
||||
])
|
||||
->withCount([
|
||||
'documents as result_letter_count' => fn ($documentQuery) => $documentQuery
|
||||
->where('type', MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE),
|
||||
])
|
||||
->orderBy($sortBy, $sortOrder);
|
||||
|
||||
if ($status !== null && $status !== '') {
|
||||
|
||||
@@ -1,12 +1,73 @@
|
||||
{{--
|
||||
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 --}}
|
||||
@section('letter-title', 'Surat')
|
||||
|
||||
@section('letter-reference', $application->application_number)
|
||||
|
||||
@section('letter-date', now()->translatedFormat('d F Y'))
|
||||
|
||||
@section('letter-recipient')
|
||||
<strong>{{ $applicant->name }}</strong><br>
|
||||
{!! nl2br(e($applicant->address)) !!}<br>
|
||||
{{ $applicant->postcode }}
|
||||
@endsection
|
||||
|
||||
@section('letter-subject')
|
||||
<strong>{{ $letterSubject }}</strong>
|
||||
@endsection
|
||||
|
||||
@section('letter-body')
|
||||
<p style="line-height: 1;">
|
||||
Dengan segala hormatnya saya merujuk kepada perkara di atas.
|
||||
</p>
|
||||
<p style="line-height: 1;">
|
||||
2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
|
||||
Permodalan Kelantan Berhad (KoPKB) telah <strong>{{ $boardResultLabel }}</strong> dalam {{ $boardMeetingReference }}.
|
||||
</p>
|
||||
|
||||
@if ($isPassed)
|
||||
<p style="line-height: 1;">
|
||||
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaimana ketetapan berikut:-
|
||||
<ul style="line-height: 1;">
|
||||
<li>Saham</li>
|
||||
<ul style="line-height: 1;">
|
||||
<li>Potongan sebanyak RM {{ $monthlyShareInstallment }} ({{ $shareInstallmentMonths }} bulan)</li>
|
||||
<li>Potongan sebanyak RM {{ $stockMonthlyContribution }} sehingga Syer Maksima</li>
|
||||
</ul>
|
||||
<li>Yuran</li>
|
||||
<ul style="line-height: 1;">
|
||||
<li>Potongan sebanyak RM {{ $feeMonthlyContribution }} setiap bulan</li>
|
||||
</ul>
|
||||
<li>Fi Masuk</li>
|
||||
<ul style="line-height: 1;">
|
||||
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</p>
|
||||
<p style="line-height: 1;">
|
||||
4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki
|
||||
menjelaskan modal syer minimum sebanyak RM{{ $minimumShareCapital }}.
|
||||
Bayaran tersebut perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan.
|
||||
Pemenuhan syarat ini adalah bagi melayakkan tuan memperoleh hak, kewajipan dan liabiliti sebagai anggota KoPKB
|
||||
sebagaimana terkandung di dalam Undang-Undang Kecil (UUK), Perkara 59 yang telah didaftarkan pada 11 September 2024.
|
||||
Manakala had maksima modal syer adalah sebanyak RM5,000.00.
|
||||
</p>
|
||||
@else
|
||||
<p style="line-height: 1;">
|
||||
3. Oleh yang demikian, permohonan tuan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
|
||||
</p>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@section('letter-closing')
|
||||
Sekian, terima kasih.
|
||||
@endsection
|
||||
|
||||
@section('letter-signature')
|
||||
<div class="signature-header">Dengan hormatnya,</div>
|
||||
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
|
||||
<div class="signature-space"></div>
|
||||
<div class="signatory-name">MUHAMMAD NAFIS BIN ZAINUDDIN</div>
|
||||
<div class="signatory-title">Pengurus Besar</div>
|
||||
<div class="signatory-title">b/p Setiausaha</div>
|
||||
@endsection
|
||||
|
||||
@@ -43,4 +43,6 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct
|
||||
|
||||
// approved letter preview (html or pdf)
|
||||
Route::get('membership-applications/{membershipApplication}/approved-letter', [MembershipApplicationController::class, 'approvedLetter'])->name('membership-application.approved-letter');
|
||||
// generate and store result letter (one-time)
|
||||
Route::post('membership-applications/{membershipApplication}/result-letter', [MembershipApplicationController::class, 'generateResultLetter'])->name('membership-application.generate-result-letter');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Modules\MembershipApplication\Services;
|
||||
|
||||
use App\Services\DocumentService;
|
||||
use App\Services\LetterService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -30,6 +31,7 @@ class MembershipApplicationService
|
||||
public function __construct(
|
||||
protected MembershipApplicationRepositoryInterface $repository,
|
||||
protected DocumentService $documentService,
|
||||
protected LetterService $letterService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -301,6 +303,106 @@ class MembershipApplicationService
|
||||
];
|
||||
}
|
||||
|
||||
public function generateResultLetter(MembershipApplication $application, string $boardMeetingReference,): MembershipApplication {
|
||||
if ($application->status !== ApplicationStatus::Completed) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'],
|
||||
]);
|
||||
}
|
||||
|
||||
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, $boardMeetingReference) {
|
||||
$lockedApplication = MembershipApplication::query()
|
||||
->whereKey($application->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ($lockedApplication->hasDocumentsOfType(MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE)) {
|
||||
throw ValidationException::withMessages([
|
||||
'result_letter' => ['Surat keputusan telah dijana dan tidak boleh dijana semula.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$application = $this->repository->findByIdWithRelations($lockedApplication->id);
|
||||
|
||||
if (! $application || ! $application->applicant) {
|
||||
throw ValidationException::withMessages([
|
||||
'application' => ['Maklumat pemohon tidak lengkap.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$viewData = $this->buildResultLetterViewData($application, $boardMeetingReference);
|
||||
$pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData);
|
||||
$fileName = 'surat-keputusan-'.$application->application_number.'.pdf';
|
||||
|
||||
$this->documentService->storeGeneratedDocument(
|
||||
$application,
|
||||
$pdf,
|
||||
$fileName,
|
||||
MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE,
|
||||
);
|
||||
|
||||
return $this->repository->findByIdWithRelations($application->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getResultLetterViewData(
|
||||
MembershipApplication $application,
|
||||
string $boardMeetingReference,
|
||||
): array {
|
||||
$application = $this->repository->findByIdWithRelations($application->id);
|
||||
|
||||
if (! $application || ! $application->applicant) {
|
||||
throw ValidationException::withMessages([
|
||||
'application' => ['Maklumat pemohon tidak lengkap.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->buildResultLetterViewData($application, $boardMeetingReference);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function buildResultLetterViewData(
|
||||
MembershipApplication $application,
|
||||
string $boardMeetingReference,
|
||||
): array {
|
||||
$applicant = $application->applicant;
|
||||
$isPassed = $application->board_result === BoardResult::Pass->value;
|
||||
$monthlyShareInstallment = number_format(
|
||||
MembershipApplication::MINIMUM_SHARE_CAPITAL / MembershipApplication::SHARE_INSTALLMENT_MONTHS,
|
||||
2,
|
||||
'.',
|
||||
'',
|
||||
);
|
||||
|
||||
return [
|
||||
'application' => $application,
|
||||
'applicant' => $applicant,
|
||||
'boardMeetingReference' => $boardMeetingReference,
|
||||
'isPassed' => $isPassed,
|
||||
'boardResultLabel' => $isPassed ? 'lulus' : 'gagal',
|
||||
'openingTone' => $isPassed ? 'Sukacita' : 'Dukacita',
|
||||
'letterSubject' => $isPassed
|
||||
? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD'
|
||||
: 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD',
|
||||
'monthlyShareInstallment' => $monthlyShareInstallment,
|
||||
'stockMonthlyContribution' => number_format((float) $applicant->stock_monthly_contribution, 2, '.', ''),
|
||||
'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''),
|
||||
'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS,
|
||||
'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''),
|
||||
];
|
||||
}
|
||||
|
||||
protected function createMemberFromApplication(MembershipApplication $application): User
|
||||
{
|
||||
$application->loadMissing(['applicant', 'heirs']);
|
||||
|
||||
@@ -15,6 +15,11 @@ class MembershipApplicationListResource extends JsonResource
|
||||
'status' => $this->status?->value ?? $this->status,
|
||||
'board_result' => $this->board_result ?: null,
|
||||
'submitted_at' => $this->submitted_at,
|
||||
'has_result_letter' => ($this->result_letter_count ?? 0) > 0,
|
||||
'result_letter_document' => $this->when(
|
||||
($this->result_letter_count ?? 0) > 0 && $this->relationLoaded('resultLetterDocument') && $this->resultLetterDocument,
|
||||
fn () => new MembershipApplicationDocumentResource($this->resultLetterDocument),
|
||||
),
|
||||
'applicant' => $this->whenLoaded('applicant', fn () => [
|
||||
'name' => $this->applicant->name,
|
||||
'email' => $this->applicant->email,
|
||||
|
||||
Reference in New Issue
Block a user