DONE: generate letter after the membership-application is completed
This commit is contained in:
+2
-1
@@ -67,11 +67,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
# Browsershot (PDF generation via headless Chrome + Puppeteer in be/)
|
# Browsershot (PDF generation via headless Chrome + Puppeteer in be/)
|
||||||
|
# Required in Docker/Sail. After first sail up: sail npm install && sail npx puppeteer browsers install chrome-headless-shell
|
||||||
|
BROWSERSHOT_NO_SANDBOX=true
|
||||||
# BROWSERSHOT_NODE_BINARY=
|
# BROWSERSHOT_NODE_BINARY=
|
||||||
# BROWSERSHOT_NPM_BINARY=
|
# BROWSERSHOT_NPM_BINARY=
|
||||||
# BROWSERSHOT_NODE_MODULE_PATH=
|
# BROWSERSHOT_NODE_MODULE_PATH=
|
||||||
# BROWSERSHOT_CHROME_PATH=
|
# BROWSERSHOT_CHROME_PATH=
|
||||||
# BROWSERSHOT_NO_SANDBOX=false
|
|
||||||
# BROWSERSHOT_TIMEOUT=60
|
# BROWSERSHOT_TIMEOUT=60
|
||||||
|
|
||||||
# HttpOnly Sanctum token cookie (set on api.* domain)
|
# HttpOnly Sanctum token cookie (set on api.* domain)
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
namespace Modules\MembershipApplication\Entities;
|
namespace Modules\MembershipApplication\Entities;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
use App\Traits\HasDocuments;
|
use App\Traits\HasDocuments;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||||
use Modules\Auth\Entities\User;
|
use Modules\Auth\Entities\User;
|
||||||
use Modules\MembershipApplication\Enums\ApplicationStatus;
|
use Modules\MembershipApplication\Enums\ApplicationStatus;
|
||||||
|
|
||||||
@@ -14,6 +16,12 @@ class MembershipApplication extends Model
|
|||||||
{
|
{
|
||||||
use HasDocuments, HasUuids;
|
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';
|
protected $table = 'membership_applications';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,4 +73,10 @@ class MembershipApplication extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
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\AssignReferencesRequest;
|
||||||
use Modules\MembershipApplication\Http\Requests\BatchCompleteRequest;
|
use Modules\MembershipApplication\Http\Requests\BatchCompleteRequest;
|
||||||
use Modules\MembershipApplication\Http\Requests\BoardReviewRequest;
|
use Modules\MembershipApplication\Http\Requests\BoardReviewRequest;
|
||||||
|
use Modules\MembershipApplication\Http\Requests\GenerateResultLetterRequest;
|
||||||
use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest;
|
use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest;
|
||||||
use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest;
|
use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest;
|
||||||
use Modules\MembershipApplication\Http\Requests\UploadMembershipApplicationDocumentRequest;
|
use Modules\MembershipApplication\Http\Requests\UploadMembershipApplicationDocumentRequest;
|
||||||
use Modules\MembershipApplication\Services\MembershipApplicationService;
|
use Modules\MembershipApplication\Services\MembershipApplicationService;
|
||||||
|
use Modules\MembershipApplication\Transformers\MembershipApplicationDocumentResource;
|
||||||
use Modules\MembershipApplication\Transformers\MembershipApplicationListResource;
|
use Modules\MembershipApplication\Transformers\MembershipApplicationListResource;
|
||||||
use Modules\MembershipApplication\Transformers\MembershipApplicationResource;
|
use Modules\MembershipApplication\Transformers\MembershipApplicationResource;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
@@ -243,6 +245,29 @@ class MembershipApplicationController extends Controller
|
|||||||
return $this->documentService->downloadDocument($document->id);
|
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
|
public function approvedLetter(Request $request, MembershipApplication $membershipApplication): Response
|
||||||
{
|
{
|
||||||
$this->authorize('view', $membershipApplication);
|
$this->authorize('view', $membershipApplication);
|
||||||
@@ -253,10 +278,18 @@ class MembershipApplicationController extends Controller
|
|||||||
abort(404, 'Permohonan tidak dijumpai.');
|
abort(404, 'Permohonan tidak dijumpai.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$boardMeetingReference = (string) $request->query(
|
||||||
|
'board_meeting_reference',
|
||||||
|
'Mesyuarat Lembaga',
|
||||||
|
);
|
||||||
|
|
||||||
$view = 'membershipapplication::pdf.approved-letter';
|
$view = 'membershipapplication::pdf.approved-letter';
|
||||||
$data = ['application' => $application];
|
$data = $this->membershipApplicationService->getResultLetterViewData(
|
||||||
|
$application,
|
||||||
|
$boardMeetingReference,
|
||||||
|
);
|
||||||
$format = $request->query('format', 'html');
|
$format = $request->query('format', 'html');
|
||||||
$filename = 'surat-kelulusan-'.$application->application_number.'.pdf';
|
$filename = 'surat-keputusan-'.$application->application_number.'.pdf';
|
||||||
|
|
||||||
if ($format === 'pdf') {
|
if ($format === 'pdf') {
|
||||||
return $this->letterService->pdfResponse($view, $data, $filename);
|
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');
|
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.
|
* Admin: edit application details before completion.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class MembershipApplicationServiceProvider extends ServiceProvider
|
|||||||
public function registerViews(): void
|
public function registerViews(): void
|
||||||
{
|
{
|
||||||
$viewPath = resource_path('views/modules/'.$this->nameLower);
|
$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']);
|
$this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']);
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,14 @@ class MembershipApplicationRepository implements MembershipApplicationRepository
|
|||||||
$sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
|
$sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
|
||||||
|
|
||||||
$query = MembershipApplication::query()
|
$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);
|
->orderBy($sortBy, $sortOrder);
|
||||||
|
|
||||||
if ($status !== null && $status !== '') {
|
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')
|
@extends('pdf.layouts.letter')
|
||||||
|
|
||||||
{{-- @section('letter-reference', $application->application_number) --}}
|
@section('letter-title', 'Surat')
|
||||||
{{-- @section('letter-date', ...) --}}
|
|
||||||
{{-- @section('letter-recipient') ... @endsection --}}
|
@section('letter-reference', $application->application_number)
|
||||||
{{-- @section('letter-subject') PERKARA: ... @endsection --}}
|
|
||||||
{{-- @section('letter-body') ... @endsection --}}
|
@section('letter-date', now()->translatedFormat('d F Y'))
|
||||||
{{-- @section('letter-signature') ... @endsection --}}
|
|
||||||
|
@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)
|
// approved letter preview (html or pdf)
|
||||||
Route::get('membership-applications/{membershipApplication}/approved-letter', [MembershipApplicationController::class, 'approvedLetter'])->name('membership-application.approved-letter');
|
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;
|
namespace Modules\MembershipApplication\Services;
|
||||||
|
|
||||||
use App\Services\DocumentService;
|
use App\Services\DocumentService;
|
||||||
|
use App\Services\LetterService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -30,6 +31,7 @@ class MembershipApplicationService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
protected MembershipApplicationRepositoryInterface $repository,
|
protected MembershipApplicationRepositoryInterface $repository,
|
||||||
protected DocumentService $documentService,
|
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
|
protected function createMemberFromApplication(MembershipApplication $application): User
|
||||||
{
|
{
|
||||||
$application->loadMissing(['applicant', 'heirs']);
|
$application->loadMissing(['applicant', 'heirs']);
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class MembershipApplicationListResource extends JsonResource
|
|||||||
'status' => $this->status?->value ?? $this->status,
|
'status' => $this->status?->value ?? $this->status,
|
||||||
'board_result' => $this->board_result ?: null,
|
'board_result' => $this->board_result ?: null,
|
||||||
'submitted_at' => $this->submitted_at,
|
'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 () => [
|
'applicant' => $this->whenLoaded('applicant', fn () => [
|
||||||
'name' => $this->applicant->name,
|
'name' => $this->applicant->name,
|
||||||
'email' => $this->applicant->email,
|
'email' => $this->applicant->email,
|
||||||
|
|||||||
@@ -13,12 +13,7 @@ class DocumentService
|
|||||||
/**
|
/**
|
||||||
* Upload a document for any model.
|
* Upload a document for any model.
|
||||||
*/
|
*/
|
||||||
public function uploadDocument(
|
public function uploadDocument(Model $model, UploadedFile $file, string $documentType = 'general', ?string $description = null): Document {
|
||||||
Model $model,
|
|
||||||
UploadedFile $file,
|
|
||||||
string $documentType = 'general',
|
|
||||||
?string $description = null
|
|
||||||
): Document {
|
|
||||||
$fileName = time().'_'.$file->getClientOriginalName();
|
$fileName = time().'_'.$file->getClientOriginalName();
|
||||||
$folderName = strtolower(class_basename($model));
|
$folderName = strtolower(class_basename($model));
|
||||||
$filePath = $file->storeAs("documents/{$folderName}", $fileName, Document::STORAGE_DISK);
|
$filePath = $file->storeAs("documents/{$folderName}", $fileName, Document::STORAGE_DISK);
|
||||||
@@ -34,6 +29,27 @@ class DocumentService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store generated file contents (e.g. PDF) for a model.
|
||||||
|
*/
|
||||||
|
public function storeGeneratedDocument(Model $model, string $contents, string $fileName, string $documentType = 'general', string $mimeType = 'application/pdf', ?string $description = null,): Document {
|
||||||
|
$folderName = strtolower(class_basename($model));
|
||||||
|
$storedFileName = time().'_'.$fileName;
|
||||||
|
$filePath = "documents/{$folderName}/{$storedFileName}";
|
||||||
|
|
||||||
|
Storage::disk(Document::STORAGE_DISK)->put($filePath, $contents);
|
||||||
|
|
||||||
|
return $model->documents()->create([
|
||||||
|
'name' => $fileName,
|
||||||
|
'path' => $filePath,
|
||||||
|
'file_size' => strlen($contents),
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'type' => $documentType,
|
||||||
|
'description' => $description,
|
||||||
|
'uploaded_by' => auth()->id(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a document.
|
* Delete a document.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class LetterService
|
|||||||
*/
|
*/
|
||||||
public function pdfResponse(string $view, array $data = [], string $filename = 'letter.pdf'): Response
|
public function pdfResponse(string $view, array $data = [], string $filename = 'letter.pdf'): Response
|
||||||
{
|
{
|
||||||
$pdf = $this->makeBrowsershot($this->renderHtml($view, $data))->pdf();
|
$pdf = $this->renderPdf($view, $data);
|
||||||
|
|
||||||
return response($pdf, SymfonyResponse::HTTP_OK, [
|
return response($pdf, SymfonyResponse::HTTP_OK, [
|
||||||
'Content-Type' => 'application/pdf',
|
'Content-Type' => 'application/pdf',
|
||||||
@@ -41,6 +41,14 @@ class LetterService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function renderPdf(string $view, array $data = []): string
|
||||||
|
{
|
||||||
|
return $this->makeBrowsershot($this->renderHtml($view, $data))->pdf();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $overrides
|
* @param array<string, mixed> $overrides
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
laravel.test:
|
laravel.test:
|
||||||
|
# Apple Silicon / OrbStack: Puppeteer's Chrome must match container arch.
|
||||||
|
# amd64 emulation avoids linux_arm + linux64 chrome-headless-shell mismatch.
|
||||||
|
platform: linux/amd64
|
||||||
build:
|
build:
|
||||||
context: "./vendor/laravel/sail/runtimes/8.4"
|
context: "./vendor/laravel/sail/runtimes/8.4"
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev": "vite"
|
"dev": "vite",
|
||||||
|
"browsershot:install": "puppeteer browsers install chrome-headless-shell"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@section('letter-date', now()->translatedFormat('d F Y'))
|
@section('letter-date', now()->translatedFormat('d F Y'))
|
||||||
|
|
||||||
@section('letter-recipient')
|
@section('letter-recipient')
|
||||||
|
{{-- auto fetch from membership_application_applicants --}}
|
||||||
<strong>Ahmad bin Abdullah</strong><br>
|
<strong>Ahmad bin Abdullah</strong><br>
|
||||||
No. 12, Jalan Contoh,<br>
|
No. 12, Jalan Contoh,<br>
|
||||||
43000 Kajang, Selangor
|
43000 Kajang, Selangor
|
||||||
@@ -26,20 +27,19 @@
|
|||||||
</p>
|
</p>
|
||||||
<p style="line-height: 1;">
|
<p style="line-height: 1;">
|
||||||
2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
|
2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
|
||||||
Permodalan Kelantan Berhad(KoPKB) telah <strong>DILULUSKAN</strong> dalam Mesyuarat Lembaga
|
Permodalan Kelantan Berhad(KoPKB) telah <strong>{{ membership_applications.board_result }}</strong> dalam {{ User manual fills before generate }}
|
||||||
Koperasi Bil. 4-2025/2026 pada 24 Jun 2026.
|
|
||||||
</p>
|
</p>
|
||||||
<p style="line-height: 1;">
|
<p style="line-height: 1;">
|
||||||
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaiman ketetapan berikut:-
|
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaiman ketetapan berikut:-
|
||||||
<ul style="line-height: 1;">
|
<ul style="line-height: 1;">
|
||||||
<li>Saham</li>
|
<li>Saham</li>
|
||||||
<ul style="line-height: 1;">
|
<ul style="line-height: 1;">
|
||||||
<li>Potongan sebanyak RM 84.00 (Mei 2026 - Jun 2026)</li>
|
<li>Potongan sebanyak RM 84.00 {{(6 months auto calculate)}}</li>
|
||||||
<li>Potongan sebanyak RM 50.00 sehingga Syer Maksima</li>
|
<li>Potongan sebanyak RM {{ membership_application_applicants.stock_monthly_contribution }} sehingga Syer Maksima</li>
|
||||||
</ul>
|
</ul>
|
||||||
<li>Yuran</li>
|
<li>Yuran</li>
|
||||||
<ul style="line-height: 1;">
|
<ul style="line-height: 1;">
|
||||||
<li>Potongan sebanyak RM 50.00 setiap bulan</li>
|
<li>Potongan sebanyak RM {{ membership_application_applicants.fee_monthly_contribution }} setiap bulan</li>
|
||||||
</ul>
|
</ul>
|
||||||
<li>Fi Masuk</li>
|
<li>Fi Masuk</li>
|
||||||
<ul style="line-height: 1;">
|
<ul style="line-height: 1;">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { CircleAlert, CircleCheck, Download, Eye, Pencil } from '@lucide/vue'
|
import { CircleAlert, CircleCheck, Download, Eye, FileText, Pencil } from '@lucide/vue'
|
||||||
import {
|
import {
|
||||||
AlertRoot,
|
AlertRoot,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
@@ -13,7 +13,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Box } from '@/components/ui/box'
|
import { Box } from '@/components/ui/box'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||||
import { Field, FieldLabel } from '@/components/ui/field'
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
completeMembershipApplication,
|
completeMembershipApplication,
|
||||||
downloadMembershipApplicationDocument,
|
downloadMembershipApplicationDocument,
|
||||||
fetchMembershipApplicationDocument,
|
fetchMembershipApplicationDocument,
|
||||||
|
generateMembershipApplicationResultLetter,
|
||||||
getMembershipApplication,
|
getMembershipApplication,
|
||||||
submitBoardReview,
|
submitBoardReview,
|
||||||
submitManagementReview,
|
submitManagementReview,
|
||||||
@@ -42,6 +43,7 @@ import type {
|
|||||||
MembershipApplicationReviewDetail,
|
MembershipApplicationReviewDetail,
|
||||||
MembershipApplicationStatus,
|
MembershipApplicationStatus,
|
||||||
} from '../types/membership-application.types'
|
} from '../types/membership-application.types'
|
||||||
|
import { RESULT_LETTER_DOCUMENT_TYPE } from '../types/membership-application.types'
|
||||||
|
|
||||||
const WORKFLOW_STEPS = [
|
const WORKFLOW_STEPS = [
|
||||||
{ id: 1, label: 'Dihantar' },
|
{ id: 1, label: 'Dihantar' },
|
||||||
@@ -56,6 +58,7 @@ const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
|||||||
photo: 'Gambar Passport',
|
photo: 'Gambar Passport',
|
||||||
salary_slip: 'Slip Gaji',
|
salary_slip: 'Slip Gaji',
|
||||||
employer_letter: 'Surat Pengesahan Majikan',
|
employer_letter: 'Surat Pengesahan Majikan',
|
||||||
|
[RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan',
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -83,6 +86,10 @@ const previewOpen = ref(false)
|
|||||||
const previewLoading = ref(false)
|
const previewLoading = ref(false)
|
||||||
const previewUrl = ref<string | null>(null)
|
const previewUrl = ref<string | null>(null)
|
||||||
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||||
|
const generateDialogOpen = ref(false)
|
||||||
|
const generateSubmitting = ref(false)
|
||||||
|
const boardMeetingReference = ref('')
|
||||||
|
const boardMeetingReferenceError = ref<string | null>(null)
|
||||||
|
|
||||||
function statusLabel(status: MembershipApplicationStatus): string {
|
function statusLabel(status: MembershipApplicationStatus): string {
|
||||||
const labels: Record<MembershipApplicationStatus, string> = {
|
const labels: Record<MembershipApplicationStatus, string> = {
|
||||||
@@ -135,6 +142,20 @@ const showCompleteAction = computed(
|
|||||||
application.value?.status === 'PENDING_NOTIFICATION',
|
application.value?.status === 'PENDING_NOTIFICATION',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const resultLetterDocument = computed(() =>
|
||||||
|
application.value?.documents.find((document) => document.type === RESULT_LETTER_DOCUMENT_TYPE) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const showGenerateResultLetter = computed(
|
||||||
|
() =>
|
||||||
|
hasPermission('jana surat keputusan keahlian') &&
|
||||||
|
application.value?.status === 'COMPLETED' &&
|
||||||
|
!resultLetterDocument.value &&
|
||||||
|
(application.value.board_result === 'PASS' || application.value.board_result === 'FAIL'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const applicant = computed(() => application.value?.applicant ?? null)
|
||||||
|
|
||||||
const confirmDialogTitle = computed(() => {
|
const confirmDialogTitle = computed(() => {
|
||||||
if (!pendingAction.value) return 'Sahkan Tindakan'
|
if (!pendingAction.value) return 'Sahkan Tindakan'
|
||||||
|
|
||||||
@@ -207,8 +228,6 @@ function workflowStepLabelClass(stepId: number) {
|
|||||||
: 'ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32'
|
: 'ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32'
|
||||||
}
|
}
|
||||||
|
|
||||||
const applicant = computed(() => application.value?.applicant ?? null)
|
|
||||||
|
|
||||||
const sortedReviews = computed(() => {
|
const sortedReviews = computed(() => {
|
||||||
if (!application.value?.reviews.length) return []
|
if (!application.value?.reviews.length) return []
|
||||||
|
|
||||||
@@ -219,6 +238,10 @@ const sortedReviews = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const uploadedDocuments = computed(() =>
|
||||||
|
application.value?.documents.filter((document) => document.type !== RESULT_LETTER_DOCUMENT_TYPE) ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
function displayValue(value: string | number | null | undefined): string {
|
function displayValue(value: string | number | null | undefined): string {
|
||||||
if (value === null || value === undefined || value === '') return '-'
|
if (value === null || value === undefined || value === '') return '-'
|
||||||
return String(value)
|
return String(value)
|
||||||
@@ -390,6 +413,49 @@ async function confirmPendingAction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openGenerateDialog() {
|
||||||
|
boardMeetingReference.value = ''
|
||||||
|
boardMeetingReferenceError.value = null
|
||||||
|
generateDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGenerateDialog() {
|
||||||
|
if (generateSubmitting.value) return
|
||||||
|
generateDialogOpen.value = false
|
||||||
|
boardMeetingReference.value = ''
|
||||||
|
boardMeetingReferenceError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmGenerateLetter() {
|
||||||
|
if (!application.value || generateSubmitting.value) return
|
||||||
|
|
||||||
|
const reference = boardMeetingReference.value.trim()
|
||||||
|
if (!reference) {
|
||||||
|
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
generateSubmitting.value = true
|
||||||
|
boardMeetingReferenceError.value = null
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await generateMembershipApplicationResultLetter(application.value.id, {
|
||||||
|
board_meeting_reference: reference,
|
||||||
|
})
|
||||||
|
|
||||||
|
application.value = response.data.application
|
||||||
|
successMessage.value = response.message
|
||||||
|
closeGenerateDialog()
|
||||||
|
} catch (err) {
|
||||||
|
const validationErrors = getApiValidationErrors(err)
|
||||||
|
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
|
||||||
|
} finally {
|
||||||
|
generateSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) {
|
async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) {
|
||||||
if (!application.value || downloadingDocumentId.value) return
|
if (!application.value || downloadingDocumentId.value) return
|
||||||
|
|
||||||
@@ -594,6 +660,46 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box v-if="showGenerateResultLetter" class="p-5 sm:p-6">
|
||||||
|
<div class="font-medium">Surat Keputusan</div>
|
||||||
|
<p class="mt-1 text-sm opacity-70">
|
||||||
|
Jana surat keputusan lembaga untuk permohonan ini. Surat hanya boleh dijana sekali.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="primary" @click="openGenerateDialog">
|
||||||
|
<FileText class="mr-2 size-4" />
|
||||||
|
Jana Surat Keputusan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box v-else-if="resultLetterDocument" class="p-5 sm:p-6">
|
||||||
|
<div class="font-medium">Surat Keputusan</div>
|
||||||
|
<p class="mt-1 text-sm opacity-70">
|
||||||
|
{{ resultLetterDocument.name }} · {{ formatFileSize(resultLetterDocument.file_size) }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
look="outline"
|
||||||
|
:disabled="previewLoading && previewDocument?.id === resultLetterDocument.id"
|
||||||
|
@click="handleViewDocument(resultLetterDocument)"
|
||||||
|
>
|
||||||
|
<Eye class="mr-2 size-4" />
|
||||||
|
Lihat
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
look="outline"
|
||||||
|
:disabled="downloadingDocumentId === resultLetterDocument.id"
|
||||||
|
@click="handleDownloadDocument(resultLetterDocument)"
|
||||||
|
>
|
||||||
|
<Download class="mr-2 size-4" />
|
||||||
|
{{ downloadingDocumentId === resultLetterDocument.id ? 'Memuat turun...' : 'Muat Turun' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<TabsRoot defaultValue="personal" class="w-full">
|
<TabsRoot defaultValue="personal" class="w-full">
|
||||||
<Box raised="single" class="w-full p-0">
|
<Box raised="single" class="w-full p-0">
|
||||||
<div class="w-full px-5 py-4">
|
<div class="w-full px-5 py-4">
|
||||||
@@ -783,9 +889,9 @@ onUnmounted(() => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="documents" class="mt-6">
|
<TabsContent value="documents" class="mt-6">
|
||||||
<div v-if="!application.documents.length" class="opacity-70">Tiada dokumen dimuat naik.</div>
|
<div v-if="!uploadedDocuments.length" class="opacity-70">Tiada dokumen dimuat naik.</div>
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<div v-for="document in application.documents" :key="document.id"
|
<div v-for="document in uploadedDocuments" :key="document.id"
|
||||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-foreground/10 p-4">
|
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-foreground/10 p-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium">{{ documentLabel(document.type, document.name) }}</div>
|
<div class="font-medium">{{ documentLabel(document.type, document.name) }}</div>
|
||||||
@@ -871,6 +977,43 @@ onUnmounted(() => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
|
|
||||||
|
<DialogRoot :open="generateDialogOpen" @openChange="(details) => { if (!details.open) closeGenerateDialog() }">
|
||||||
|
<DialogContent>
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="text-2xl font-medium">Jana Surat Keputusan</div>
|
||||||
|
<p v-if="application" class="mt-2 text-sm opacity-70">
|
||||||
|
{{ application.application_number }} · {{ application.applicant?.name ?? '-' }}
|
||||||
|
</p>
|
||||||
|
<Field class="mt-5">
|
||||||
|
<FieldLabel for="detail-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="detail-board-meeting-reference"
|
||||||
|
v-model="boardMeetingReference"
|
||||||
|
type="text"
|
||||||
|
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026"
|
||||||
|
:disabled="generateSubmitting"
|
||||||
|
@input="boardMeetingReferenceError = null"
|
||||||
|
/>
|
||||||
|
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 pb-8 text-center">
|
||||||
|
<DialogCloseTrigger class="mr-2 w-32" :disabled="generateSubmitting" @click="closeGenerateDialog">
|
||||||
|
Batal
|
||||||
|
</DialogCloseTrigger>
|
||||||
|
<Button
|
||||||
|
class="w-32"
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="generateSubmitting"
|
||||||
|
@click="confirmGenerateLetter"
|
||||||
|
>
|
||||||
|
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="previewOpen" class="fixed inset-0 z-70 flex items-center justify-center p-4 sm:p-6" role="dialog"
|
<div v-if="previewOpen" class="fixed inset-0 z-70 flex items-center justify-center p-4 sm:p-6" role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { CircleAlert, CircleCheck, Search, Eye, Pencil } from '@lucide/vue'
|
import { CircleAlert, CircleCheck, Search, Eye, Pencil, FileText, Download } from '@lucide/vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import * as select from '@zag-js/select'
|
import * as select from '@zag-js/select'
|
||||||
import {
|
import {
|
||||||
@@ -26,13 +26,18 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useMembershipApplicationList } from '../composables/useMembershipApplicationList'
|
import { useMembershipApplicationList } from '../composables/useMembershipApplicationList'
|
||||||
import { usePermissions } from '@/composables/usePermissions'
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
import { batchCompleteMembershipApplications } from '../services/membership-application.service'
|
import {
|
||||||
|
batchCompleteMembershipApplications,
|
||||||
|
downloadMembershipApplicationDocument,
|
||||||
|
generateMembershipApplicationResultLetter,
|
||||||
|
} from '../services/membership-application.service'
|
||||||
import {
|
import {
|
||||||
boardResultBadgeVariant,
|
boardResultBadgeVariant,
|
||||||
statusBadgeLook,
|
statusBadgeLook,
|
||||||
@@ -41,6 +46,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
BatchCompleteFailedItem,
|
BatchCompleteFailedItem,
|
||||||
BatchCompleteResponse,
|
BatchCompleteResponse,
|
||||||
|
GenerateResultLetterResponse,
|
||||||
MembershipApplicationBoardResult,
|
MembershipApplicationBoardResult,
|
||||||
MembershipApplicationListItem,
|
MembershipApplicationListItem,
|
||||||
MembershipApplicationStatus,
|
MembershipApplicationStatus,
|
||||||
@@ -99,11 +105,19 @@ const {
|
|||||||
} = useMembershipApplicationList()
|
} = useMembershipApplicationList()
|
||||||
|
|
||||||
const canBatchComplete = computed(() => hasPermission('selesaikan permohonan keahlian'))
|
const canBatchComplete = computed(() => hasPermission('selesaikan permohonan keahlian'))
|
||||||
|
const canGenerateResultLetter = computed(() => hasPermission('jana surat keputusan keahlian'))
|
||||||
const selectedIds = ref<string[]>([])
|
const selectedIds = ref<string[]>([])
|
||||||
const batchSubmitting = ref(false)
|
const batchSubmitting = ref(false)
|
||||||
const batchConfirmOpen = ref(false)
|
const batchConfirmOpen = ref(false)
|
||||||
const batchSuccessMessage = ref<string | null>(null)
|
const batchSuccessMessage = ref<string | null>(null)
|
||||||
|
const letterSuccessMessage = ref<string | null>(null)
|
||||||
const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
|
const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
|
||||||
|
const generateDialogOpen = ref(false)
|
||||||
|
const generateSubmitting = ref(false)
|
||||||
|
const boardMeetingReference = ref('')
|
||||||
|
const boardMeetingReferenceError = ref<string | null>(null)
|
||||||
|
const generateTarget = ref<MembershipApplicationListItem | null>(null)
|
||||||
|
const downloadingResultLetterId = ref<string | null>(null)
|
||||||
|
|
||||||
const selectableApplications = computed(() =>
|
const selectableApplications = computed(() =>
|
||||||
applications.value.filter((item) => item.status === 'PENDING_NOTIFICATION'),
|
applications.value.filter((item) => item.status === 'PENDING_NOTIFICATION'),
|
||||||
@@ -227,6 +241,90 @@ function goToApplicationEdit(id: string) {
|
|||||||
router.push({ name: 'edit-membership-application', params: { id } })
|
router.push({ name: 'edit-membership-application', params: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canGenerateLetter(item: MembershipApplicationListItem): boolean {
|
||||||
|
return (
|
||||||
|
canGenerateResultLetter.value &&
|
||||||
|
item.status === 'COMPLETED' &&
|
||||||
|
!item.has_result_letter &&
|
||||||
|
(item.board_result === 'PASS' || item.board_result === 'FAIL')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDownloadLetter(item: MembershipApplicationListItem): boolean {
|
||||||
|
return !!item.has_result_letter && !!item.result_letter_document
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGenerateDialog(item: MembershipApplicationListItem) {
|
||||||
|
generateTarget.value = item
|
||||||
|
boardMeetingReference.value = ''
|
||||||
|
boardMeetingReferenceError.value = null
|
||||||
|
generateDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGenerateDialog() {
|
||||||
|
if (generateSubmitting.value) return
|
||||||
|
generateDialogOpen.value = false
|
||||||
|
generateTarget.value = null
|
||||||
|
boardMeetingReference.value = ''
|
||||||
|
boardMeetingReferenceError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmGenerateLetter() {
|
||||||
|
if (!generateTarget.value || generateSubmitting.value) return
|
||||||
|
|
||||||
|
const reference = boardMeetingReference.value.trim()
|
||||||
|
if (!reference) {
|
||||||
|
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
generateSubmitting.value = true
|
||||||
|
boardMeetingReferenceError.value = null
|
||||||
|
error.value = null
|
||||||
|
letterSuccessMessage.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await generateMembershipApplicationResultLetter(generateTarget.value.id, {
|
||||||
|
board_meeting_reference: reference,
|
||||||
|
})
|
||||||
|
|
||||||
|
letterSuccessMessage.value = response.message
|
||||||
|
closeGenerateDialog()
|
||||||
|
await fetchApplications(page.value)
|
||||||
|
} catch (err) {
|
||||||
|
if (axios.isAxiosError(err) && err.response?.data) {
|
||||||
|
const responseData = err.response.data as GenerateResultLetterResponse
|
||||||
|
const validationErrors = getApiValidationErrors(err)
|
||||||
|
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
|
||||||
|
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
|
||||||
|
} else {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
generateSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadResultLetter(item: MembershipApplicationListItem) {
|
||||||
|
const document = item.result_letter_document
|
||||||
|
if (!document) return
|
||||||
|
|
||||||
|
downloadingResultLetterId.value = item.id
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadMembershipApplicationDocument(
|
||||||
|
item.id,
|
||||||
|
document.id,
|
||||||
|
document.name,
|
||||||
|
document.mime_type,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuat turun surat keputusan.')
|
||||||
|
} finally {
|
||||||
|
downloadingResultLetterId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const headers = computed<TableHeader[]>(() => {
|
const headers = computed<TableHeader[]>(() => {
|
||||||
const base: TableHeader[] = [
|
const base: TableHeader[] = [
|
||||||
{ title: 'Bil.', key: '#', sortable: false },
|
{ title: 'Bil.', key: '#', sortable: false },
|
||||||
@@ -286,6 +384,13 @@ const headers = computed<TableHeader[]>(() => {
|
|||||||
<p class="mt-1 text-sm opacity-70">Urus dan semak permohonan keahlian koperasi.</p>
|
<p class="mt-1 text-sm opacity-70">Urus dan semak permohonan keahlian koperasi.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="letterSuccessMessage" variant="success">
|
||||||
|
<CircleCheck />
|
||||||
|
<AlertTitle>Berjaya</AlertTitle>
|
||||||
|
<AlertDescription>{{ letterSuccessMessage }}</AlertDescription>
|
||||||
|
<AlertCloseTrigger @click="letterSuccessMessage = null" />
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
<AlertRoot v-if="batchSuccessMessage" variant="success">
|
<AlertRoot v-if="batchSuccessMessage" variant="success">
|
||||||
<CircleCheck />
|
<CircleCheck />
|
||||||
<AlertTitle>Berjaya</AlertTitle>
|
<AlertTitle>Berjaya</AlertTitle>
|
||||||
@@ -411,6 +516,29 @@ const headers = computed<TableHeader[]>(() => {
|
|||||||
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)">
|
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)">
|
||||||
<Pencil class="size-4" aria-hidden="true" />
|
<Pencil class="size-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="canGenerateLetter(item as MembershipApplicationListItem)"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="bg-amber-600 text-white"
|
||||||
|
title="Jana surat keputusan"
|
||||||
|
@click="openGenerateDialog(item as MembershipApplicationListItem)"
|
||||||
|
>
|
||||||
|
<FileText class="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="canDownloadLetter(item as MembershipApplicationListItem)"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="bg-purple-600 text-white"
|
||||||
|
title="Muat turun surat keputusan"
|
||||||
|
:disabled="downloadingResultLetterId === (item as MembershipApplicationListItem).id"
|
||||||
|
@click="handleDownloadResultLetter(item as MembershipApplicationListItem)"
|
||||||
|
>
|
||||||
|
<Download class="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
@@ -434,5 +562,45 @@ const headers = computed<TableHeader[]>(() => {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
|
|
||||||
|
<DialogRoot :open="generateDialogOpen" @openChange="(details) => { if (!details.open) closeGenerateDialog() }">
|
||||||
|
<DialogContent>
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="text-2xl font-medium">Jana Surat Keputusan</div>
|
||||||
|
<p v-if="generateTarget" class="mt-2 text-sm opacity-70">
|
||||||
|
{{ generateTarget.application_number }} · {{ generateTarget.applicant?.name ?? '-' }}
|
||||||
|
</p>
|
||||||
|
<Field class="mt-5">
|
||||||
|
<FieldLabel for="board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="board-meeting-reference"
|
||||||
|
v-model="boardMeetingReference"
|
||||||
|
type="text"
|
||||||
|
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026"
|
||||||
|
:disabled="generateSubmitting"
|
||||||
|
@input="boardMeetingReferenceError = null"
|
||||||
|
/>
|
||||||
|
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||||
|
</Field>
|
||||||
|
<p class="mt-3 text-sm opacity-70">
|
||||||
|
Surat hanya boleh dijana sekali dan akan disimpan sebagai dokumen permohonan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 pb-8 text-center">
|
||||||
|
<DialogCloseTrigger class="mr-2 w-32" :disabled="generateSubmitting" @click="closeGenerateDialog">
|
||||||
|
Batal
|
||||||
|
</DialogCloseTrigger>
|
||||||
|
<Button
|
||||||
|
class="w-32"
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="generateSubmitting"
|
||||||
|
@click="confirmGenerateLetter"
|
||||||
|
>
|
||||||
|
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { api } from '@/core/services/api'
|
import { api } from '@/core/services/api'
|
||||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||||
import type {
|
import type {
|
||||||
|
BatchCompleteResponse,
|
||||||
DocumentUploadType,
|
DocumentUploadType,
|
||||||
|
GenerateResultLetterPayload,
|
||||||
|
GenerateResultLetterResponse,
|
||||||
ListMembershipApplicationsParams,
|
ListMembershipApplicationsParams,
|
||||||
MembershipApplicationApiResponse,
|
MembershipApplicationApiResponse,
|
||||||
MembershipApplicationFormState,
|
MembershipApplicationFormState,
|
||||||
@@ -9,7 +12,6 @@ import type {
|
|||||||
MembershipApplicationReviewPayload,
|
MembershipApplicationReviewPayload,
|
||||||
MembershipApplicationReviewResponse,
|
MembershipApplicationReviewResponse,
|
||||||
MembershipApplicationSubmitResponse,
|
MembershipApplicationSubmitResponse,
|
||||||
BatchCompleteResponse,
|
|
||||||
MembershipApplicationUpdateResponse,
|
MembershipApplicationUpdateResponse,
|
||||||
MemberLookupResponse,
|
MemberLookupResponse,
|
||||||
UpdateMembershipApplicationPayload,
|
UpdateMembershipApplicationPayload,
|
||||||
@@ -215,6 +217,22 @@ export async function batchCompleteMembershipApplications(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMembershipApplicationResultLetter(
|
||||||
|
id: string,
|
||||||
|
payload: GenerateResultLetterPayload,
|
||||||
|
): Promise<GenerateResultLetterResponse> {
|
||||||
|
const { data } = await api.post<GenerateResultLetterResponse>(
|
||||||
|
`/v1/membership-applications/${id}/result-letter`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Failed to generate result letter')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch membership application document/ view in modal
|
// Fetch membership application document/ view in modal
|
||||||
export async function fetchMembershipApplicationDocument(
|
export async function fetchMembershipApplicationDocument(
|
||||||
applicationId: string,
|
applicationId: string,
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export type MembershipApplicationStatus =
|
|||||||
|
|
||||||
export type MembershipApplicationBoardResult = 'PASS' | 'FAIL'
|
export type MembershipApplicationBoardResult = 'PASS' | 'FAIL'
|
||||||
|
|
||||||
|
export const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter' as const
|
||||||
|
|
||||||
export interface MembershipApplicationApplicantSummary {
|
export interface MembershipApplicationApplicantSummary {
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
@@ -92,6 +94,8 @@ export interface MembershipApplicationListItem {
|
|||||||
status: MembershipApplicationStatus
|
status: MembershipApplicationStatus
|
||||||
board_result: MembershipApplicationBoardResult | null
|
board_result: MembershipApplicationBoardResult | null
|
||||||
submitted_at: string | null
|
submitted_at: string | null
|
||||||
|
has_result_letter: boolean
|
||||||
|
result_letter_document: MembershipApplicationDocumentDetail | null
|
||||||
applicant: MembershipApplicationApplicantSummary | null
|
applicant: MembershipApplicationApplicantSummary | null
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
}
|
}
|
||||||
@@ -232,3 +236,16 @@ export type BatchCompleteResponse = {
|
|||||||
failed: BatchCompleteFailedItem[]
|
failed: BatchCompleteFailedItem[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerateResultLetterPayload {
|
||||||
|
board_meeting_reference: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerateResultLetterResponse = {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
application: MembershipApplicationDetail
|
||||||
|
document: MembershipApplicationDocumentDetail | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user