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 name: Build Docker Image
# Builds images on every push to main or tag, but does NOT auto-deploy
on: on:
push: push:
branches: # branches:
- main # - main
tags: tags:
- "v*" # v1.0.0, v1.0.1, etc. - "v*"
jobs: jobs:
# test-backend: # test-backend:
@@ -92,6 +91,6 @@ jobs:
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \ --cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
-f fe/docker/Dockerfile \ -f fe/docker/Dockerfile \
--build-arg VITE_API_BASE_URL=https://api.koppkb.com \ --build-arg VITE_API_BASE_URL=https://api.koppkb.com \
--build-arg VITE_APP_URL=https://anggota.koppkb.com \ --build-arg VITE_APP_URL=https://mykopkb.koppkb.com \
${TAGS} \ ${TAGS} \
--push . --push .
+4 -3
View File
@@ -18,8 +18,8 @@
## Present to Boss (2/7/2026) ## Present to Boss (2/7/2026)
[x] discuss logo baru MyKOPKB [x] discuss logo baru MyKOPKB
[x] layout footer untuk surat rasmi [x] layout footer untuk surat rasmi
[ ] maklumat pekerjaan kena buat dropdown untuk nama majikan [x] maklumat pekerjaan kena buat dropdown untuk nama majikan
[ ] tambah minima untuk borang permohonan (tukar jadi caruman saham potongan bulanan) [x] tambah minima untuk borang permohonan (tukar jadi caruman saham potongan bulanan)
[x] tukar waris ke penama [x] tukar waris ke penama
[x] ic dan surat pengesahan majikkan perlu mandatori [x] ic dan surat pengesahan majikkan perlu mandatori
[ ] semakan lembaga tukar jadi maklumat keputusan [ ] semakan lembaga tukar jadi maklumat keputusan
@@ -27,5 +27,6 @@
[x] surat attach dekat email selepas bos [x] surat attach dekat email selepas bos
[ ] pembayaran pertama tukar jadi one-off [ ] pembayaran pertama tukar jadi one-off
[ ] syer maksima silap tukar jadi rm50.00 [ ] syer maksima silap tukar jadi rm50.00
[ ] letak sign digital [x] letak sign digital
[x] running no anggota dalam surat dan masa. [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}" VITE_APP_NAME="${APP_NAME}"
# Browsershot (PDF generation via headless Chrome + Puppeteer in be/) # 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_NO_SANDBOX=true
# BROWSERSHOT_NODE_BINARY= # BROWSERSHOT_NODE_BINARY=/usr/local/bin/node
# BROWSERSHOT_NPM_BINARY= # BROWSERSHOT_NPM_BINARY=
# BROWSERSHOT_NODE_MODULE_PATH= # BROWSERSHOT_NODE_MODULE_PATH=/var/www
# BROWSERSHOT_CHROME_PATH= # BROWSERSHOT_CHROME_PATH=
# BROWSERSHOT_TIMEOUT=60 # BROWSERSHOT_TIMEOUT=60
+1
View File
@@ -106,3 +106,4 @@ API_LOG_KEY_USAGE=true
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7 PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
FRONTEND_URL=https://mykopkb.koppkb.com FRONTEND_URL=https://mykopkb.koppkb.com
BROWSERSHOT_NO_SANDBOX=true
+2
View File
@@ -54,6 +54,7 @@ class User extends Authenticatable
'member_type', 'member_type',
'public_profile_token', 'public_profile_token',
'join_date', 'join_date',
'leave_date',
'birth_date', 'birth_date',
'birth_place', 'birth_place',
'onboarding_completed_at', 'onboarding_completed_at',
@@ -136,6 +137,7 @@ class User extends Authenticatable
'member_type' => 'string', 'member_type' => 'string',
'public_profile_token_expires_at' => 'datetime', 'public_profile_token_expires_at' => 'datetime',
'join_date' => 'date', 'join_date' => 'date',
'leave_date' => 'date',
'birth_date' => 'date', 'birth_date' => 'date',
'birth_place' => 'string', 'birth_place' => 'string',
'onboarding_completed_at' => 'datetime', 'onboarding_completed_at' => 'datetime',
@@ -38,6 +38,7 @@ class UserResource extends JsonResource
'member_type' => $this->member_type, 'member_type' => $this->member_type,
'public_profile_token' => $this->public_profile_token, 'public_profile_token' => $this->public_profile_token,
'join_date' => $this->join_date, 'join_date' => $this->join_date,
'leave_date' => $this->leave_date,
'birth_date' => $this->birth_date, 'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place, 'birth_place' => $this->birth_place,
'onboarding_completed_at' => $this->onboarding_completed_at, 'onboarding_completed_at' => $this->onboarding_completed_at,
@@ -19,6 +19,7 @@ class MembershipApplicationPassedNotification extends Notification implements Sh
public function __construct( public function __construct(
public MembershipApplication $application, public MembershipApplication $application,
public Document $resultLetter, public Document $resultLetter,
public ?string $plainPassword = null,
) {} ) {}
/** /**
@@ -40,6 +41,8 @@ class MembershipApplicationPassedNotification extends Notification implements Sh
data: [ data: [
'application' => $this->application, 'application' => $this->application,
'applicant' => $applicant, 'applicant' => $applicant,
'plainPassword' => $this->plainPassword,
'loginUrl' => rtrim(config('user.frontend_url'), '/'),
], ],
)->attach( )->attach(
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path), Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
@@ -189,6 +189,7 @@ class MembershipApplicationController extends Controller
$membershipApplication, $membershipApplication,
$request->user(), $request->user(),
$request->validated('board_meeting_reference'), $request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
); );
$message = $application->board_result === BoardResult::Pass->value $message = $application->board_result === BoardResult::Pass->value
@@ -210,6 +211,7 @@ class MembershipApplicationController extends Controller
$request->validated('application_ids'), $request->validated('application_ids'),
$request->user(), $request->user(),
$request->validated('board_meeting_reference'), $request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
); );
$succeededCount = count($result['succeeded']); $succeededCount = count($result['succeeded']);
@@ -270,6 +272,7 @@ class MembershipApplicationController extends Controller
$application = $this->membershipApplicationService->generateResultLetter( $application = $this->membershipApplicationService->generateResultLetter(
$membershipApplication, $membershipApplication,
$request->validated('board_meeting_reference'), $request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
); );
$resultLetter = $application->documents $resultLetter = $application->documents
@@ -301,11 +304,16 @@ class MembershipApplicationController extends Controller
'board_meeting_reference', 'board_meeting_reference',
'Mesyuarat Lembaga', 'Mesyuarat Lembaga',
); );
$boardMeetingDate = (string) $request->query(
'board_meeting_date',
now()->toDateString(),
);
$view = 'membershipapplication::pdf.approved-letter'; $view = 'membershipapplication::pdf.approved-letter';
$data = $this->membershipApplicationService->getResultLetterViewData( $data = $this->membershipApplicationService->getResultLetterViewData(
$application, $application,
$boardMeetingReference, $boardMeetingReference,
$boardMeetingDate,
); );
$format = $request->query('format', 'html'); $format = $request->query('format', 'html');
$filename = 'surat-keputusan-'.$application->application_number.'.pdf'; $filename = 'surat-keputusan-'.$application->application_number.'.pdf';
@@ -20,6 +20,7 @@ class BatchCompleteRequest extends FormRequest
'application_ids' => 'required|array|min:1|max:100', 'application_ids' => 'required|array|min:1|max:100',
'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id', 'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id',
'board_meeting_reference' => 'required|string|max:255', 'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
]; ];
} }
@@ -36,6 +37,8 @@ class BatchCompleteRequest extends FormRequest
'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.', 'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.',
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.', 'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.', 'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
]; ];
} }
} }
@@ -18,6 +18,7 @@ class CompleteMembershipApplicationRequest extends FormRequest
{ {
return [ return [
'board_meeting_reference' => 'required|string|max:255', 'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
]; ];
} }
@@ -29,6 +30,8 @@ class CompleteMembershipApplicationRequest extends FormRequest
return [ return [
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.', 'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.', 'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
]; ];
} }
} }
@@ -18,6 +18,7 @@ class GenerateResultLetterRequest extends FormRequest
{ {
return [ return [
'board_meeting_reference' => 'required|string|max:255', 'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
]; ];
} }
@@ -29,6 +30,8 @@ class GenerateResultLetterRequest extends FormRequest
return [ return [
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.', 'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.', 'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
]; ];
} }
} }
@@ -5,7 +5,7 @@
Assalamualaikum **{{ $applicant->name }}**, 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. Surat keputusan rasmi dilampirkan dalam e-mel ini.
@@ -5,10 +5,19 @@
Assalamualaikum **{{ $applicant->name }}**, 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. Surat keputusan rasmi dilampirkan dalam e-mel ini.
@if ($plainPassword)
Akaun portal anda telah didaftarkan. Maklumat log masuk adalah seperti berikut:
- **E-mel:** {{ $applicant->email }}
- **Kata laluan:** {{ $plainPassword }}
Sila log masuk di [{{ $loginUrl }}]({{ $loginUrl }}) dan tukar kata laluan anda selepas log masuk kali pertama.
@endif
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi. Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
@include('emails.partials.footer') @include('emails.partials.footer')
@@ -12,49 +12,77 @@
{{ $applicant->postcode }} {{ $applicant->postcode }}
@endsection @endsection
@section('letter-honorific')
Tuan/Puan,
@endsection
@section('letter-subject') @section('letter-subject')
<strong>{{ $letterSubject }}</strong> <strong>{{ $letterSubject }}</strong>
@endsection @endsection
@section('letter-body') @section('letter-body')
<p style="line-height: 1;"> <p style="line-height: 1;">
Dengan segala hormatnya saya merujuk kepada perkara di atas. Dengan segala hormatnya perkara di atas adalah dirujuk.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi 2.
Permodalan Kelantan Berhad (KoPKB) telah <strong>{{ $boardResultLabel }}</strong> dalam {{ $boardMeetingReference }}@if ($isPassed && $memberNumber). Nombor keanggotaan tuan adalah <strong>{{ $memberNumber }}</strong>@endif. @if ($isPassed)
Sukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>Anggota Koperasi Permodalan Kelantan Berhad (KoPKB)</strong> telah dipertimbangkan dan <strong>DILULUSKAN</strong> oleh Lembaga Koperasi dalam {{ $boardMeetingReference }} yang diadakan pada {{ $boardMeetingDate }}.
@else
Dukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>Anggota Koperasi Permodalan Kelantan Berhad (KoPKB)</strong> telah dipertimbangkan dan <strong>TIDAK DILULUSKAN</strong> oleh Lembaga Koperasi dalam {{ $boardMeetingReference }} yang diadakan pada {{ $boardMeetingDate }}.
@endif
</p> </p>
@if ($isPassed) @if ($isPassed)
<p style="line-height: 1;"> <p style="line-height: 1;">
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaimana ketetapan berikut:- 3. Maklumat keanggotaan tuan/puan adalah seperti berikut:-
<ul style="line-height: 1;"> </p>
<li>Saham</li> <table class="letter-info-table" cellpadding="0" cellspacing="0">
<ul style="line-height: 1;"> <tr>
<li>Potongan sebanyak RM {{ $monthlyShareInstallment }} ({{ $shareInstallmentMonths }} bulan)</li> <th>Perkara</th>
<li>Potongan sebanyak RM {{ $stockMonthlyContribution }} sehingga Syer Maksima</li> <th>Maklumat</th>
</ul> </tr>
<li>Yuran</li> <tr>
<ul style="line-height: 1;"> <td>Nama Anggota</td>
<li>Potongan sebanyak RM {{ $feeMonthlyContribution }} setiap bulan</li> <td>{{ $applicant->name }}</td>
</ul> </tr>
<li>Fi Masuk</li> <tr>
<ul style="line-height: 1;"> <td>No. Kad Pengenalan</td>
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li> <td>{{ $applicant->ic_number }}</td>
</ul> </tr>
</ul> <tr>
<td>No. Anggota</td>
<td>{{ $memberNumber }}</td>
</tr>
<tr>
<td>Tarikh Kuat Kuasa Keanggotaan</td>
<td>{{ $boardMeetingDate }}</td>
</tr>
<tr>
<td>Modal Syer Permulaan</td>
<td>RM500.00 (perlu dijelaskan dalam tempoh 6 bulan)</td>
</tr>
<tr>
<td>Fi Masuk</td>
<td>RM10.00</td>
</tr>
<tr>
<td>Yuran Tahunan</td>
<td>RM30.00 sebulan</td>
</tr>
</table>
<p style="line-height: 1;">
4. Sebagai anggota KoPKB, tuan/puan adalah tertakluk kepada Undang-Undang Kecil (UUK), dasar, polisi dan peraturan-peraturan KoPKB yang berkuat kuasa dari semasa ke semasa.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki 5. Sebagai anggota, tuan/puan adalah layak menikmati kemudahan, produk dan manfaat yang ditawarkan oleh koperasi dari semasa ke semasa, tertakluk kepada syarat-syarat yang ditetapkan.
menjelaskan modal syer minimum sebanyak RM{{ $minimumShareCapital }}. </p>
Bayaran tersebut perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan. <p style="line-height: 1;">
Pemenuhan syarat ini adalah bagi melayakkan tuan memperoleh hak, kewajipan dan liabiliti sebagai anggota KoPKB 6. Pihak KoPKB mengucapkan tahniah dan mengalu-alukan penyertaan tuan/puan sebagai anggota. Semoga keanggotaan ini akan memberi manfaat kepada tuan/puan serta menyumbang kepada kemajuan dan kecemerlangan KoPKB.
sebagaimana terkandung di dalam Undang-Undang Kecil (UUK), Perkara 59 yang telah didaftarkan pada 11 September 2024.
Manakala had maksima modal syer adalah sebanyak RM5,000.00.
</p> </p>
@else @else
<p style="line-height: 1;"> <p style="line-height: 1;">
3. Oleh yang demikian, permohonan tuan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini. 3. Oleh yang demikian, permohonan tuan/puan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
</p> </p>
@endif @endif
@endsection @endsection
@@ -64,14 +92,5 @@
@endsection @endsection
@section('letter-signature') @section('letter-signature')
<div class="signature-header">Dengan hormatnya,</div>
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div> <div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
<div class="signature-space">
@if (! empty($signatureUrl))
<img src="{{ $signatureUrl }}" alt="" class="signature-space-img">
@endif
</div>
<div class="signatory-name">MUHAMMAD NAFIS BIN ZAINUDIN</div>
<div class="signatory-title">Pengurus Besar</div>
<div class="signatory-title">b/p Setiausaha</div>
@endsection @endsection
@@ -7,6 +7,7 @@ use App\Services\DocumentService;
use App\Services\LetterService; use App\Services\LetterService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
@@ -200,7 +201,7 @@ class MembershipApplicationService
{ {
if ($application->status !== ApplicationStatus::PendingBoard) { if ($application->status !== ApplicationStatus::PendingBoard) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh disemak pada peringkat lembaga.'], 'status' => ['Permohonan ini tidak boleh disemak pada peringkat ahli lembaga.'],
]); ]);
} }
@@ -258,6 +259,7 @@ class MembershipApplicationService
MembershipApplication $application, MembershipApplication $application,
User $processedBy, User $processedBy,
string $boardMeetingReference, string $boardMeetingReference,
string $boardMeetingDate,
): MembershipApplication { ): MembershipApplication {
if ($application->status !== ApplicationStatus::PendingNotification) { if ($application->status !== ApplicationStatus::PendingNotification) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -271,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) { if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
$user = $this->createMemberFromApplication($application); $member = $this->createMemberFromApplication($application);
$application->update(['user_id' => $user->id]); $application->update(['user_id' => $member['user']->id]);
$plainPassword = $member['plainPassword'];
$application->refresh(); $application->refresh();
} }
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference); $resultLetter = $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
$this->sendResultNotification($application, $resultLetter); $this->sendResultNotification($application, $resultLetter, $plainPassword);
$application->update([ $application->update([
'status' => ApplicationStatus::Completed, 'status' => ApplicationStatus::Completed,
@@ -298,8 +303,12 @@ class MembershipApplicationService
* failed: list<array{id: string, application_number: string|null, message: string}> * failed: list<array{id: string, application_number: string|null, message: string}>
* } * }
*/ */
public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference): array public function completeBatch(
{ array $applicationIds,
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$succeeded = []; $succeeded = [];
$failed = []; $failed = [];
@@ -317,7 +326,12 @@ class MembershipApplicationService
} }
try { try {
$succeeded[] = $this->complete($application, $processedBy, $boardMeetingReference); $succeeded[] = $this->complete(
$application,
$processedBy,
$boardMeetingReference,
$boardMeetingDate,
);
} catch (ValidationException $e) { } catch (ValidationException $e) {
$failed[] = [ $failed[] = [
'id' => $id, 'id' => $id,
@@ -333,7 +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) { if ($application->status !== ApplicationStatus::Completed) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'], 'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'],
@@ -346,8 +364,8 @@ class MembershipApplicationService
]); ]);
} }
return DB::transaction(function () use ($application, $boardMeetingReference) { return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
$this->storeResultLetter($application, $boardMeetingReference); $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
return $this->repository->findByIdWithRelations($application->id); return $this->repository->findByIdWithRelations($application->id);
}); });
@@ -356,6 +374,7 @@ class MembershipApplicationService
protected function storeResultLetter( protected function storeResultLetter(
MembershipApplication $application, MembershipApplication $application,
string $boardMeetingReference, string $boardMeetingReference,
string $boardMeetingDate,
): Document { ): Document {
$lockedApplication = MembershipApplication::query() $lockedApplication = MembershipApplication::query()
->whereKey($application->id) ->whereKey($application->id)
@@ -376,7 +395,7 @@ class MembershipApplicationService
]); ]);
} }
$viewData = $this->buildResultLetterViewData($application, $boardMeetingReference); $viewData = $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate);
$pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData); $pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData);
$fileName = 'surat-keputusan-'.$application->application_number.'.pdf'; $fileName = 'surat-keputusan-'.$application->application_number.'.pdf';
@@ -394,6 +413,7 @@ class MembershipApplicationService
public function getResultLetterViewData( public function getResultLetterViewData(
MembershipApplication $application, MembershipApplication $application,
string $boardMeetingReference, string $boardMeetingReference,
string $boardMeetingDate,
): array { ): array {
$application = $this->repository->findByIdWithRelations($application->id); $application = $this->repository->findByIdWithRelations($application->id);
@@ -403,7 +423,7 @@ class MembershipApplicationService
]); ]);
} }
return $this->buildResultLetterViewData($application, $boardMeetingReference); return $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate);
} }
/** /**
@@ -412,37 +432,28 @@ class MembershipApplicationService
protected function buildResultLetterViewData( protected function buildResultLetterViewData(
MembershipApplication $application, MembershipApplication $application,
string $boardMeetingReference, string $boardMeetingReference,
string $boardMeetingDate,
): array { ): array {
$applicant = $application->applicant; $applicant = $application->applicant;
$isPassed = $application->board_result === BoardResult::Pass->value; $isPassed = $application->board_result === BoardResult::Pass->value;
$stockMonthlyContribution = (float) $applicant->stock_monthly_contribution;
$monthlyShareInstallment = number_format(
$stockMonthlyContribution < 84 ? 84 : $stockMonthlyContribution,
2,
'.',
'',
);
return [ return [
'application' => $application, 'application' => $application,
'applicant' => $applicant, 'applicant' => $applicant,
'boardMeetingReference' => $boardMeetingReference, 'boardMeetingReference' => $boardMeetingReference,
'boardMeetingDate' => Carbon::parse($boardMeetingDate)->translatedFormat('d F Y'),
'isPassed' => $isPassed, 'isPassed' => $isPassed,
'boardResultLabel' => $isPassed ? 'lulus' : 'gagal',
'openingTone' => $isPassed ? 'Sukacita' : 'Dukacita',
'letterSubject' => $isPassed 'letterSubject' => $isPassed
? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD' ? 'KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)'
: 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD', : 'KEPUTUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)',
'monthlyShareInstallment' => $monthlyShareInstallment,
'stockMonthlyContribution' => number_format($stockMonthlyContribution, 2, '.', ''),
'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''),
'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS,
'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''),
'memberNumber' => $isPassed ? $application->user?->member_number : null, 'memberNumber' => $isPassed ? $application->user?->member_number : null,
]; ];
} }
protected function createMemberFromApplication(MembershipApplication $application): User /**
* @return array{user: User, plainPassword: string}
*/
protected function createMemberFromApplication(MembershipApplication $application): array
{ {
$application->loadMissing(['applicant', 'heirs']); $application->loadMissing(['applicant', 'heirs']);
$applicant = $application->applicant; $applicant = $application->applicant;
@@ -453,10 +464,12 @@ class MembershipApplicationService
]); ]);
} }
$plainPassword = Str::password(16);
$user = User::create([ $user = User::create([
'name' => $applicant->name, 'name' => $applicant->name,
'email' => $applicant->email, 'email' => $applicant->email,
'password' => Hash::make(Str::password(16)), 'password' => Hash::make($plainPassword),
'ic_number' => $applicant->ic_number, 'ic_number' => $applicant->ic_number,
'phone_number' => $applicant->phone_number, 'phone_number' => $applicant->phone_number,
'position' => $applicant->current_position, 'position' => $applicant->current_position,
@@ -502,7 +515,10 @@ class MembershipApplicationService
app(EmailVerificationOtpService::class)->send($user); app(EmailVerificationOtpService::class)->send($user);
return $user; return [
'user' => $user,
'plainPassword' => $plainPassword,
];
} }
public function lookupMemberByIcNumber(string $icNumber): ?User public function lookupMemberByIcNumber(string $icNumber): ?User
@@ -527,12 +543,15 @@ class MembershipApplicationService
->notify(new MembershipApplicationSubmittedNotification($application)); ->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'); $application->loadMissing('applicant');
$notification = $application->board_result === BoardResult::Pass->value $notification = $application->board_result === BoardResult::Pass->value
? new MembershipApplicationPassedNotification($application, $resultLetter) ? new MembershipApplicationPassedNotification($application, $resultLetter, $plainPassword)
: new MembershipApplicationFailedNotification($application, $resultLetter); : new MembershipApplicationFailedNotification($application, $resultLetter);
Notification::route('mail', $application->applicant->email)->notify($notification); Notification::route('mail', $application->applicant->email)->notify($notification);
+1 -1
View File
@@ -4,5 +4,5 @@ return [
'name' => 'User', 'name' => 'User',
'public_profile_token_ttl_days' => (int) env('PUBLIC_PROFILE_TOKEN_TTL_DAYS', 90), 'public_profile_token_ttl_days' => (int) env('PUBLIC_PROFILE_TOKEN_TTL_DAYS', 90),
'frontend_url' => rtrim(env('FRONTEND_URL', env('APP_URL', 'http://localhost')), '/'), 'frontend_url' => rtrim(env('FRONTEND_URL', env('APP_URL', 'http://localhost')), '/'),
'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo.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['uuid'] = Str::uuid();
$validated['password'] = Hash::make('suteraselamanya'); $validated['password'] = Hash::make('suteraselamanya');
return $validated; return $this->applyLeaveDateForStatus($validated);
} }
/** /**
@@ -58,6 +58,25 @@ class UserController extends BaseCrudController
unset($validated['email']); unset($validated['email']);
} }
return $this->applyLeaveDateForStatus($validated, $user);
}
/**
* Set or clear leave_date based on membership status transitions.
*/
protected function applyLeaveDateForStatus(array $validated, ?User $user = null): array
{
$newStatus = $validated['status'] ?? $user?->status;
$currentStatus = $user?->status;
if ($newStatus === 'inactive') {
if ($currentStatus !== 'inactive') {
$validated['leave_date'] = $validated['leave_date'] ?? now()->toDateString();
}
} elseif (in_array($newStatus, ['active', 'pending'], true)) {
$validated['leave_date'] = null;
}
return $validated; return $validated;
} }
@@ -171,13 +190,27 @@ class UserController extends BaseCrudController
$this->authorize('viewAny', $this->modelClass); $this->authorize('viewAny', $this->modelClass);
try { try {
$request->validate([
'join_date_from' => 'nullable|date',
'join_date_to' => 'nullable|date',
'leave_date_from' => 'nullable|date',
'leave_date_to' => 'nullable|date',
]);
$perPage = min((int) $request->get('per_page', 10), 500); $perPage = min((int) $request->get('per_page', 10), 500);
$dateFilters = array_filter([
'join_date_from' => $request->get('join_date_from'),
'join_date_to' => $request->get('join_date_to'),
'leave_date_from' => $request->get('leave_date_from'),
'leave_date_to' => $request->get('leave_date_to'),
]);
$items = $this->userService->getPaginatedList( $items = $this->userService->getPaginatedList(
$perPage, $perPage,
$request->get('search', ''), $request->get('search', ''),
$request->get('status', ''), $request->get('status', ''),
$request->get('sort_by', 'id'), $request->get('sort_by', 'id'),
$request->get('sort_order', 'asc') $request->get('sort_order', 'asc'),
$dateFilters
); );
return response()->json([ return response()->json([
@@ -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 public function restore(string $id): JsonResponse
{ {
$this->authorize('restore', $this->modelClass); $this->authorize('restore', $this->modelClass);
@@ -44,6 +44,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer', 'member_number' => 'required|integer',
'member_type' => 'required|string|max:255', 'member_type' => 'required|string|max:255',
'join_date' => 'nullable|date', 'join_date' => 'nullable|date',
'leave_date' => 'nullable|date',
'birth_date' => 'nullable|date', 'birth_date' => 'nullable|date',
'birth_place' => 'nullable|string|max:255', 'birth_place' => 'nullable|string|max:255',
]; ];
@@ -74,6 +75,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer', 'member_number' => 'required|integer',
'member_type' => 'required|string|max:255', 'member_type' => 'required|string|max:255',
'join_date' => 'nullable|date', 'join_date' => 'nullable|date',
'leave_date' => 'nullable|date',
'birth_date' => 'nullable|date', 'birth_date' => 'nullable|date',
'birth_place' => 'nullable|string|max:255', 'birth_place' => 'nullable|string|max:255',
]; ];
@@ -100,6 +102,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer', 'member_number' => 'required|integer',
'member_type' => 'required|string|max:255', 'member_type' => 'required|string|max:255',
'join_date' => 'required|date', 'join_date' => 'required|date',
'leave_date' => 'nullable|date',
'birth_date' => 'required|date', 'birth_date' => 'required|date',
'birth_place' => 'required|string|max:255', 'birth_place' => 'required|string|max:255',
]; ];
@@ -136,6 +139,7 @@ class UserRequest extends FormRequest
'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.', 'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.',
'join_date.required' => 'Tarikh join diperlukan.', 'join_date.required' => 'Tarikh join diperlukan.',
'join_date.date' => 'Tarikh join tidak sah.', 'join_date.date' => 'Tarikh join tidak sah.',
'leave_date.date' => 'Tarikh berhenti tidak sah.',
'birth_date.required' => 'Tarikh lahir diperlukan.', 'birth_date.required' => 'Tarikh lahir diperlukan.',
'birth_date.date' => 'Tarikh lahir tidak sah.', 'birth_date.date' => 'Tarikh lahir tidak sah.',
'birth_place.required' => 'Tempat lahir diperlukan.', 'birth_place.required' => 'Tempat lahir diperlukan.',
@@ -20,7 +20,8 @@ interface UserRepositoryInterface
string $search = '', string $search = '',
string $status = '', string $status = '',
string $sortBy = 'name', string $sortBy = 'name',
string $sortOrder = 'asc' string $sortOrder = 'asc',
array $dateFilters = []
); );
/** /**
@@ -54,6 +55,17 @@ interface UserRepositoryInterface
string $sortOrder = 'desc' 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 * Find soft-deleted User by ID
*/ */
@@ -3,6 +3,7 @@
namespace Modules\User\Repositories; namespace Modules\User\Repositories;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Modules\Auth\Entities\User; use Modules\Auth\Entities\User;
use Modules\User\Repositories\Contracts\UserRepositoryInterface; 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 * Get all Users with pagination and search
*/ */
@@ -46,7 +66,8 @@ class UserRepository implements UserRepositoryInterface
string $search = '', string $search = '',
string $status = '', string $status = '',
string $sortBy = 'name', string $sortBy = 'name',
string $sortOrder = 'asc' string $sortOrder = 'asc',
array $dateFilters = []
) { ) {
$allowedSortColumns = [ $allowedSortColumns = [
'id', 'id',
@@ -55,6 +76,8 @@ class UserRepository implements UserRepositoryInterface
'position', 'position',
'status', 'status',
'member_number', 'member_number',
'join_date',
'leave_date',
'created_at', 'created_at',
'deleted_at', 'deleted_at',
]; ];
@@ -63,7 +86,18 @@ class UserRepository implements UserRepositoryInterface
$query = User::excludeDevelopersUnlessDeveloper() $query = User::excludeDevelopersUnlessDeveloper()
->with([ ->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); ->orderBy($sortBy, $sortOrder);
@@ -73,6 +107,8 @@ class UserRepository implements UserRepositoryInterface
$query->where('status', $status); $query->where('status', $status);
} }
$this->applyDateRangeFilters($query, $dateFilters);
return $query->paginate($perPage); return $query->paginate($perPage);
} }
@@ -93,6 +129,8 @@ class UserRepository implements UserRepositoryInterface
'position', 'position',
'status', 'status',
'member_number', 'member_number',
'join_date',
'leave_date',
'created_at', 'created_at',
'deleted_at', 'deleted_at',
]; ];
@@ -103,6 +141,17 @@ class UserRepository implements UserRepositoryInterface
->excludeDevelopersUnlessDeveloper() ->excludeDevelopersUnlessDeveloper()
->with([ ->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); ->orderBy($sortBy, $sortOrder);
@@ -115,6 +164,36 @@ class UserRepository implements UserRepositoryInterface
return $query->paginate($perPage); 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 * 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::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () {
Route::get('users/deleted', [UserController::class, 'deletedIndex'])->name('users.deleted.index'); 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::post('users/{id}/restore', [UserController::class, 'restore'])->name('users.restore');
Route::apiResource('users', UserController::class)->names('user'); Route::apiResource('users', UserController::class)->names('user');
Route::apiResource('addresses', AddressController::class)->names('address'); Route::apiResource('addresses', AddressController::class)->names('address');
+12 -2
View File
@@ -21,14 +21,16 @@ class UserService
string $search, string $search,
string $status, string $status,
string $sortBy, string $sortBy,
string $sortOrder string $sortOrder,
array $dateFilters = []
): LengthAwarePaginator { ): LengthAwarePaginator {
return $this->repository->getAllWithRelationsPaginated( return $this->repository->getAllWithRelationsPaginated(
$perPage, $perPage,
$search, $search,
$status, $status,
$sortBy, $sortBy,
$sortOrder $sortOrder,
$dateFilters
); );
} }
@@ -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 public function restoreUser(string $id): ?User
{ {
if (! $this->repository->restore($id)) { if (! $this->repository->restore($id)) {
@@ -5,6 +5,7 @@ namespace Modules\User\Transformers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Modules\User\Transformers\EmploymentResource;
class UserListResource extends JsonResource class UserListResource extends JsonResource
{ {
@@ -25,11 +26,13 @@ class UserListResource extends JsonResource
'member_number' => $this->member_number, 'member_number' => $this->member_number,
'member_type' => $this->member_type, 'member_type' => $this->member_type,
'join_date' => $this->join_date, 'join_date' => $this->join_date,
'leave_date' => $this->leave_date,
'birth_date' => $this->birth_date, 'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place, 'birth_place' => $this->birth_place,
'created_at' => $this->created_at, 'created_at' => $this->created_at,
'updated_at' => $this->updated_at, 'updated_at' => $this->updated_at,
'deleted_at' => $this->deleted_at, 'deleted_at' => $this->deleted_at,
'employments' => EmploymentResource::collection($this->whenLoaded('employments')),
'roles' => $this->roles->map(function ($role) { 'roles' => $this->roles->map(function ($role) {
return [ return [
'id' => $role->id, '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); $item = $this->repository->create($data);
ActivityLogger::log("Created {$this->resourceName}: {$this->getItemName($item)}", $item);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => new $this->resourceClass($item), 'data' => new $this->resourceClass($item),
@@ -175,8 +173,6 @@ abstract class BaseCrudController extends Controller
$item->update($data); $item->update($data);
ActivityLogger::log("Updated {$this->resourceName}: {$this->getItemName($item)}", $item);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => new $this->resourceClass($item), 'data' => new $this->resourceClass($item),
@@ -221,8 +217,6 @@ abstract class BaseCrudController extends Controller
$this->repository->delete($id); $this->repository->delete($id);
ActivityLogger::log("Deleted {$this->resourceName}: {$this->getItemName($item)}", $item);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => $this->getSuccessMessage('destroy'), '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://127.0.0.1',
'http://localhost:5173', 'http://localhost:5173',
'http://127.0.0.1:5173', 'http://127.0.0.1:5173',
'https://anggota.koppkb.com', 'https://mykopkb.koppkb.com',
'http://anggota.koppkb.com', 'http://mykopkb.koppkb.com',
], ],
'allowed_origins_patterns' => [], '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 \ RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist --no-scripts \
&& rm -rf bootstrap/cache/services.php bootstrap/cache/packages.php && rm -rf bootstrap/cache/services.php bootstrap/cache/packages.php
# Stage 1b: Node + Puppeteer for Browsershot PDF generation
FROM node:22-bookworm-slim AS node-deps
WORKDIR /var/www
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
unzip \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY be/package.json be/package-lock.json ./
ENV PUPPETEER_SKIP_DOWNLOAD=true \
PUPPETEER_CACHE_DIR=/var/www/.cache/puppeteer
RUN npm ci --omit=dev
RUN npx puppeteer browsers install chrome-headless-shell
# Stage 2: Unified production image with PHP-FPM + Nginx # Stage 2: Unified production image with PHP-FPM + Nginx
FROM php:8.4-fpm AS base FROM php:8.4-fpm AS base
@@ -79,6 +98,37 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& docker-php-ext-enable redis \ && docker-php-ext-enable redis \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Chromium runtime libraries for Puppeteer (Browsershot PDF generation)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libexpat1 \
libfontconfig1 \
libgbm1 \
libglib2.0-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxrender1 \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Download and install php-fpm health check script # Download and install php-fpm health check script
RUN curl -o /usr/local/bin/php-fpm-healthcheck \ RUN curl -o /usr/local/bin/php-fpm-healthcheck \
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \ https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
@@ -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 the application code and dependencies from the build stage first
COPY --from=builder /var/www /var/www COPY --from=builder /var/www /var/www
# Node runtime + Puppeteer for Browsershot (letter PDF generation)
COPY --from=node-deps /usr/local/bin/node /usr/local/bin/node
COPY --from=node-deps /var/www/node_modules /var/www/node_modules
COPY --from=node-deps /var/www/.cache/puppeteer /var/www/.cache/puppeteer
ENV PUPPETEER_CACHE_DIR=/var/www/.cache/puppeteer \
BROWSERSHOT_NO_SANDBOX=true \
BROWSERSHOT_NODE_BINARY=/usr/local/bin/node
# Set working directory # Set working directory
WORKDIR /var/www WORKDIR /var/www
# Ensure correct permissions # Ensure correct permissions
RUN chown -R www-data:www-data /var/www RUN chown -R www-data:www-data /var/www /var/www/.cache/puppeteer
# Create Nginx log directory # Create Nginx log directory
RUN mkdir -p /var/log/nginx && chown -R www-data:www-data /var/log/nginx RUN mkdir -p /var/log/nginx && chown -R www-data:www-data /var/log/nginx
File diff suppressed because one or more lines are too long

Before

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

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[]; currentSort?: SortConfig[];
exportable?: boolean; exportable?: boolean;
exportFileName?: string; exportFileName?: string;
/** Title shown at the top of exported PDF documents. */
exportPdfTitle?: string;
/** When true, table body scrolls inside a max-height box instead of growing the page. */ /** When true, table body scrolls inside a max-height box instead of growing the page. */
scrollable?: boolean; scrollable?: boolean;
/** Max height of the scrollable table area (CSS value). */ /** Max height of the scrollable table area (CSS value). */
@@ -622,12 +624,22 @@ const exportToPDF = () => {
else if (colCount > 8) fontSize = 7; else if (colCount > 8) fontSize = 7;
const cellPadding = colCount > 8 ? 4 : 8; const cellPadding = colCount > 8 ? 4 : 8;
let tableStartY = 20;
if (props.exportPdfTitle?.trim()) {
doc.setFontSize(14);
doc.text(props.exportPdfTitle.trim(), doc.internal.pageSize.getWidth() / 2, 28, {
align: 'center',
});
tableStartY = 48;
}
const tableOptions: Parameters<typeof autoTable>[1] = { const tableOptions: Parameters<typeof autoTable>[1] = {
head: [headers], head: [headers],
body: data, body: data,
startY: tableStartY,
styles: { fontSize, cellPadding }, styles: { fontSize, cellPadding },
headStyles: { fillColor: [66, 139, 202] }, headStyles: { fillColor: [66, 139, 202] },
margin: { top: 20 }, margin: { top: tableStartY },
}; };
// For wide tables: let table use natural column widths and split across pages // For wide tables: let table use natural column widths and split across pages
+2 -2
View File
@@ -86,9 +86,9 @@ export function useRoleSwitcher() {
toast: true, toast: true,
position: 'top-end', position: 'top-end',
icon: 'success', icon: 'success',
title: res.message || 'Peranan telah ditukar.', title: res.message,
showConfirmButton: false, showConfirmButton: false,
timer: 3000, timer: 500,
}) })
await router.push(resolvePostLoginRoute(res.redirect_path)) 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" <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 /> placeholder="Email" autocomplete="email" required />
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md" <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="flex text-xs sm:text-sm">
<div class="mr-auto flex-row items-center"> <div class="mr-auto flex-row items-center">
<CheckboxRoot :checked="remember" @checked-change="({ checked }) => (remember = checked === true)"> <CheckboxRoot :checked="remember" @checked-change="({ checked }) => (remember = checked === true)">
@@ -127,7 +127,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION
</div> </div>
<button type="button" class="opacity-70 hover:opacity-100" <button type="button" class="opacity-70 hover:opacity-100"
@click="router.push({ name: 'forgot-password' })"> @click="router.push({ name: 'forgot-password' })">
Lupa Password? Lupa Kata Laluan?
</button> </button>
</div> </div>
<div class="mt-5 text-center xl:mt-10 xl:text-left"> <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 { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { PasswordInput } from '@/components/ui/password-input' import { PasswordInput } from '@/components/ui/password-input'
import { getRegisterErrorMessage, register } from '@/modules/auth' import { getRegisterErrorMessage, register } from '@/modules/auth'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import illustrationUrl from '@/assets/images/logo.svg' import illustrationUrl from '@/assets/images/logo.svg'
const router = useRouter() const router = useRouter()
@@ -21,6 +22,14 @@ const termsAccepted = ref(false)
const loading = ref(false) const loading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const handleNameInput = () => {
name.value = sanitizeNameInput(name.value)
}
const handleIcNumberInput = () => {
icNumber.value = sanitizeIcNumberInput(icNumber.value)
}
const handleRegister = async () => { const handleRegister = async () => {
if (!termsAccepted.value) { if (!termsAccepted.value) {
errorMessage.value = 'Sila bersetuju dengan Dasar Privasi dan Terma dan Syarat.' 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"> <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" <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" <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 /> 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" <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" <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" <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"> <div class="flex text-xs sm:text-sm">
<CheckboxRoot :checked="termsAccepted" <CheckboxRoot :checked="termsAccepted"
@@ -59,7 +59,7 @@ const availableModules = computed(() => [
visible: hasPermission('lihat aktiviti'), visible: hasPermission('lihat aktiviti'),
}, },
{ {
title: 'Senarai Pengguna', title: 'Senarai Daftar Anggota',
description: 'Pantau dan urus akaun pengguna sistem.', description: 'Pantau dan urus akaun pengguna sistem.',
route: 'list-users', route: 'list-users',
icon: 'Users' as Icon, icon: 'Users' as Icon,
@@ -3,25 +3,25 @@ import type { ExternalSystem } from '../types/external-system.types'
export const dummyExternalSystems: ExternalSystem[] = [ export const dummyExternalSystems: ExternalSystem[] = [
{ {
id: 'ext-001', id: 'ext-001',
code: 'KOPKB-PORTAL', code: 'portal-mykopkb',
name: 'Portal Ahli KOPKB', name: 'Portal Ahli KOPKB',
description: description:
'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.', 'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.',
url: 'https://anggota.koppkb.com', url: 'https://mykopkb.koppkb.com',
icon: 'Users', icon: 'Users',
is_active: true, is_active: true,
starts_at: '2026-01-01T00:00:00+08:00', starts_at: '2026-01-01T00:00:00+08:00',
ends_at: null, ends_at: null,
opens_in_new_tab: true, opens_in_new_tab: true,
contact_email: 'sokongan@koppkb.com', contact_email: 'dev_kopkb@gmail.com',
notes: 'Log masuk menggunakan e-mel berdaftar ahli KOPKB.', notes: 'Log masuk menggunakan e-mel berdaftar ahli KOPKB.',
created_at: '2026-01-15T09:00:00+08:00', created_at: '2026-01-15T09:00:00+08:00',
updated_at: '2026-06-01T14:30:00+08:00', updated_at: '2026-06-01T14:30:00+08:00',
}, },
{ {
id: 'ext-002', id: 'ext-002',
code: 'AGM-VOTE', code: 'e-vote',
name: 'Sistem Pengundian AGM', name: 'Sistem Pengundian AGM KOPKB',
description: description:
'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.', 'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.',
url: 'https://e-vote.erahn.com.my/login', url: 'https://e-vote.erahn.com.my/login',
@@ -30,7 +30,7 @@ export const dummyExternalSystems: ExternalSystem[] = [
starts_at: '2026-05-01T08:00:00+08:00', starts_at: '2026-05-01T08:00:00+08:00',
ends_at: null, ends_at: null,
opens_in_new_tab: true, opens_in_new_tab: true,
contact_email: 'agm@koppkb.com', contact_email: 'dev_kopkb@gmail.com',
notes: 'Sila lengkapkan profil sebelum mengundi.', notes: 'Sila lengkapkan profil sebelum mengundi.',
created_at: '2026-05-20T10:00:00+08:00', created_at: '2026-05-20T10:00:00+08:00',
updated_at: '2026-06-28T11:15:00+08:00', updated_at: '2026-06-28T11:15:00+08:00',
@@ -22,6 +22,7 @@ import { AlertRoot, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Lucide } from '@/components/ui/lucide' import { Lucide } from '@/components/ui/lucide'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage' import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service' import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import type { import type {
MembershipApplicationFormState, MembershipApplicationFormState,
MembershipApplicationHeirForm, MembershipApplicationHeirForm,
@@ -37,7 +38,7 @@ const MIN_FEE_MONTHLY_CONTRIBUTION = 30
const steps = [ const steps = [
{ id: 1, label: 'Maklumat Peribadi' }, { id: 1, label: 'Maklumat Peribadi' },
{ id: 2, label: 'Hubungan & Alamat' }, { id: 2, label: 'No. Telefon & Alamat' },
{ id: 3, label: 'Maklumat Pekerjaan' }, { id: 3, label: 'Maklumat Pekerjaan' },
{ id: 4, label: 'Maklumat Penama' }, { id: 4, label: 'Maklumat Penama' },
{ id: 5, label: 'Dokumen & Hantar' }, { id: 5, label: 'Dokumen & Hantar' },
@@ -79,15 +80,15 @@ const RELATIONSHIP_OPTIONS: SelectOption[] = [
// TODO: replace with API lookup // TODO: replace with API lookup
const EMPLOYERS = [ const EMPLOYERS = [
{ {
name: 'INFRA QUEST SDN BHD', name: 'Infra Quest Sdn. Bhd. (IQSB)',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan', 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', 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.', 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') { function handleReferenceIcInput(role: 'proposer' | 'supporter') {
form.references[role].ic_number = sanitizeIcNumberInput(form.references[role].ic_number)
clearReference(role) 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') { async function lookupReference(role: 'proposer' | 'supporter') {
const reference = form.references[role] const reference = form.references[role]
const icNumber = reference.ic_number.trim() const icNumber = reference.ic_number.trim()
@@ -608,7 +634,7 @@ function stepLabelClass(stepId: number) {
<template v-if="currentStep === 1"> <template v-if="currentStep === 1">
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel for="name">Nama Penuh</FieldLabel> <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> <FieldError v-if="fieldErrors['applicant.name']">{{ fieldErrors['applicant.name'] }}</FieldError>
</Field> </Field>
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
@@ -618,7 +644,8 @@ function stepLabelClass(stepId: number) {
</Field> </Field>
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel for="ic_number">No. Kad Pengenalan</FieldLabel> <FieldLabel for="ic_number">No. Kad Pengenalan</FieldLabel>
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" /> <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 v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}
</FieldError> </FieldError>
</Field> </Field>
@@ -793,14 +820,16 @@ function stepLabelClass(stepId: number) {
<div class="grid grid-cols-12 gap-4"> <div class="grid grid-cols-12 gap-4">
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-name-${index}`">Nama</FieldLabel> <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`]"> <FieldError v-if="fieldErrors[`heirs.${index}.name`]">
{{ fieldErrors[`heirs.${index}.name`] }} {{ fieldErrors[`heirs.${index}.name`] }}
</FieldError> </FieldError>
</Field> </Field>
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel> <FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" /> <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`]"> <FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">
{{ fieldErrors[`heirs.${index}.ic_number`] }} {{ fieldErrors[`heirs.${index}.ic_number`] }}
</FieldError> </FieldError>
@@ -857,8 +886,9 @@ function stepLabelClass(stepId: number) {
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel> <FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel>
<Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text" <Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text"
placeholder="Contoh: 900101011234" :disabled="referenceLookupLoading.proposer" inputmode="numeric" maxlength="12" placeholder="Contoh: 900101011234"
@input="handleReferenceIcInput('proposer')" @blur="lookupReference('proposer')" /> :disabled="referenceLookupLoading.proposer" @input="handleReferenceIcInput('proposer')"
@blur="lookupReference('proposer')" />
<p v-if="form.references.proposer.name" class="mt-1 text-sm text-success"> <p v-if="form.references.proposer.name" class="mt-1 text-sm text-success">
{{ form.references.proposer.name }} {{ form.references.proposer.name }}
</p> </p>
@@ -870,8 +900,9 @@ function stepLabelClass(stepId: number) {
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel> <FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel>
<Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text" <Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text"
placeholder="Contoh: 850505055678" :disabled="referenceLookupLoading.supporter" inputmode="numeric" maxlength="12" placeholder="Contoh: 850505055678"
@input="handleReferenceIcInput('supporter')" @blur="lookupReference('supporter')" /> :disabled="referenceLookupLoading.supporter" @input="handleReferenceIcInput('supporter')"
@blur="lookupReference('supporter')" />
<p v-if="form.references.supporter.name" class="mt-1 text-sm text-success"> <p v-if="form.references.supporter.name" class="mt-1 text-sm text-success">
{{ form.references.supporter.name }} {{ form.references.supporter.name }}
</p> </p>
@@ -907,7 +938,7 @@ function stepLabelClass(stepId: number) {
<Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png" <Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileChange('employer_letter', $event)" /> @change="handleFileChange('employer_letter', $event)" />
<FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter'] <FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter']
}}</FieldError> }}</FieldError>
</Field> </Field>
<div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4"> <div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4">

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