Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 501545959f | |||
| cc2492c3fc | |||
| ac8f00175a | |||
| 61345e9cf0 |
@@ -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 .
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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}"];
|
||||
}
|
||||
}
|
||||
@@ -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}"];
|
||||
}
|
||||
}
|
||||
@@ -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' => [],
|
||||
|
||||
@@ -124,6 +124,6 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'logo_path' => public_path('images/logo/logo-original.svg'),
|
||||
'logo_path' => public_path('images/logo/logo.svg'),
|
||||
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 46 KiB |
@@ -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"> </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"> </td>
|
||||
<td class="letterhead-rule-orange" width="50%" height="6" bgcolor="#d35400"> </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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
@extends('pdf.layouts.letter')
|
||||
|
||||
@section('letter-title', 'Pratonton Surat')
|
||||
|
||||
@section('letter-reference', 'APP-2026-00001')
|
||||
|
||||
@section('letter-date', now()->translatedFormat('d F Y'))
|
||||
|
||||
@section('letter-recipient')
|
||||
<strong>Ahmad bin Abdullah</strong><br>
|
||||
No. 12, Jalan Contoh,<br>
|
||||
43000 Kajang, Selangor
|
||||
@endsection
|
||||
|
||||
@section('letter-subject')
|
||||
<strong>PERKARA: PRATONTON REKA BENTUK SURAT RASMI</strong>
|
||||
@endsection
|
||||
|
||||
@section('letter-salutation')
|
||||
Assalamualaikum dan Salam Sejahtera,
|
||||
@endsection
|
||||
|
||||
@section('letter-body')
|
||||
<p>
|
||||
Dengan hormatnya perkara di atas adalah dirujuk.
|
||||
</p>
|
||||
<p>
|
||||
1. Ini adalah kandungan contoh untuk pratonton reka bentuk surat. Gunakan halaman ini
|
||||
untuk menguji susun atur, fon, jarak, dan elemen letterhead sebelum menghasilkan PDF sebenar.
|
||||
</p>
|
||||
<p>
|
||||
2. Teks ini boleh diganti apabila templat surat muktamad disediakan.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@section('letter-closing')
|
||||
Sekian, terima kasih.
|
||||
@endsection
|
||||
|
||||
@section('letter-signature')
|
||||
<div class="signatory-name">NAMA PENGARAH</div>
|
||||
<div class="signatory-title">Jawatan / {{ config('app.name') }}</div>
|
||||
@endsection
|
||||
@@ -3,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');
|
||||
@@ -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');
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 30 KiB |
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||