7 Commits

Author SHA1 Message Date
ismailmasseran ebd3caecc6 DONE: use full name for date in letter, include password in email after membership is done, block ic_number to exclude - (#9)
Build Docker Image / build-backend (push) Successful in 44s
Build Docker Image / build-frontend (push) Successful in 18s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #9
2026-07-08 12:15:41 +08:00
ismailmasseran 18c0d268b1 DONE: update logo to original official logo, update letterhead and letterfooter layout (#8)
Build Docker Image / build-backend (push) Successful in 52s
Build Docker Image / build-frontend (push) Successful in 32s
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #8
2026-07-07 14:45:20 +08:00
ismailmasseran 1cc3351ca6 DONE: renaming the frontend url to mykopkb (#7)
Build Docker Image / build-backend (push) Successful in 51s
Build Docker Image / build-frontend (push) Successful in 21s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #7
2026-07-06 15:56:28 +08:00
ismailmasseran 33372f2c95 DONE: fix cors issues, use custom-logo for letterhead (#6)
Build Docker Image / build-backend (push) Successful in 54s
Build Docker Image / build-frontend (push) Successful in 7s
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #6
2026-07-06 14:48:50 +08:00
ISMAIL MASSERAN e6e2df72eb DONE: fix error on Dockerfile build
Build Docker Image / build-backend (push) Successful in 1m4s
Build Docker Image / build-frontend (push) Successful in 7s
2026-07-06 13:26:25 +08:00
ismailmasseran 580c7d2392 DONE: fix error where image not showing on digital card, generate letter not working on prod (#5)
Build Docker Image / build-backend (push) Failing after 23s
Build Docker Image / build-frontend (push) Successful in 21s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #5
2026-07-06 13:24:42 +08:00
ismailmasseran d06c4701a4 Dev/v1.2 (#4)
Build Docker Image / build-backend (push) Successful in 1m56s
Build Docker Image / build-frontend (push) Successful in 1m55s
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #4
2026-07-06 12:50:55 +08:00
104 changed files with 900 additions and 535 deletions
+4 -5
View File
@@ -1,12 +1,11 @@
name: Build Docker Image
# Builds images on every push to main or tag, but does NOT auto-deploy
on:
push:
branches:
- main
# branches:
# - main
tags:
- "v*" # v1.0.0, v1.0.1, etc.
- "v*"
jobs:
# test-backend:
@@ -92,6 +91,6 @@ jobs:
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
-f fe/docker/Dockerfile \
--build-arg VITE_API_BASE_URL=https://api.koppkb.com \
--build-arg VITE_APP_URL=https://anggota.koppkb.com \
--build-arg VITE_APP_URL=https://mykopkb.koppkb.com \
${TAGS} \
--push .
+5 -4
View File
@@ -18,8 +18,8 @@
## Present to Boss (2/7/2026)
[x] discuss logo baru MyKOPKB
[x] layout footer untuk surat rasmi
[ ] maklumat pekerjaan kena buat dropdown untuk nama majikan
[ ] tambah minima untuk borang permohonan (tukar jadi caruman saham potongan bulanan)
[x] maklumat pekerjaan kena buat dropdown untuk nama majikan
[x] tambah minima untuk borang permohonan (tukar jadi caruman saham potongan bulanan)
[x] tukar waris ke penama
[x] ic dan surat pengesahan majikkan perlu mandatori
[ ] semakan lembaga tukar jadi maklumat keputusan
@@ -27,5 +27,6 @@
[x] surat attach dekat email selepas bos
[ ] pembayaran pertama tukar jadi one-off
[ ] syer maksima silap tukar jadi rm50.00
[ ] letak sign digital
[x] running no anggota dalam surat dan masa.
[x] letak sign digital
[x] running no anggota dalam surat dan masa.
[ ] tukar logo di surat offer
+4 -3
View File
@@ -67,11 +67,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Browsershot (PDF generation via headless Chrome + Puppeteer in be/)
# Required in Docker/Sail. After first sail up: sail npm install && sail npx puppeteer browsers install chrome-headless-shell
# Local/Sail: 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_NODE_BINARY=
# BROWSERSHOT_NODE_BINARY=/usr/local/bin/node
# BROWSERSHOT_NPM_BINARY=
# BROWSERSHOT_NODE_MODULE_PATH=
# BROWSERSHOT_NODE_MODULE_PATH=/var/www
# BROWSERSHOT_CHROME_PATH=
# BROWSERSHOT_TIMEOUT=60
+2 -1
View File
@@ -105,4 +105,5 @@ API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
API_LOG_KEY_USAGE=true
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,6 +54,7 @@ class User extends Authenticatable
'member_type',
'public_profile_token',
'join_date',
'leave_date',
'birth_date',
'birth_place',
'onboarding_completed_at',
@@ -136,6 +137,7 @@ class User extends Authenticatable
'member_type' => 'string',
'public_profile_token_expires_at' => 'datetime',
'join_date' => 'date',
'leave_date' => 'date',
'birth_date' => 'date',
'birth_place' => 'string',
'onboarding_completed_at' => 'datetime',
@@ -38,6 +38,7 @@ class UserResource extends JsonResource
'member_type' => $this->member_type,
'public_profile_token' => $this->public_profile_token,
'join_date' => $this->join_date,
'leave_date' => $this->leave_date,
'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place,
'onboarding_completed_at' => $this->onboarding_completed_at,
@@ -19,6 +19,7 @@ class MembershipApplicationPassedNotification extends Notification implements Sh
public function __construct(
public MembershipApplication $application,
public Document $resultLetter,
public ?string $plainPassword = null,
) {}
/**
@@ -40,6 +41,8 @@ class MembershipApplicationPassedNotification extends Notification implements Sh
data: [
'application' => $this->application,
'applicant' => $applicant,
'plainPassword' => $this->plainPassword,
'loginUrl' => rtrim(config('user.frontend_url'), '/'),
],
)->attach(
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
@@ -189,6 +189,7 @@ class MembershipApplicationController extends Controller
$membershipApplication,
$request->user(),
$request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
);
$message = $application->board_result === BoardResult::Pass->value
@@ -210,6 +211,7 @@ class MembershipApplicationController extends Controller
$request->validated('application_ids'),
$request->user(),
$request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
);
$succeededCount = count($result['succeeded']);
@@ -270,6 +272,7 @@ class MembershipApplicationController extends Controller
$application = $this->membershipApplicationService->generateResultLetter(
$membershipApplication,
$request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
);
$resultLetter = $application->documents
@@ -301,11 +304,16 @@ class MembershipApplicationController extends Controller
'board_meeting_reference',
'Mesyuarat Lembaga',
);
$boardMeetingDate = (string) $request->query(
'board_meeting_date',
now()->toDateString(),
);
$view = 'membershipapplication::pdf.approved-letter';
$data = $this->membershipApplicationService->getResultLetterViewData(
$application,
$boardMeetingReference,
$boardMeetingDate,
);
$format = $request->query('format', 'html');
$filename = 'surat-keputusan-'.$application->application_number.'.pdf';
@@ -20,6 +20,7 @@ class BatchCompleteRequest extends FormRequest
'application_ids' => 'required|array|min:1|max:100',
'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id',
'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
];
}
@@ -36,6 +37,8 @@ class BatchCompleteRequest extends FormRequest
'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.',
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
];
}
}
@@ -18,6 +18,7 @@ class CompleteMembershipApplicationRequest extends FormRequest
{
return [
'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
];
}
@@ -29,6 +30,8 @@ class CompleteMembershipApplicationRequest extends FormRequest
return [
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
];
}
}
@@ -18,6 +18,7 @@ class GenerateResultLetterRequest extends FormRequest
{
return [
'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
];
}
@@ -29,6 +30,8 @@ class GenerateResultLetterRequest extends FormRequest
return [
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'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 }}**,
Permohonan keahlian anda (**{{ $application->application_number }}**) **tidak diluluskan** pada peringkat lembaga.
Permohonan keahlian anda (**{{ $application->application_number }}**) **tidak diluluskan**.
Surat keputusan rasmi dilampirkan dalam e-mel ini.
@@ -5,10 +5,19 @@
Assalamualaikum **{{ $applicant->name }}**,
Permohonan keahlian anda (**{{ $application->application_number }}**) **diluluskan** pada peringkat lembaga.
Permohonan keahlian anda (**{{ $application->application_number }}**) **diluluskan**.
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.
@include('emails.partials.footer')
@@ -12,49 +12,77 @@
{{ $applicant->postcode }}
@endsection
@section('letter-honorific')
Tuan/Puan,
@endsection
@section('letter-subject')
<strong>{{ $letterSubject }}</strong>
@endsection
@section('letter-body')
<p style="line-height: 1;">
Dengan segala hormatnya saya merujuk kepada perkara di atas.
Dengan segala hormatnya perkara di atas adalah dirujuk.
</p>
<p style="line-height: 1;">
2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
Permodalan Kelantan Berhad (KoPKB) telah <strong>{{ $boardResultLabel }}</strong> dalam {{ $boardMeetingReference }}@if ($isPassed && $memberNumber). Nombor keanggotaan tuan adalah <strong>{{ $memberNumber }}</strong>@endif.
2.
@if ($isPassed)
Sukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>Anggota Koperasi Permodalan Kelantan Berhad (KoPKB)</strong> telah dipertimbangkan dan <strong>DILULUSKAN</strong> oleh Lembaga Koperasi dalam {{ $boardMeetingReference }} yang diadakan pada {{ $boardMeetingDate }}.
@else
Dukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>Anggota Koperasi Permodalan Kelantan Berhad (KoPKB)</strong> telah dipertimbangkan dan <strong>TIDAK DILULUSKAN</strong> oleh Lembaga Koperasi dalam {{ $boardMeetingReference }} yang diadakan pada {{ $boardMeetingDate }}.
@endif
</p>
@if ($isPassed)
<p style="line-height: 1;">
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaimana ketetapan berikut:-
<ul style="line-height: 1;">
<li>Saham</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM {{ $monthlyShareInstallment }} ({{ $shareInstallmentMonths }} bulan)</li>
<li>Potongan sebanyak RM {{ $stockMonthlyContribution }} sehingga Syer Maksima</li>
</ul>
<li>Yuran</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM {{ $feeMonthlyContribution }} setiap bulan</li>
</ul>
<li>Fi Masuk</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li>
</ul>
</ul>
3. Maklumat keanggotaan tuan/puan adalah seperti berikut:-
</p>
<table class="letter-info-table" cellpadding="0" cellspacing="0">
<tr>
<th>Perkara</th>
<th>Maklumat</th>
</tr>
<tr>
<td>Nama Anggota</td>
<td>{{ $applicant->name }}</td>
</tr>
<tr>
<td>No. Kad Pengenalan</td>
<td>{{ $applicant->ic_number }}</td>
</tr>
<tr>
<td>No. Anggota</td>
<td>{{ $memberNumber }}</td>
</tr>
<tr>
<td>Tarikh Kuat Kuasa Keanggotaan</td>
<td>{{ $boardMeetingDate }}</td>
</tr>
<tr>
<td>Modal Syer Permulaan</td>
<td>RM500.00 (perlu dijelaskan dalam tempoh 6 bulan)</td>
</tr>
<tr>
<td>Fi Masuk</td>
<td>RM10.00</td>
</tr>
<tr>
<td>Yuran Tahunan</td>
<td>RM30.00 sebulan</td>
</tr>
</table>
<p style="line-height: 1;">
4. Sebagai anggota KoPKB, tuan/puan adalah tertakluk kepada Undang-Undang Kecil (UUK), dasar, polisi dan peraturan-peraturan KoPKB yang berkuat kuasa dari semasa ke semasa.
</p>
<p style="line-height: 1;">
4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki
menjelaskan modal syer minimum sebanyak RM{{ $minimumShareCapital }}.
Bayaran tersebut perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan.
Pemenuhan syarat ini adalah bagi melayakkan tuan memperoleh hak, kewajipan dan liabiliti sebagai anggota KoPKB
sebagaimana terkandung di dalam Undang-Undang Kecil (UUK), Perkara 59 yang telah didaftarkan pada 11 September 2024.
Manakala had maksima modal syer adalah sebanyak RM5,000.00.
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>
<p style="line-height: 1;">
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.
</p>
@else
<p style="line-height: 1;">
3. Oleh yang demikian, permohonan tuan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
3. Oleh yang demikian, permohonan tuan/puan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
</p>
@endif
@endsection
@@ -64,14 +92,5 @@
@endsection
@section('letter-signature')
<div class="signature-header">Dengan hormatnya,</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
@@ -7,6 +7,7 @@ use App\Services\DocumentService;
use App\Services\LetterService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
@@ -200,7 +201,7 @@ class MembershipApplicationService
{
if ($application->status !== ApplicationStatus::PendingBoard) {
throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh disemak pada peringkat lembaga.'],
'status' => ['Permohonan ini tidak boleh disemak pada peringkat ahli lembaga.'],
]);
}
@@ -258,6 +259,7 @@ class MembershipApplicationService
MembershipApplication $application,
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): MembershipApplication {
if ($application->status !== ApplicationStatus::PendingNotification) {
throw ValidationException::withMessages([
@@ -271,16 +273,19 @@ class MembershipApplicationService
]);
}
return DB::transaction(function () use ($application, $boardMeetingReference) {
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
$plainPassword = null;
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
$user = $this->createMemberFromApplication($application);
$application->update(['user_id' => $user->id]);
$member = $this->createMemberFromApplication($application);
$application->update(['user_id' => $member['user']->id]);
$plainPassword = $member['plainPassword'];
$application->refresh();
}
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference);
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
$this->sendResultNotification($application, $resultLetter);
$this->sendResultNotification($application, $resultLetter, $plainPassword);
$application->update([
'status' => ApplicationStatus::Completed,
@@ -298,8 +303,12 @@ class MembershipApplicationService
* failed: list<array{id: string, application_number: string|null, message: string}>
* }
*/
public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference): array
{
public function completeBatch(
array $applicationIds,
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$succeeded = [];
$failed = [];
@@ -317,7 +326,12 @@ class MembershipApplicationService
}
try {
$succeeded[] = $this->complete($application, $processedBy, $boardMeetingReference);
$succeeded[] = $this->complete(
$application,
$processedBy,
$boardMeetingReference,
$boardMeetingDate,
);
} catch (ValidationException $e) {
$failed[] = [
'id' => $id,
@@ -333,7 +347,11 @@ class MembershipApplicationService
];
}
public function generateResultLetter(MembershipApplication $application, string $boardMeetingReference,): MembershipApplication {
public function generateResultLetter(
MembershipApplication $application,
string $boardMeetingReference,
string $boardMeetingDate,
): MembershipApplication {
if ($application->status !== ApplicationStatus::Completed) {
throw ValidationException::withMessages([
'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'],
@@ -346,8 +364,8 @@ class MembershipApplicationService
]);
}
return DB::transaction(function () use ($application, $boardMeetingReference) {
$this->storeResultLetter($application, $boardMeetingReference);
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
$this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
return $this->repository->findByIdWithRelations($application->id);
});
@@ -356,6 +374,7 @@ class MembershipApplicationService
protected function storeResultLetter(
MembershipApplication $application,
string $boardMeetingReference,
string $boardMeetingDate,
): Document {
$lockedApplication = MembershipApplication::query()
->whereKey($application->id)
@@ -376,7 +395,7 @@ class MembershipApplicationService
]);
}
$viewData = $this->buildResultLetterViewData($application, $boardMeetingReference);
$viewData = $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate);
$pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData);
$fileName = 'surat-keputusan-'.$application->application_number.'.pdf';
@@ -394,6 +413,7 @@ class MembershipApplicationService
public function getResultLetterViewData(
MembershipApplication $application,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$application = $this->repository->findByIdWithRelations($application->id);
@@ -403,7 +423,7 @@ class MembershipApplicationService
]);
}
return $this->buildResultLetterViewData($application, $boardMeetingReference);
return $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate);
}
/**
@@ -412,37 +432,28 @@ class MembershipApplicationService
protected function buildResultLetterViewData(
MembershipApplication $application,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$applicant = $application->applicant;
$isPassed = $application->board_result === BoardResult::Pass->value;
$stockMonthlyContribution = (float) $applicant->stock_monthly_contribution;
$monthlyShareInstallment = number_format(
$stockMonthlyContribution < 84 ? 84 : $stockMonthlyContribution,
2,
'.',
'',
);
return [
'application' => $application,
'applicant' => $applicant,
'boardMeetingReference' => $boardMeetingReference,
'boardMeetingDate' => Carbon::parse($boardMeetingDate)->translatedFormat('d F Y'),
'isPassed' => $isPassed,
'boardResultLabel' => $isPassed ? 'lulus' : 'gagal',
'openingTone' => $isPassed ? 'Sukacita' : 'Dukacita',
'letterSubject' => $isPassed
? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD'
: 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD',
'monthlyShareInstallment' => $monthlyShareInstallment,
'stockMonthlyContribution' => number_format($stockMonthlyContribution, 2, '.', ''),
'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''),
'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS,
'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''),
? 'KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)'
: 'KEPUTUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)',
'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']);
$applicant = $application->applicant;
@@ -453,10 +464,12 @@ class MembershipApplicationService
]);
}
$plainPassword = Str::password(16);
$user = User::create([
'name' => $applicant->name,
'email' => $applicant->email,
'password' => Hash::make(Str::password(16)),
'password' => Hash::make($plainPassword),
'ic_number' => $applicant->ic_number,
'phone_number' => $applicant->phone_number,
'position' => $applicant->current_position,
@@ -502,7 +515,10 @@ class MembershipApplicationService
app(EmailVerificationOtpService::class)->send($user);
return $user;
return [
'user' => $user,
'plainPassword' => $plainPassword,
];
}
public function lookupMemberByIcNumber(string $icNumber): ?User
@@ -527,12 +543,15 @@ class MembershipApplicationService
->notify(new MembershipApplicationSubmittedNotification($application));
}
protected function sendResultNotification(MembershipApplication $application, Document $resultLetter): void
{
protected function sendResultNotification(
MembershipApplication $application,
Document $resultLetter,
?string $plainPassword = null,
): void {
$application->loadMissing('applicant');
$notification = $application->board_result === BoardResult::Pass->value
? new MembershipApplicationPassedNotification($application, $resultLetter)
? new MembershipApplicationPassedNotification($application, $resultLetter, $plainPassword)
: new MembershipApplicationFailedNotification($application, $resultLetter);
Notification::route('mail', $application->applicant->email)->notify($notification);
+1 -1
View File
@@ -4,5 +4,5 @@ return [
'name' => 'User',
'public_profile_token_ttl_days' => (int) env('PUBLIC_PROFILE_TOKEN_TTL_DAYS', 90),
'frontend_url' => rtrim(env('FRONTEND_URL', env('APP_URL', 'http://localhost')), '/'),
'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo.svg')),
'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo-original.svg')),
];
@@ -0,0 +1,22 @@
<?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['password'] = Hash::make('suteraselamanya');
return $validated;
return $this->applyLeaveDateForStatus($validated);
}
/**
@@ -58,6 +58,25 @@ class UserController extends BaseCrudController
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;
}
@@ -171,13 +190,27 @@ class UserController extends BaseCrudController
$this->authorize('viewAny', $this->modelClass);
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);
$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(
$perPage,
$request->get('search', ''),
$request->get('status', ''),
$request->get('sort_by', 'id'),
$request->get('sort_order', 'asc')
$request->get('sort_order', 'asc'),
$dateFilters
);
return response()->json([
@@ -44,6 +44,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer',
'member_type' => 'required|string|max:255',
'join_date' => 'nullable|date',
'leave_date' => 'nullable|date',
'birth_date' => 'nullable|date',
'birth_place' => 'nullable|string|max:255',
];
@@ -74,6 +75,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer',
'member_type' => 'required|string|max:255',
'join_date' => 'nullable|date',
'leave_date' => 'nullable|date',
'birth_date' => 'nullable|date',
'birth_place' => 'nullable|string|max:255',
];
@@ -100,6 +102,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer',
'member_type' => 'required|string|max:255',
'join_date' => 'required|date',
'leave_date' => 'nullable|date',
'birth_date' => 'required|date',
'birth_place' => 'required|string|max:255',
];
@@ -136,6 +139,7 @@ class UserRequest extends FormRequest
'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.',
'join_date.required' => 'Tarikh join diperlukan.',
'join_date.date' => 'Tarikh join tidak sah.',
'leave_date.date' => 'Tarikh berhenti tidak sah.',
'birth_date.required' => 'Tarikh lahir diperlukan.',
'birth_date.date' => 'Tarikh lahir tidak sah.',
'birth_place.required' => 'Tempat lahir diperlukan.',
@@ -20,7 +20,8 @@ interface UserRepositoryInterface
string $search = '',
string $status = '',
string $sortBy = 'name',
string $sortOrder = 'asc'
string $sortOrder = 'asc',
array $dateFilters = []
);
/**
@@ -26,6 +26,25 @@ 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
*/
@@ -46,7 +65,8 @@ class UserRepository implements UserRepositoryInterface
string $search = '',
string $status = '',
string $sortBy = 'name',
string $sortOrder = 'asc'
string $sortOrder = 'asc',
array $dateFilters = []
) {
$allowedSortColumns = [
'id',
@@ -55,6 +75,8 @@ class UserRepository implements UserRepositoryInterface
'position',
'status',
'member_number',
'join_date',
'leave_date',
'created_at',
'deleted_at',
];
@@ -73,6 +95,8 @@ class UserRepository implements UserRepositoryInterface
$query->where('status', $status);
}
$this->applyDateRangeFilters($query, $dateFilters);
return $query->paginate($perPage);
}
@@ -93,6 +117,8 @@ class UserRepository implements UserRepositoryInterface
'position',
'status',
'member_number',
'join_date',
'leave_date',
'created_at',
'deleted_at',
];
+4 -2
View File
@@ -21,14 +21,16 @@ class UserService
string $search,
string $status,
string $sortBy,
string $sortOrder
string $sortOrder,
array $dateFilters = []
): LengthAwarePaginator {
return $this->repository->getAllWithRelationsPaginated(
$perPage,
$search,
$status,
$sortBy,
$sortOrder
$sortOrder,
$dateFilters
);
}
@@ -25,6 +25,7 @@ class UserListResource extends JsonResource
'member_number' => $this->member_number,
'member_type' => $this->member_type,
'join_date' => $this->join_date,
'leave_date' => $this->leave_date,
'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place,
'created_at' => $this->created_at,
+2 -2
View File
@@ -24,8 +24,8 @@ return [
'http://127.0.0.1',
'http://localhost:5173',
'http://127.0.0.1:5173',
'https://anggota.koppkb.com',
'http://anggota.koppkb.com',
'https://mykopkb.koppkb.com',
'http://mykopkb.koppkb.com',
],
'allowed_origins_patterns' => [],
+1 -1
View File
@@ -124,6 +124,6 @@ return [
|
*/
'logo_path' => public_path('images/logo/logo.svg'),
'logo_path' => public_path('images/logo/logo-original.svg'),
];
+60 -1
View File
@@ -46,6 +46,25 @@ 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 \
&& 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
FROM php:8.4-fpm AS base
@@ -79,6 +98,37 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& docker-php-ext-enable redis \
&& 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
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
@@ -120,11 +170,20 @@ RUN echo '[www]' > /usr/local/etc/php-fpm.d/zzz-status.conf && \
# Copy the application code and dependencies from the build stage first
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
WORKDIR /var/www
# Ensure correct permissions
RUN chown -R www-data:www-data /var/www
RUN chown -R www-data:www-data /var/www /var/www/.cache/puppeteer
# Create Nginx log directory
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: 50 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

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

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

+13 -1
View File
@@ -100,6 +100,8 @@ const props = withDefaults(defineProps<{
currentSort?: SortConfig[];
exportable?: boolean;
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. */
scrollable?: boolean;
/** Max height of the scrollable table area (CSS value). */
@@ -622,12 +624,22 @@ const exportToPDF = () => {
else if (colCount > 8) fontSize = 7;
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] = {
head: [headers],
body: data,
startY: tableStartY,
styles: { fontSize, cellPadding },
headStyles: { fillColor: [66, 139, 202] },
margin: { top: 20 },
margin: { top: tableStartY },
};
// For wide tables: let table use natural column widths and split across pages
@@ -59,7 +59,7 @@ const availableModules = computed(() => [
visible: hasPermission('lihat aktiviti'),
},
{
title: 'Senarai Pengguna',
title: 'Senarai Daftar Anggota',
description: 'Pantau dan urus akaun pengguna sistem.',
route: 'list-users',
icon: 'Users' as Icon,
@@ -3,25 +3,25 @@ import type { ExternalSystem } from '../types/external-system.types'
export const dummyExternalSystems: ExternalSystem[] = [
{
id: 'ext-001',
code: 'KOPKB-PORTAL',
code: 'portal-mykopkb',
name: 'Portal Ahli KOPKB',
description:
'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.',
url: 'https://anggota.koppkb.com',
url: 'https://mykopkb.koppkb.com',
icon: 'Users',
is_active: true,
starts_at: '2026-01-01T00:00:00+08:00',
ends_at: null,
opens_in_new_tab: true,
contact_email: 'sokongan@koppkb.com',
contact_email: 'dev_kopkb@gmail.com',
notes: 'Log masuk menggunakan e-mel berdaftar ahli KOPKB.',
created_at: '2026-01-15T09:00:00+08:00',
updated_at: '2026-06-01T14:30:00+08:00',
},
{
id: 'ext-002',
code: 'AGM-VOTE',
name: 'Sistem Pengundian AGM',
code: 'e-vote',
name: 'Sistem Pengundian AGM KOPKB',
description:
'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.',
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',
ends_at: null,
opens_in_new_tab: true,
contact_email: 'agm@koppkb.com',
contact_email: 'dev_kopkb@gmail.com',
notes: 'Sila lengkapkan profil sebelum mengundi.',
created_at: '2026-05-20T10:00:00+08:00',
updated_at: '2026-06-28T11:15:00+08:00',
@@ -22,6 +22,7 @@ import { AlertRoot, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Lucide } from '@/components/ui/lucide'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
import { sanitizeIcNumberInput } from '../utils/membership-application-form.utils'
import type {
MembershipApplicationFormState,
MembershipApplicationHeirForm,
@@ -37,7 +38,7 @@ const MIN_FEE_MONTHLY_CONTRIBUTION = 30
const steps = [
{ id: 1, label: 'Maklumat Peribadi' },
{ id: 2, label: 'Hubungan & Alamat' },
{ id: 2, label: 'No. Telefon & Alamat' },
{ id: 3, label: 'Maklumat Pekerjaan' },
{ id: 4, label: 'Maklumat Penama' },
{ id: 5, label: 'Dokumen & Hantar' },
@@ -221,9 +222,22 @@ function clearReference(role: 'proposer' | 'supporter') {
}
function handleReferenceIcInput(role: 'proposer' | 'supporter') {
form.references[role].ic_number = sanitizeIcNumberInput(form.references[role].ic_number)
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') {
const reference = form.references[role]
const icNumber = reference.ic_number.trim()
@@ -618,7 +632,8 @@ function stepLabelClass(stepId: number) {
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="ic_number">No. Kad Pengenalan</FieldLabel>
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" />
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" inputmode="numeric" maxlength="15"
placeholder="Contoh: 900101011234" @input="handleApplicantIcInput" />
<FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}
</FieldError>
</Field>
@@ -800,7 +815,8 @@ function stepLabelClass(stepId: number) {
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" />
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" inputmode="numeric"
maxlength="15" placeholder="Contoh: 900101011234" @input="handleHeirIcInput(index)" />
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">
{{ fieldErrors[`heirs.${index}.ic_number`] }}
</FieldError>
@@ -857,8 +873,9 @@ function stepLabelClass(stepId: number) {
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel>
<Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text"
placeholder="Contoh: 900101011234" :disabled="referenceLookupLoading.proposer"
@input="handleReferenceIcInput('proposer')" @blur="lookupReference('proposer')" />
inputmode="numeric" maxlength="12" placeholder="Contoh: 900101011234"
:disabled="referenceLookupLoading.proposer" @input="handleReferenceIcInput('proposer')"
@blur="lookupReference('proposer')" />
<p v-if="form.references.proposer.name" class="mt-1 text-sm text-success">
{{ form.references.proposer.name }}
</p>
@@ -870,8 +887,9 @@ function stepLabelClass(stepId: number) {
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel>
<Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text"
placeholder="Contoh: 850505055678" :disabled="referenceLookupLoading.supporter"
@input="handleReferenceIcInput('supporter')" @blur="lookupReference('supporter')" />
inputmode="numeric" maxlength="12" placeholder="Contoh: 850505055678"
:disabled="referenceLookupLoading.supporter" @input="handleReferenceIcInput('supporter')"
@blur="lookupReference('supporter')" />
<p v-if="form.references.supporter.name" class="mt-1 text-sm text-success">
{{ form.references.supporter.name }}
</p>
@@ -907,7 +925,7 @@ function stepLabelClass(stepId: number) {
<Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileChange('employer_letter', $event)" />
<FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter']
}}</FieldError>
}}</FieldError>
</Field>
<div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4">
@@ -65,6 +65,8 @@ const DOCUMENT_TYPE_LABELS: Record<string, string> = {
[RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan',
}
const BOARD_MEETING_REFERENCE_PREFIX = 'Mesyuarat Lembaga Bil.'
const router = useRouter()
const route = useRoute()
const { hasPermission } = usePermissions()
@@ -93,6 +95,8 @@ const previewUrl = ref<string | null>(null)
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
const boardMeetingReference = ref('')
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 pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null)
@@ -372,6 +376,8 @@ function openConfirmAction(
if (action.type === 'complete') {
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
boardMeetingDateError.value = null
}
pendingAction.value = action
@@ -383,6 +389,8 @@ function closeConfirmDialog() {
pendingAction.value = null
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
boardMeetingDateError.value = null
}
async function confirmPendingAction() {
@@ -391,6 +399,7 @@ async function confirmPendingAction() {
error.value = null
successMessage.value = null
boardMeetingReferenceError.value = null
boardMeetingDateError.value = null
if (pendingAction.value.type === 'complete') {
const reference = boardMeetingReference.value.trim()
@@ -398,6 +407,10 @@ async function confirmPendingAction() {
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
return
}
if (!boardMeetingDate.value) {
boardMeetingDateError.value = 'Tarikh mesyuarat lembaga diperlukan.'
return
}
completeSubmitting.value = true
} else {
reviewSubmitting.value = true
@@ -419,8 +432,11 @@ async function confirmPendingAction() {
})
boardRemarks.value = ''
} else {
const boardMeetingReferenceValue = `${BOARD_MEETING_REFERENCE_PREFIX} ${boardMeetingReference.value.trim()}`
response = await completeMembershipApplication(applicationId.value, {
board_meeting_reference: boardMeetingReference.value.trim(),
board_meeting_reference: boardMeetingReferenceValue,
board_meeting_date: boardMeetingDate.value,
})
}
@@ -436,6 +452,9 @@ async function confirmPendingAction() {
if (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 {
reviewSubmitting.value = false
completeSubmitting.value = false
@@ -998,11 +1017,23 @@ onUnmounted(() => {
<div class="mt-2 opacity-70">{{ confirmDialogDescription }}</div>
<Field v-if="pendingAction?.type === 'complete'" class="mt-5 text-left">
<FieldLabel for="detail-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
<Input id="detail-board-meeting-reference" v-model="boardMeetingReference" type="text"
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026" :disabled="completeSubmitting"
@input="boardMeetingReferenceError = null" />
<div class="flex">
<span
class="inline-flex items-center rounded-l-lg border border-r-0 border-foreground/10 bg-foreground/5 px-3 text-sm opacity-80">
{{ 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>
</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 class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-28" :disabled="reviewSubmitting || completeSubmitting">
@@ -110,6 +110,8 @@ const batchSuccessMessage = ref<string | null>(null)
const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
const boardMeetingReference = ref('')
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 selectableApplications = computed(() =>
@@ -162,6 +164,8 @@ function openBatchConfirm() {
if (!selectedIds.value.length) return
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
boardMeetingDateError.value = null
batchConfirmOpen.value = true
}
@@ -173,15 +177,23 @@ async function confirmBatchComplete() {
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
return
}
if (!boardMeetingDate.value) {
boardMeetingDateError.value = 'Tarikh mesyuarat lembaga diperlukan.'
return
}
batchSubmitting.value = true
batchSuccessMessage.value = null
batchFailedItems.value = []
boardMeetingReferenceError.value = null
boardMeetingDateError.value = null
error.value = null
try {
const response = await batchCompleteMembershipApplications(selectedIds.value, reference)
const response = await batchCompleteMembershipApplications(selectedIds.value, {
board_meeting_reference: reference,
board_meeting_date: boardMeetingDate.value,
})
if (response.success) {
batchSuccessMessage.value = response.message
@@ -200,6 +212,7 @@ async function confirmBatchComplete() {
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
const validationErrors = getApiValidationErrors(err)
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
boardMeetingDateError.value = validationErrors?.board_meeting_date?.[0] ?? null
} else {
error.value = getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
}
@@ -245,30 +258,6 @@ function goToApplicationEdit(id: string) {
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 base: TableHeader[] = [
{ title: 'Bil.', key: '#', sortable: false },
@@ -450,12 +439,6 @@ const headers = computed<TableHeader[]>(() => {
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)">
<Pencil class="size-4" aria-hidden="true" />
</Button>
<Button v-if="canDownloadLetter(item as MembershipApplicationListItem)" type="button" variant="ghost"
size="sm" class="bg-purple-600 text-white" title="Muat turun surat keputusan"
:disabled="downloadingResultLetterId === (item as MembershipApplicationListItem).id"
@click="handleDownloadResultLetter(item as MembershipApplicationListItem)">
<Download class="size-4" aria-hidden="true" />
</Button>
</div>
</template>
</DataTable>
@@ -475,6 +458,12 @@ const headers = computed<TableHeader[]>(() => {
@input="boardMeetingReferenceError = null" />
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
</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 class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-32" :disabled="batchSubmitting">
@@ -225,13 +225,13 @@ export async function completeMembershipApplication(
export async function batchCompleteMembershipApplications(
applicationIds: string[],
boardMeetingReference: string,
payload: GenerateResultLetterPayload,
): Promise<BatchCompleteResponse> {
const { data } = await api.post<BatchCompleteResponse>(
'/v1/membership-applications/batch-complete',
{
application_ids: applicationIds,
board_meeting_reference: boardMeetingReference,
...payload,
},
)
@@ -245,6 +245,7 @@ export type BatchCompleteResponse = {
export interface GenerateResultLetterPayload {
board_meeting_reference: string
board_meeting_date: string
}
export type GenerateResultLetterResponse = {
@@ -61,6 +61,10 @@ export type ApplicantDocumentUploadType = (typeof APPLICANT_DOCUMENT_UPLOAD_TYPE
export type AdminDocumentUploadType = (typeof ADMIN_DOCUMENT_UPLOAD_TYPES)[number]
export type DocumentUploadType = ApplicantDocumentUploadType | AdminDocumentUploadType
export function sanitizeIcNumberInput(value: string): string {
return value.replace(/\D/g, '')
}
export function createSelectCollection(options: SelectOption[]) {
return select.collection({
items: options,
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import QRCode from 'qrcode'
import logoUrl from '@/assets/images/logo.svg'
import logoUrl from '@/assets/images/logo-original.svg'
import { displayCardValue } from '../utils/member-digital-card.utils'
const props = withDefaults(
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { computed } from 'vue'
import logoUrl from '@/assets/images/logo.svg'
import logoUrl from '@/assets/images/logo-original.svg'
import { displayCardValue } from '../utils/member-digital-card.utils'
const props = withDefaults(
@@ -14,14 +14,22 @@ export function buildCardDownloadFileName(
export function toProxiedStorageUrl(url: string | null | undefined): string | null | undefined {
if (!url) return url
try {
const parsed = new URL(url, window.location.origin)
if (parsed.pathname.startsWith('/storage/')) {
return `${parsed.pathname}${parsed.search}`
}
} catch {
// Keep original URL when parsing fails.
}
const apiBase = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '')
return url
try {
const parsed = new URL(url, apiBase || window.location.origin)
if (!parsed.pathname.startsWith('/storage/')) {
return url
}
// Production serves the SPA from a different host than Laravel storage files.
if (apiBase) {
return `${apiBase}${parsed.pathname}${parsed.search}`
}
// Dev fallback: Vite proxies /storage to the API.
return `${parsed.pathname}${parsed.search}`
} catch {
return url
}
}
@@ -109,7 +109,7 @@ export function useImpersonate() {
title: response.message,
showConfirmButton: false,
showCloseButton: true,
timer: 2000,
timer: 500,
})
await router.push(resolvePostLoginRoute(response.redirect_path))
@@ -143,7 +143,7 @@ export function useImpersonate() {
title: response.message,
showConfirmButton: false,
showCloseButton: true,
timer: 2000,
timer: 500,
})
await router.push(resolvePostLoginRoute(response.redirect_path))
+36 -1
View File
@@ -1,4 +1,4 @@
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import debounce from 'lodash/debounce'
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
import { useApiPagination } from '@/composables/useApiPagination'
@@ -12,12 +12,29 @@ export function useUserList() {
const error = ref<string | null>(null)
const search = 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 page = ref(1)
const itemsPerPage = ref(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) {
loading.value = true
error.value = null
@@ -31,11 +48,17 @@ export function useUserList() {
sort_order: activeSort?.order ?? 'asc',
search: search.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
applyPagination(data.pagination)
page.value = data.pagination.current_page
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai pengguna.')
} finally {
loading.value = false
}
@@ -58,6 +81,10 @@ export function useUserList() {
fetchUsers(1)
})
watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => {
fetchUsers(1)
})
watch(page, (nextPage, previousPage) => {
if (nextPage !== previousPage) {
fetchUsers(nextPage)
@@ -80,6 +107,14 @@ export function useUserList() {
error,
search,
statusFilter,
joinDateFrom,
joinDateTo,
leaveDateFrom,
leaveDateTo,
hasJoinDateFilters,
hasLeaveDateFilters,
clearJoinDateFilters,
clearLeaveDateFilters,
sortBy,
page,
itemsPerPage,
+1 -1
View File
@@ -4,7 +4,7 @@ export const userMenu: Menu[] = [
{
icon: 'Users',
route_name: 'list-users',
title: 'Senarai Pengguna',
title: 'Senarai Daftar Anggota',
permission: 'lihat pengguna',
},
]

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