4 Commits

104 changed files with 531 additions and 896 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 .
+4 -5
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 -2
View File
@@ -105,5 +105,4 @@ API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
API_LOG_KEY_USAGE=true 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
-2
View File
@@ -54,7 +54,6 @@ class User extends Authenticatable
'member_type', 'member_type',
'public_profile_token', 'public_profile_token',
'join_date', 'join_date',
'leave_date',
'birth_date', 'birth_date',
'birth_place', 'birth_place',
'onboarding_completed_at', 'onboarding_completed_at',
@@ -137,7 +136,6 @@ class User extends Authenticatable
'member_type' => 'string', 'member_type' => 'string',
'public_profile_token_expires_at' => 'datetime', 'public_profile_token_expires_at' => 'datetime',
'join_date' => 'date', 'join_date' => 'date',
'leave_date' => 'date',
'birth_date' => 'date', 'birth_date' => 'date',
'birth_place' => 'string', 'birth_place' => 'string',
'onboarding_completed_at' => 'datetime', 'onboarding_completed_at' => 'datetime',
@@ -38,7 +38,6 @@ class UserResource extends JsonResource
'member_type' => $this->member_type, 'member_type' => $this->member_type,
'public_profile_token' => $this->public_profile_token, 'public_profile_token' => $this->public_profile_token,
'join_date' => $this->join_date, 'join_date' => $this->join_date,
'leave_date' => $this->leave_date,
'birth_date' => $this->birth_date, 'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place, 'birth_place' => $this->birth_place,
'onboarding_completed_at' => $this->onboarding_completed_at, 'onboarding_completed_at' => $this->onboarding_completed_at,
@@ -19,7 +19,6 @@ class MembershipApplicationPassedNotification extends Notification implements Sh
public function __construct( public function __construct(
public MembershipApplication $application, public MembershipApplication $application,
public Document $resultLetter, public Document $resultLetter,
public ?string $plainPassword = null,
) {} ) {}
/** /**
@@ -41,8 +40,6 @@ class MembershipApplicationPassedNotification extends Notification implements Sh
data: [ data: [
'application' => $this->application, 'application' => $this->application,
'applicant' => $applicant, 'applicant' => $applicant,
'plainPassword' => $this->plainPassword,
'loginUrl' => rtrim(config('user.frontend_url'), '/'),
], ],
)->attach( )->attach(
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path), Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
@@ -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,19 +5,10 @@
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.
@if ($plainPassword)
Akaun portal anda telah didaftarkan. Maklumat log masuk adalah seperti berikut:
- **E-mel:** {{ $applicant->email }}
- **Kata laluan:** {{ $plainPassword }}
Sila log masuk di [{{ $loginUrl }}]({{ $loginUrl }}) dan tukar kata laluan anda selepas log masuk kali pertama.
@endif
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi. Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
@include('emails.partials.footer') @include('emails.partials.footer')
@@ -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,19 +271,16 @@ class MembershipApplicationService
]); ]);
} }
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) { return DB::transaction(function () use ($application, $boardMeetingReference) {
$plainPassword = null;
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) { if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
$member = $this->createMemberFromApplication($application); $user = $this->createMemberFromApplication($application);
$application->update(['user_id' => $member['user']->id]); $application->update(['user_id' => $user->id]);
$plainPassword = $member['plainPassword'];
$application->refresh(); $application->refresh();
} }
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate); $resultLetter = $this->storeResultLetter($application, $boardMeetingReference);
$this->sendResultNotification($application, $resultLetter, $plainPassword); $this->sendResultNotification($application, $resultLetter);
$application->update([ $application->update([
'status' => ApplicationStatus::Completed, 'status' => ApplicationStatus::Completed,
@@ -303,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 = [];
@@ -326,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,
@@ -347,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.'],
@@ -364,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);
}); });
@@ -374,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)
@@ -395,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';
@@ -413,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);
@@ -423,7 +403,7 @@ class MembershipApplicationService
]); ]);
} }
return $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate); return $this->buildResultLetterViewData($application, $boardMeetingReference);
} }
/** /**
@@ -432,28 +412,37 @@ 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 F 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,
]; ];
} }
/** protected function createMemberFromApplication(MembershipApplication $application): User
* @return array{user: User, plainPassword: string}
*/
protected function createMemberFromApplication(MembershipApplication $application): array
{ {
$application->loadMissing(['applicant', 'heirs']); $application->loadMissing(['applicant', 'heirs']);
$applicant = $application->applicant; $applicant = $application->applicant;
@@ -464,12 +453,10 @@ class MembershipApplicationService
]); ]);
} }
$plainPassword = Str::password(16);
$user = User::create([ $user = User::create([
'name' => $applicant->name, 'name' => $applicant->name,
'email' => $applicant->email, 'email' => $applicant->email,
'password' => Hash::make($plainPassword), 'password' => Hash::make(Str::password(16)),
'ic_number' => $applicant->ic_number, 'ic_number' => $applicant->ic_number,
'phone_number' => $applicant->phone_number, 'phone_number' => $applicant->phone_number,
'position' => $applicant->current_position, 'position' => $applicant->current_position,
@@ -515,10 +502,7 @@ class MembershipApplicationService
app(EmailVerificationOtpService::class)->send($user); app(EmailVerificationOtpService::class)->send($user);
return [ return $user;
'user' => $user,
'plainPassword' => $plainPassword,
];
} }
public function lookupMemberByIcNumber(string $icNumber): ?User public function lookupMemberByIcNumber(string $icNumber): ?User
@@ -543,15 +527,12 @@ class MembershipApplicationService
->notify(new MembershipApplicationSubmittedNotification($application)); ->notify(new MembershipApplicationSubmittedNotification($application));
} }
protected function sendResultNotification( protected function sendResultNotification(MembershipApplication $application, Document $resultLetter): void
MembershipApplication $application, {
Document $resultLetter,
?string $plainPassword = null,
): void {
$application->loadMissing('applicant'); $application->loadMissing('applicant');
$notification = $application->board_result === BoardResult::Pass->value $notification = $application->board_result === BoardResult::Pass->value
? new MembershipApplicationPassedNotification($application, $resultLetter, $plainPassword) ? new MembershipApplicationPassedNotification($application, $resultLetter)
: new MembershipApplicationFailedNotification($application, $resultLetter); : new MembershipApplicationFailedNotification($application, $resultLetter);
Notification::route('mail', $application->applicant->email)->notify($notification); Notification::route('mail', $application->applicant->email)->notify($notification);
+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')),
]; ];
@@ -1,22 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->date('leave_date')->nullable()->after('join_date');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('leave_date');
});
}
};
@@ -44,7 +44,7 @@ class UserController extends BaseCrudController
$validated['uuid'] = Str::uuid(); $validated['uuid'] = Str::uuid();
$validated['password'] = Hash::make('suteraselamanya'); $validated['password'] = Hash::make('suteraselamanya');
return $this->applyLeaveDateForStatus($validated); return $validated;
} }
/** /**
@@ -58,25 +58,6 @@ class UserController extends BaseCrudController
unset($validated['email']); unset($validated['email']);
} }
return $this->applyLeaveDateForStatus($validated, $user);
}
/**
* Set or clear leave_date based on membership status transitions.
*/
protected function applyLeaveDateForStatus(array $validated, ?User $user = null): array
{
$newStatus = $validated['status'] ?? $user?->status;
$currentStatus = $user?->status;
if ($newStatus === 'inactive') {
if ($currentStatus !== 'inactive') {
$validated['leave_date'] = $validated['leave_date'] ?? now()->toDateString();
}
} elseif (in_array($newStatus, ['active', 'pending'], true)) {
$validated['leave_date'] = null;
}
return $validated; return $validated;
} }
@@ -190,27 +171,13 @@ class UserController extends BaseCrudController
$this->authorize('viewAny', $this->modelClass); $this->authorize('viewAny', $this->modelClass);
try { try {
$request->validate([
'join_date_from' => 'nullable|date',
'join_date_to' => 'nullable|date',
'leave_date_from' => 'nullable|date',
'leave_date_to' => 'nullable|date',
]);
$perPage = min((int) $request->get('per_page', 10), 500); $perPage = min((int) $request->get('per_page', 10), 500);
$dateFilters = array_filter([
'join_date_from' => $request->get('join_date_from'),
'join_date_to' => $request->get('join_date_to'),
'leave_date_from' => $request->get('leave_date_from'),
'leave_date_to' => $request->get('leave_date_to'),
]);
$items = $this->userService->getPaginatedList( $items = $this->userService->getPaginatedList(
$perPage, $perPage,
$request->get('search', ''), $request->get('search', ''),
$request->get('status', ''), $request->get('status', ''),
$request->get('sort_by', 'id'), $request->get('sort_by', 'id'),
$request->get('sort_order', 'asc'), $request->get('sort_order', 'asc')
$dateFilters
); );
return response()->json([ return response()->json([
@@ -44,7 +44,6 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer', 'member_number' => 'required|integer',
'member_type' => 'required|string|max:255', 'member_type' => 'required|string|max:255',
'join_date' => 'nullable|date', 'join_date' => 'nullable|date',
'leave_date' => 'nullable|date',
'birth_date' => 'nullable|date', 'birth_date' => 'nullable|date',
'birth_place' => 'nullable|string|max:255', 'birth_place' => 'nullable|string|max:255',
]; ];
@@ -75,7 +74,6 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer', 'member_number' => 'required|integer',
'member_type' => 'required|string|max:255', 'member_type' => 'required|string|max:255',
'join_date' => 'nullable|date', 'join_date' => 'nullable|date',
'leave_date' => 'nullable|date',
'birth_date' => 'nullable|date', 'birth_date' => 'nullable|date',
'birth_place' => 'nullable|string|max:255', 'birth_place' => 'nullable|string|max:255',
]; ];
@@ -102,7 +100,6 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer', 'member_number' => 'required|integer',
'member_type' => 'required|string|max:255', 'member_type' => 'required|string|max:255',
'join_date' => 'required|date', 'join_date' => 'required|date',
'leave_date' => 'nullable|date',
'birth_date' => 'required|date', 'birth_date' => 'required|date',
'birth_place' => 'required|string|max:255', 'birth_place' => 'required|string|max:255',
]; ];
@@ -139,7 +136,6 @@ class UserRequest extends FormRequest
'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.', 'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.',
'join_date.required' => 'Tarikh join diperlukan.', 'join_date.required' => 'Tarikh join diperlukan.',
'join_date.date' => 'Tarikh join tidak sah.', 'join_date.date' => 'Tarikh join tidak sah.',
'leave_date.date' => 'Tarikh berhenti tidak sah.',
'birth_date.required' => 'Tarikh lahir diperlukan.', 'birth_date.required' => 'Tarikh lahir diperlukan.',
'birth_date.date' => 'Tarikh lahir tidak sah.', 'birth_date.date' => 'Tarikh lahir tidak sah.',
'birth_place.required' => 'Tempat lahir diperlukan.', 'birth_place.required' => 'Tempat lahir diperlukan.',
@@ -20,8 +20,7 @@ interface UserRepositoryInterface
string $search = '', string $search = '',
string $status = '', string $status = '',
string $sortBy = 'name', string $sortBy = 'name',
string $sortOrder = 'asc', string $sortOrder = 'asc'
array $dateFilters = []
); );
/** /**
@@ -26,25 +26,6 @@ class UserRepository implements UserRepositoryInterface
}); });
} }
private function applyDateRangeFilters($query, array $dateFilters): void
{
if (! empty($dateFilters['join_date_from'])) {
$query->whereDate('join_date', '>=', $dateFilters['join_date_from']);
}
if (! empty($dateFilters['join_date_to'])) {
$query->whereDate('join_date', '<=', $dateFilters['join_date_to']);
}
if (! empty($dateFilters['leave_date_from'])) {
$query->whereDate('leave_date', '>=', $dateFilters['leave_date_from']);
}
if (! empty($dateFilters['leave_date_to'])) {
$query->whereDate('leave_date', '<=', $dateFilters['leave_date_to']);
}
}
/** /**
* Get all Users with pagination and search * Get all Users with pagination and search
*/ */
@@ -65,8 +46,7 @@ class UserRepository implements UserRepositoryInterface
string $search = '', string $search = '',
string $status = '', string $status = '',
string $sortBy = 'name', string $sortBy = 'name',
string $sortOrder = 'asc', string $sortOrder = 'asc'
array $dateFilters = []
) { ) {
$allowedSortColumns = [ $allowedSortColumns = [
'id', 'id',
@@ -75,8 +55,6 @@ class UserRepository implements UserRepositoryInterface
'position', 'position',
'status', 'status',
'member_number', 'member_number',
'join_date',
'leave_date',
'created_at', 'created_at',
'deleted_at', 'deleted_at',
]; ];
@@ -95,8 +73,6 @@ class UserRepository implements UserRepositoryInterface
$query->where('status', $status); $query->where('status', $status);
} }
$this->applyDateRangeFilters($query, $dateFilters);
return $query->paginate($perPage); return $query->paginate($perPage);
} }
@@ -117,8 +93,6 @@ class UserRepository implements UserRepositoryInterface
'position', 'position',
'status', 'status',
'member_number', 'member_number',
'join_date',
'leave_date',
'created_at', 'created_at',
'deleted_at', 'deleted_at',
]; ];
+2 -4
View File
@@ -21,16 +21,14 @@ class UserService
string $search, string $search,
string $status, string $status,
string $sortBy, string $sortBy,
string $sortOrder, string $sortOrder
array $dateFilters = []
): LengthAwarePaginator { ): LengthAwarePaginator {
return $this->repository->getAllWithRelationsPaginated( return $this->repository->getAllWithRelationsPaginated(
$perPage, $perPage,
$search, $search,
$status, $status,
$sortBy, $sortBy,
$sortOrder, $sortOrder
$dateFilters
); );
} }
@@ -25,7 +25,6 @@ class UserListResource extends JsonResource
'member_number' => $this->member_number, 'member_number' => $this->member_number,
'member_type' => $this->member_type, 'member_type' => $this->member_type,
'join_date' => $this->join_date, 'join_date' => $this->join_date,
'leave_date' => $this->leave_date,
'birth_date' => $this->birth_date, 'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place, 'birth_place' => $this->birth_place,
'created_at' => $this->created_at, 'created_at' => $this->created_at,
+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

+105 -66
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,22 +328,17 @@
<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 !== '')) @if ($showReference && trim($__env->yieldContent('letter-reference')) !== '')
<table cellpadding="0" cellspacing="0"> <table cellpadding="0" cellspacing="0">
@if ($showReference && trim($__env->yieldContent('letter-reference')) !== '') <tr>
<tr> <td class="meta-label">Ruj.</td>
<td class="meta-label">RUJ. KAMI</td> <td class="meta-value">: @yield('letter-reference')</td>
<td class="meta-value">: @yield('letter-reference')</td> </tr>
</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')
@@ -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">
</tr> <table class="letter-footer-lines" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr> <tr>
<td class="letter-footer-nota"> <td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'phone'])</td>
<strong>Nota:</strong><br> <td class="letter-footer-line-text" valign="middle">Tel. Pejabat: 09-741 4331</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' => '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,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">
@if (! empty($logoUrl)) <table class="letterhead-rule" width="100%" cellpadding="0" cellspacing="0">
<img src="{{ $logoUrl }}" alt="Koperasi Permodalan Kelantan Berhad" class="letterhead-logo-img"> <tr>
@endif <td class="letterhead-rule-blue" width="50%" height="6" bgcolor="#0e579b">&nbsp;</td>
<div class="letterhead-address"> <td class="letterhead-rule-orange" width="50%" height="6" bgcolor="#d35400">&nbsp;</td>
Lot Pt 448, Tingkat 1, Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan. </tr>
</div> </table>
<div class="letterhead-contact"> </td>
Tel: 09-7414331 | Fax: 09-7414330 | Web:www.koppkb.com <td class="letterhead-brand" valign="middle" align="right" width="1">
</div> <table class="letterhead-brand-inner" cellpadding="0" cellspacing="0" align="right">
<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,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

+1 -13
View File
@@ -100,8 +100,6 @@ const props = withDefaults(defineProps<{
currentSort?: SortConfig[]; currentSort?: SortConfig[];
exportable?: boolean; exportable?: boolean;
exportFileName?: string; exportFileName?: string;
/** Title shown at the top of exported PDF documents. */
exportPdfTitle?: string;
/** When true, table body scrolls inside a max-height box instead of growing the page. */ /** When true, table body scrolls inside a max-height box instead of growing the page. */
scrollable?: boolean; scrollable?: boolean;
/** Max height of the scrollable table area (CSS value). */ /** Max height of the scrollable table area (CSS value). */
@@ -624,22 +622,12 @@ const exportToPDF = () => {
else if (colCount > 8) fontSize = 7; else if (colCount > 8) fontSize = 7;
const cellPadding = colCount > 8 ? 4 : 8; const cellPadding = colCount > 8 ? 4 : 8;
let tableStartY = 20;
if (props.exportPdfTitle?.trim()) {
doc.setFontSize(14);
doc.text(props.exportPdfTitle.trim(), doc.internal.pageSize.getWidth() / 2, 28, {
align: 'center',
});
tableStartY = 48;
}
const tableOptions: Parameters<typeof autoTable>[1] = { const tableOptions: Parameters<typeof autoTable>[1] = {
head: [headers], head: [headers],
body: data, body: data,
startY: tableStartY,
styles: { fontSize, cellPadding }, styles: { fontSize, cellPadding },
headStyles: { fillColor: [66, 139, 202] }, headStyles: { fillColor: [66, 139, 202] },
margin: { top: tableStartY }, margin: { top: 20 },
}; };
// For wide tables: let table use natural column widths and split across pages // For wide tables: let table use natural column widths and split across pages
@@ -59,7 +59,7 @@ const availableModules = computed(() => [
visible: hasPermission('lihat aktiviti'), visible: hasPermission('lihat aktiviti'),
}, },
{ {
title: 'Senarai Daftar Anggota', title: 'Senarai Pengguna',
description: 'Pantau dan urus akaun pengguna sistem.', description: 'Pantau dan urus akaun pengguna sistem.',
route: 'list-users', route: 'list-users',
icon: 'Users' as Icon, icon: 'Users' as Icon,
@@ -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',
@@ -22,7 +22,6 @@ import { AlertRoot, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Lucide } from '@/components/ui/lucide' import { Lucide } from '@/components/ui/lucide'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage' import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service' import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
import { sanitizeIcNumberInput } from '../utils/membership-application-form.utils'
import type { import type {
MembershipApplicationFormState, MembershipApplicationFormState,
MembershipApplicationHeirForm, MembershipApplicationHeirForm,
@@ -38,7 +37,7 @@ const MIN_FEE_MONTHLY_CONTRIBUTION = 30
const steps = [ const steps = [
{ id: 1, label: 'Maklumat Peribadi' }, { id: 1, label: 'Maklumat Peribadi' },
{ id: 2, label: 'No. Telefon & Alamat' }, { id: 2, label: 'Hubungan & Alamat' },
{ id: 3, label: 'Maklumat Pekerjaan' }, { id: 3, label: 'Maklumat Pekerjaan' },
{ id: 4, label: 'Maklumat Penama' }, { id: 4, label: 'Maklumat Penama' },
{ id: 5, label: 'Dokumen & Hantar' }, { id: 5, label: 'Dokumen & Hantar' },
@@ -222,22 +221,9 @@ function clearReference(role: 'proposer' | 'supporter') {
} }
function handleReferenceIcInput(role: 'proposer' | 'supporter') { function handleReferenceIcInput(role: 'proposer' | 'supporter') {
form.references[role].ic_number = sanitizeIcNumberInput(form.references[role].ic_number)
clearReference(role) clearReference(role)
} }
function handleApplicantIcInput() {
form.applicant.ic_number = sanitizeIcNumberInput(form.applicant.ic_number)
delete fieldErrors['applicant.ic_number']
}
function handleHeirIcInput(index: number) {
const heir = form.heirs[index]
if (!heir) return
heir.ic_number = sanitizeIcNumberInput(heir.ic_number)
delete fieldErrors[`heirs.${index}.ic_number`]
}
async function lookupReference(role: 'proposer' | 'supporter') { async function lookupReference(role: 'proposer' | 'supporter') {
const reference = form.references[role] const reference = form.references[role]
const icNumber = reference.ic_number.trim() const icNumber = reference.ic_number.trim()
@@ -632,8 +618,7 @@ function stepLabelClass(stepId: number) {
</Field> </Field>
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel for="ic_number">No. Kad Pengenalan</FieldLabel> <FieldLabel for="ic_number">No. Kad Pengenalan</FieldLabel>
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" inputmode="numeric" maxlength="15" <Input id="ic_number" v-model="form.applicant.ic_number" type="text" />
placeholder="Contoh: 900101011234" @input="handleApplicantIcInput" />
<FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }} <FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}
</FieldError> </FieldError>
</Field> </Field>
@@ -815,8 +800,7 @@ function stepLabelClass(stepId: number) {
</Field> </Field>
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel> <FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" inputmode="numeric" <Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" />
maxlength="15" placeholder="Contoh: 900101011234" @input="handleHeirIcInput(index)" />
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]"> <FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">
{{ fieldErrors[`heirs.${index}.ic_number`] }} {{ fieldErrors[`heirs.${index}.ic_number`] }}
</FieldError> </FieldError>
@@ -873,9 +857,8 @@ function stepLabelClass(stepId: number) {
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel> <FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel>
<Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text" <Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text"
inputmode="numeric" maxlength="12" placeholder="Contoh: 900101011234" placeholder="Contoh: 900101011234" :disabled="referenceLookupLoading.proposer"
:disabled="referenceLookupLoading.proposer" @input="handleReferenceIcInput('proposer')" @input="handleReferenceIcInput('proposer')" @blur="lookupReference('proposer')" />
@blur="lookupReference('proposer')" />
<p v-if="form.references.proposer.name" class="mt-1 text-sm text-success"> <p v-if="form.references.proposer.name" class="mt-1 text-sm text-success">
{{ form.references.proposer.name }} {{ form.references.proposer.name }}
</p> </p>
@@ -887,9 +870,8 @@ function stepLabelClass(stepId: number) {
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel> <FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel>
<Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text" <Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text"
inputmode="numeric" maxlength="12" placeholder="Contoh: 850505055678" placeholder="Contoh: 850505055678" :disabled="referenceLookupLoading.supporter"
:disabled="referenceLookupLoading.supporter" @input="handleReferenceIcInput('supporter')" @input="handleReferenceIcInput('supporter')" @blur="lookupReference('supporter')" />
@blur="lookupReference('supporter')" />
<p v-if="form.references.supporter.name" class="mt-1 text-sm text-success"> <p v-if="form.references.supporter.name" class="mt-1 text-sm text-success">
{{ form.references.supporter.name }} {{ form.references.supporter.name }}
</p> </p>
@@ -925,7 +907,7 @@ function stepLabelClass(stepId: number) {
<Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png" <Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileChange('employer_letter', $event)" /> @change="handleFileChange('employer_letter', $event)" />
<FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter'] <FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter']
}}</FieldError> }}</FieldError>
</Field> </Field>
<div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4"> <div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4">
@@ -65,8 +65,6 @@ const DOCUMENT_TYPE_LABELS: Record<string, string> = {
[RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan', [RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan',
} }
const BOARD_MEETING_REFERENCE_PREFIX = 'Mesyuarat Lembaga Bil.'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { hasPermission } = usePermissions() const { hasPermission } = usePermissions()
@@ -95,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)
@@ -376,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
@@ -389,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() {
@@ -399,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()
@@ -407,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,11 +419,8 @@ async function confirmPendingAction() {
}) })
boardRemarks.value = '' boardRemarks.value = ''
} else { } else {
const boardMeetingReferenceValue = `${BOARD_MEETING_REFERENCE_PREFIX} ${boardMeetingReference.value.trim()}`
response = await completeMembershipApplication(applicationId.value, { response = await completeMembershipApplication(applicationId.value, {
board_meeting_reference: boardMeetingReferenceValue, board_meeting_reference: boardMeetingReference.value.trim(),
board_meeting_date: boardMeetingDate.value,
}) })
} }
@@ -452,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
@@ -1017,23 +998,11 @@ onUnmounted(() => {
<div class="mt-2 opacity-70">{{ confirmDialogDescription }}</div> <div class="mt-2 opacity-70">{{ confirmDialogDescription }}</div>
<Field v-if="pendingAction?.type === 'complete'" class="mt-5 text-left"> <Field v-if="pendingAction?.type === 'complete'" class="mt-5 text-left">
<FieldLabel for="detail-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel> <FieldLabel for="detail-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
<div class="flex"> <Input id="detail-board-meeting-reference" v-model="boardMeetingReference" type="text"
<span placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026" :disabled="completeSubmitting"
class="inline-flex items-center rounded-l-lg border border-r-0 border-foreground/10 bg-foreground/5 px-3 text-sm opacity-80"> @input="boardMeetingReferenceError = null" />
{{ BOARD_MEETING_REFERENCE_PREFIX }}
</span>
<Input id="detail-board-meeting-reference" v-model="boardMeetingReference" type="text"
class="rounded-l-none" placeholder="Contoh: 3/2026" :disabled="completeSubmitting"
@input="boardMeetingReferenceError = null" />
</div>
<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 = {
@@ -61,10 +61,6 @@ export type ApplicantDocumentUploadType = (typeof APPLICANT_DOCUMENT_UPLOAD_TYPE
export type AdminDocumentUploadType = (typeof ADMIN_DOCUMENT_UPLOAD_TYPES)[number] export type AdminDocumentUploadType = (typeof ADMIN_DOCUMENT_UPLOAD_TYPES)[number]
export type DocumentUploadType = ApplicantDocumentUploadType | AdminDocumentUploadType export type DocumentUploadType = ApplicantDocumentUploadType | AdminDocumentUploadType
export function sanitizeIcNumberInput(value: string): string {
return value.replace(/\D/g, '')
}
export function createSelectCollection(options: SelectOption[]) { export function createSelectCollection(options: SelectOption[]) {
return select.collection({ return select.collection({
items: options, items: options,
@@ -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 return `${parsed.pathname}${parsed.search}`
} }
// 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}`
} catch { } catch {
return url // 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))
+1 -36
View File
@@ -1,4 +1,4 @@
import { computed, onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import debounce from 'lodash/debounce' import debounce from 'lodash/debounce'
import type { SortConfig } from '@/components/ui/usage/DataTable.vue' import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
import { useApiPagination } from '@/composables/useApiPagination' import { useApiPagination } from '@/composables/useApiPagination'
@@ -12,29 +12,12 @@ export function useUserList() {
const error = ref<string | null>(null) const error = ref<string | null>(null)
const search = ref('') const search = ref('')
const statusFilter = ref('') const statusFilter = ref('')
const joinDateFrom = ref('')
const joinDateTo = ref('')
const leaveDateFrom = ref('')
const leaveDateTo = ref('')
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }]) const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
const page = ref(1) const page = ref(1)
const itemsPerPage = ref(10) const itemsPerPage = ref(10)
const { pagination, applyPagination } = useApiPagination({ per_page: 10 }) const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
const hasJoinDateFilters = computed(() => Boolean(joinDateFrom.value || joinDateTo.value))
const hasLeaveDateFilters = computed(() => Boolean(leaveDateFrom.value || leaveDateTo.value))
function clearJoinDateFilters() {
joinDateFrom.value = ''
joinDateTo.value = ''
}
function clearLeaveDateFilters() {
leaveDateFrom.value = ''
leaveDateTo.value = ''
}
async function fetchUsers(requestPage = page.value) { async function fetchUsers(requestPage = page.value) {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -48,17 +31,11 @@ export function useUserList() {
sort_order: activeSort?.order ?? 'asc', sort_order: activeSort?.order ?? 'asc',
search: search.value.trim() || undefined, search: search.value.trim() || undefined,
status: statusFilter.value.trim() || undefined, status: statusFilter.value.trim() || undefined,
join_date_from: joinDateFrom.value || undefined,
join_date_to: joinDateTo.value || undefined,
leave_date_from: leaveDateFrom.value || undefined,
leave_date_to: leaveDateTo.value || undefined,
}) })
users.value = data.data users.value = data.data
applyPagination(data.pagination) applyPagination(data.pagination)
page.value = data.pagination.current_page page.value = data.pagination.current_page
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai pengguna.')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -81,10 +58,6 @@ export function useUserList() {
fetchUsers(1) fetchUsers(1)
}) })
watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => {
fetchUsers(1)
})
watch(page, (nextPage, previousPage) => { watch(page, (nextPage, previousPage) => {
if (nextPage !== previousPage) { if (nextPage !== previousPage) {
fetchUsers(nextPage) fetchUsers(nextPage)
@@ -107,14 +80,6 @@ export function useUserList() {
error, error,
search, search,
statusFilter, statusFilter,
joinDateFrom,
joinDateTo,
leaveDateFrom,
leaveDateTo,
hasJoinDateFilters,
hasLeaveDateFilters,
clearJoinDateFilters,
clearLeaveDateFilters,
sortBy, sortBy,
page, page,
itemsPerPage, itemsPerPage,
+1 -1
View File
@@ -4,7 +4,7 @@ export const userMenu: Menu[] = [
{ {
icon: 'Users', icon: 'Users',
route_name: 'list-users', route_name: 'list-users',
title: 'Senarai Daftar Anggota', title: 'Senarai Pengguna',
permission: 'lihat pengguna', permission: 'lihat pengguna',
}, },
] ]

Some files were not shown because too many files have changed in this diff Show More