9 Commits

Author SHA1 Message Date
ismailmasseran 1e50e3d19f Merge pull request 'DONE: merge heir tab into profile tab, santize userIcNum and name, rename to malay, add stat card on user list' (#10) from feature/stat-card-on-users into main
Build Docker Image / build-backend (push) Successful in 54s
Build Docker Image / build-frontend (push) Successful in 20s
Reviewed-on: #10
2026-07-09 12:48:57 +08:00
ISMAIL MASSERAN af7f47bf71 DONE: merge heir tab into profile tab, santize userIcNum and name, rename to malay, add stat card on user list 2026-07-09 12:48:26 +08:00
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
122 changed files with 1414 additions and 1065 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([
@@ -236,6 +269,43 @@ class UserController extends BaseCrudController
}
}
public function stats(Request $request): JsonResponse
{
$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',
]);
$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'),
]);
$stats = $this->userService->getListStats(
$request->get('search', ''),
$request->get('status', ''),
$dateFilters
);
return response()->json([
'success' => true,
'data' => $stats,
'message' => 'User stats retrieved successfully.',
]);
} catch (Exception $e) {
Log::error("Error fetching {$this->resourceNamePlural} stats: ".$e->getMessage());
return $this->errorResponse('Failed to retrieve user stats.', 500);
}
}
public function restore(string $id): JsonResponse
{
$this->authorize('restore', $this->modelClass);
@@ -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 = []
);
/**
@@ -54,6 +55,17 @@ interface UserRepositoryInterface
string $sortOrder = 'desc'
);
/**
* Get stats for the Users list (filtered).
*
* @return array{total: int, joined_this_month: int}
*/
public function getListStats(
string $search = '',
string $status = '',
array $dateFilters = []
): array;
/**
* Find soft-deleted User by ID
*/
@@ -3,6 +3,7 @@
namespace Modules\User\Repositories;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Modules\Auth\Entities\User;
use Modules\User\Repositories\Contracts\UserRepositoryInterface;
@@ -26,6 +27,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 +66,8 @@ class UserRepository implements UserRepositoryInterface
string $search = '',
string $status = '',
string $sortBy = 'name',
string $sortOrder = 'asc'
string $sortOrder = 'asc',
array $dateFilters = []
) {
$allowedSortColumns = [
'id',
@@ -55,6 +76,8 @@ class UserRepository implements UserRepositoryInterface
'position',
'status',
'member_number',
'join_date',
'leave_date',
'created_at',
'deleted_at',
];
@@ -63,7 +86,18 @@ class UserRepository implements UserRepositoryInterface
$query = User::excludeDevelopersUnlessDeveloper()
->with([
'roles:id,name,guard_name'
'roles:id,name,guard_name',
'employments' => function ($q) {
$q->select([
'id',
'user_id',
'company_name',
'is_current',
'start_date',
])
->orderByDesc('is_current')
->orderByDesc('start_date');
},
])
->orderBy($sortBy, $sortOrder);
@@ -73,6 +107,8 @@ class UserRepository implements UserRepositoryInterface
$query->where('status', $status);
}
$this->applyDateRangeFilters($query, $dateFilters);
return $query->paginate($perPage);
}
@@ -93,6 +129,8 @@ class UserRepository implements UserRepositoryInterface
'position',
'status',
'member_number',
'join_date',
'leave_date',
'created_at',
'deleted_at',
];
@@ -103,6 +141,17 @@ class UserRepository implements UserRepositoryInterface
->excludeDevelopersUnlessDeveloper()
->with([
'roles:id,name,guard_name',
'employments' => function ($q) {
$q->select([
'id',
'user_id',
'company_name',
'is_current',
'start_date',
])
->orderByDesc('is_current')
->orderByDesc('start_date');
},
])
->orderBy($sortBy, $sortOrder);
@@ -115,6 +164,36 @@ class UserRepository implements UserRepositoryInterface
return $query->paginate($perPage);
}
public function getListStats(
string $search = '',
string $status = '',
array $dateFilters = []
): array {
$baseQuery = User::excludeDevelopersUnlessDeveloper();
$this->applySearch($baseQuery, $search);
if (! empty($status)) {
$baseQuery->where('status', $status);
}
$this->applyDateRangeFilters($baseQuery, $dateFilters);
$total = (int) (clone $baseQuery)->count();
$monthStart = Carbon::now()->startOfMonth()->toDateString();
$monthEnd = Carbon::now()->endOfMonth()->toDateString();
$joinedThisMonth = (int) (clone $baseQuery)
->whereDate('join_date', '>=', $monthStart)
->whereDate('join_date', '<=', $monthEnd)
->count();
return [
'total' => $total,
'joined_this_month' => $joinedThisMonth,
];
}
/**
* Get all Users with their relationships and search
*/
+1
View File
@@ -19,6 +19,7 @@ Route::prefix('v1/public')->group(function () {
Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () {
Route::get('users/deleted', [UserController::class, 'deletedIndex'])->name('users.deleted.index');
Route::get('users/stats', [UserController::class, 'stats'])->name('users.stats');
Route::post('users/{id}/restore', [UserController::class, 'restore'])->name('users.restore');
Route::apiResource('users', UserController::class)->names('user');
Route::apiResource('addresses', AddressController::class)->names('address');
+12 -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
);
}
@@ -48,6 +50,14 @@ class UserService
);
}
/**
* @return array{total: int, joined_this_month: int}
*/
public function getListStats(string $search, string $status, array $dateFilters = []): array
{
return $this->repository->getListStats($search, $status, $dateFilters);
}
public function restoreUser(string $id): ?User
{
if (! $this->repository->restore($id)) {
@@ -5,6 +5,7 @@ namespace Modules\User\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use Modules\User\Transformers\EmploymentResource;
class UserListResource extends JsonResource
{
@@ -25,11 +26,13 @@ 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,
'updated_at' => $this->updated_at,
'deleted_at' => $this->deleted_at,
'employments' => EmploymentResource::collection($this->whenLoaded('employments')),
'roles' => $this->roles->map(function ($role) {
return [
'id' => $role->id,
@@ -1,56 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\KJCReport\Entities\KJCReport;
use Modules\KJCReport\Jobs\GenerateTeamWeeklyReports;
use Modules\Unit\Entities\Unit;
use Modules\Auth\Entities\User;
class AutoGenerateWeeklyReportForUnit extends Command
{
protected $signature = 'kjc:auto-generate-weekly-report
{unit : Unit name to generate reports for (e.g. "Jabatan Arah RAJD")}
{--force : Force generation even if reports already exist}';
protected $description = 'Auto-generate weekly KJC reports for a specific unit';
public function handle(): int
{
$unitName = $this->argument('unit');
$unit = Unit::where('name', $unitName)->first();
if (! $unit) {
$this->error("Unit '{$unitName}' not found.");
return 1;
}
$month = strtolower(now()->format('F'));
$week = (int) ceil(now()->day / 7);
if (! $this->option('force')) {
$existingReports = KJCReport::where('unit_id', $unit->id)
->where('report_type', 'weekly')
->where('report_month', $month)
->where('report_week', $week)
->exists();
if ($existingReports) {
$this->info("Reports for {$unitName} - {$month} week {$week} already exist. Skipping.");
return 0;
}
}
$user = User::where('unit_id', $unit->id)->first();
GenerateTeamWeeklyReports::dispatch($unit->id, $month, $week, $user?->id);
$this->info("✓ Queued weekly report generation for {$unitName} - {$month} week {$week}");
return 0;
}
}
@@ -1,68 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CaptureKJCHistoricalDataJob;
use App\Services\KJCHistoricalDataService;
use Carbon\Carbon;
use Illuminate\Console\Command;
class TriggerKJCHistoricalDataCapture extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'kjc:historical:trigger
{--month= : Month to capture (1-12)}
{--year= : Year to capture (YYYY)}
{--queue : Dispatch to queue instead of running immediately}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Manually trigger KJC historical data capture';
/**
* Execute the console command.
*/
public function handle()
{
$month = $this->option('month');
$year = $this->option('year');
$useQueue = $this->option('queue');
// If no month/year provided, use previous month
if (! $month || ! $year) {
$lastMonth = Carbon::now()->subMonth();
$month = $month ?: $lastMonth->format('m');
$year = $year ?: $lastMonth->format('Y');
}
$this->info("Triggering KJC historical data capture for {$month}/{$year}...");
try {
if ($useQueue) {
// Dispatch to queue
CaptureKJCHistoricalDataJob::dispatch($month, $year);
$this->info('✓ KJC historical data capture job dispatched to queue');
$this->info('You can monitor the job in Horizon dashboard');
} else {
// Run immediately
$job = new CaptureKJCHistoricalDataJob($month, $year);
$job->handle(app(KJCHistoricalDataService::class));
$this->info('✓ KJC historical data capture completed immediately');
}
} catch (\Exception $e) {
$this->error('Error: '.$e->getMessage());
return 1;
}
return 0;
}
}
@@ -1,55 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CapturePKJHistoricalDataJob;
use App\Services\PKJHistoricalDataService;
use Carbon\Carbon;
use Illuminate\Console\Command;
class TriggerPKJHistoricalDataCapture extends Command
{
protected $signature = 'pkj:historical:trigger
{--month= : Month to capture (1-12)}
{--year= : Year to capture (YYYY)}
{--queue : Dispatch to queue instead of running immediately}';
protected $description = 'Manually trigger PKJ historical data capture';
public function handle()
{
$month = $this->option('month');
$year = $this->option('year');
$useQueue = $this->option('queue');
// If no month/year provided, use previous month
if (! $month || ! $year) {
$lastMonth = Carbon::now()->subMonth();
$month = $month ?: $lastMonth->format('m');
$year = $year ?: $lastMonth->format('Y');
}
$this->info("Triggering PKJ historical data capture for {$month}/{$year}...");
try {
if ($useQueue) {
// Dispatch to queue
CapturePKJHistoricalDataJob::dispatch($month, $year);
$this->info('✓ PKJ historical data capture job dispatched to queue');
$this->info('You can monitor the job in Horizon dashboard');
} else {
// Run immediately
$job = new CapturePKJHistoricalDataJob($month, $year);
$job->handle(app(PKJHistoricalDataService::class));
$this->info('✓ PKJ historical data capture completed immediately');
}
} catch (\Exception $e) {
$this->error('Error: '.$e->getMessage());
return 1;
}
return 0;
}
}
@@ -113,8 +113,6 @@ abstract class BaseCrudController extends Controller
$item = $this->repository->create($data);
ActivityLogger::log("Created {$this->resourceName}: {$this->getItemName($item)}", $item);
return response()->json([
'success' => true,
'data' => new $this->resourceClass($item),
@@ -175,8 +173,6 @@ abstract class BaseCrudController extends Controller
$item->update($data);
ActivityLogger::log("Updated {$this->resourceName}: {$this->getItemName($item)}", $item);
return response()->json([
'success' => true,
'data' => new $this->resourceClass($item),
@@ -221,8 +217,6 @@ abstract class BaseCrudController extends Controller
$this->repository->delete($id);
ActivityLogger::log("Deleted {$this->resourceName}: {$this->getItemName($item)}", $item);
return response()->json([
'success' => true,
'message' => $this->getSuccessMessage('destroy'),
-102
View File
@@ -1,102 +0,0 @@
<?php
namespace App\Jobs;
use App\Services\KJCHistoricalDataService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class CaptureKJCHistoricalDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 300; // 5 minutes timeout
public $tries = 3; // Retry 3 times if failed
public $backoff = 60; // Wait 60 seconds between retries
protected $month;
protected $year;
/**
* Create a new job instance.
*/
public function __construct($month = null, $year = null)
{
$this->month = $month;
$this->year = $year;
// Set queue name for Horizon monitoring
$this->onQueue('kjc-historical-data');
}
/**
* Execute the job.
*/
public function handle(KJCHistoricalDataService $kjcHistoricalService)
{
try {
Log::info('Starting KJC historical data capture job', [
'month' => $this->month,
'year' => $this->year,
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
]);
$success = $kjcHistoricalService->captureMonthlySnapshots($this->month, $this->year);
if ($success) {
Log::info('KJC historical data capture job completed successfully', [
'month' => $this->month,
'year' => $this->year,
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
]);
} else {
Log::error('KJC historical data capture job failed', [
'month' => $this->month,
'year' => $this->year,
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
]);
throw new \Exception('KJC historical data capture failed');
}
} catch (\Exception $e) {
Log::error('KJC historical data capture job exception', [
'month' => $this->month,
'year' => $this->year,
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception)
{
Log::error('KJC historical data capture job failed permanently', [
'month' => $this->month,
'year' => $this->year,
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
'error' => $exception->getMessage(),
]);
}
/**
* Get the tags that should be assigned to the job.
*/
public function tags()
{
return ['kjc-historical-data', "month-{$this->month}", "year-{$this->year}"];
}
}
-102
View File
@@ -1,102 +0,0 @@
<?php
namespace App\Jobs;
use App\Services\PKJHistoricalDataService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class CapturePKJHistoricalDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 300; // 5 minutes timeout
public $tries = 3; // Retry 3 times if failed
public $backoff = 60; // Wait 60 seconds between retries
protected $month;
protected $year;
/**
* Create a new job instance.
*/
public function __construct($month = null, $year = null)
{
$this->month = $month;
$this->year = $year;
// Set queue name for Horizon monitoring
$this->onQueue('pkj-historical-data');
}
/**
* Execute the job.
*/
public function handle(PKJHistoricalDataService $historicalService)
{
try {
Log::info('Starting PKJ historical data capture job', [
'month' => $this->month,
'year' => $this->year,
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
]);
$success = $historicalService->captureMonthlySnapshots($this->month, $this->year);
if ($success) {
Log::info('PKJ historical data capture job completed successfully', [
'month' => $this->month,
'year' => $this->year,
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
]);
} else {
Log::error('PKJ historical data capture job failed', [
'month' => $this->month,
'year' => $this->year,
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
]);
throw new \Exception('PKJ historical data capture failed');
}
} catch (\Exception $e) {
Log::error('PKJ historical data capture job exception', [
'month' => $this->month,
'year' => $this->year,
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception)
{
Log::error('PKJ historical data capture job failed permanently', [
'month' => $this->month,
'year' => $this->year,
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
'error' => $exception->getMessage(),
]);
}
/**
* Get the tags that should be assigned to the job.
*/
public function tags()
{
return ['pkj-historical-data', "month-{$this->month}", "year-{$this->year}"];
}
}
+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
+1 -21
View File
@@ -3,27 +3,7 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
use App\Jobs\CaptureKJCHistoricalDataJob;
use App\Jobs\CapturePKJHistoricalDataJob;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
// Schedule KJC Historical Data Capture to run monthly on the 1st day at 2:00 AM
Schedule::job(new CaptureKJCHistoricalDataJob())
->monthlyOn(1, '02:00')
->withoutOverlapping()
->name('kjc-historical-data-capture');
// Schedule PKJ Historical Data Capture to run monthly on the 1st day at 2:30 AM
Schedule::job(new CapturePKJHistoricalDataJob())
->monthlyOn(1, '02:30')
->withoutOverlapping()
->name('pkj-historical-data-capture');
// Auto-generate weekly KJC reports for Jabatan Arah RAJD every Monday at 3:00 AM
Schedule::command('kjc:auto-generate-weekly-report "Jabatan Arah RAJD"')
->weeklyOn(1, '03:00') // Monday at 3:00 AM
->withoutOverlapping()
->name('kjc-auto-weekly-report');
})->purpose('Display an inspiring quote');
-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
+2 -2
View File
@@ -86,9 +86,9 @@ export function useRoleSwitcher() {
toast: true,
position: 'top-end',
icon: 'success',
title: res.message || 'Peranan telah ditukar.',
title: res.message,
showConfirmButton: false,
timer: 3000,
timer: 500,
})
await router.push(resolvePostLoginRoute(res.redirect_path))
+2 -2
View File
@@ -117,7 +117,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION
<Input v-model="email" class="box block min-w-full px-5 py-6 xl:min-w-md" type="email"
placeholder="Email" autocomplete="email" required />
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md"
placeholder="Password" autocomplete="current-password" required />
placeholder="Kata Laluan" autocomplete="current-password" required />
<div class="flex text-xs sm:text-sm">
<div class="mr-auto flex-row items-center">
<CheckboxRoot :checked="remember" @checked-change="({ checked }) => (remember = checked === true)">
@@ -127,7 +127,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION
</div>
<button type="button" class="opacity-70 hover:opacity-100"
@click="router.push({ name: 'forgot-password' })">
Lupa Password?
Lupa Kata Laluan?
</button>
</div>
<div class="mt-5 text-center xl:mt-10 xl:text-left">
+14 -4
View File
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { PasswordInput } from '@/components/ui/password-input'
import { getRegisterErrorMessage, register } from '@/modules/auth'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import illustrationUrl from '@/assets/images/logo.svg'
const router = useRouter()
@@ -21,6 +22,14 @@ const termsAccepted = ref(false)
const loading = ref(false)
const errorMessage = ref('')
const handleNameInput = () => {
name.value = sanitizeNameInput(name.value)
}
const handleIcNumberInput = () => {
icNumber.value = sanitizeIcNumberInput(icNumber.value)
}
const handleRegister = async () => {
if (!termsAccepted.value) {
errorMessage.value = 'Sila bersetuju dengan Dasar Privasi dan Terma dan Syarat.'
@@ -90,15 +99,16 @@ const handleRegister = async () => {
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleRegister">
<Input v-model="name" class="box block min-w-full px-5 py-6 xl:min-w-md" type="text"
placeholder="Nama Penuh" autocomplete="name" required />
placeholder="Nama Penuh" autocomplete="name" required @input="handleNameInput" />
<Input v-model="email" class="box block min-w-full px-5 py-6 xl:min-w-md" type="email"
placeholder="Email" autocomplete="email" required />
<Input v-model="icNumber" class="box block min-w-full px-5 py-6 xl:min-w-md" type="text"
placeholder="No. Kad Pengenalan" required />
inputmode="numeric" maxlength="15" placeholder="Contoh: 900101011234" required
@input="handleIcNumberInput" />
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md" type="password"
placeholder="Password" autocomplete="new-password" minlength="8" required />
placeholder="Kata Laluan" autocomplete="new-password" minlength="8" required />
<PasswordInput v-model="passwordConfirmation" class="box block min-w-full px-5 py-6 xl:min-w-md"
placeholder="Sahkan Password" autocomplete="new-password" minlength="8" required />
placeholder="Sahkan Kata Laluan" autocomplete="new-password" minlength="8" required />
<div class="flex text-xs sm:text-sm">
<CheckboxRoot :checked="termsAccepted"
@@ -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, sanitizeNameInput } from '@/utils/form-input.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' },
@@ -79,15 +80,15 @@ const RELATIONSHIP_OPTIONS: SelectOption[] = [
// TODO: replace with API lookup
const EMPLOYERS = [
{
name: 'INFRA QUEST SDN BHD',
name: 'Infra Quest Sdn. Bhd. (IQSB)',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
},
{
name: 'Permodalan Kelantan Berhad',
name: 'Permodalan Kelantan Berhad (PKB)',
address: 'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
},
{
name: 'Koperasi Permodalan Kelantan Berhad',
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
address: 'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
},
{
@@ -221,9 +222,34 @@ function clearReference(role: 'proposer' | 'supporter') {
}
function handleReferenceIcInput(role: 'proposer' | 'supporter') {
form.references[role].ic_number = sanitizeIcNumberInput(form.references[role].ic_number)
clearReference(role)
}
function handleApplicantNameInput() {
form.applicant.name = sanitizeNameInput(form.applicant.name)
delete fieldErrors['applicant.name']
}
function handleApplicantIcInput() {
form.applicant.ic_number = sanitizeIcNumberInput(form.applicant.ic_number)
delete fieldErrors['applicant.ic_number']
}
function handleHeirNameInput(index: number) {
const heir = form.heirs[index]
if (!heir) return
heir.name = sanitizeNameInput(heir.name)
delete fieldErrors[`heirs.${index}.name`]
}
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()
@@ -608,7 +634,7 @@ function stepLabelClass(stepId: number) {
<template v-if="currentStep === 1">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="name">Nama Penuh</FieldLabel>
<Input id="name" v-model="form.applicant.name" type="text" />
<Input id="name" v-model="form.applicant.name" type="text" @input="handleApplicantNameInput" />
<FieldError v-if="fieldErrors['applicant.name']">{{ fieldErrors['applicant.name'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
@@ -618,7 +644,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>
@@ -793,14 +820,16 @@ function stepLabelClass(stepId: number) {
<div class="grid grid-cols-12 gap-4">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-name-${index}`">Nama</FieldLabel>
<Input :id="`heir-name-${index}`" v-model="heir.name" type="text" />
<Input :id="`heir-name-${index}`" v-model="heir.name" type="text"
@input="handleHeirNameInput(index)" />
<FieldError v-if="fieldErrors[`heirs.${index}.name`]">
{{ fieldErrors[`heirs.${index}.name`] }}
</FieldError>
</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 +886,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 +900,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 +938,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">

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