4 Commits

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

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

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

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

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

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