4 Commits

85 changed files with 491 additions and 545 deletions
+5 -4
View File
@@ -1,11 +1,12 @@
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*" - "v*" # v1.0.0, v1.0.1, etc.
jobs: jobs:
# test-backend: # test-backend:
@@ -91,6 +92,6 @@ jobs:
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \ --cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
-f fe/docker/Dockerfile \ -f fe/docker/Dockerfile \
--build-arg VITE_API_BASE_URL=https://api.koppkb.com \ --build-arg VITE_API_BASE_URL=https://api.koppkb.com \
--build-arg VITE_APP_URL=https://mykopkb.koppkb.com \ --build-arg VITE_APP_URL=https://anggota.koppkb.com \
${TAGS} \ ${TAGS} \
--push . --push .
+3 -4
View File
@@ -18,8 +18,8 @@
## Present to Boss (2/7/2026) ## Present to Boss (2/7/2026)
[x] discuss logo baru MyKOPKB [x] discuss logo baru MyKOPKB
[x] layout footer untuk surat rasmi [x] layout footer untuk surat rasmi
[x] maklumat pekerjaan kena buat dropdown untuk nama majikan [ ] maklumat pekerjaan kena buat dropdown untuk nama majikan
[x] tambah minima untuk borang permohonan (tukar jadi caruman saham potongan bulanan) [ ] tambah minima untuk borang permohonan (tukar jadi caruman saham potongan bulanan)
[x] tukar waris ke penama [x] tukar waris ke penama
[x] ic dan surat pengesahan majikkan perlu mandatori [x] ic dan surat pengesahan majikkan perlu mandatori
[ ] semakan lembaga tukar jadi maklumat keputusan [ ] semakan lembaga tukar jadi maklumat keputusan
@@ -27,6 +27,5 @@
[x] surat attach dekat email selepas bos [x] surat attach dekat email selepas bos
[ ] pembayaran pertama tukar jadi one-off [ ] pembayaran pertama tukar jadi one-off
[ ] syer maksima silap tukar jadi rm50.00 [ ] syer maksima silap tukar jadi rm50.00
[x] letak sign digital [ ] letak sign digital
[x] running no anggota dalam surat dan masa. [x] running no anggota dalam surat dan masa.
[ ] tukar logo di surat offer
+3 -4
View File
@@ -67,12 +67,11 @@ 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/)
# Local/Sail: sail npm install && sail npx puppeteer browsers install chrome-headless-shell # Required in Docker/Sail. After first sail up: sail npm install && sail npx puppeteer browsers install chrome-headless-shell
# Production Docker image bundles Node + Puppeteer; keep NO_SANDBOX enabled in containers.
BROWSERSHOT_NO_SANDBOX=true BROWSERSHOT_NO_SANDBOX=true
# BROWSERSHOT_NODE_BINARY=/usr/local/bin/node # BROWSERSHOT_NODE_BINARY=
# BROWSERSHOT_NPM_BINARY= # BROWSERSHOT_NPM_BINARY=
# BROWSERSHOT_NODE_MODULE_PATH=/var/www # BROWSERSHOT_NODE_MODULE_PATH=
# BROWSERSHOT_CHROME_PATH= # BROWSERSHOT_CHROME_PATH=
# BROWSERSHOT_TIMEOUT=60 # BROWSERSHOT_TIMEOUT=60
-1
View File
@@ -106,4 +106,3 @@ API_LOG_KEY_USAGE=true
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7 PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
FRONTEND_URL=https://mykopkb.koppkb.com FRONTEND_URL=https://mykopkb.koppkb.com
BROWSERSHOT_NO_SANDBOX=true
@@ -189,7 +189,6 @@ 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
@@ -211,7 +210,6 @@ 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']);
@@ -272,7 +270,6 @@ 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
@@ -304,16 +301,11 @@ 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,7 +20,6 @@ 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',
]; ];
} }
@@ -37,8 +36,6 @@ 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,7 +18,6 @@ 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',
]; ];
} }
@@ -30,8 +29,6 @@ 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,7 +18,6 @@ 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',
]; ];
} }
@@ -30,8 +29,6 @@ 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.',
]; ];
} }
} }
@@ -5,7 +5,7 @@
Assalamualaikum **{{ $applicant->name }}**, Assalamualaikum **{{ $applicant->name }}**,
Permohonan keahlian anda (**{{ $application->application_number }}**) **tidak diluluskan**. Permohonan keahlian anda (**{{ $application->application_number }}**) **tidak diluluskan** pada peringkat lembaga.
Surat keputusan rasmi dilampirkan dalam e-mel ini. Surat keputusan rasmi dilampirkan dalam e-mel ini.
@@ -5,7 +5,7 @@
Assalamualaikum **{{ $applicant->name }}**, Assalamualaikum **{{ $applicant->name }}**,
Permohonan keahlian anda (**{{ $application->application_number }}**) **diluluskan**. Permohonan keahlian anda (**{{ $application->application_number }}**) **diluluskan** pada peringkat lembaga.
Surat keputusan rasmi dilampirkan dalam e-mel ini. Surat keputusan rasmi dilampirkan dalam e-mel ini.
@@ -12,77 +12,49 @@
{{ $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 perkara di atas adalah dirujuk. Dengan segala hormatnya saya merujuk kepada perkara di atas.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
2. 2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
@if ($isPassed) Permodalan Kelantan Berhad (KoPKB) telah <strong>{{ $boardResultLabel }}</strong> dalam {{ $boardMeetingReference }}@if ($isPassed && $memberNumber). Nombor keanggotaan tuan adalah <strong>{{ $memberNumber }}</strong>@endif.
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. Maklumat keanggotaan tuan/puan adalah seperti berikut:- 3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaimana ketetapan berikut:-
</p> <ul style="line-height: 1;">
<table class="letter-info-table" cellpadding="0" cellspacing="0"> <li>Saham</li>
<tr> <ul style="line-height: 1;">
<th>Perkara</th> <li>Potongan sebanyak RM {{ $monthlyShareInstallment }} ({{ $shareInstallmentMonths }} bulan)</li>
<th>Maklumat</th> <li>Potongan sebanyak RM {{ $stockMonthlyContribution }} sehingga Syer Maksima</li>
</tr> </ul>
<tr> <li>Yuran</li>
<td>Nama Anggota</td> <ul style="line-height: 1;">
<td>{{ $applicant->name }}</td> <li>Potongan sebanyak RM {{ $feeMonthlyContribution }} setiap bulan</li>
</tr> </ul>
<tr> <li>Fi Masuk</li>
<td>No. Kad Pengenalan</td> <ul style="line-height: 1;">
<td>{{ $applicant->ic_number }}</td> <li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li>
</tr> </ul>
<tr> </ul>
<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;">
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. 4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki
</p> menjelaskan modal syer minimum sebanyak RM{{ $minimumShareCapital }}.
<p style="line-height: 1;"> Bayaran tersebut 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>
@else @else
<p style="line-height: 1;"> <p style="line-height: 1;">
3. Oleh yang demikian, permohonan tuan/puan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini. 3. Oleh yang demikian, permohonan tuan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
</p> </p>
@endif @endif
@endsection @endsection
@@ -92,5 +64,14 @@
@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,7 +7,6 @@ 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;
@@ -201,7 +200,7 @@ class MembershipApplicationService
{ {
if ($application->status !== ApplicationStatus::PendingBoard) { if ($application->status !== ApplicationStatus::PendingBoard) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh disemak pada peringkat ahli lembaga.'], 'status' => ['Permohonan ini tidak boleh disemak pada peringkat lembaga.'],
]); ]);
} }
@@ -259,7 +258,6 @@ 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([
@@ -273,14 +271,14 @@ class MembershipApplicationService
]); ]);
} }
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) { return DB::transaction(function () use ($application, $boardMeetingReference) {
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, $boardMeetingDate); $resultLetter = $this->storeResultLetter($application, $boardMeetingReference);
$this->sendResultNotification($application, $resultLetter); $this->sendResultNotification($application, $resultLetter);
@@ -300,12 +298,8 @@ 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( public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference): array
array $applicationIds, {
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$succeeded = []; $succeeded = [];
$failed = []; $failed = [];
@@ -323,12 +317,7 @@ class MembershipApplicationService
} }
try { try {
$succeeded[] = $this->complete( $succeeded[] = $this->complete($application, $processedBy, $boardMeetingReference);
$application,
$processedBy,
$boardMeetingReference,
$boardMeetingDate,
);
} catch (ValidationException $e) { } catch (ValidationException $e) {
$failed[] = [ $failed[] = [
'id' => $id, 'id' => $id,
@@ -344,11 +333,7 @@ class MembershipApplicationService
]; ];
} }
public function generateResultLetter( public function generateResultLetter(MembershipApplication $application, string $boardMeetingReference,): MembershipApplication {
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.'],
@@ -361,8 +346,8 @@ class MembershipApplicationService
]); ]);
} }
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) { return DB::transaction(function () use ($application, $boardMeetingReference) {
$this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate); $this->storeResultLetter($application, $boardMeetingReference);
return $this->repository->findByIdWithRelations($application->id); return $this->repository->findByIdWithRelations($application->id);
}); });
@@ -371,7 +356,6 @@ 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)
@@ -392,7 +376,7 @@ class MembershipApplicationService
]); ]);
} }
$viewData = $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate); $viewData = $this->buildResultLetterViewData($application, $boardMeetingReference);
$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';
@@ -410,7 +394,6 @@ 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);
@@ -420,7 +403,7 @@ class MembershipApplicationService
]); ]);
} }
return $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate); return $this->buildResultLetterViewData($application, $boardMeetingReference);
} }
/** /**
@@ -429,20 +412,32 @@ 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 PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)' ? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD'
: 'KEPUTUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)', : 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD',
'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-original.svg')), 'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo.svg')),
]; ];
+2 -2
View File
@@ -24,8 +24,8 @@ return [
'http://127.0.0.1', 'http://127.0.0.1',
'http://localhost:5173', 'http://localhost:5173',
'http://127.0.0.1:5173', 'http://127.0.0.1:5173',
'https://mykopkb.koppkb.com', 'https://anggota.koppkb.com',
'http://mykopkb.koppkb.com', 'http://anggota.koppkb.com',
], ],
'allowed_origins_patterns' => [], 'allowed_origins_patterns' => [],
+1 -1
View File
@@ -124,6 +124,6 @@ return [
| |
*/ */
'logo_path' => public_path('images/logo/logo-original.svg'), 'logo_path' => public_path('images/logo/logo.svg'),
]; ];
+1 -60
View File
@@ -46,25 +46,6 @@ COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist --no-scripts \ RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist --no-scripts \
&& rm -rf bootstrap/cache/services.php bootstrap/cache/packages.php && rm -rf bootstrap/cache/services.php bootstrap/cache/packages.php
# Stage 1b: Node + Puppeteer for Browsershot PDF generation
FROM node:22-bookworm-slim AS node-deps
WORKDIR /var/www
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
unzip \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY be/package.json be/package-lock.json ./
ENV PUPPETEER_SKIP_DOWNLOAD=true \
PUPPETEER_CACHE_DIR=/var/www/.cache/puppeteer
RUN npm ci --omit=dev
RUN npx puppeteer browsers install chrome-headless-shell
# Stage 2: Unified production image with PHP-FPM + Nginx # Stage 2: Unified production image with PHP-FPM + Nginx
FROM php:8.4-fpm AS base FROM php:8.4-fpm AS base
@@ -98,37 +79,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& docker-php-ext-enable redis \ && docker-php-ext-enable redis \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Chromium runtime libraries for Puppeteer (Browsershot PDF generation)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libexpat1 \
libfontconfig1 \
libgbm1 \
libglib2.0-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxrender1 \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Download and install php-fpm health check script # Download and install php-fpm health check script
RUN curl -o /usr/local/bin/php-fpm-healthcheck \ RUN curl -o /usr/local/bin/php-fpm-healthcheck \
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \ https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
@@ -170,20 +120,11 @@ RUN echo '[www]' > /usr/local/etc/php-fpm.d/zzz-status.conf && \
# Copy the application code and dependencies from the build stage first # Copy the application code and dependencies from the build stage first
COPY --from=builder /var/www /var/www COPY --from=builder /var/www /var/www
# Node runtime + Puppeteer for Browsershot (letter PDF generation)
COPY --from=node-deps /usr/local/bin/node /usr/local/bin/node
COPY --from=node-deps /var/www/node_modules /var/www/node_modules
COPY --from=node-deps /var/www/.cache/puppeteer /var/www/.cache/puppeteer
ENV PUPPETEER_CACHE_DIR=/var/www/.cache/puppeteer \
BROWSERSHOT_NO_SANDBOX=true \
BROWSERSHOT_NODE_BINARY=/usr/local/bin/node
# Set working directory # Set working directory
WORKDIR /var/www WORKDIR /var/www
# Ensure correct permissions # Ensure correct permissions
RUN chown -R www-data:www-data /var/www /var/www/.cache/puppeteer RUN chown -R www-data:www-data /var/www
# Create Nginx log directory # Create Nginx log directory
RUN mkdir -p /var/log/nginx && chown -R www-data:www-data /var/log/nginx RUN mkdir -p /var/log/nginx && chown -R www-data:www-data /var/log/nginx
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

+101 -62
View File
@@ -8,7 +8,6 @@
@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
@@ -111,27 +110,74 @@
border-collapse: collapse; border-collapse: collapse;
} }
.letterhead-center { .letterhead-line {
text-align: center; vertical-align: middle;
vertical-align: top; padding-right: 16px;
}
.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-address, .letterhead-brand {
.letterhead-contact { vertical-align: middle;
font-size: var(--letterhead-registration-size); white-space: nowrap;
line-height: 1.4; 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); color: var(--letter-color-black);
text-transform: uppercase;
letter-spacing: 0.3px;
} }
.letterhead-address { .letterhead-registration {
margin-bottom: 4px; font-size: var(--letterhead-registration-size);
font-weight: bold;
line-height: 1.3;
color: var(--letter-color-black);
margin-top: 2px;
letter-spacing: 0.5px;
} }
.meta-table { .meta-table {
@@ -146,23 +192,20 @@
} }
.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;
} }
.recipient-block { .meta-date {
margin-bottom: var(--letter-recipient-margin-bottom); margin-top: 4px;
} }
.letter-honorific { .recipient-block {
font-weight: bold; margin-bottom: var(--letter-recipient-margin-bottom);
margin-bottom: 8px;
} }
.subject-block { .subject-block {
@@ -180,26 +223,6 @@
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);
@@ -244,18 +267,43 @@
table-layout: fixed; table-layout: fixed;
} }
.letter-footer-divider { .letter-footer-contact-box {
border-top: 1px solid var(--letter-color-black); background-color: var(--letter-color-orange);
font-size: 0; padding: 8px 12px;
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-body-color); color: var(--letter-color-white);
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 {
@@ -280,21 +328,16 @@
<div class="letter-content"> <div class="letter-content">
<div class="meta-table"> <div class="meta-table">
@if (($showReference && trim($__env->yieldContent('letter-reference')) !== '') || ($showDate && $letterDate !== ''))
<table cellpadding="0" cellspacing="0">
@if ($showReference && trim($__env->yieldContent('letter-reference')) !== '') @if ($showReference && trim($__env->yieldContent('letter-reference')) !== '')
<table cellpadding="0" cellspacing="0">
<tr> <tr>
<td class="meta-label">RUJ. KAMI</td> <td class="meta-label">Ruj.</td>
<td class="meta-value">: @yield('letter-reference')</td> <td class="meta-value">: @yield('letter-reference')</td>
</tr> </tr>
</table>
@endif @endif
@if ($showDate && $letterDate !== '') @if ($showDate && $letterDate !== '')
<tr> <div class="meta-date">{{ $letterDate }}</div>
<td class="meta-label">TARIKH</td>
<td class="meta-value">: {{ $letterDate }}</td>
</tr>
@endif
</table>
@endif @endif
</div> </div>
@@ -304,10 +347,6 @@
</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,11 +1,38 @@
<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-divider">&nbsp;</td> <td class="letter-footer-contact-box" valign="middle" width="50%" bgcolor="#d35400">
<table class="letter-footer-lines" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'phone'])</td>
<td class="letter-footer-line-text" valign="middle">Tel. Pejabat: 09-741 4331</td>
</tr> </tr>
<tr> <tr>
<td class="letter-footer-nota"> <td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'fax'])</td>
<strong>Nota:</strong><br> <td class="letter-footer-line-text" valign="middle">Faks: 09-741 4330</td>
<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>
<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,15 +1,28 @@
<table class="letterhead" width="100%" cellpadding="0" cellspacing="0"> <table class="letterhead" width="100%" cellpadding="0" cellspacing="0">
<tr> <tr>
<td class="letterhead-center" align="center" valign="top"> <td class="letterhead-line" valign="middle">
<table class="letterhead-rule" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="letterhead-rule-blue" width="50%" height="6" bgcolor="#0e579b">&nbsp;</td>
<td class="letterhead-rule-orange" width="50%" height="6" bgcolor="#d35400">&nbsp;</td>
</tr>
</table>
</td>
<td class="letterhead-brand" valign="middle" align="right" width="1">
<table class="letterhead-brand-inner" cellpadding="0" cellspacing="0" align="right">
<tr>
@if (! empty($logoUrl)) @if (! empty($logoUrl))
<td class="letterhead-logo" valign="middle">
<img src="{{ $logoUrl }}" alt="Koperasi Permodalan Kelantan Berhad" class="letterhead-logo-img"> <img src="{{ $logoUrl }}" alt="Koperasi Permodalan Kelantan Berhad" class="letterhead-logo-img">
</td>
@endif @endif
<div class="letterhead-address"> <td class="letterhead-name" valign="middle">
Lot Pt 448, Tingkat 1, Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan. <div class="letterhead-name-line">KOPERASI PERMODALAN</div>
</div> <div class="letterhead-name-line">KELANTAN BERHAD</div>
<div class="letterhead-contact"> <div class="letterhead-registration">D-5-0228</div>
Tel: 09-7414331 | Fax: 09-7414330 | Web:www.koppkb.com </td>
</div> </tr>
</table>
</td> </td>
</tr> </tr>
</table> </table>
@@ -13,12 +13,8 @@
43000 Kajang, Selangor 43000 Kajang, Selangor
@endsection @endsection
@section('letter-honorific')
Tuan/Puan,
@endsection
@section('letter-subject') @section('letter-subject')
<strong>KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)</strong> <strong>KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD</strong>
@endsection @endsection
{{-- @section('letter-salutation') {{-- @section('letter-salutation')
@@ -27,57 +23,38 @@
@section('letter-body') @section('letter-body')
<p style="line-height: 1;"> <p style="line-height: 1;">
Dengan segala hormatnya perkara di atas adalah dirujuk. Dengan segala hormatnya saya merujuk kepada perkara di atas.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
2. Sukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong> 2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
Anggota Koperasi Permodalan Kelantan Berhad(KoPKB)</strong> telah dipertimbangkan dan <strong>DILULUSKAN</strong> Permodalan Kelantan Berhad(KoPKB) telah <strong></strong> dalam
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. Maklumat keanggotaan tuan/puan adalah seperti berikut:- 3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaiman ketetapan berikut:-
</p> <ul style="line-height: 1;">
<table class="letter-info-table" cellpadding="0" cellspacing="0"> <li>Saham</li>
<tr> <ul style="line-height: 1;">
<th>Perkara</th> <li>Potongan sebanyak RM 84.00 </li>
<th>Maklumat</th> <li>Potongan sebanyak RM sehingga Syer Maksima</li>
</tr> </ul>
<tr> <li>Yuran</li>
<td>Nama Anggota</td> <ul style="line-height: 1;">
<td>Ali</td> <li>Potongan sebanyak RM setiap bulan</li>
</tr> </ul>
<tr> <li>Fi Masuk</li>
<td>No. Kad Pengenalan</td> <ul style="line-height: 1;">
<td>0123456789012345</td> <li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li>
</tr> </ul>
<tr> </ul>
<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;">
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> 4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki
<p style="line-height: 1;"> menjelaskan modal syer minimum sebanyak RM500.00. Bayaran tersebut
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. 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> </p>
@endsection @endsection
@@ -86,5 +63,14 @@
@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
@@ -0,0 +1,43 @@
@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,5 +7,8 @@ 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');
+1 -1
View File
@@ -10,7 +10,7 @@ COPY fe/ ./
ARG VITE_APP_NAME="MyKOPKB" ARG VITE_APP_NAME="MyKOPKB"
ARG VITE_APP_VERSION="1.0" ARG VITE_APP_VERSION="1.0"
ARG VITE_API_BASE_URL=https://api.koppkb.com ARG VITE_API_BASE_URL=https://api.koppkb.com
ARG VITE_APP_URL=https://mykopkb.com ARG VITE_APP_URL=https://anggota.koppkb.com
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_APP_URL=$VITE_APP_URL ENV VITE_APP_URL=$VITE_APP_URL
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

@@ -3,25 +3,25 @@ import type { ExternalSystem } from '../types/external-system.types'
export const dummyExternalSystems: ExternalSystem[] = [ export const dummyExternalSystems: ExternalSystem[] = [
{ {
id: 'ext-001', id: 'ext-001',
code: 'portal-mykopkb', code: 'KOPKB-PORTAL',
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.',
url: 'https://mykopkb.koppkb.com', url: 'https://anggota.koppkb.com',
icon: 'Users', icon: 'Users',
is_active: true, is_active: true,
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: 'dev_kopkb@gmail.com', contact_email: 'sokongan@koppkb.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: 'e-vote', code: 'AGM-VOTE',
name: 'Sistem Pengundian AGM KOPKB', name: 'Sistem Pengundian AGM',
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: 'dev_kopkb@gmail.com', contact_email: 'agm@koppkb.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,8 +93,6 @@ 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)
@@ -374,8 +372,6 @@ 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
@@ -387,8 +383,6 @@ 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() {
@@ -397,7 +391,6 @@ 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()
@@ -405,10 +398,6 @@ 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
@@ -432,7 +421,6 @@ 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,
}) })
} }
@@ -448,9 +436,6 @@ 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
@@ -1018,12 +1003,6 @@ 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,8 +110,6 @@ 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(() =>
@@ -164,8 +162,6 @@ 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
} }
@@ -177,23 +173,15 @@ 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, { const response = await batchCompleteMembershipApplications(selectedIds.value, reference)
board_meeting_reference: reference,
board_meeting_date: boardMeetingDate.value,
})
if (response.success) { if (response.success) {
batchSuccessMessage.value = response.message batchSuccessMessage.value = response.message
@@ -212,7 +200,6 @@ 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.')
} }
@@ -258,6 +245,30 @@ 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 },
@@ -439,6 +450,12 @@ 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>
@@ -458,12 +475,6 @@ 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[],
payload: GenerateResultLetterPayload, boardMeetingReference: string,
): 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,
...payload, board_meeting_reference: boardMeetingReference,
}, },
) )
@@ -245,7 +245,6 @@ 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-original.svg' import logoUrl from '@/assets/images/logo.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-original.svg' import logoUrl from '@/assets/images/logo.svg'
import { displayCardValue } from '../utils/member-digital-card.utils' import { displayCardValue } from '../utils/member-digital-card.utils'
const props = withDefaults( const props = withDefaults(
@@ -14,22 +14,14 @@ export function buildCardDownloadFileName(
export function toProxiedStorageUrl(url: string | null | undefined): string | null | undefined { export function toProxiedStorageUrl(url: string | null | undefined): string | null | undefined {
if (!url) return url if (!url) return url
const apiBase = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '')
try { try {
const parsed = new URL(url, apiBase || window.location.origin) const parsed = new URL(url, window.location.origin)
if (!parsed.pathname.startsWith('/storage/')) { if (parsed.pathname.startsWith('/storage/')) {
return url
}
// Production serves the SPA from a different host than Laravel storage files.
if (apiBase) {
return `${apiBase}${parsed.pathname}${parsed.search}`
}
// Dev fallback: Vite proxies /storage to the API.
return `${parsed.pathname}${parsed.search}` return `${parsed.pathname}${parsed.search}`
} catch {
return url
} }
} catch {
// Keep original URL when parsing fails.
}
return url
} }
@@ -109,7 +109,7 @@ export function useImpersonate() {
title: response.message, title: response.message,
showConfirmButton: false, showConfirmButton: false,
showCloseButton: true, showCloseButton: true,
timer: 500, timer: 2000,
}) })
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: 500, timer: 2000,
}) })
await router.push(resolvePostLoginRoute(response.redirect_path)) await router.push(resolvePostLoginRoute(response.redirect_path))
+49 -24
View File
@@ -321,8 +321,7 @@ 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 <Search class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
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" />
@@ -334,10 +333,18 @@ 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 v-for="chip in STATUS_FILTER_CHIPS" :key="chip.value || 'all'" :variant="chip.variant" <Badge
:look="isStatusFilterActive(chip.value) ? 'filled' : 'outline'" class="capitalize" role="button" v-for="chip in STATUS_FILTER_CHIPS"
tabindex="0" :aria-pressed="isStatusFilterActive(chip.value)" @click="setStatusFilter(chip.value)" :key="chip.value || 'all'"
@keydown.enter="setStatusFilter(chip.value)"> :variant="chip.variant"
: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>
@@ -378,28 +385,28 @@ onMounted(() => {
<template #item.actions="{ item }"> <template #item.actions="{ item }">
<div class="flex items-center"> <div class="flex items-center">
<Button v-if="hasPermission('lihat peranan')" type="button" variant="ghost" size="sm" <Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" 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="ghost" size="sm" <Button v-if="hasPermission('lihat pengguna')" type="button" variant="outline" 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="ghost" size="sm" <Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" 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="ghost" size="sm" <Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="outline" 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="ghost" size="sm" <Button v-if="showImpersonateButton(item as UserListItem)" type="button" variant="outline" 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)">
@@ -410,7 +417,6 @@ 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>
@@ -422,10 +428,19 @@ onMounted(() => {
<AlertDescription>{{ deletedError }}</AlertDescription> <AlertDescription>{{ deletedError }}</AlertDescription>
</AlertRoot> </AlertRoot>
<DataTable :headers="deletedHeaders" :items="deletedUsers" :loading="deletedLoading" <DataTable
:pagination="deletedPagination" :current-sort="deletedSortBy" show-pagination exportable :headers="deletedHeaders"
export-file-name="deleted-users" v-model:page="deletedPage" v-model:items-per-page="deletedItemsPerPage" :items="deletedUsers"
@update:sort-by="handleDeletedSortUpdate"> :loading="deletedLoading"
: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" />
@@ -460,9 +475,16 @@ onMounted(() => {
<template #item.actions="{ item }"> <template #item.actions="{ item }">
<div class="flex items-center"> <div class="flex items-center">
<Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="ghost" size="sm" <Button
class="bg-blue-600 text-white disabled:opacity-50" :disabled="restoring" title="Pulihkan pengguna" v-if="hasPermission('padam akaun pengguna')"
@click="openRestoreConfirmation(item as UserListItem)"> type="button"
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>
@@ -471,7 +493,6 @@ 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">
@@ -516,7 +537,6 @@ 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">
@@ -544,7 +564,6 @@ 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">
@@ -562,8 +581,14 @@ onMounted(() => {
<DialogCloseTrigger class="mr-2 w-24" :disabled="restoring"> <DialogCloseTrigger class="mr-2 w-24" :disabled="restoring">
Batal Batal
</DialogCloseTrigger> </DialogCloseTrigger>
<Button class="w-24" type="button" variant="primary" look="outline" :disabled="restoring" <Button
@click="confirmRestore"> class="w-24"
type="button"
variant="primary"
look="outline"
:disabled="restoring"
@click="confirmRestore"
>
{{ restoring ? 'Memulihkan...' : 'Pulihkan' }} {{ restoring ? 'Memulihkan...' : 'Pulihkan' }}
</Button> </Button>
</div> </div>