Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18c0d268b1 | |||
| 1cc3351ca6 | |||
| 33372f2c95 | |||
| e6e2df72eb | |||
| 580c7d2392 | |||
| d06c4701a4 |
@@ -1,12 +1,11 @@
|
||||
name: Build Docker Image
|
||||
|
||||
# Builds images on every push to main or tag, but does NOT auto-deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# branches:
|
||||
# - main
|
||||
tags:
|
||||
- "v*" # v1.0.0, v1.0.1, etc.
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
# test-backend:
|
||||
@@ -92,6 +91,6 @@ jobs:
|
||||
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
|
||||
-f fe/docker/Dockerfile \
|
||||
--build-arg VITE_API_BASE_URL=https://api.koppkb.com \
|
||||
--build-arg VITE_APP_URL=https://anggota.koppkb.com \
|
||||
--build-arg VITE_APP_URL=https://mykopkb.koppkb.com \
|
||||
${TAGS} \
|
||||
--push .
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
## Present to Boss (2/7/2026)
|
||||
[x] discuss logo baru MyKOPKB
|
||||
[x] layout footer untuk surat rasmi
|
||||
[ ] maklumat pekerjaan kena buat dropdown untuk nama majikan
|
||||
[ ] tambah minima untuk borang permohonan (tukar jadi caruman saham potongan bulanan)
|
||||
[x] maklumat pekerjaan kena buat dropdown untuk nama majikan
|
||||
[x] tambah minima untuk borang permohonan (tukar jadi caruman saham potongan bulanan)
|
||||
[x] tukar waris ke penama
|
||||
[x] ic dan surat pengesahan majikkan perlu mandatori
|
||||
[ ] semakan lembaga tukar jadi maklumat keputusan
|
||||
@@ -27,5 +27,6 @@
|
||||
[x] surat attach dekat email selepas bos
|
||||
[ ] pembayaran pertama tukar jadi one-off
|
||||
[ ] syer maksima silap tukar jadi rm50.00
|
||||
[ ] letak sign digital
|
||||
[x] running no anggota dalam surat dan masa.
|
||||
[x] letak sign digital
|
||||
[x] running no anggota dalam surat dan masa.
|
||||
[ ] tukar logo di surat offer
|
||||
@@ -67,11 +67,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Browsershot (PDF generation via headless Chrome + Puppeteer in be/)
|
||||
# Required in Docker/Sail. After first sail up: sail npm install && sail npx puppeteer browsers install chrome-headless-shell
|
||||
# Local/Sail: sail npm install && sail npx puppeteer browsers install chrome-headless-shell
|
||||
# Production Docker image bundles Node + Puppeteer; keep NO_SANDBOX enabled in containers.
|
||||
BROWSERSHOT_NO_SANDBOX=true
|
||||
# BROWSERSHOT_NODE_BINARY=
|
||||
# BROWSERSHOT_NODE_BINARY=/usr/local/bin/node
|
||||
# BROWSERSHOT_NPM_BINARY=
|
||||
# BROWSERSHOT_NODE_MODULE_PATH=
|
||||
# BROWSERSHOT_NODE_MODULE_PATH=/var/www
|
||||
# BROWSERSHOT_CHROME_PATH=
|
||||
# BROWSERSHOT_TIMEOUT=60
|
||||
|
||||
|
||||
@@ -105,4 +105,5 @@ API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
|
||||
API_LOG_KEY_USAGE=true
|
||||
|
||||
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
|
||||
FRONTEND_URL=https://mykopkb.koppkb.com
|
||||
FRONTEND_URL=https://mykopkb.koppkb.com
|
||||
BROWSERSHOT_NO_SANDBOX=true
|
||||
@@ -189,6 +189,7 @@ class MembershipApplicationController extends Controller
|
||||
$membershipApplication,
|
||||
$request->user(),
|
||||
$request->validated('board_meeting_reference'),
|
||||
$request->validated('board_meeting_date'),
|
||||
);
|
||||
|
||||
$message = $application->board_result === BoardResult::Pass->value
|
||||
@@ -210,6 +211,7 @@ class MembershipApplicationController extends Controller
|
||||
$request->validated('application_ids'),
|
||||
$request->user(),
|
||||
$request->validated('board_meeting_reference'),
|
||||
$request->validated('board_meeting_date'),
|
||||
);
|
||||
|
||||
$succeededCount = count($result['succeeded']);
|
||||
@@ -270,6 +272,7 @@ class MembershipApplicationController extends Controller
|
||||
$application = $this->membershipApplicationService->generateResultLetter(
|
||||
$membershipApplication,
|
||||
$request->validated('board_meeting_reference'),
|
||||
$request->validated('board_meeting_date'),
|
||||
);
|
||||
|
||||
$resultLetter = $application->documents
|
||||
@@ -301,11 +304,16 @@ class MembershipApplicationController extends Controller
|
||||
'board_meeting_reference',
|
||||
'Mesyuarat Lembaga',
|
||||
);
|
||||
$boardMeetingDate = (string) $request->query(
|
||||
'board_meeting_date',
|
||||
now()->toDateString(),
|
||||
);
|
||||
|
||||
$view = 'membershipapplication::pdf.approved-letter';
|
||||
$data = $this->membershipApplicationService->getResultLetterViewData(
|
||||
$application,
|
||||
$boardMeetingReference,
|
||||
$boardMeetingDate,
|
||||
);
|
||||
$format = $request->query('format', 'html');
|
||||
$filename = 'surat-keputusan-'.$application->application_number.'.pdf';
|
||||
|
||||
@@ -20,6 +20,7 @@ class BatchCompleteRequest extends FormRequest
|
||||
'application_ids' => 'required|array|min:1|max:100',
|
||||
'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id',
|
||||
'board_meeting_reference' => 'required|string|max:255',
|
||||
'board_meeting_date' => 'required|date',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -36,6 +37,8 @@ class BatchCompleteRequest extends FormRequest
|
||||
'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.',
|
||||
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
|
||||
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
|
||||
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
|
||||
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class CompleteMembershipApplicationRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'board_meeting_reference' => 'required|string|max:255',
|
||||
'board_meeting_date' => 'required|date',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -29,6 +30,8 @@ class CompleteMembershipApplicationRequest extends FormRequest
|
||||
return [
|
||||
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
|
||||
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
|
||||
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
|
||||
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class GenerateResultLetterRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'board_meeting_reference' => 'required|string|max:255',
|
||||
'board_meeting_date' => 'required|date',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -29,6 +30,8 @@ class GenerateResultLetterRequest extends FormRequest
|
||||
return [
|
||||
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
|
||||
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
|
||||
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
|
||||
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
Assalamualaikum **{{ $applicant->name }}**,
|
||||
|
||||
Permohonan keahlian anda (**{{ $application->application_number }}**) **tidak diluluskan** pada peringkat lembaga.
|
||||
Permohonan keahlian anda (**{{ $application->application_number }}**) **tidak diluluskan**.
|
||||
|
||||
Surat keputusan rasmi dilampirkan dalam e-mel ini.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
Assalamualaikum **{{ $applicant->name }}**,
|
||||
|
||||
Permohonan keahlian anda (**{{ $application->application_number }}**) **diluluskan** pada peringkat lembaga.
|
||||
Permohonan keahlian anda (**{{ $application->application_number }}**) **diluluskan**.
|
||||
|
||||
Surat keputusan rasmi dilampirkan dalam e-mel ini.
|
||||
|
||||
|
||||
@@ -12,49 +12,77 @@
|
||||
{{ $applicant->postcode }}
|
||||
@endsection
|
||||
|
||||
@section('letter-honorific')
|
||||
Tuan/Puan,
|
||||
@endsection
|
||||
|
||||
@section('letter-subject')
|
||||
<strong>{{ $letterSubject }}</strong>
|
||||
@endsection
|
||||
|
||||
@section('letter-body')
|
||||
<p style="line-height: 1;">
|
||||
Dengan segala hormatnya saya merujuk kepada perkara di atas.
|
||||
Dengan segala hormatnya perkara di atas adalah dirujuk.
|
||||
</p>
|
||||
<p style="line-height: 1;">
|
||||
2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
|
||||
Permodalan Kelantan Berhad (KoPKB) telah <strong>{{ $boardResultLabel }}</strong> dalam {{ $boardMeetingReference }}@if ($isPassed && $memberNumber). Nombor keanggotaan tuan adalah <strong>{{ $memberNumber }}</strong>@endif.
|
||||
2.
|
||||
@if ($isPassed)
|
||||
Sukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>Anggota Koperasi Permodalan Kelantan Berhad (KoPKB)</strong> telah dipertimbangkan dan <strong>DILULUSKAN</strong> oleh Lembaga Koperasi dalam {{ $boardMeetingReference }} yang diadakan pada {{ $boardMeetingDate }}.
|
||||
@else
|
||||
Dukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>Anggota Koperasi Permodalan Kelantan Berhad (KoPKB)</strong> telah dipertimbangkan dan <strong>TIDAK DILULUSKAN</strong> oleh Lembaga Koperasi dalam {{ $boardMeetingReference }} yang diadakan pada {{ $boardMeetingDate }}.
|
||||
@endif
|
||||
</p>
|
||||
|
||||
@if ($isPassed)
|
||||
<p style="line-height: 1;">
|
||||
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaimana ketetapan berikut:-
|
||||
<ul style="line-height: 1;">
|
||||
<li>Saham</li>
|
||||
<ul style="line-height: 1;">
|
||||
<li>Potongan sebanyak RM {{ $monthlyShareInstallment }} ({{ $shareInstallmentMonths }} bulan)</li>
|
||||
<li>Potongan sebanyak RM {{ $stockMonthlyContribution }} sehingga Syer Maksima</li>
|
||||
</ul>
|
||||
<li>Yuran</li>
|
||||
<ul style="line-height: 1;">
|
||||
<li>Potongan sebanyak RM {{ $feeMonthlyContribution }} setiap bulan</li>
|
||||
</ul>
|
||||
<li>Fi Masuk</li>
|
||||
<ul style="line-height: 1;">
|
||||
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li>
|
||||
</ul>
|
||||
</ul>
|
||||
3. Maklumat keanggotaan tuan/puan adalah seperti berikut:-
|
||||
</p>
|
||||
<table class="letter-info-table" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<th>Perkara</th>
|
||||
<th>Maklumat</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Nama Anggota</td>
|
||||
<td>{{ $applicant->name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>No. Kad Pengenalan</td>
|
||||
<td>{{ $applicant->ic_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>No. Anggota</td>
|
||||
<td>{{ $memberNumber }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tarikh Kuat Kuasa Keanggotaan</td>
|
||||
<td>{{ $boardMeetingDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Modal Syer Permulaan</td>
|
||||
<td>RM500.00 (perlu dijelaskan dalam tempoh 6 bulan)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Fi Masuk</td>
|
||||
<td>RM10.00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Yuran Tahunan</td>
|
||||
<td>RM30.00 sebulan</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="line-height: 1;">
|
||||
4. Sebagai anggota KoPKB, tuan/puan adalah tertakluk kepada Undang-Undang Kecil (UUK), dasar, polisi dan peraturan-peraturan KoPKB yang berkuat kuasa dari semasa ke semasa.
|
||||
</p>
|
||||
<p style="line-height: 1;">
|
||||
4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki
|
||||
menjelaskan modal syer minimum sebanyak RM{{ $minimumShareCapital }}.
|
||||
Bayaran tersebut perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan.
|
||||
Pemenuhan syarat ini adalah bagi melayakkan tuan memperoleh hak, kewajipan dan liabiliti sebagai anggota KoPKB
|
||||
sebagaimana terkandung di dalam Undang-Undang Kecil (UUK), Perkara 59 yang telah didaftarkan pada 11 September 2024.
|
||||
Manakala had maksima modal syer adalah sebanyak RM5,000.00.
|
||||
5. Sebagai anggota, tuan/puan adalah layak menikmati kemudahan, produk dan manfaat yang ditawarkan oleh koperasi dari semasa ke semasa, tertakluk kepada syarat-syarat yang ditetapkan.
|
||||
</p>
|
||||
<p style="line-height: 1;">
|
||||
6. Pihak KoPKB mengucapkan tahniah dan mengalu-alukan penyertaan tuan/puan sebagai anggota. Semoga keanggotaan ini akan memberi manfaat kepada tuan/puan serta menyumbang kepada kemajuan dan kecemerlangan KoPKB.
|
||||
</p>
|
||||
@else
|
||||
<p style="line-height: 1;">
|
||||
3. Oleh yang demikian, permohonan tuan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
|
||||
3. Oleh yang demikian, permohonan tuan/puan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
|
||||
</p>
|
||||
@endif
|
||||
@endsection
|
||||
@@ -64,14 +92,5 @@
|
||||
@endsection
|
||||
|
||||
@section('letter-signature')
|
||||
<div class="signature-header">Dengan hormatnya,</div>
|
||||
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
|
||||
<div class="signature-space">
|
||||
@if (! empty($signatureUrl))
|
||||
<img src="{{ $signatureUrl }}" alt="" class="signature-space-img">
|
||||
@endif
|
||||
</div>
|
||||
<div class="signatory-name">MUHAMMAD NAFIS BIN ZAINUDIN</div>
|
||||
<div class="signatory-title">Pengurus Besar</div>
|
||||
<div class="signatory-title">b/p Setiausaha</div>
|
||||
@endsection
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Services\DocumentService;
|
||||
use App\Services\LetterService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
@@ -200,7 +201,7 @@ class MembershipApplicationService
|
||||
{
|
||||
if ($application->status !== ApplicationStatus::PendingBoard) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Permohonan ini tidak boleh disemak pada peringkat lembaga.'],
|
||||
'status' => ['Permohonan ini tidak boleh disemak pada peringkat ahli lembaga.'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -258,6 +259,7 @@ class MembershipApplicationService
|
||||
MembershipApplication $application,
|
||||
User $processedBy,
|
||||
string $boardMeetingReference,
|
||||
string $boardMeetingDate,
|
||||
): MembershipApplication {
|
||||
if ($application->status !== ApplicationStatus::PendingNotification) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -271,14 +273,14 @@ class MembershipApplicationService
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($application, $boardMeetingReference) {
|
||||
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
|
||||
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
|
||||
$user = $this->createMemberFromApplication($application);
|
||||
$application->update(['user_id' => $user->id]);
|
||||
$application->refresh();
|
||||
}
|
||||
|
||||
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference);
|
||||
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
|
||||
|
||||
$this->sendResultNotification($application, $resultLetter);
|
||||
|
||||
@@ -298,8 +300,12 @@ class MembershipApplicationService
|
||||
* failed: list<array{id: string, application_number: string|null, message: string}>
|
||||
* }
|
||||
*/
|
||||
public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference): array
|
||||
{
|
||||
public function completeBatch(
|
||||
array $applicationIds,
|
||||
User $processedBy,
|
||||
string $boardMeetingReference,
|
||||
string $boardMeetingDate,
|
||||
): array {
|
||||
$succeeded = [];
|
||||
$failed = [];
|
||||
|
||||
@@ -317,7 +323,12 @@ class MembershipApplicationService
|
||||
}
|
||||
|
||||
try {
|
||||
$succeeded[] = $this->complete($application, $processedBy, $boardMeetingReference);
|
||||
$succeeded[] = $this->complete(
|
||||
$application,
|
||||
$processedBy,
|
||||
$boardMeetingReference,
|
||||
$boardMeetingDate,
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
$failed[] = [
|
||||
'id' => $id,
|
||||
@@ -333,7 +344,11 @@ class MembershipApplicationService
|
||||
];
|
||||
}
|
||||
|
||||
public function generateResultLetter(MembershipApplication $application, string $boardMeetingReference,): MembershipApplication {
|
||||
public function generateResultLetter(
|
||||
MembershipApplication $application,
|
||||
string $boardMeetingReference,
|
||||
string $boardMeetingDate,
|
||||
): MembershipApplication {
|
||||
if ($application->status !== ApplicationStatus::Completed) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'],
|
||||
@@ -346,8 +361,8 @@ class MembershipApplicationService
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($application, $boardMeetingReference) {
|
||||
$this->storeResultLetter($application, $boardMeetingReference);
|
||||
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
|
||||
$this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
|
||||
|
||||
return $this->repository->findByIdWithRelations($application->id);
|
||||
});
|
||||
@@ -356,6 +371,7 @@ class MembershipApplicationService
|
||||
protected function storeResultLetter(
|
||||
MembershipApplication $application,
|
||||
string $boardMeetingReference,
|
||||
string $boardMeetingDate,
|
||||
): Document {
|
||||
$lockedApplication = MembershipApplication::query()
|
||||
->whereKey($application->id)
|
||||
@@ -376,7 +392,7 @@ class MembershipApplicationService
|
||||
]);
|
||||
}
|
||||
|
||||
$viewData = $this->buildResultLetterViewData($application, $boardMeetingReference);
|
||||
$viewData = $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate);
|
||||
$pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData);
|
||||
$fileName = 'surat-keputusan-'.$application->application_number.'.pdf';
|
||||
|
||||
@@ -394,6 +410,7 @@ class MembershipApplicationService
|
||||
public function getResultLetterViewData(
|
||||
MembershipApplication $application,
|
||||
string $boardMeetingReference,
|
||||
string $boardMeetingDate,
|
||||
): array {
|
||||
$application = $this->repository->findByIdWithRelations($application->id);
|
||||
|
||||
@@ -403,7 +420,7 @@ class MembershipApplicationService
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->buildResultLetterViewData($application, $boardMeetingReference);
|
||||
return $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,32 +429,20 @@ 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 M Y'),
|
||||
'isPassed' => $isPassed,
|
||||
'boardResultLabel' => $isPassed ? 'lulus' : 'gagal',
|
||||
'openingTone' => $isPassed ? 'Sukacita' : 'Dukacita',
|
||||
'letterSubject' => $isPassed
|
||||
? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD'
|
||||
: 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD',
|
||||
'monthlyShareInstallment' => $monthlyShareInstallment,
|
||||
'stockMonthlyContribution' => number_format($stockMonthlyContribution, 2, '.', ''),
|
||||
'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''),
|
||||
'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS,
|
||||
'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''),
|
||||
? 'KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)'
|
||||
: 'KEPUTUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)',
|
||||
'memberNumber' => $isPassed ? $application->user?->member_number : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ return [
|
||||
'name' => 'User',
|
||||
'public_profile_token_ttl_days' => (int) env('PUBLIC_PROFILE_TOKEN_TTL_DAYS', 90),
|
||||
'frontend_url' => rtrim(env('FRONTEND_URL', env('APP_URL', 'http://localhost')), '/'),
|
||||
'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo.svg')),
|
||||
'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo-original.svg')),
|
||||
];
|
||||
|
||||
@@ -24,8 +24,8 @@ return [
|
||||
'http://127.0.0.1',
|
||||
'http://localhost:5173',
|
||||
'http://127.0.0.1:5173',
|
||||
'https://anggota.koppkb.com',
|
||||
'http://anggota.koppkb.com',
|
||||
'https://mykopkb.koppkb.com',
|
||||
'http://mykopkb.koppkb.com',
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
@@ -124,6 +124,6 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'logo_path' => public_path('images/logo/logo.svg'),
|
||||
'logo_path' => public_path('images/logo/logo-original.svg'),
|
||||
|
||||
];
|
||||
|
||||
@@ -46,6 +46,25 @@ COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist --no-scripts \
|
||||
&& rm -rf bootstrap/cache/services.php bootstrap/cache/packages.php
|
||||
|
||||
# Stage 1b: Node + Puppeteer for Browsershot PDF generation
|
||||
FROM node:22-bookworm-slim AS node-deps
|
||||
|
||||
WORKDIR /var/www
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
unzip \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY be/package.json be/package-lock.json ./
|
||||
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=true \
|
||||
PUPPETEER_CACHE_DIR=/var/www/.cache/puppeteer
|
||||
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
RUN npx puppeteer browsers install chrome-headless-shell
|
||||
|
||||
# Stage 2: Unified production image with PHP-FPM + Nginx
|
||||
FROM php:8.4-fpm AS base
|
||||
|
||||
@@ -79,6 +98,37 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& docker-php-ext-enable redis \
|
||||
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Chromium runtime libraries for Puppeteer (Browsershot PDF generation)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
fonts-liberation \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libcairo2 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libdrm2 \
|
||||
libexpat1 \
|
||||
libfontconfig1 \
|
||||
libgbm1 \
|
||||
libglib2.0-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcb1 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxext6 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
libxrender1 \
|
||||
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Download and install php-fpm health check script
|
||||
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
|
||||
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
|
||||
@@ -120,11 +170,20 @@ RUN echo '[www]' > /usr/local/etc/php-fpm.d/zzz-status.conf && \
|
||||
# Copy the application code and dependencies from the build stage first
|
||||
COPY --from=builder /var/www /var/www
|
||||
|
||||
# Node runtime + Puppeteer for Browsershot (letter PDF generation)
|
||||
COPY --from=node-deps /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node-deps /var/www/node_modules /var/www/node_modules
|
||||
COPY --from=node-deps /var/www/.cache/puppeteer /var/www/.cache/puppeteer
|
||||
|
||||
ENV PUPPETEER_CACHE_DIR=/var/www/.cache/puppeteer \
|
||||
BROWSERSHOT_NO_SANDBOX=true \
|
||||
BROWSERSHOT_NODE_BINARY=/usr/local/bin/node
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www
|
||||
|
||||
# Ensure correct permissions
|
||||
RUN chown -R www-data:www-data /var/www
|
||||
RUN chown -R www-data:www-data /var/www /var/www/.cache/puppeteer
|
||||
|
||||
# Create Nginx log directory
|
||||
RUN mkdir -p /var/log/nginx && chown -R www-data:www-data /var/log/nginx
|
||||
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -8,6 +8,7 @@
|
||||
@section('letter-reference') Ruj: APP-2026-00001 @endsection
|
||||
@section('letter-date') {{ now()->format('d F Y') }} @endsection
|
||||
@section('letter-recipient') ... @endsection
|
||||
@section('letter-honorific') Tuan/Puan @endsection
|
||||
@section('letter-subject') Perkara: ... @endsection
|
||||
@section('letter-salutation') Assalamualaikum dan Salam Sejahtera, @endsection
|
||||
@section('letter-body') ... @endsection
|
||||
@@ -110,74 +111,27 @@
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.letterhead-line {
|
||||
vertical-align: middle;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.letterhead-rule {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.letterhead-rule-blue {
|
||||
background-color: var(--letter-color-blue);
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.letterhead-rule-orange {
|
||||
background-color: var(--letter-color-orange);
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.letterhead-logo {
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
padding-right: 10px;
|
||||
.letterhead-center {
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.letterhead-logo-img {
|
||||
height: var(--letter-logo-height);
|
||||
width: auto;
|
||||
display: block;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.letterhead-brand {
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.letterhead-brand-inner {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.letterhead-name {
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.letterhead-name-line {
|
||||
font-size: var(--letter-font-size);
|
||||
font-weight: bold;
|
||||
line-height: 1.25;
|
||||
color: var(--letter-color-black);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.letterhead-registration {
|
||||
.letterhead-address,
|
||||
.letterhead-contact {
|
||||
font-size: var(--letterhead-registration-size);
|
||||
font-weight: bold;
|
||||
line-height: 1.3;
|
||||
line-height: 1.4;
|
||||
color: var(--letter-color-black);
|
||||
margin-top: 2px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.letterhead-address {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta-table {
|
||||
@@ -192,22 +146,25 @@
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
width: 70px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
padding-right: 4px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.meta-date {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.recipient-block {
|
||||
margin-bottom: var(--letter-recipient-margin-bottom);
|
||||
}
|
||||
|
||||
.letter-honorific {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subject-block {
|
||||
font-weight: bold;
|
||||
margin-bottom: var(--letter-subject-margin-bottom);
|
||||
@@ -223,6 +180,26 @@
|
||||
margin: 0 0 var(--letter-body-paragraph-margin-bottom);
|
||||
}
|
||||
|
||||
.letter-info-table {
|
||||
width: 80%;
|
||||
/* max-width: 50%; */
|
||||
margin: 0 auto var(--letter-body-paragraph-margin-bottom);
|
||||
border-collapse: collapse;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.letter-info-table th,
|
||||
.letter-info-table td {
|
||||
border: 1px solid var(--letter-color-black);
|
||||
padding: 4px 8px;
|
||||
vertical-align: top;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.letter-info-table th {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.signature-block {
|
||||
padding-left: var(--letter-content-padding-x);
|
||||
padding-right: var(--letter-content-padding-x);
|
||||
@@ -267,43 +244,18 @@
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.letter-footer-contact-box {
|
||||
background-color: var(--letter-color-orange);
|
||||
padding: 8px 12px;
|
||||
.letter-footer-divider {
|
||||
border-top: 1px solid var(--letter-color-black);
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.letter-footer-nota {
|
||||
padding-top: 8px;
|
||||
font-size: var(--letter-footer-font-size);
|
||||
line-height: var(--letter-footer-line-height);
|
||||
color: var(--letter-color-white);
|
||||
vertical-align: middle;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.letter-footer-address-inner {
|
||||
background-color: var(--letter-color-blue);
|
||||
color: var(--letter-color-white);
|
||||
padding: 8px 12px;
|
||||
font-size: var(--letter-footer-font-size);
|
||||
line-height: var(--letter-footer-line-height);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.letter-footer-lines {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.letter-footer-icon {
|
||||
width: 18px;
|
||||
padding: 2px 8px 2px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.letter-footer-line-text {
|
||||
padding: 2px 0;
|
||||
color: var(--letter-color-white);
|
||||
vertical-align: middle;
|
||||
color: var(--letter-body-color);
|
||||
}
|
||||
|
||||
@media print {
|
||||
@@ -328,17 +280,22 @@
|
||||
|
||||
<div class="letter-content">
|
||||
<div class="meta-table">
|
||||
@if ($showReference && trim($__env->yieldContent('letter-reference')) !== '')
|
||||
@if (($showReference && trim($__env->yieldContent('letter-reference')) !== '') || ($showDate && $letterDate !== ''))
|
||||
<table cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td class="meta-label">Ruj.</td>
|
||||
<td class="meta-value">: @yield('letter-reference')</td>
|
||||
</tr>
|
||||
@if ($showReference && trim($__env->yieldContent('letter-reference')) !== '')
|
||||
<tr>
|
||||
<td class="meta-label">RUJ. KAMI</td>
|
||||
<td class="meta-value">: @yield('letter-reference')</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if ($showDate && $letterDate !== '')
|
||||
<tr>
|
||||
<td class="meta-label">TARIKH</td>
|
||||
<td class="meta-value">: {{ $letterDate }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
@endif
|
||||
@if ($showDate && $letterDate !== '')
|
||||
<div class="meta-date">{{ $letterDate }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@hasSection('letter-recipient')
|
||||
@@ -347,6 +304,10 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@hasSection('letter-honorific')
|
||||
<div class="letter-honorific">@yield('letter-honorific')</div>
|
||||
@endif
|
||||
|
||||
@if ($showSubject && trim($__env->yieldContent('letter-subject')) !== '')
|
||||
<div class="subject-block">
|
||||
@yield('letter-subject')
|
||||
|
||||
@@ -1,38 +1,11 @@
|
||||
<table class="letter-footer" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td class="letter-footer-contact-box" valign="middle" width="50%" bgcolor="#d35400">
|
||||
<table class="letter-footer-lines" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'phone'])</td>
|
||||
<td class="letter-footer-line-text" valign="middle">Tel. Pejabat: 09-741 4331</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'fax'])</td>
|
||||
<td class="letter-footer-line-text" valign="middle">Faks: 09-741 4330</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'email'])</td>
|
||||
<td class="letter-footer-line-text" valign="middle">E-mel: admin@koppkb.com</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'web'])</td>
|
||||
<td class="letter-footer-line-text" valign="middle">Laman Web: www.koppkb.com</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td class="letter-footer-address-inner" valign="middle" width="50%" bgcolor="#0e579b">
|
||||
<table class="letter-footer-lines" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td class="letter-footer-icon" rowspan="3" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'location'])</td>
|
||||
<td class="letter-footer-line-text" valign="middle">Lot PT 448. Tingkat 1, Jalan Kuala Krai</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="letter-footer-line-text" valign="middle">Batu 3 Wakaf Che Yeh</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="letter-footer-line-text" valign="middle">15150 Kota Bharu, Kelantan Darul Naim</td>
|
||||
</tr>
|
||||
</table>
|
||||
<td class="letter-footer-divider"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="letter-footer-nota">
|
||||
<strong>Nota:</strong><br>
|
||||
<em>Surat ini dijana secara komputer dan merupakan dokumen rasmi Koperasi Permodalan Kelantan Berhad (KoPKB). Oleh itu, surat ini adalah sah tanpa tandatangan.</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
<table class="letterhead" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td class="letterhead-line" valign="middle">
|
||||
<table class="letterhead-rule" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td class="letterhead-rule-blue" width="50%" height="6" bgcolor="#0e579b"> </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 class="letterhead-center" align="center" valign="top">
|
||||
@if (! empty($logoUrl))
|
||||
<img src="{{ $logoUrl }}" alt="Koperasi Permodalan Kelantan Berhad" class="letterhead-logo-img">
|
||||
@endif
|
||||
<div class="letterhead-address">
|
||||
Lot Pt 448, Tingkat 1, Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.
|
||||
</div>
|
||||
<div class="letterhead-contact">
|
||||
Tel: 09-7414331 | Fax: 09-7414330 | Web:www.koppkb.com
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -13,8 +13,12 @@
|
||||
43000 Kajang, Selangor
|
||||
@endsection
|
||||
|
||||
@section('letter-honorific')
|
||||
Tuan/Puan,
|
||||
@endsection
|
||||
|
||||
@section('letter-subject')
|
||||
<strong>KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD</strong>
|
||||
<strong>KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)</strong>
|
||||
@endsection
|
||||
|
||||
{{-- @section('letter-salutation')
|
||||
@@ -23,38 +27,57 @@
|
||||
|
||||
@section('letter-body')
|
||||
<p style="line-height: 1;">
|
||||
Dengan segala hormatnya saya merujuk kepada perkara di atas.
|
||||
Dengan segala hormatnya perkara di atas adalah dirujuk.
|
||||
</p>
|
||||
<p style="line-height: 1;">
|
||||
2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
|
||||
Permodalan Kelantan Berhad(KoPKB) telah <strong></strong> dalam
|
||||
2. Sukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>
|
||||
Anggota Koperasi Permodalan Kelantan Berhad(KoPKB)</strong> telah dipertimbangkan dan <strong>DILULUSKAN</strong>
|
||||
oleh Lembaga Koperasi dalam Mesyuarat Lembaga Bil. yang diadakan pada 01 Jan 2026.
|
||||
</p>
|
||||
<p style="line-height: 1;">
|
||||
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaiman ketetapan berikut:-
|
||||
<ul style="line-height: 1;">
|
||||
<li>Saham</li>
|
||||
<ul style="line-height: 1;">
|
||||
<li>Potongan sebanyak RM 84.00 </li>
|
||||
<li>Potongan sebanyak RM sehingga Syer Maksima</li>
|
||||
</ul>
|
||||
<li>Yuran</li>
|
||||
<ul style="line-height: 1;">
|
||||
<li>Potongan sebanyak RM setiap bulan</li>
|
||||
</ul>
|
||||
<li>Fi Masuk</li>
|
||||
<ul style="line-height: 1;">
|
||||
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li>
|
||||
</ul>
|
||||
</ul>
|
||||
3. Maklumat keanggotaan tuan/puan adalah seperti berikut:-
|
||||
</p>
|
||||
<table class="letter-info-table" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<th>Perkara</th>
|
||||
<th>Maklumat</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Nama Anggota</td>
|
||||
<td>Ali</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>No. Kad Pengenalan</td>
|
||||
<td>0123456789012345</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>No. Anggota</td>
|
||||
<td>123</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tarikh Kuat Kuasa Keanggotaan</td>
|
||||
<td>01 Jan</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Modal Syer Permulaan</td>
|
||||
<td>RM500.00 (perlu dijelaskan dalam tempoh 6 bulan)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Fi Masuk</td>
|
||||
<td>RM10.00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Yuran Tahunan</td>
|
||||
<td>RM30.00 sebulan</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="line-height: 1;">
|
||||
4. Sebagai anggota KoPKB, tuan/puan adalah tertakluk kepada Undang-Undang Kecil (UUK), dasar, polisi dan peraturan-peraturan KoPKB yang berkuat kuasa dari semasa ke semasa.
|
||||
</p>
|
||||
<p style="line-height: 1;">
|
||||
4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki
|
||||
menjelaskan modal syer minimum sebanyak RM500.00. Bayaran tersebut
|
||||
perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan.
|
||||
Pemenuhan syarat ini adalah bagi melayakkan tuan memperoleh hak, kewajipan dan
|
||||
liabiliti sebagai anggota KoPKB sebagaimana terkandung di dalam Undang-Undang Kecil (UUK),
|
||||
Perkara 59 yang telah didaftarkan pada 11 September 2024. Manakala had maksima modal syer adalah
|
||||
sebanyak RM5,000.00.
|
||||
5. Sebagai anggota, tuan/puan adalah layak menikmati kemudahan, produk dan manfaat yang ditawarkan oleh koperasi dari semasa ke semasa, tertakluk kepada syarat-syarat yang ditetapkan. </p>
|
||||
<p style="line-height: 1;">
|
||||
6. Pihak KoPKB mengucapkan tahniah dan mengalu-alukan penyertaan tuan/puan sebagai anggota. Semoga keanggotaan ini akan memberi manfaat kepada tuan/puan serta menyumbang kepada kemajuan dan kecemerlangan KoPKB.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -63,14 +86,5 @@
|
||||
@endsection
|
||||
|
||||
@section('letter-signature')
|
||||
<div class="signature-header">Dengan hormatnya,</div>
|
||||
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
|
||||
<div class="signature-space">
|
||||
@if (! empty($signatureUrl))
|
||||
<img src="{{ $signatureUrl }}" alt="" class="signature-space-img">
|
||||
@endif
|
||||
</div>
|
||||
<div class="signatory-name">MUHAMMAD NAFIS BIN ZAINUDDIN</div>
|
||||
<div class="signatory-title">Pengurus Besar</div>
|
||||
<div class="signatory-title">b/p Setiausaha</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
@extends('pdf.layouts.letter')
|
||||
|
||||
@section('letter-title', 'Pratonton Surat')
|
||||
|
||||
@section('letter-reference', 'APP-2026-00001')
|
||||
|
||||
@section('letter-date', now()->translatedFormat('d F Y'))
|
||||
|
||||
@section('letter-recipient')
|
||||
<strong>Ahmad bin Abdullah</strong><br>
|
||||
No. 12, Jalan Contoh,<br>
|
||||
43000 Kajang, Selangor
|
||||
@endsection
|
||||
|
||||
@section('letter-subject')
|
||||
<strong>PERKARA: PRATONTON REKA BENTUK SURAT RASMI</strong>
|
||||
@endsection
|
||||
|
||||
@section('letter-salutation')
|
||||
Assalamualaikum dan Salam Sejahtera,
|
||||
@endsection
|
||||
|
||||
@section('letter-body')
|
||||
<p>
|
||||
Dengan hormatnya perkara di atas adalah dirujuk.
|
||||
</p>
|
||||
<p>
|
||||
1. Ini adalah kandungan contoh untuk pratonton reka bentuk surat. Gunakan halaman ini
|
||||
untuk menguji susun atur, fon, jarak, dan elemen letterhead sebelum menghasilkan PDF sebenar.
|
||||
</p>
|
||||
<p>
|
||||
2. Teks ini boleh diganti apabila templat surat muktamad disediakan.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@section('letter-closing')
|
||||
Sekian, terima kasih.
|
||||
@endsection
|
||||
|
||||
@section('letter-signature')
|
||||
<div class="signatory-name">NAMA PENGARAH</div>
|
||||
<div class="signatory-title">Jawatan / {{ config('app.name') }}</div>
|
||||
@endsection
|
||||
@@ -7,8 +7,5 @@ Route::get('/', function () {
|
||||
return redirect('/login');
|
||||
});
|
||||
|
||||
Route::get('/letters/preview', [LetterPreviewController::class, 'show'])
|
||||
->name('letters.preview');
|
||||
|
||||
Route::get('/letters/preview-approved-membership', [LetterPreviewController::class, 'showMembershipApprovedLetter'])
|
||||
->name('letters.preview-membership-approved-membership');
|
||||
@@ -10,7 +10,7 @@ COPY fe/ ./
|
||||
ARG VITE_APP_NAME="MyKOPKB"
|
||||
ARG VITE_APP_VERSION="1.0"
|
||||
ARG VITE_API_BASE_URL=https://api.koppkb.com
|
||||
ARG VITE_APP_URL=https://anggota.koppkb.com
|
||||
ARG VITE_APP_URL=https://mykopkb.com
|
||||
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ENV VITE_APP_URL=$VITE_APP_URL
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -3,25 +3,25 @@ import type { ExternalSystem } from '../types/external-system.types'
|
||||
export const dummyExternalSystems: ExternalSystem[] = [
|
||||
{
|
||||
id: 'ext-001',
|
||||
code: 'KOPKB-PORTAL',
|
||||
code: 'portal-mykopkb',
|
||||
name: 'Portal Ahli KOPKB',
|
||||
description:
|
||||
'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.',
|
||||
url: 'https://anggota.koppkb.com',
|
||||
url: 'https://mykopkb.koppkb.com',
|
||||
icon: 'Users',
|
||||
is_active: true,
|
||||
starts_at: '2026-01-01T00:00:00+08:00',
|
||||
ends_at: null,
|
||||
opens_in_new_tab: true,
|
||||
contact_email: 'sokongan@koppkb.com',
|
||||
contact_email: 'dev_kopkb@gmail.com',
|
||||
notes: 'Log masuk menggunakan e-mel berdaftar ahli KOPKB.',
|
||||
created_at: '2026-01-15T09:00:00+08:00',
|
||||
updated_at: '2026-06-01T14:30:00+08:00',
|
||||
},
|
||||
{
|
||||
id: 'ext-002',
|
||||
code: 'AGM-VOTE',
|
||||
name: 'Sistem Pengundian AGM',
|
||||
code: 'e-vote',
|
||||
name: 'Sistem Pengundian AGM KOPKB',
|
||||
description:
|
||||
'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.',
|
||||
url: 'https://e-vote.erahn.com.my/login',
|
||||
@@ -30,7 +30,7 @@ export const dummyExternalSystems: ExternalSystem[] = [
|
||||
starts_at: '2026-05-01T08:00:00+08:00',
|
||||
ends_at: null,
|
||||
opens_in_new_tab: true,
|
||||
contact_email: 'agm@koppkb.com',
|
||||
contact_email: 'dev_kopkb@gmail.com',
|
||||
notes: 'Sila lengkapkan profil sebelum mengundi.',
|
||||
created_at: '2026-05-20T10:00:00+08:00',
|
||||
updated_at: '2026-06-28T11:15:00+08:00',
|
||||
|
||||
@@ -93,6 +93,8 @@ const previewUrl = ref<string | null>(null)
|
||||
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
const boardMeetingReference = ref('')
|
||||
const boardMeetingReferenceError = ref<string | null>(null)
|
||||
const boardMeetingDate = ref(dayjs().format('YYYY-MM-DD'))
|
||||
const boardMeetingDateError = ref<string | null>(null)
|
||||
const deleteConfirmDialogOpen = ref(false)
|
||||
const pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
|
||||
@@ -372,6 +374,8 @@ function openConfirmAction(
|
||||
if (action.type === 'complete') {
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
|
||||
boardMeetingDateError.value = null
|
||||
}
|
||||
|
||||
pendingAction.value = action
|
||||
@@ -383,6 +387,8 @@ function closeConfirmDialog() {
|
||||
pendingAction.value = null
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
|
||||
boardMeetingDateError.value = null
|
||||
}
|
||||
|
||||
async function confirmPendingAction() {
|
||||
@@ -391,6 +397,7 @@ async function confirmPendingAction() {
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
boardMeetingReferenceError.value = null
|
||||
boardMeetingDateError.value = null
|
||||
|
||||
if (pendingAction.value.type === 'complete') {
|
||||
const reference = boardMeetingReference.value.trim()
|
||||
@@ -398,6 +405,10 @@ async function confirmPendingAction() {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
if (!boardMeetingDate.value) {
|
||||
boardMeetingDateError.value = 'Tarikh mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
completeSubmitting.value = true
|
||||
} else {
|
||||
reviewSubmitting.value = true
|
||||
@@ -421,6 +432,7 @@ async function confirmPendingAction() {
|
||||
} else {
|
||||
response = await completeMembershipApplication(applicationId.value, {
|
||||
board_meeting_reference: boardMeetingReference.value.trim(),
|
||||
board_meeting_date: boardMeetingDate.value,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -436,6 +448,9 @@ async function confirmPendingAction() {
|
||||
if (validationErrors?.board_meeting_reference?.[0]) {
|
||||
boardMeetingReferenceError.value = validationErrors.board_meeting_reference[0]
|
||||
}
|
||||
if (validationErrors?.board_meeting_date?.[0]) {
|
||||
boardMeetingDateError.value = validationErrors.board_meeting_date[0]
|
||||
}
|
||||
} finally {
|
||||
reviewSubmitting.value = false
|
||||
completeSubmitting.value = false
|
||||
@@ -1003,6 +1018,12 @@ onUnmounted(() => {
|
||||
@input="boardMeetingReferenceError = null" />
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
<Field v-if="pendingAction?.type === 'complete'" class="mt-4 text-left">
|
||||
<FieldLabel for="detail-board-meeting-date">Tarikh Mesyuarat Lembaga</FieldLabel>
|
||||
<Input id="detail-board-meeting-date" v-model="boardMeetingDate" type="date"
|
||||
:disabled="completeSubmitting" @input="boardMeetingDateError = null" />
|
||||
<FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-28" :disabled="reviewSubmitting || completeSubmitting">
|
||||
|
||||
@@ -110,6 +110,8 @@ const batchSuccessMessage = ref<string | null>(null)
|
||||
const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
|
||||
const boardMeetingReference = ref('')
|
||||
const boardMeetingReferenceError = ref<string | null>(null)
|
||||
const boardMeetingDate = ref(dayjs().format('YYYY-MM-DD'))
|
||||
const boardMeetingDateError = ref<string | null>(null)
|
||||
const downloadingResultLetterId = ref<string | null>(null)
|
||||
|
||||
const selectableApplications = computed(() =>
|
||||
@@ -162,6 +164,8 @@ function openBatchConfirm() {
|
||||
if (!selectedIds.value.length) return
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
|
||||
boardMeetingDateError.value = null
|
||||
batchConfirmOpen.value = true
|
||||
}
|
||||
|
||||
@@ -173,15 +177,23 @@ async function confirmBatchComplete() {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
if (!boardMeetingDate.value) {
|
||||
boardMeetingDateError.value = 'Tarikh mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
batchSubmitting.value = true
|
||||
batchSuccessMessage.value = null
|
||||
batchFailedItems.value = []
|
||||
boardMeetingReferenceError.value = null
|
||||
boardMeetingDateError.value = null
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await batchCompleteMembershipApplications(selectedIds.value, reference)
|
||||
const response = await batchCompleteMembershipApplications(selectedIds.value, {
|
||||
board_meeting_reference: reference,
|
||||
board_meeting_date: boardMeetingDate.value,
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
batchSuccessMessage.value = response.message
|
||||
@@ -200,6 +212,7 @@ async function confirmBatchComplete() {
|
||||
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
|
||||
boardMeetingDateError.value = validationErrors?.board_meeting_date?.[0] ?? null
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
||||
}
|
||||
@@ -245,30 +258,6 @@ function goToApplicationEdit(id: string) {
|
||||
router.push({ name: 'edit-membership-application', params: { id } })
|
||||
}
|
||||
|
||||
function canDownloadLetter(item: MembershipApplicationListItem): boolean {
|
||||
return !!item.has_result_letter && !!item.result_letter_document
|
||||
}
|
||||
|
||||
async function handleDownloadResultLetter(item: MembershipApplicationListItem) {
|
||||
const document = item.result_letter_document
|
||||
if (!document) return
|
||||
|
||||
downloadingResultLetterId.value = item.id
|
||||
|
||||
try {
|
||||
await downloadMembershipApplicationDocument(
|
||||
item.id,
|
||||
document.id,
|
||||
document.name,
|
||||
document.mime_type,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat turun surat keputusan.')
|
||||
} finally {
|
||||
downloadingResultLetterId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const headers = computed<TableHeader[]>(() => {
|
||||
const base: TableHeader[] = [
|
||||
{ title: 'Bil.', key: '#', sortable: false },
|
||||
@@ -450,12 +439,6 @@ const headers = computed<TableHeader[]>(() => {
|
||||
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)">
|
||||
<Pencil class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="canDownloadLetter(item as MembershipApplicationListItem)" type="button" variant="ghost"
|
||||
size="sm" class="bg-purple-600 text-white" title="Muat turun surat keputusan"
|
||||
:disabled="downloadingResultLetterId === (item as MembershipApplicationListItem).id"
|
||||
@click="handleDownloadResultLetter(item as MembershipApplicationListItem)">
|
||||
<Download class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
@@ -475,6 +458,12 @@ const headers = computed<TableHeader[]>(() => {
|
||||
@input="boardMeetingReferenceError = null" />
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
<Field class="mt-4 text-left">
|
||||
<FieldLabel for="batch-board-meeting-date">Tarikh Mesyuarat Lembaga</FieldLabel>
|
||||
<Input id="batch-board-meeting-date" v-model="boardMeetingDate" type="date" :disabled="batchSubmitting"
|
||||
@input="boardMeetingDateError = null" />
|
||||
<FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-32" :disabled="batchSubmitting">
|
||||
|
||||
@@ -225,13 +225,13 @@ export async function completeMembershipApplication(
|
||||
|
||||
export async function batchCompleteMembershipApplications(
|
||||
applicationIds: string[],
|
||||
boardMeetingReference: string,
|
||||
payload: GenerateResultLetterPayload,
|
||||
): Promise<BatchCompleteResponse> {
|
||||
const { data } = await api.post<BatchCompleteResponse>(
|
||||
'/v1/membership-applications/batch-complete',
|
||||
{
|
||||
application_ids: applicationIds,
|
||||
board_meeting_reference: boardMeetingReference,
|
||||
...payload,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ export type BatchCompleteResponse = {
|
||||
|
||||
export interface GenerateResultLetterPayload {
|
||||
board_meeting_reference: string
|
||||
board_meeting_date: string
|
||||
}
|
||||
|
||||
export type GenerateResultLetterResponse = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import logoUrl from '@/assets/images/logo-original.svg'
|
||||
import { displayCardValue } from '../utils/member-digital-card.utils'
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import logoUrl from '@/assets/images/logo-original.svg'
|
||||
import { displayCardValue } from '../utils/member-digital-card.utils'
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -14,14 +14,22 @@ export function buildCardDownloadFileName(
|
||||
export function toProxiedStorageUrl(url: string | null | undefined): string | null | undefined {
|
||||
if (!url) return url
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin)
|
||||
if (parsed.pathname.startsWith('/storage/')) {
|
||||
return `${parsed.pathname}${parsed.search}`
|
||||
}
|
||||
} catch {
|
||||
// Keep original URL when parsing fails.
|
||||
}
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '')
|
||||
|
||||
return url
|
||||
try {
|
||||
const parsed = new URL(url, apiBase || window.location.origin)
|
||||
if (!parsed.pathname.startsWith('/storage/')) {
|
||||
return url
|
||||
}
|
||||
|
||||
// Production serves the SPA from a different host than Laravel storage files.
|
||||
if (apiBase) {
|
||||
return `${apiBase}${parsed.pathname}${parsed.search}`
|
||||
}
|
||||
|
||||
// Dev fallback: Vite proxies /storage to the API.
|
||||
return `${parsed.pathname}${parsed.search}`
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export function useImpersonate() {
|
||||
title: response.message,
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
timer: 2000,
|
||||
timer: 500,
|
||||
})
|
||||
|
||||
await router.push(resolvePostLoginRoute(response.redirect_path))
|
||||
@@ -143,7 +143,7 @@ export function useImpersonate() {
|
||||
title: response.message,
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
timer: 2000,
|
||||
timer: 500,
|
||||
})
|
||||
|
||||
await router.push(resolvePostLoginRoute(response.redirect_path))
|
||||
|
||||
@@ -321,7 +321,8 @@ onMounted(() => {
|
||||
<div class="flex w-full flex-col gap-3">
|
||||
<div class="flex w-full flex-wrap items-center gap-3">
|
||||
<div class="relative w-full max-w-md flex-1">
|
||||
<Search class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
|
||||
<Search
|
||||
class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
|
||||
aria-hidden="true" />
|
||||
<Input v-model="search" type="search" placeholder="Search name, email, IC, phone, role..."
|
||||
class="w-full pl-9" aria-label="Search users" />
|
||||
@@ -333,18 +334,10 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm opacity-70">Status:</span>
|
||||
<Badge
|
||||
v-for="chip in STATUS_FILTER_CHIPS"
|
||||
:key="chip.value || 'all'"
|
||||
:variant="chip.variant"
|
||||
:look="isStatusFilterActive(chip.value) ? 'filled' : 'outline'"
|
||||
class="capitalize"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-pressed="isStatusFilterActive(chip.value)"
|
||||
@click="setStatusFilter(chip.value)"
|
||||
@keydown.enter="setStatusFilter(chip.value)"
|
||||
>
|
||||
<Badge v-for="chip in STATUS_FILTER_CHIPS" :key="chip.value || 'all'" :variant="chip.variant"
|
||||
:look="isStatusFilterActive(chip.value) ? 'filled' : 'outline'" class="capitalize" role="button"
|
||||
tabindex="0" :aria-pressed="isStatusFilterActive(chip.value)" @click="setStatusFilter(chip.value)"
|
||||
@keydown.enter="setStatusFilter(chip.value)">
|
||||
{{ chip.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -385,28 +378,28 @@ onMounted(() => {
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center">
|
||||
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" size="sm"
|
||||
<Button v-if="hasPermission('lihat peranan')" type="button" variant="ghost" size="sm"
|
||||
class="bg-purple-600 text-white disabled:opacity-50" :disabled="savingRoles"
|
||||
:title="savingRoles ? 'Menyimpan perubahan...' : 'Tetapkan peranan'"
|
||||
@click="openAssignRolesDialog(item as UserListItem)"> Urus Peranan
|
||||
<Shield class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="hasPermission('lihat pengguna')" type="button" variant="outline" size="sm"
|
||||
<Button v-if="hasPermission('lihat pengguna')" type="button" variant="ghost" size="sm"
|
||||
class="bg-green-600 text-white disabled:opacity-50" :disabled="savingRoles"
|
||||
:title="savingRoles ? 'Menyimpan perubahan...' : 'Lihat profil'" @click="goToViewUser(item.id)">
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" size="sm"
|
||||
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="ghost" size="sm"
|
||||
class="bg-yellow-600 text-white disabled:opacity-50" :disabled="savingRoles"
|
||||
:title="savingRoles ? 'Menyimpan perubahan...' : 'Kemaskini pengguna'" @click="goToEditUser(item.id)">
|
||||
<SquarePen class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="outline" size="sm"
|
||||
<Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="ghost" size="sm"
|
||||
class="bg-red-600 text-white disabled:opacity-50" :disabled="deleting"
|
||||
@click="openDeleteConfirmation(item as UserListItem)">
|
||||
<Trash2 class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="showImpersonateButton(item as UserListItem)" type="button" variant="outline" size="sm"
|
||||
<Button v-if="showImpersonateButton(item as UserListItem)" type="button" variant="ghost" size="sm"
|
||||
class="bg-orange-600 text-white disabled:opacity-50"
|
||||
:disabled="!canImpersonateUser(item as UserListItem) || impersonateLoading || impersonating"
|
||||
:title="impersonateButtonTitle(item as UserListItem)" @click="impersonateUser(item as UserListItem)">
|
||||
@@ -417,6 +410,7 @@ onMounted(() => {
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<!-- Start of deleted users table -->
|
||||
<div class="space-y-4 border-t border-foreground/10 pt-8">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Pengguna Dipadam</h3>
|
||||
@@ -428,19 +422,10 @@ onMounted(() => {
|
||||
<AlertDescription>{{ deletedError }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<DataTable
|
||||
:headers="deletedHeaders"
|
||||
:items="deletedUsers"
|
||||
:loading="deletedLoading"
|
||||
:pagination="deletedPagination"
|
||||
:current-sort="deletedSortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="deleted-users"
|
||||
v-model:page="deletedPage"
|
||||
v-model:items-per-page="deletedItemsPerPage"
|
||||
@update:sort-by="handleDeletedSortUpdate"
|
||||
>
|
||||
<DataTable :headers="deletedHeaders" :items="deletedUsers" :loading="deletedLoading"
|
||||
:pagination="deletedPagination" :current-sort="deletedSortBy" show-pagination exportable
|
||||
export-file-name="deleted-users" v-model:page="deletedPage" v-model:items-per-page="deletedItemsPerPage"
|
||||
@update:sort-by="handleDeletedSortUpdate">
|
||||
<template #item.name="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Profile Image" class="size-10 rounded-full" />
|
||||
@@ -475,16 +460,9 @@ onMounted(() => {
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
v-if="hasPermission('padam akaun pengguna')"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="bg-blue-600 text-white disabled:opacity-50"
|
||||
:disabled="restoring"
|
||||
title="Pulihkan pengguna"
|
||||
@click="openRestoreConfirmation(item as UserListItem)"
|
||||
>
|
||||
<Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="ghost" size="sm"
|
||||
class="bg-blue-600 text-white disabled:opacity-50" :disabled="restoring" title="Pulihkan pengguna"
|
||||
@click="openRestoreConfirmation(item as UserListItem)">
|
||||
<RotateCcw class="size-4" aria-hidden="true" />
|
||||
Pulihkan
|
||||
</Button>
|
||||
@@ -493,6 +471,7 @@ onMounted(() => {
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Assign roles dialog -->
|
||||
<DialogRoot :open="assignRolesOpen" @openChange="(details) => (assignRolesOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5">
|
||||
@@ -537,6 +516,7 @@ onMounted(() => {
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<DialogRoot :open="deleteConfirmationOpen" @openChange="(details) => (deleteConfirmationOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
@@ -564,6 +544,7 @@ onMounted(() => {
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<!-- Restore confirmation dialog -->
|
||||
<DialogRoot :open="restoreConfirmationOpen" @openChange="(details) => (restoreConfirmationOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
@@ -581,14 +562,8 @@ onMounted(() => {
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="restoring">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="primary"
|
||||
look="outline"
|
||||
:disabled="restoring"
|
||||
@click="confirmRestore"
|
||||
>
|
||||
<Button class="w-24" type="button" variant="primary" look="outline" :disabled="restoring"
|
||||
@click="confirmRestore">
|
||||
{{ restoring ? 'Memulihkan...' : 'Pulihkan' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||