DONE: update logo to original official logo, update letterhead and letterfooter layout (#8)
Build Docker Image / build-backend (push) Successful in 52s
Build Docker Image / build-frontend (push) Successful in 32s

Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-07-07 14:45:20 +08:00
parent 1cc3351ca6
commit 18c0d268b1
76 changed files with 547 additions and 402 deletions
+3 -4
View File
@@ -1,12 +1,11 @@
name: Build Docker Image name: Build Docker Image
# Builds images on every push to main or tag, but does NOT auto-deploy
on: on:
push: push:
branches: # branches:
- main # - main
tags: tags:
- "v*" # v1.0.0, v1.0.1, etc. - "v*"
jobs: jobs:
# test-backend: # test-backend:
@@ -189,6 +189,7 @@ class MembershipApplicationController extends Controller
$membershipApplication, $membershipApplication,
$request->user(), $request->user(),
$request->validated('board_meeting_reference'), $request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
); );
$message = $application->board_result === BoardResult::Pass->value $message = $application->board_result === BoardResult::Pass->value
@@ -210,6 +211,7 @@ class MembershipApplicationController extends Controller
$request->validated('application_ids'), $request->validated('application_ids'),
$request->user(), $request->user(),
$request->validated('board_meeting_reference'), $request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
); );
$succeededCount = count($result['succeeded']); $succeededCount = count($result['succeeded']);
@@ -270,6 +272,7 @@ class MembershipApplicationController extends Controller
$application = $this->membershipApplicationService->generateResultLetter( $application = $this->membershipApplicationService->generateResultLetter(
$membershipApplication, $membershipApplication,
$request->validated('board_meeting_reference'), $request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
); );
$resultLetter = $application->documents $resultLetter = $application->documents
@@ -301,11 +304,16 @@ class MembershipApplicationController extends Controller
'board_meeting_reference', 'board_meeting_reference',
'Mesyuarat Lembaga', 'Mesyuarat Lembaga',
); );
$boardMeetingDate = (string) $request->query(
'board_meeting_date',
now()->toDateString(),
);
$view = 'membershipapplication::pdf.approved-letter'; $view = 'membershipapplication::pdf.approved-letter';
$data = $this->membershipApplicationService->getResultLetterViewData( $data = $this->membershipApplicationService->getResultLetterViewData(
$application, $application,
$boardMeetingReference, $boardMeetingReference,
$boardMeetingDate,
); );
$format = $request->query('format', 'html'); $format = $request->query('format', 'html');
$filename = 'surat-keputusan-'.$application->application_number.'.pdf'; $filename = 'surat-keputusan-'.$application->application_number.'.pdf';
@@ -20,6 +20,7 @@ class BatchCompleteRequest extends FormRequest
'application_ids' => 'required|array|min:1|max:100', 'application_ids' => 'required|array|min:1|max:100',
'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id', 'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id',
'board_meeting_reference' => 'required|string|max:255', 'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
]; ];
} }
@@ -36,6 +37,8 @@ class BatchCompleteRequest extends FormRequest
'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.', 'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.',
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.', 'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.', 'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
]; ];
} }
} }
@@ -18,6 +18,7 @@ class CompleteMembershipApplicationRequest extends FormRequest
{ {
return [ return [
'board_meeting_reference' => 'required|string|max:255', 'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
]; ];
} }
@@ -29,6 +30,8 @@ class CompleteMembershipApplicationRequest extends FormRequest
return [ return [
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.', 'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.', 'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
]; ];
} }
} }
@@ -18,6 +18,7 @@ class GenerateResultLetterRequest extends FormRequest
{ {
return [ return [
'board_meeting_reference' => 'required|string|max:255', 'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
]; ];
} }
@@ -29,6 +30,8 @@ class GenerateResultLetterRequest extends FormRequest
return [ return [
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.', 'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.', 'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
]; ];
} }
} }
@@ -12,49 +12,77 @@
{{ $applicant->postcode }} {{ $applicant->postcode }}
@endsection @endsection
@section('letter-honorific')
Tuan/Puan,
@endsection
@section('letter-subject') @section('letter-subject')
<strong>{{ $letterSubject }}</strong> <strong>{{ $letterSubject }}</strong>
@endsection @endsection
@section('letter-body') @section('letter-body')
<p style="line-height: 1;"> <p style="line-height: 1;">
Dengan segala hormatnya saya merujuk kepada perkara di atas. Dengan segala hormatnya perkara di atas adalah dirujuk.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi 2.
Permodalan Kelantan Berhad (KoPKB) telah <strong>{{ $boardResultLabel }}</strong> dalam {{ $boardMeetingReference }}@if ($isPassed && $memberNumber). Nombor keanggotaan tuan adalah <strong>{{ $memberNumber }}</strong>@endif. @if ($isPassed)
Sukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>Anggota Koperasi Permodalan Kelantan Berhad (KoPKB)</strong> telah dipertimbangkan dan <strong>DILULUSKAN</strong> oleh Lembaga Koperasi dalam {{ $boardMeetingReference }} yang diadakan pada {{ $boardMeetingDate }}.
@else
Dukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>Anggota Koperasi Permodalan Kelantan Berhad (KoPKB)</strong> telah dipertimbangkan dan <strong>TIDAK DILULUSKAN</strong> oleh Lembaga Koperasi dalam {{ $boardMeetingReference }} yang diadakan pada {{ $boardMeetingDate }}.
@endif
</p> </p>
@if ($isPassed) @if ($isPassed)
<p style="line-height: 1;"> <p style="line-height: 1;">
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaimana ketetapan berikut:- 3. Maklumat keanggotaan tuan/puan adalah seperti berikut:-
<ul style="line-height: 1;"> </p>
<li>Saham</li> <table class="letter-info-table" cellpadding="0" cellspacing="0">
<ul style="line-height: 1;"> <tr>
<li>Potongan sebanyak RM {{ $monthlyShareInstallment }} ({{ $shareInstallmentMonths }} bulan)</li> <th>Perkara</th>
<li>Potongan sebanyak RM {{ $stockMonthlyContribution }} sehingga Syer Maksima</li> <th>Maklumat</th>
</ul> </tr>
<li>Yuran</li> <tr>
<ul style="line-height: 1;"> <td>Nama Anggota</td>
<li>Potongan sebanyak RM {{ $feeMonthlyContribution }} setiap bulan</li> <td>{{ $applicant->name }}</td>
</ul> </tr>
<li>Fi Masuk</li> <tr>
<ul style="line-height: 1;"> <td>No. Kad Pengenalan</td>
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li> <td>{{ $applicant->ic_number }}</td>
</ul> </tr>
</ul> <tr>
<td>No. Anggota</td>
<td>{{ $memberNumber }}</td>
</tr>
<tr>
<td>Tarikh Kuat Kuasa Keanggotaan</td>
<td>{{ $boardMeetingDate }}</td>
</tr>
<tr>
<td>Modal Syer Permulaan</td>
<td>RM500.00 (perlu dijelaskan dalam tempoh 6 bulan)</td>
</tr>
<tr>
<td>Fi Masuk</td>
<td>RM10.00</td>
</tr>
<tr>
<td>Yuran Tahunan</td>
<td>RM30.00 sebulan</td>
</tr>
</table>
<p style="line-height: 1;">
4. Sebagai anggota KoPKB, tuan/puan adalah tertakluk kepada Undang-Undang Kecil (UUK), dasar, polisi dan peraturan-peraturan KoPKB yang berkuat kuasa dari semasa ke semasa.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki 5. Sebagai anggota, tuan/puan adalah layak menikmati kemudahan, produk dan manfaat yang ditawarkan oleh koperasi dari semasa ke semasa, tertakluk kepada syarat-syarat yang ditetapkan.
menjelaskan modal syer minimum sebanyak RM{{ $minimumShareCapital }}. </p>
Bayaran tersebut perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan. <p style="line-height: 1;">
Pemenuhan syarat ini adalah bagi melayakkan tuan memperoleh hak, kewajipan dan liabiliti sebagai anggota KoPKB 6. Pihak KoPKB mengucapkan tahniah dan mengalu-alukan penyertaan tuan/puan sebagai anggota. Semoga keanggotaan ini akan memberi manfaat kepada tuan/puan serta menyumbang kepada kemajuan dan kecemerlangan 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> </p>
@else @else
<p style="line-height: 1;"> <p style="line-height: 1;">
3. Oleh yang demikian, permohonan tuan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini. 3. Oleh yang demikian, permohonan tuan/puan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
</p> </p>
@endif @endif
@endsection @endsection
@@ -64,14 +92,5 @@
@endsection @endsection
@section('letter-signature') @section('letter-signature')
<div class="signature-header">Dengan hormatnya,</div>
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div> <div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
<div class="signature-space">
@if (! empty($signatureUrl))
<img src="{{ $signatureUrl }}" alt="" class="signature-space-img">
@endif
</div>
<div class="signatory-name">MUHAMMAD NAFIS BIN ZAINUDIN</div>
<div class="signatory-title">Pengurus Besar</div>
<div class="signatory-title">b/p Setiausaha</div>
@endsection @endsection
@@ -7,6 +7,7 @@ use App\Services\DocumentService;
use App\Services\LetterService; 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\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
@@ -258,6 +259,7 @@ class MembershipApplicationService
MembershipApplication $application, MembershipApplication $application,
User $processedBy, User $processedBy,
string $boardMeetingReference, string $boardMeetingReference,
string $boardMeetingDate,
): MembershipApplication { ): MembershipApplication {
if ($application->status !== ApplicationStatus::PendingNotification) { if ($application->status !== ApplicationStatus::PendingNotification) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -271,14 +273,14 @@ class MembershipApplicationService
]); ]);
} }
return DB::transaction(function () use ($application, $boardMeetingReference) { return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) { if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
$user = $this->createMemberFromApplication($application); $user = $this->createMemberFromApplication($application);
$application->update(['user_id' => $user->id]); $application->update(['user_id' => $user->id]);
$application->refresh(); $application->refresh();
} }
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference); $resultLetter = $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
$this->sendResultNotification($application, $resultLetter); $this->sendResultNotification($application, $resultLetter);
@@ -298,8 +300,12 @@ class MembershipApplicationService
* failed: list<array{id: string, application_number: string|null, message: string}> * failed: list<array{id: string, application_number: string|null, message: string}>
* } * }
*/ */
public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference): array public function completeBatch(
{ array $applicationIds,
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$succeeded = []; $succeeded = [];
$failed = []; $failed = [];
@@ -317,7 +323,12 @@ class MembershipApplicationService
} }
try { try {
$succeeded[] = $this->complete($application, $processedBy, $boardMeetingReference); $succeeded[] = $this->complete(
$application,
$processedBy,
$boardMeetingReference,
$boardMeetingDate,
);
} catch (ValidationException $e) { } catch (ValidationException $e) {
$failed[] = [ $failed[] = [
'id' => $id, 'id' => $id,
@@ -333,7 +344,11 @@ class MembershipApplicationService
]; ];
} }
public function generateResultLetter(MembershipApplication $application, string $boardMeetingReference,): MembershipApplication { public function generateResultLetter(
MembershipApplication $application,
string $boardMeetingReference,
string $boardMeetingDate,
): MembershipApplication {
if ($application->status !== ApplicationStatus::Completed) { if ($application->status !== ApplicationStatus::Completed) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'], 'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'],
@@ -346,8 +361,8 @@ class MembershipApplicationService
]); ]);
} }
return DB::transaction(function () use ($application, $boardMeetingReference) { return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
$this->storeResultLetter($application, $boardMeetingReference); $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
return $this->repository->findByIdWithRelations($application->id); return $this->repository->findByIdWithRelations($application->id);
}); });
@@ -356,6 +371,7 @@ class MembershipApplicationService
protected function storeResultLetter( protected function storeResultLetter(
MembershipApplication $application, MembershipApplication $application,
string $boardMeetingReference, string $boardMeetingReference,
string $boardMeetingDate,
): Document { ): Document {
$lockedApplication = MembershipApplication::query() $lockedApplication = MembershipApplication::query()
->whereKey($application->id) ->whereKey($application->id)
@@ -376,7 +392,7 @@ class MembershipApplicationService
]); ]);
} }
$viewData = $this->buildResultLetterViewData($application, $boardMeetingReference); $viewData = $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate);
$pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData); $pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData);
$fileName = 'surat-keputusan-'.$application->application_number.'.pdf'; $fileName = 'surat-keputusan-'.$application->application_number.'.pdf';
@@ -394,6 +410,7 @@ class MembershipApplicationService
public function getResultLetterViewData( public function getResultLetterViewData(
MembershipApplication $application, MembershipApplication $application,
string $boardMeetingReference, string $boardMeetingReference,
string $boardMeetingDate,
): array { ): array {
$application = $this->repository->findByIdWithRelations($application->id); $application = $this->repository->findByIdWithRelations($application->id);
@@ -403,7 +420,7 @@ class MembershipApplicationService
]); ]);
} }
return $this->buildResultLetterViewData($application, $boardMeetingReference); return $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate);
} }
/** /**
@@ -412,32 +429,20 @@ class MembershipApplicationService
protected function buildResultLetterViewData( protected function buildResultLetterViewData(
MembershipApplication $application, MembershipApplication $application,
string $boardMeetingReference, string $boardMeetingReference,
string $boardMeetingDate,
): array { ): array {
$applicant = $application->applicant; $applicant = $application->applicant;
$isPassed = $application->board_result === BoardResult::Pass->value; $isPassed = $application->board_result === BoardResult::Pass->value;
$stockMonthlyContribution = (float) $applicant->stock_monthly_contribution;
$monthlyShareInstallment = number_format(
$stockMonthlyContribution < 84 ? 84 : $stockMonthlyContribution,
2,
'.',
'',
);
return [ return [
'application' => $application, 'application' => $application,
'applicant' => $applicant, 'applicant' => $applicant,
'boardMeetingReference' => $boardMeetingReference, 'boardMeetingReference' => $boardMeetingReference,
'boardMeetingDate' => Carbon::parse($boardMeetingDate)->translatedFormat('d M Y'),
'isPassed' => $isPassed, 'isPassed' => $isPassed,
'boardResultLabel' => $isPassed ? 'lulus' : 'gagal',
'openingTone' => $isPassed ? 'Sukacita' : 'Dukacita',
'letterSubject' => $isPassed 'letterSubject' => $isPassed
? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD' ? 'KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)'
: 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD', : 'KEPUTUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)',
'monthlyShareInstallment' => $monthlyShareInstallment,
'stockMonthlyContribution' => number_format($stockMonthlyContribution, 2, '.', ''),
'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''),
'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS,
'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''),
'memberNumber' => $isPassed ? $application->user?->member_number : null, 'memberNumber' => $isPassed ? $application->user?->member_number : null,
]; ];
} }
+1 -1
View File
@@ -4,5 +4,5 @@ return [
'name' => 'User', 'name' => 'User',
'public_profile_token_ttl_days' => (int) env('PUBLIC_PROFILE_TOKEN_TTL_DAYS', 90), 'public_profile_token_ttl_days' => (int) env('PUBLIC_PROFILE_TOKEN_TTL_DAYS', 90),
'frontend_url' => rtrim(env('FRONTEND_URL', env('APP_URL', 'http://localhost')), '/'), 'frontend_url' => rtrim(env('FRONTEND_URL', env('APP_URL', 'http://localhost')), '/'),
'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo.svg')), 'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo-original.svg')),
]; ];
+1 -1
View File
@@ -124,6 +124,6 @@ return [
| |
*/ */
'logo_path' => public_path('images/logo/logo-letterhead.png'), 'logo_path' => public_path('images/logo/logo-original.svg'),
]; ];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

+67 -106
View File
@@ -8,6 +8,7 @@
@section('letter-reference') Ruj: APP-2026-00001 @endsection @section('letter-reference') Ruj: APP-2026-00001 @endsection
@section('letter-date') {{ now()->format('d F Y') }} @endsection @section('letter-date') {{ now()->format('d F Y') }} @endsection
@section('letter-recipient') ... @endsection @section('letter-recipient') ... @endsection
@section('letter-honorific') Tuan/Puan @endsection
@section('letter-subject') Perkara: ... @endsection @section('letter-subject') Perkara: ... @endsection
@section('letter-salutation') Assalamualaikum dan Salam Sejahtera, @endsection @section('letter-salutation') Assalamualaikum dan Salam Sejahtera, @endsection
@section('letter-body') ... @endsection @section('letter-body') ... @endsection
@@ -110,74 +111,27 @@
border-collapse: collapse; border-collapse: collapse;
} }
.letterhead-line { .letterhead-center {
vertical-align: middle; text-align: center;
padding-right: 16px; vertical-align: top;
}
.letterhead-rule {
width: 100%;
border-collapse: collapse;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.letterhead-rule-blue {
background-color: var(--letter-color-blue);
font-size: 0;
line-height: 0;
}
.letterhead-rule-orange {
background-color: var(--letter-color-orange);
font-size: 0;
line-height: 0;
}
.letterhead-logo {
vertical-align: middle;
white-space: nowrap;
padding-right: 10px;
} }
.letterhead-logo-img { .letterhead-logo-img {
height: var(--letter-logo-height); height: var(--letter-logo-height);
width: auto; width: auto;
display: block; display: block;
margin: 0 auto 8px;
} }
.letterhead-brand { .letterhead-address,
vertical-align: middle; .letterhead-contact {
white-space: nowrap;
padding-left: 4px;
}
.letterhead-brand-inner {
border-collapse: collapse;
}
.letterhead-name {
vertical-align: middle;
text-align: left;
padding-left: 2px;
}
.letterhead-name-line {
font-size: var(--letter-font-size);
font-weight: bold;
line-height: 1.25;
color: var(--letter-color-black);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.letterhead-registration {
font-size: var(--letterhead-registration-size); font-size: var(--letterhead-registration-size);
font-weight: bold; line-height: 1.4;
line-height: 1.3;
color: var(--letter-color-black); color: var(--letter-color-black);
margin-top: 2px; }
letter-spacing: 0.5px;
.letterhead-address {
margin-bottom: 4px;
} }
.meta-table { .meta-table {
@@ -192,22 +146,25 @@
} }
.meta-label { .meta-label {
width: 70px;
font-weight: bold; font-weight: bold;
white-space: nowrap;
padding-right: 4px;
vertical-align: top;
} }
.meta-value { .meta-value {
padding-left: 4px; padding-left: 4px;
} }
.meta-date {
margin-top: 4px;
}
.recipient-block { .recipient-block {
margin-bottom: var(--letter-recipient-margin-bottom); margin-bottom: var(--letter-recipient-margin-bottom);
} }
.letter-honorific {
font-weight: bold;
margin-bottom: 8px;
}
.subject-block { .subject-block {
font-weight: bold; font-weight: bold;
margin-bottom: var(--letter-subject-margin-bottom); margin-bottom: var(--letter-subject-margin-bottom);
@@ -223,6 +180,26 @@
margin: 0 0 var(--letter-body-paragraph-margin-bottom); margin: 0 0 var(--letter-body-paragraph-margin-bottom);
} }
.letter-info-table {
width: 80%;
/* max-width: 50%; */
margin: 0 auto var(--letter-body-paragraph-margin-bottom);
border-collapse: collapse;
line-height: 1;
}
.letter-info-table th,
.letter-info-table td {
border: 1px solid var(--letter-color-black);
padding: 4px 8px;
vertical-align: top;
text-align: left;
}
.letter-info-table th {
font-weight: bold;
}
.signature-block { .signature-block {
padding-left: var(--letter-content-padding-x); padding-left: var(--letter-content-padding-x);
padding-right: var(--letter-content-padding-x); padding-right: var(--letter-content-padding-x);
@@ -267,43 +244,18 @@
table-layout: fixed; table-layout: fixed;
} }
.letter-footer-contact-box { .letter-footer-divider {
background-color: var(--letter-color-orange); border-top: 1px solid var(--letter-color-black);
padding: 8px 12px; font-size: 0;
line-height: 0;
padding: 0;
}
.letter-footer-nota {
padding-top: 8px;
font-size: var(--letter-footer-font-size); font-size: var(--letter-footer-font-size);
line-height: var(--letter-footer-line-height); line-height: var(--letter-footer-line-height);
color: var(--letter-color-white); color: var(--letter-body-color);
vertical-align: middle;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.letter-footer-address-inner {
background-color: var(--letter-color-blue);
color: var(--letter-color-white);
padding: 8px 12px;
font-size: var(--letter-footer-font-size);
line-height: var(--letter-footer-line-height);
text-align: left;
vertical-align: middle;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.letter-footer-lines {
border-collapse: collapse;
}
.letter-footer-icon {
width: 18px;
padding: 2px 8px 2px 0;
vertical-align: middle;
}
.letter-footer-line-text {
padding: 2px 0;
color: var(--letter-color-white);
vertical-align: middle;
} }
@media print { @media print {
@@ -328,17 +280,22 @@
<div class="letter-content"> <div class="letter-content">
<div class="meta-table"> <div class="meta-table">
@if ($showReference && trim($__env->yieldContent('letter-reference')) !== '') @if (($showReference && trim($__env->yieldContent('letter-reference')) !== '') || ($showDate && $letterDate !== ''))
<table cellpadding="0" cellspacing="0"> <table cellpadding="0" cellspacing="0">
<tr> @if ($showReference && trim($__env->yieldContent('letter-reference')) !== '')
<td class="meta-label">Ruj.</td> <tr>
<td class="meta-value">: @yield('letter-reference')</td> <td class="meta-label">RUJ. KAMI</td>
</tr> <td class="meta-value">: @yield('letter-reference')</td>
</tr>
@endif
@if ($showDate && $letterDate !== '')
<tr>
<td class="meta-label">TARIKH</td>
<td class="meta-value">: {{ $letterDate }}</td>
</tr>
@endif
</table> </table>
@endif @endif
@if ($showDate && $letterDate !== '')
<div class="meta-date">{{ $letterDate }}</div>
@endif
</div> </div>
@hasSection('letter-recipient') @hasSection('letter-recipient')
@@ -347,6 +304,10 @@
</div> </div>
@endif @endif
@hasSection('letter-honorific')
<div class="letter-honorific">@yield('letter-honorific')</div>
@endif
@if ($showSubject && trim($__env->yieldContent('letter-subject')) !== '') @if ($showSubject && trim($__env->yieldContent('letter-subject')) !== '')
<div class="subject-block"> <div class="subject-block">
@yield('letter-subject') @yield('letter-subject')
@@ -1,38 +1,11 @@
<table class="letter-footer" width="100%" cellpadding="0" cellspacing="0" border="0"> <table class="letter-footer" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr> <tr>
<td class="letter-footer-contact-box" valign="middle" width="50%" bgcolor="#d35400"> <td class="letter-footer-divider">&nbsp;</td>
<table class="letter-footer-lines" width="100%" cellpadding="0" cellspacing="0" border="0"> </tr>
<tr> <tr>
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'phone'])</td> <td class="letter-footer-nota">
<td class="letter-footer-line-text" valign="middle">Tel. Pejabat: 09-741 4331</td> <strong>Nota:</strong><br>
</tr> <em>Surat ini dijana secara komputer dan merupakan dokumen rasmi Koperasi Permodalan Kelantan Berhad (KoPKB). Oleh itu, surat ini adalah sah tanpa tandatangan.</em>
<tr>
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'fax'])</td>
<td class="letter-footer-line-text" valign="middle">Faks: 09-741 4330</td>
</tr>
<tr>
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'email'])</td>
<td class="letter-footer-line-text" valign="middle">E-mel: admin@koppkb.com</td>
</tr>
<tr>
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'web'])</td>
<td class="letter-footer-line-text" valign="middle">Laman Web: www.koppkb.com</td>
</tr>
</table>
</td>
<td class="letter-footer-address-inner" valign="middle" width="50%" bgcolor="#0e579b">
<table class="letter-footer-lines" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="letter-footer-icon" rowspan="3" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'location'])</td>
<td class="letter-footer-line-text" valign="middle">Lot PT 448. Tingkat 1, Jalan Kuala Krai</td>
</tr>
<tr>
<td class="letter-footer-line-text" valign="middle">Batu 3 Wakaf Che Yeh</td>
</tr>
<tr>
<td class="letter-footer-line-text" valign="middle">15150 Kota Bharu, Kelantan Darul Naim</td>
</tr>
</table>
</td> </td>
</tr> </tr>
</table> </table>
@@ -1,28 +1,15 @@
<table class="letterhead" width="100%" cellpadding="0" cellspacing="0"> <table class="letterhead" width="100%" cellpadding="0" cellspacing="0">
<tr> <tr>
<td class="letterhead-line" valign="middle"> <td class="letterhead-center" align="center" valign="top">
<table class="letterhead-rule" width="100%" cellpadding="0" cellspacing="0"> @if (! empty($logoUrl))
<tr> <img src="{{ $logoUrl }}" alt="Koperasi Permodalan Kelantan Berhad" class="letterhead-logo-img">
<td class="letterhead-rule-blue" width="50%" height="6" bgcolor="#0e579b">&nbsp;</td> @endif
<td class="letterhead-rule-orange" width="50%" height="6" bgcolor="#d35400">&nbsp;</td> <div class="letterhead-address">
</tr> Lot Pt 448, Tingkat 1, Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.
</table> </div>
</td> <div class="letterhead-contact">
<td class="letterhead-brand" valign="middle" align="right" width="1"> Tel: 09-7414331 | Fax: 09-7414330 | Web:www.koppkb.com
<table class="letterhead-brand-inner" cellpadding="0" cellspacing="0" align="right"> </div>
<tr>
@if (! empty($logoUrl))
<td class="letterhead-logo" valign="middle">
<img src="{{ $logoUrl }}" alt="Koperasi Permodalan Kelantan Berhad" class="letterhead-logo-img">
</td>
@endif
<td class="letterhead-name" valign="middle">
<div class="letterhead-name-line">KOPERASI PERMODALAN</div>
<div class="letterhead-name-line">KELANTAN BERHAD</div>
<div class="letterhead-registration">D-5-0228</div>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
</table> </table>
@@ -13,8 +13,12 @@
43000 Kajang, Selangor 43000 Kajang, Selangor
@endsection @endsection
@section('letter-honorific')
Tuan/Puan,
@endsection
@section('letter-subject') @section('letter-subject')
<strong>KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD</strong> <strong>KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)</strong>
@endsection @endsection
{{-- @section('letter-salutation') {{-- @section('letter-salutation')
@@ -23,38 +27,57 @@
@section('letter-body') @section('letter-body')
<p style="line-height: 1;"> <p style="line-height: 1;">
Dengan segala hormatnya saya merujuk kepada perkara di atas. Dengan segala hormatnya perkara di atas adalah dirujuk.
</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/puan untuk menjadi <strong>
Permodalan Kelantan Berhad(KoPKB) telah <strong></strong> dalam Anggota Koperasi Permodalan Kelantan Berhad(KoPKB)</strong> telah dipertimbangkan dan <strong>DILULUSKAN</strong>
oleh Lembaga Koperasi dalam Mesyuarat Lembaga Bil. yang diadakan pada 01 Jan 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. Maklumat keanggotaan tuan/puan adalah seperti berikut:-
<ul style="line-height: 1;"> </p>
<li>Saham</li> <table class="letter-info-table" cellpadding="0" cellspacing="0">
<ul style="line-height: 1;"> <tr>
<li>Potongan sebanyak RM 84.00 </li> <th>Perkara</th>
<li>Potongan sebanyak RM sehingga Syer Maksima</li> <th>Maklumat</th>
</ul> </tr>
<li>Yuran</li> <tr>
<ul style="line-height: 1;"> <td>Nama Anggota</td>
<li>Potongan sebanyak RM setiap bulan</li> <td>Ali</td>
</ul> </tr>
<li>Fi Masuk</li> <tr>
<ul style="line-height: 1;"> <td>No. Kad Pengenalan</td>
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li> <td>0123456789012345</td>
</ul> </tr>
</ul> <tr>
<td>No. Anggota</td>
<td>123</td>
</tr>
<tr>
<td>Tarikh Kuat Kuasa Keanggotaan</td>
<td>01 Jan</td>
</tr>
<tr>
<td>Modal Syer Permulaan</td>
<td>RM500.00 (perlu dijelaskan dalam tempoh 6 bulan)</td>
</tr>
<tr>
<td>Fi Masuk</td>
<td>RM10.00</td>
</tr>
<tr>
<td>Yuran Tahunan</td>
<td>RM30.00 sebulan</td>
</tr>
</table>
<p style="line-height: 1;">
4. Sebagai anggota KoPKB, tuan/puan adalah tertakluk kepada Undang-Undang Kecil (UUK), dasar, polisi dan peraturan-peraturan KoPKB yang berkuat kuasa dari semasa ke semasa.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki 5. Sebagai anggota, tuan/puan adalah layak menikmati kemudahan, produk dan manfaat yang ditawarkan oleh koperasi dari semasa ke semasa, tertakluk kepada syarat-syarat yang ditetapkan. </p>
menjelaskan modal syer minimum sebanyak RM500.00. Bayaran tersebut <p style="line-height: 1;">
perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan. 6. Pihak KoPKB mengucapkan tahniah dan mengalu-alukan penyertaan tuan/puan sebagai anggota. Semoga keanggotaan ini akan memberi manfaat kepada tuan/puan serta menyumbang kepada kemajuan dan kecemerlangan KoPKB.
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> </p>
@endsection @endsection
@@ -63,14 +86,5 @@
@endsection @endsection
@section('letter-signature') @section('letter-signature')
<div class="signature-header">Dengan hormatnya,</div>
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div> <div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
<div class="signature-space">
@if (! empty($signatureUrl))
<img src="{{ $signatureUrl }}" alt="" class="signature-space-img">
@endif
</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 @endsection
-43
View File
@@ -1,43 +0,0 @@
@extends('pdf.layouts.letter')
@section('letter-title', 'Pratonton Surat')
@section('letter-reference', 'APP-2026-00001')
@section('letter-date', now()->translatedFormat('d F Y'))
@section('letter-recipient')
<strong>Ahmad bin Abdullah</strong><br>
No. 12, Jalan Contoh,<br>
43000 Kajang, Selangor
@endsection
@section('letter-subject')
<strong>PERKARA: PRATONTON REKA BENTUK SURAT RASMI</strong>
@endsection
@section('letter-salutation')
Assalamualaikum dan Salam Sejahtera,
@endsection
@section('letter-body')
<p>
Dengan hormatnya perkara di atas adalah dirujuk.
</p>
<p>
1. Ini adalah kandungan contoh untuk pratonton reka bentuk surat. Gunakan halaman ini
untuk menguji susun atur, fon, jarak, dan elemen letterhead sebelum menghasilkan PDF sebenar.
</p>
<p>
2. Teks ini boleh diganti apabila templat surat muktamad disediakan.
</p>
@endsection
@section('letter-closing')
Sekian, terima kasih.
@endsection
@section('letter-signature')
<div class="signatory-name">NAMA PENGARAH</div>
<div class="signatory-title">Jawatan / {{ config('app.name') }}</div>
@endsection
-3
View File
@@ -7,8 +7,5 @@ Route::get('/', function () {
return redirect('/login'); return redirect('/login');
}); });
Route::get('/letters/preview', [LetterPreviewController::class, 'show'])
->name('letters.preview');
Route::get('/letters/preview-approved-membership', [LetterPreviewController::class, 'showMembershipApprovedLetter']) Route::get('/letters/preview-approved-membership', [LetterPreviewController::class, 'showMembershipApprovedLetter'])
->name('letters.preview-membership-approved-membership'); ->name('letters.preview-membership-approved-membership');
Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

@@ -3,7 +3,7 @@ import type { ExternalSystem } from '../types/external-system.types'
export const dummyExternalSystems: ExternalSystem[] = [ export const dummyExternalSystems: ExternalSystem[] = [
{ {
id: 'ext-001', id: 'ext-001',
code: 'KOPKB-PORTAL', code: 'portal-mykopkb',
name: 'Portal Ahli KOPKB', name: 'Portal Ahli KOPKB',
description: description:
'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.', 'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.',
@@ -13,15 +13,15 @@ export const dummyExternalSystems: ExternalSystem[] = [
starts_at: '2026-01-01T00:00:00+08:00', starts_at: '2026-01-01T00:00:00+08:00',
ends_at: null, ends_at: null,
opens_in_new_tab: true, opens_in_new_tab: true,
contact_email: 'sokongan@koppkb.com', contact_email: 'dev_kopkb@gmail.com',
notes: 'Log masuk menggunakan e-mel berdaftar ahli KOPKB.', notes: 'Log masuk menggunakan e-mel berdaftar ahli KOPKB.',
created_at: '2026-01-15T09:00:00+08:00', created_at: '2026-01-15T09:00:00+08:00',
updated_at: '2026-06-01T14:30:00+08:00', updated_at: '2026-06-01T14:30:00+08:00',
}, },
{ {
id: 'ext-002', id: 'ext-002',
code: 'AGM-VOTE', code: 'e-vote',
name: 'Sistem Pengundian AGM', name: 'Sistem Pengundian AGM KOPKB',
description: description:
'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.', 'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.',
url: 'https://e-vote.erahn.com.my/login', url: 'https://e-vote.erahn.com.my/login',
@@ -30,7 +30,7 @@ export const dummyExternalSystems: ExternalSystem[] = [
starts_at: '2026-05-01T08:00:00+08:00', starts_at: '2026-05-01T08:00:00+08:00',
ends_at: null, ends_at: null,
opens_in_new_tab: true, opens_in_new_tab: true,
contact_email: 'agm@koppkb.com', contact_email: 'dev_kopkb@gmail.com',
notes: 'Sila lengkapkan profil sebelum mengundi.', notes: 'Sila lengkapkan profil sebelum mengundi.',
created_at: '2026-05-20T10:00:00+08:00', created_at: '2026-05-20T10:00:00+08:00',
updated_at: '2026-06-28T11:15:00+08:00', updated_at: '2026-06-28T11:15:00+08:00',
@@ -93,6 +93,8 @@ const previewUrl = ref<string | null>(null)
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null) const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
const boardMeetingReference = ref('') const boardMeetingReference = ref('')
const boardMeetingReferenceError = ref<string | null>(null) const boardMeetingReferenceError = ref<string | null>(null)
const boardMeetingDate = ref(dayjs().format('YYYY-MM-DD'))
const boardMeetingDateError = ref<string | null>(null)
const deleteConfirmDialogOpen = ref(false) const deleteConfirmDialogOpen = ref(false)
const pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null) const pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null)
@@ -372,6 +374,8 @@ function openConfirmAction(
if (action.type === 'complete') { if (action.type === 'complete') {
boardMeetingReference.value = '' boardMeetingReference.value = ''
boardMeetingReferenceError.value = null boardMeetingReferenceError.value = null
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
boardMeetingDateError.value = null
} }
pendingAction.value = action pendingAction.value = action
@@ -383,6 +387,8 @@ function closeConfirmDialog() {
pendingAction.value = null pendingAction.value = null
boardMeetingReference.value = '' boardMeetingReference.value = ''
boardMeetingReferenceError.value = null boardMeetingReferenceError.value = null
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
boardMeetingDateError.value = null
} }
async function confirmPendingAction() { async function confirmPendingAction() {
@@ -391,6 +397,7 @@ async function confirmPendingAction() {
error.value = null error.value = null
successMessage.value = null successMessage.value = null
boardMeetingReferenceError.value = null boardMeetingReferenceError.value = null
boardMeetingDateError.value = null
if (pendingAction.value.type === 'complete') { if (pendingAction.value.type === 'complete') {
const reference = boardMeetingReference.value.trim() const reference = boardMeetingReference.value.trim()
@@ -398,6 +405,10 @@ async function confirmPendingAction() {
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.' boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
return return
} }
if (!boardMeetingDate.value) {
boardMeetingDateError.value = 'Tarikh mesyuarat lembaga diperlukan.'
return
}
completeSubmitting.value = true completeSubmitting.value = true
} else { } else {
reviewSubmitting.value = true reviewSubmitting.value = true
@@ -421,6 +432,7 @@ async function confirmPendingAction() {
} else { } else {
response = await completeMembershipApplication(applicationId.value, { response = await completeMembershipApplication(applicationId.value, {
board_meeting_reference: boardMeetingReference.value.trim(), board_meeting_reference: boardMeetingReference.value.trim(),
board_meeting_date: boardMeetingDate.value,
}) })
} }
@@ -436,6 +448,9 @@ async function confirmPendingAction() {
if (validationErrors?.board_meeting_reference?.[0]) { if (validationErrors?.board_meeting_reference?.[0]) {
boardMeetingReferenceError.value = validationErrors.board_meeting_reference[0] boardMeetingReferenceError.value = validationErrors.board_meeting_reference[0]
} }
if (validationErrors?.board_meeting_date?.[0]) {
boardMeetingDateError.value = validationErrors.board_meeting_date[0]
}
} finally { } finally {
reviewSubmitting.value = false reviewSubmitting.value = false
completeSubmitting.value = false completeSubmitting.value = false
@@ -1003,6 +1018,12 @@ onUnmounted(() => {
@input="boardMeetingReferenceError = null" /> @input="boardMeetingReferenceError = null" />
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError> <FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
</Field> </Field>
<Field v-if="pendingAction?.type === 'complete'" class="mt-4 text-left">
<FieldLabel for="detail-board-meeting-date">Tarikh Mesyuarat Lembaga</FieldLabel>
<Input id="detail-board-meeting-date" v-model="boardMeetingDate" type="date"
:disabled="completeSubmitting" @input="boardMeetingDateError = null" />
<FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError>
</Field>
</div> </div>
<div class="px-5 pb-8 text-center"> <div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-28" :disabled="reviewSubmitting || completeSubmitting"> <DialogCloseTrigger class="mr-2 w-28" :disabled="reviewSubmitting || completeSubmitting">
@@ -110,6 +110,8 @@ const batchSuccessMessage = ref<string | null>(null)
const batchFailedItems = ref<BatchCompleteFailedItem[]>([]) const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
const boardMeetingReference = ref('') const boardMeetingReference = ref('')
const boardMeetingReferenceError = ref<string | null>(null) const boardMeetingReferenceError = ref<string | null>(null)
const boardMeetingDate = ref(dayjs().format('YYYY-MM-DD'))
const boardMeetingDateError = ref<string | null>(null)
const downloadingResultLetterId = ref<string | null>(null) const downloadingResultLetterId = ref<string | null>(null)
const selectableApplications = computed(() => const selectableApplications = computed(() =>
@@ -162,6 +164,8 @@ function openBatchConfirm() {
if (!selectedIds.value.length) return if (!selectedIds.value.length) return
boardMeetingReference.value = '' boardMeetingReference.value = ''
boardMeetingReferenceError.value = null boardMeetingReferenceError.value = null
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
boardMeetingDateError.value = null
batchConfirmOpen.value = true batchConfirmOpen.value = true
} }
@@ -173,15 +177,23 @@ async function confirmBatchComplete() {
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.' boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
return return
} }
if (!boardMeetingDate.value) {
boardMeetingDateError.value = 'Tarikh mesyuarat lembaga diperlukan.'
return
}
batchSubmitting.value = true batchSubmitting.value = true
batchSuccessMessage.value = null batchSuccessMessage.value = null
batchFailedItems.value = [] batchFailedItems.value = []
boardMeetingReferenceError.value = null boardMeetingReferenceError.value = null
boardMeetingDateError.value = null
error.value = null error.value = null
try { try {
const response = await batchCompleteMembershipApplications(selectedIds.value, reference) const response = await batchCompleteMembershipApplications(selectedIds.value, {
board_meeting_reference: reference,
board_meeting_date: boardMeetingDate.value,
})
if (response.success) { if (response.success) {
batchSuccessMessage.value = response.message batchSuccessMessage.value = response.message
@@ -200,6 +212,7 @@ async function confirmBatchComplete() {
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.') error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
const validationErrors = getApiValidationErrors(err) const validationErrors = getApiValidationErrors(err)
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
boardMeetingDateError.value = validationErrors?.board_meeting_date?.[0] ?? null
} else { } else {
error.value = getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.') error.value = getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
} }
@@ -245,30 +258,6 @@ function goToApplicationEdit(id: string) {
router.push({ name: 'edit-membership-application', params: { id } }) router.push({ name: 'edit-membership-application', params: { id } })
} }
function canDownloadLetter(item: MembershipApplicationListItem): boolean {
return !!item.has_result_letter && !!item.result_letter_document
}
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 },
@@ -450,12 +439,6 @@ 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="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>
@@ -475,6 +458,12 @@ const headers = computed<TableHeader[]>(() => {
@input="boardMeetingReferenceError = null" /> @input="boardMeetingReferenceError = null" />
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError> <FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
</Field> </Field>
<Field class="mt-4 text-left">
<FieldLabel for="batch-board-meeting-date">Tarikh Mesyuarat Lembaga</FieldLabel>
<Input id="batch-board-meeting-date" v-model="boardMeetingDate" type="date" :disabled="batchSubmitting"
@input="boardMeetingDateError = null" />
<FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError>
</Field>
</div> </div>
<div class="px-5 pb-8 text-center"> <div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-32" :disabled="batchSubmitting"> <DialogCloseTrigger class="mr-2 w-32" :disabled="batchSubmitting">
@@ -225,13 +225,13 @@ export async function completeMembershipApplication(
export async function batchCompleteMembershipApplications( export async function batchCompleteMembershipApplications(
applicationIds: string[], applicationIds: string[],
boardMeetingReference: string, payload: GenerateResultLetterPayload,
): Promise<BatchCompleteResponse> { ): Promise<BatchCompleteResponse> {
const { data } = await api.post<BatchCompleteResponse>( const { data } = await api.post<BatchCompleteResponse>(
'/v1/membership-applications/batch-complete', '/v1/membership-applications/batch-complete',
{ {
application_ids: applicationIds, application_ids: applicationIds,
board_meeting_reference: boardMeetingReference, ...payload,
}, },
) )
@@ -245,6 +245,7 @@ export type BatchCompleteResponse = {
export interface GenerateResultLetterPayload { export interface GenerateResultLetterPayload {
board_meeting_reference: string board_meeting_reference: string
board_meeting_date: string
} }
export type GenerateResultLetterResponse = { export type GenerateResultLetterResponse = {
@@ -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 QRCode from 'qrcode' import QRCode from 'qrcode'
import logoUrl from '@/assets/images/logo.svg' import logoUrl from '@/assets/images/logo-original.svg'
import { displayCardValue } from '../utils/member-digital-card.utils' import { displayCardValue } from '../utils/member-digital-card.utils'
const props = withDefaults( const props = withDefaults(
@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import logoUrl from '@/assets/images/logo.svg' import logoUrl from '@/assets/images/logo-original.svg'
import { displayCardValue } from '../utils/member-digital-card.utils' import { displayCardValue } from '../utils/member-digital-card.utils'
const props = withDefaults( const props = withDefaults(
@@ -109,7 +109,7 @@ export function useImpersonate() {
title: response.message, title: response.message,
showConfirmButton: false, showConfirmButton: false,
showCloseButton: true, showCloseButton: true,
timer: 2000, timer: 500,
}) })
await router.push(resolvePostLoginRoute(response.redirect_path)) await router.push(resolvePostLoginRoute(response.redirect_path))
@@ -143,7 +143,7 @@ export function useImpersonate() {
title: response.message, title: response.message,
showConfirmButton: false, showConfirmButton: false,
showCloseButton: true, showCloseButton: true,
timer: 2000, timer: 500,
}) })
await router.push(resolvePostLoginRoute(response.redirect_path)) await router.push(resolvePostLoginRoute(response.redirect_path))
+24 -49
View File
@@ -321,7 +321,8 @@ onMounted(() => {
<div class="flex w-full flex-col gap-3"> <div class="flex w-full flex-col gap-3">
<div class="flex w-full flex-wrap items-center gap-3"> <div class="flex w-full flex-wrap items-center gap-3">
<div class="relative w-full max-w-md flex-1"> <div class="relative w-full max-w-md flex-1">
<Search class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50" <Search
class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
aria-hidden="true" /> aria-hidden="true" />
<Input v-model="search" type="search" placeholder="Search name, email, IC, phone, role..." <Input v-model="search" type="search" placeholder="Search name, email, IC, phone, role..."
class="w-full pl-9" aria-label="Search users" /> class="w-full pl-9" aria-label="Search users" />
@@ -333,18 +334,10 @@ onMounted(() => {
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="text-sm opacity-70">Status:</span> <span class="text-sm opacity-70">Status:</span>
<Badge <Badge v-for="chip in STATUS_FILTER_CHIPS" :key="chip.value || 'all'" :variant="chip.variant"
v-for="chip in STATUS_FILTER_CHIPS" :look="isStatusFilterActive(chip.value) ? 'filled' : 'outline'" class="capitalize" role="button"
:key="chip.value || 'all'" tabindex="0" :aria-pressed="isStatusFilterActive(chip.value)" @click="setStatusFilter(chip.value)"
:variant="chip.variant" @keydown.enter="setStatusFilter(chip.value)">
:look="isStatusFilterActive(chip.value) ? 'filled' : 'outline'"
class="capitalize"
role="button"
tabindex="0"
:aria-pressed="isStatusFilterActive(chip.value)"
@click="setStatusFilter(chip.value)"
@keydown.enter="setStatusFilter(chip.value)"
>
{{ chip.label }} {{ chip.label }}
</Badge> </Badge>
</div> </div>
@@ -385,28 +378,28 @@ onMounted(() => {
<template #item.actions="{ item }"> <template #item.actions="{ item }">
<div class="flex items-center"> <div class="flex items-center">
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" size="sm" <Button v-if="hasPermission('lihat peranan')" type="button" variant="ghost" size="sm"
class="bg-purple-600 text-white disabled:opacity-50" :disabled="savingRoles" class="bg-purple-600 text-white disabled:opacity-50" :disabled="savingRoles"
:title="savingRoles ? 'Menyimpan perubahan...' : 'Tetapkan peranan'" :title="savingRoles ? 'Menyimpan perubahan...' : 'Tetapkan peranan'"
@click="openAssignRolesDialog(item as UserListItem)"> Urus Peranan @click="openAssignRolesDialog(item as UserListItem)"> Urus Peranan
<Shield class="size-4" aria-hidden="true" /> <Shield class="size-4" aria-hidden="true" />
</Button> </Button>
<Button v-if="hasPermission('lihat pengguna')" type="button" variant="outline" size="sm" <Button v-if="hasPermission('lihat pengguna')" type="button" variant="ghost" size="sm"
class="bg-green-600 text-white disabled:opacity-50" :disabled="savingRoles" class="bg-green-600 text-white disabled:opacity-50" :disabled="savingRoles"
:title="savingRoles ? 'Menyimpan perubahan...' : 'Lihat profil'" @click="goToViewUser(item.id)"> :title="savingRoles ? 'Menyimpan perubahan...' : 'Lihat profil'" @click="goToViewUser(item.id)">
<Eye class="size-4" aria-hidden="true" /> <Eye class="size-4" aria-hidden="true" />
</Button> </Button>
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" size="sm" <Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="ghost" size="sm"
class="bg-yellow-600 text-white disabled:opacity-50" :disabled="savingRoles" class="bg-yellow-600 text-white disabled:opacity-50" :disabled="savingRoles"
:title="savingRoles ? 'Menyimpan perubahan...' : 'Kemaskini pengguna'" @click="goToEditUser(item.id)"> :title="savingRoles ? 'Menyimpan perubahan...' : 'Kemaskini pengguna'" @click="goToEditUser(item.id)">
<SquarePen class="size-4" aria-hidden="true" /> <SquarePen class="size-4" aria-hidden="true" />
</Button> </Button>
<Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="outline" size="sm" <Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="ghost" size="sm"
class="bg-red-600 text-white disabled:opacity-50" :disabled="deleting" class="bg-red-600 text-white disabled:opacity-50" :disabled="deleting"
@click="openDeleteConfirmation(item as UserListItem)"> @click="openDeleteConfirmation(item as UserListItem)">
<Trash2 class="size-4" aria-hidden="true" /> <Trash2 class="size-4" aria-hidden="true" />
</Button> </Button>
<Button v-if="showImpersonateButton(item as UserListItem)" type="button" variant="outline" size="sm" <Button v-if="showImpersonateButton(item as UserListItem)" type="button" variant="ghost" size="sm"
class="bg-orange-600 text-white disabled:opacity-50" class="bg-orange-600 text-white disabled:opacity-50"
:disabled="!canImpersonateUser(item as UserListItem) || impersonateLoading || impersonating" :disabled="!canImpersonateUser(item as UserListItem) || impersonateLoading || impersonating"
:title="impersonateButtonTitle(item as UserListItem)" @click="impersonateUser(item as UserListItem)"> :title="impersonateButtonTitle(item as UserListItem)" @click="impersonateUser(item as UserListItem)">
@@ -417,6 +410,7 @@ onMounted(() => {
</template> </template>
</DataTable> </DataTable>
<!-- Start of deleted users table -->
<div class="space-y-4 border-t border-foreground/10 pt-8"> <div class="space-y-4 border-t border-foreground/10 pt-8">
<div> <div>
<h3 class="text-lg font-medium">Pengguna Dipadam</h3> <h3 class="text-lg font-medium">Pengguna Dipadam</h3>
@@ -428,19 +422,10 @@ onMounted(() => {
<AlertDescription>{{ deletedError }}</AlertDescription> <AlertDescription>{{ deletedError }}</AlertDescription>
</AlertRoot> </AlertRoot>
<DataTable <DataTable :headers="deletedHeaders" :items="deletedUsers" :loading="deletedLoading"
:headers="deletedHeaders" :pagination="deletedPagination" :current-sort="deletedSortBy" show-pagination exportable
:items="deletedUsers" export-file-name="deleted-users" v-model:page="deletedPage" v-model:items-per-page="deletedItemsPerPage"
:loading="deletedLoading" @update:sort-by="handleDeletedSortUpdate">
:pagination="deletedPagination"
:current-sort="deletedSortBy"
show-pagination
exportable
export-file-name="deleted-users"
v-model:page="deletedPage"
v-model:items-per-page="deletedItemsPerPage"
@update:sort-by="handleDeletedSortUpdate"
>
<template #item.name="{ item }"> <template #item.name="{ item }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img v-if="item.image_url" :src="item.image_url" alt="Profile Image" class="size-10 rounded-full" /> <img v-if="item.image_url" :src="item.image_url" alt="Profile Image" class="size-10 rounded-full" />
@@ -475,16 +460,9 @@ onMounted(() => {
<template #item.actions="{ item }"> <template #item.actions="{ item }">
<div class="flex items-center"> <div class="flex items-center">
<Button <Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="ghost" size="sm"
v-if="hasPermission('padam akaun pengguna')" class="bg-blue-600 text-white disabled:opacity-50" :disabled="restoring" title="Pulihkan pengguna"
type="button" @click="openRestoreConfirmation(item as UserListItem)">
variant="outline"
size="sm"
class="bg-blue-600 text-white disabled:opacity-50"
:disabled="restoring"
title="Pulihkan pengguna"
@click="openRestoreConfirmation(item as UserListItem)"
>
<RotateCcw class="size-4" aria-hidden="true" /> <RotateCcw class="size-4" aria-hidden="true" />
Pulihkan Pulihkan
</Button> </Button>
@@ -493,6 +471,7 @@ onMounted(() => {
</DataTable> </DataTable>
</div> </div>
<!-- Assign roles dialog -->
<DialogRoot :open="assignRolesOpen" @openChange="(details) => (assignRolesOpen = details.open)"> <DialogRoot :open="assignRolesOpen" @openChange="(details) => (assignRolesOpen = details.open)">
<DialogContent> <DialogContent>
<div class="p-5"> <div class="p-5">
@@ -537,6 +516,7 @@ onMounted(() => {
</DialogContent> </DialogContent>
</DialogRoot> </DialogRoot>
<!-- Delete confirmation dialog -->
<DialogRoot :open="deleteConfirmationOpen" @openChange="(details) => (deleteConfirmationOpen = details.open)"> <DialogRoot :open="deleteConfirmationOpen" @openChange="(details) => (deleteConfirmationOpen = details.open)">
<DialogContent> <DialogContent>
<div class="p-5 text-center"> <div class="p-5 text-center">
@@ -564,6 +544,7 @@ onMounted(() => {
</DialogContent> </DialogContent>
</DialogRoot> </DialogRoot>
<!-- Restore confirmation dialog -->
<DialogRoot :open="restoreConfirmationOpen" @openChange="(details) => (restoreConfirmationOpen = details.open)"> <DialogRoot :open="restoreConfirmationOpen" @openChange="(details) => (restoreConfirmationOpen = details.open)">
<DialogContent> <DialogContent>
<div class="p-5 text-center"> <div class="p-5 text-center">
@@ -581,14 +562,8 @@ onMounted(() => {
<DialogCloseTrigger class="mr-2 w-24" :disabled="restoring"> <DialogCloseTrigger class="mr-2 w-24" :disabled="restoring">
Batal Batal
</DialogCloseTrigger> </DialogCloseTrigger>
<Button <Button class="w-24" type="button" variant="primary" look="outline" :disabled="restoring"
class="w-24" @click="confirmRestore">
type="button"
variant="primary"
look="outline"
:disabled="restoring"
@click="confirmRestore"
>
{{ restoring ? 'Memulihkan...' : 'Pulihkan' }} {{ restoring ? 'Memulihkan...' : 'Pulihkan' }}
</Button> </Button>
</div> </div>