Dev/v1.1 #2

Merged
ismailmasseran merged 2 commits from dev/v1.1 into main 2026-07-02 10:09:09 +08:00
41 changed files with 1210 additions and 971 deletions
+3 -1
View File
@@ -15,4 +15,6 @@
[ ] boleh print semua borang
[ ] jana surat lepas lulus anggota
##
## Present to Boss (2/7/2026)
[ ] discuss logo baru MyKOPKB
[ ] layout footer untuk surat rasmi
+2 -1
View File
@@ -67,11 +67,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Browsershot (PDF generation via headless Chrome + Puppeteer in be/)
# Required in Docker/Sail. After first sail up: sail npm install && sail npx puppeteer browsers install chrome-headless-shell
BROWSERSHOT_NO_SANDBOX=true
# BROWSERSHOT_NODE_BINARY=
# BROWSERSHOT_NPM_BINARY=
# BROWSERSHOT_NODE_MODULE_PATH=
# BROWSERSHOT_CHROME_PATH=
# BROWSERSHOT_NO_SANDBOX=false
# BROWSERSHOT_TIMEOUT=60
# HttpOnly Sanctum token cookie (set on api.* domain)
@@ -2,11 +2,13 @@
namespace Modules\MembershipApplication\Entities;
use App\Models\Document;
use App\Traits\HasDocuments;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Modules\Auth\Entities\User;
use Modules\MembershipApplication\Enums\ApplicationStatus;
@@ -14,6 +16,12 @@ class MembershipApplication extends Model
{
use HasDocuments, HasUuids;
public const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter';
public const MINIMUM_SHARE_CAPITAL = 500;
public const SHARE_INSTALLMENT_MONTHS = 6;
protected $table = 'membership_applications';
/**
@@ -65,4 +73,10 @@ class MembershipApplication extends Model
{
return $this->belongsTo(User::class);
}
public function resultLetterDocument(): MorphOne
{
return $this->morphOne(Document::class, 'documentable')
->where('type', self::RESULT_LETTER_DOCUMENT_TYPE);
}
}
@@ -17,10 +17,12 @@ use Modules\MembershipApplication\Enums\ManagementDecision;
use Modules\MembershipApplication\Http\Requests\AssignReferencesRequest;
use Modules\MembershipApplication\Http\Requests\BatchCompleteRequest;
use Modules\MembershipApplication\Http\Requests\BoardReviewRequest;
use Modules\MembershipApplication\Http\Requests\GenerateResultLetterRequest;
use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest;
use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest;
use Modules\MembershipApplication\Http\Requests\UploadMembershipApplicationDocumentRequest;
use Modules\MembershipApplication\Services\MembershipApplicationService;
use Modules\MembershipApplication\Transformers\MembershipApplicationDocumentResource;
use Modules\MembershipApplication\Transformers\MembershipApplicationListResource;
use Modules\MembershipApplication\Transformers\MembershipApplicationResource;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -243,6 +245,29 @@ class MembershipApplicationController extends Controller
return $this->documentService->downloadDocument($document->id);
}
public function generateResultLetter(GenerateResultLetterRequest $request, MembershipApplication $membershipApplication,): JsonResponse {
$this->authorize('generateResultLetter', $membershipApplication);
$application = $this->membershipApplicationService->generateResultLetter(
$membershipApplication,
$request->validated('board_meeting_reference'),
);
$resultLetter = $application->documents
->firstWhere('type', MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE);
return response()->json([
'success' => true,
'message' => 'Surat keputusan berjaya dijana.',
'data' => [
'application' => new MembershipApplicationResource($application),
'document' => $resultLetter
? new MembershipApplicationDocumentResource($resultLetter)
: null,
],
]);
}
public function approvedLetter(Request $request, MembershipApplication $membershipApplication): Response
{
$this->authorize('view', $membershipApplication);
@@ -253,10 +278,18 @@ class MembershipApplicationController extends Controller
abort(404, 'Permohonan tidak dijumpai.');
}
$boardMeetingReference = (string) $request->query(
'board_meeting_reference',
'Mesyuarat Lembaga',
);
$view = 'membershipapplication::pdf.approved-letter';
$data = ['application' => $application];
$data = $this->membershipApplicationService->getResultLetterViewData(
$application,
$boardMeetingReference,
);
$format = $request->query('format', 'html');
$filename = 'surat-kelulusan-'.$application->application_number.'.pdf';
$filename = 'surat-keputusan-'.$application->application_number.'.pdf';
if ($format === 'pdf') {
return $this->letterService->pdfResponse($view, $data, $filename);
@@ -0,0 +1,34 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class GenerateResultLetterRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'board_meeting_reference' => 'required|string|max:255',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
];
}
}
@@ -65,6 +65,14 @@ class MembershipApplicationPolicy
return $user->hasPermissionTo('tetapkan pencadang penyokong');
}
/**
* Generate the result letter PDF for a completed application.
*/
public function generateResultLetter($user, ?MembershipApplication $membershipApplication = null): bool
{
return $user->hasPermissionTo('jana surat keputusan keahlian');
}
/**
* Admin: edit application details before completion.
*/
@@ -155,7 +155,7 @@ class MembershipApplicationServiceProvider extends ServiceProvider
public function registerViews(): void
{
$viewPath = resource_path('views/modules/'.$this->nameLower);
$sourcePath = module_path($this->name, 'Resources/Views');
$sourcePath = module_path($this->name, 'Resources/views');
$this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']);
@@ -46,7 +46,14 @@ class MembershipApplicationRepository implements MembershipApplicationRepository
$sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
$query = MembershipApplication::query()
->with(['applicant:id,membership_application_id,name,email,ic_number'])
->with([
'applicant:id,membership_application_id,name,email,ic_number',
'resultLetterDocument:id,documentable_type,documentable_id,name,type,mime_type,file_size',
])
->withCount([
'documents as result_letter_count' => fn ($documentQuery) => $documentQuery
->where('type', MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE),
])
->orderBy($sortBy, $sortOrder);
if ($status !== null && $status !== '') {
@@ -1,12 +1,73 @@
{{--
Approval letter extends the shared base layout.
Fill in sections below when the final design is ready.
--}}
@extends('pdf.layouts.letter')
{{-- @section('letter-reference', $application->application_number) --}}
{{-- @section('letter-date', ...) --}}
{{-- @section('letter-recipient') ... @endsection --}}
{{-- @section('letter-subject') PERKARA: ... @endsection --}}
{{-- @section('letter-body') ... @endsection --}}
{{-- @section('letter-signature') ... @endsection --}}
@section('letter-title', 'Surat')
@section('letter-reference', $application->application_number)
@section('letter-date', now()->translatedFormat('d F Y'))
@section('letter-recipient')
<strong>{{ $applicant->name }}</strong><br>
{!! nl2br(e($applicant->address)) !!}<br>
{{ $applicant->postcode }}
@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.
</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 }}.
</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>
</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.
</p>
@else
<p style="line-height: 1;">
3. Oleh yang demikian, permohonan tuan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
</p>
@endif
@endsection
@section('letter-closing')
Sekian, terima kasih.
@endsection
@section('letter-signature')
<div class="signature-header">Dengan hormatnya,</div>
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
<div class="signature-space"></div>
<div class="signatory-name">MUHAMMAD NAFIS BIN ZAINUDDIN</div>
<div class="signatory-title">Pengurus Besar</div>
<div class="signatory-title">b/p Setiausaha</div>
@endsection
@@ -43,4 +43,6 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct
// approved letter preview (html or pdf)
Route::get('membership-applications/{membershipApplication}/approved-letter', [MembershipApplicationController::class, 'approvedLetter'])->name('membership-application.approved-letter');
// generate and store result letter (one-time)
Route::post('membership-applications/{membershipApplication}/result-letter', [MembershipApplicationController::class, 'generateResultLetter'])->name('membership-application.generate-result-letter');
});
@@ -3,6 +3,7 @@
namespace Modules\MembershipApplication\Services;
use App\Services\DocumentService;
use App\Services\LetterService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
@@ -30,6 +31,7 @@ class MembershipApplicationService
public function __construct(
protected MembershipApplicationRepositoryInterface $repository,
protected DocumentService $documentService,
protected LetterService $letterService,
) {}
/**
@@ -301,6 +303,106 @@ class MembershipApplicationService
];
}
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.'],
]);
}
if (! in_array($application->board_result, [BoardResult::Pass->value, BoardResult::Fail->value], true)) {
throw ValidationException::withMessages([
'board_result' => ['Keputusan lembaga belum ditetapkan.'],
]);
}
return DB::transaction(function () use ($application, $boardMeetingReference) {
$lockedApplication = MembershipApplication::query()
->whereKey($application->id)
->lockForUpdate()
->firstOrFail();
if ($lockedApplication->hasDocumentsOfType(MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE)) {
throw ValidationException::withMessages([
'result_letter' => ['Surat keputusan telah dijana dan tidak boleh dijana semula.'],
]);
}
$application = $this->repository->findByIdWithRelations($lockedApplication->id);
if (! $application || ! $application->applicant) {
throw ValidationException::withMessages([
'application' => ['Maklumat pemohon tidak lengkap.'],
]);
}
$viewData = $this->buildResultLetterViewData($application, $boardMeetingReference);
$pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData);
$fileName = 'surat-keputusan-'.$application->application_number.'.pdf';
$this->documentService->storeGeneratedDocument(
$application,
$pdf,
$fileName,
MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE,
);
return $this->repository->findByIdWithRelations($application->id);
});
}
/**
* @return array<string, mixed>
*/
public function getResultLetterViewData(
MembershipApplication $application,
string $boardMeetingReference,
): array {
$application = $this->repository->findByIdWithRelations($application->id);
if (! $application || ! $application->applicant) {
throw ValidationException::withMessages([
'application' => ['Maklumat pemohon tidak lengkap.'],
]);
}
return $this->buildResultLetterViewData($application, $boardMeetingReference);
}
/**
* @return array<string, mixed>
*/
protected function buildResultLetterViewData(
MembershipApplication $application,
string $boardMeetingReference,
): array {
$applicant = $application->applicant;
$isPassed = $application->board_result === BoardResult::Pass->value;
$monthlyShareInstallment = number_format(
MembershipApplication::MINIMUM_SHARE_CAPITAL / MembershipApplication::SHARE_INSTALLMENT_MONTHS,
2,
'.',
'',
);
return [
'application' => $application,
'applicant' => $applicant,
'boardMeetingReference' => $boardMeetingReference,
'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((float) $applicant->stock_monthly_contribution, 2, '.', ''),
'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''),
'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS,
'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''),
];
}
protected function createMemberFromApplication(MembershipApplication $application): User
{
$application->loadMissing(['applicant', 'heirs']);
@@ -15,6 +15,11 @@ class MembershipApplicationListResource extends JsonResource
'status' => $this->status?->value ?? $this->status,
'board_result' => $this->board_result ?: null,
'submitted_at' => $this->submitted_at,
'has_result_letter' => ($this->result_letter_count ?? 0) > 0,
'result_letter_document' => $this->when(
($this->result_letter_count ?? 0) > 0 && $this->relationLoaded('resultLetterDocument') && $this->resultLetterDocument,
fn () => new MembershipApplicationDocumentResource($this->resultLetterDocument),
),
'applicant' => $this->whenLoaded('applicant', fn () => [
'name' => $this->applicant->name,
'email' => $this->applicant->email,
+22 -6
View File
@@ -13,12 +13,7 @@ class DocumentService
/**
* Upload a document for any model.
*/
public function uploadDocument(
Model $model,
UploadedFile $file,
string $documentType = 'general',
?string $description = null
): Document {
public function uploadDocument(Model $model, UploadedFile $file, string $documentType = 'general', ?string $description = null): Document {
$fileName = time().'_'.$file->getClientOriginalName();
$folderName = strtolower(class_basename($model));
$filePath = $file->storeAs("documents/{$folderName}", $fileName, Document::STORAGE_DISK);
@@ -34,6 +29,27 @@ class DocumentService
]);
}
/**
* Store generated file contents (e.g. PDF) for a model.
*/
public function storeGeneratedDocument(Model $model, string $contents, string $fileName, string $documentType = 'general', string $mimeType = 'application/pdf', ?string $description = null,): Document {
$folderName = strtolower(class_basename($model));
$storedFileName = time().'_'.$fileName;
$filePath = "documents/{$folderName}/{$storedFileName}";
Storage::disk(Document::STORAGE_DISK)->put($filePath, $contents);
return $model->documents()->create([
'name' => $fileName,
'path' => $filePath,
'file_size' => strlen($contents),
'mime_type' => $mimeType,
'type' => $documentType,
'description' => $description,
'uploaded_by' => auth()->id(),
]);
}
/**
* Delete a document.
*/
+9 -1
View File
@@ -33,7 +33,7 @@ class LetterService
*/
public function pdfResponse(string $view, array $data = [], string $filename = 'letter.pdf'): Response
{
$pdf = $this->makeBrowsershot($this->renderHtml($view, $data))->pdf();
$pdf = $this->renderPdf($view, $data);
return response($pdf, SymfonyResponse::HTTP_OK, [
'Content-Type' => 'application/pdf',
@@ -41,6 +41,14 @@ class LetterService
]);
}
/**
* @param array<string, mixed> $data
*/
public function renderPdf(string $view, array $data = []): string
{
return $this->makeBrowsershot($this->renderHtml($view, $data))->pdf();
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
+3
View File
@@ -1,5 +1,8 @@
services:
laravel.test:
# Apple Silicon / OrbStack: Puppeteer's Chrome must match container arch.
# amd64 emulation avoids linux_arm + linux64 chrome-headless-shell mismatch.
platform: linux/amd64
build:
context: "./vendor/laravel/sail/runtimes/8.4"
dockerfile: Dockerfile
+2 -1
View File
@@ -4,7 +4,8 @@
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
"dev": "vite",
"browsershot:install": "puppeteer browsers install chrome-headless-shell"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 204 KiB

@@ -7,6 +7,7 @@
@section('letter-date', now()->translatedFormat('d F Y'))
@section('letter-recipient')
{{-- auto fetch from membership_application_applicants --}}
<strong>Ahmad bin Abdullah</strong><br>
No. 12, Jalan Contoh,<br>
43000 Kajang, Selangor
@@ -26,20 +27,19 @@
</p>
<p style="line-height: 1;">
2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
Permodalan Kelantan Berhad(KoPKB) telah <strong>DILULUSKAN</strong> dalam Mesyuarat Lembaga
Koperasi Bil. 4-2025/2026 pada 24 Jun 2026.
Permodalan Kelantan Berhad(KoPKB) telah <strong>{{ membership_applications.board_result }}</strong> dalam {{ User manual fills before generate }}
</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 (Mei 2026 - Jun 2026)</li>
<li>Potongan sebanyak RM 50.00 sehingga Syer Maksima</li>
<li>Potongan sebanyak RM 84.00 {{(6 months auto calculate)}}</li>
<li>Potongan sebanyak RM {{ membership_application_applicants.stock_monthly_contribution }} sehingga Syer Maksima</li>
</ul>
<li>Yuran</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM 50.00 setiap bulan</li>
<li>Potongan sebanyak RM {{ membership_application_applicants.fee_monthly_contribution }} setiap bulan</li>
</ul>
<li>Fi Masuk</li>
<ul style="line-height: 1;">
BIN
View File
Binary file not shown.
+6
View File
@@ -7,10 +7,16 @@ RUN npm install -g pnpm && \
pnpm install --frozen-lockfile --ignore-scripts && \
pnpm rebuild esbuild
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
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_APP_URL=$VITE_APP_URL
ENV VITE_APP_NAME=$VITE_APP_NAME
ENV VITE_APP_VERSION=$VITE_APP_VERSION
# RUN pnpm run typecheck # uncomment this to run typecheck
RUN pnpm exec vite build --mode=production
Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 KiB

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="311.899" height="291.707" viewBox="0 0 311.899 291.707">
<g id="Icon_ionic-ios-apps" data-name="Icon ionic-ios-apps" transform="translate(-4.57 -4.492)">
<path id="Path_9" data-name="Path 9" d="M305.678,64.318,184.327,9a65.721,65.721,0,0,0-47.842,0L15.22,64.318c-14.293,6.5-14.293,17.117,0,23.639l120.221,54.826a69.059,69.059,0,0,0,50.033,0L305.687,87.956C319.971,81.46,319.971,70.822,305.678,64.318Z" transform="translate(0.07)" fill="#fff" opacity="0.999"/>
<g id="Group_1" data-name="Group 1" transform="translate(4.533 135.941)">
<path id="Path_10" data-name="Path 10" d="M108.2,53.266,44,24a10.438,10.438,0,0,0-8.553,0L12.99,34.227c-11.32,5.145-11.32,13.556,0,18.721l95.21,43.42a54.692,54.692,0,0,0,39.625,0l95.2-43.42c11.32-5.148,11.32-13.556,0-18.721L220.582,24a10.438,10.438,0,0,0-8.553,0l-64.2,29.268a54.692,54.692,0,0,1-39.625,0Z" transform="translate(28.02 60.176)" fill="#fff" opacity="0.5"/>
<path id="Path_11" data-name="Path 11" d="M274.692,27.406l-23.1-10.493a11.652,11.652,0,0,0-9.618,0l-78.68,35.625a64.317,64.317,0,0,1-37.812,0L46.815,16.914a11.652,11.652,0,0,0-9.618,0L14.108,27.407c-12.819,5.825-12.819,15.351,0,21.2L121.925,97.783a61.937,61.937,0,0,0,44.874,0L274.615,48.608C287.51,42.773,287.51,33.232,274.692,27.406Z" transform="translate(11.592 -15.875)" fill="#fff" opacity="0.75"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="280.056" height="304.433" viewBox="0 0 280.056 304.433">
<g id="Group_2" data-name="Group 2" transform="translate(-104.693 -1.265)">
<rect id="Rectangle_76" data-name="Rectangle 76" width="4.104" height="33.821" rx="0.751" transform="matrix(0.788, -0.616, 0.616, 0.788, 250.1, 52.551)" fill="#3f3d56"/>
<path id="Rectangle_80" data-name="Rectangle 80" d="M12.78,0H124.507a12.78,12.78,0,0,1,12.78,12.78V266.236a12.78,12.78,0,0,1-12.78,12.78H12.78A12.78,12.78,0,0,1,0,266.236V12.78A12.78,12.78,0,0,1,12.78,0Z" transform="matrix(0.788, -0.616, 0.616, 0.788, 104.693, 85.834)" fill="#475569"/>
<path id="Path_176" data-name="Path 176" d="M109.443,0H93.695V1.894a8.976,8.976,0,0,1-8.981,8.976H34.63a8.976,8.976,0,0,1-8.981-8.976V0H10.9A10.9,10.9,0,0,0,0,10.9V253.6a10.9,10.9,0,0,0,10.9,10.9h98.54a10.9,10.9,0,0,0,10.9-10.9V10.922A10.9,10.9,0,0,0,109.443,0Z" transform="matrix(0.788, -0.616, 0.616, 0.788, 116.052, 86.183)" fill="#f1f5f9"/>
<rect id="Rectangle_81" data-name="Rectangle 81" width="19.171" height="3.891" rx="1.269" transform="matrix(0.788, -0.616, 0.616, 0.788, 153.48, 58.292)" fill="#e6e8ec"/>
<circle id="Ellipse_5" data-name="Ellipse 5" cx="2.209" cy="2.209" r="2.209" transform="matrix(0.788, -0.616, 0.616, 0.788, 170.71, 44.496)" fill="#e6e8ec"/>
<circle id="Ellipse_11" data-name="Ellipse 11" cx="25.971" cy="25.971" r="25.971" transform="matrix(0.788, -0.616, 0.616, 0.788, 210.99, 151.214)" fill="#fff"/>
<path id="Path_222" data-name="Path 222" d="M69.422,34.712A34.711,34.711,0,1,1,34.711,0,34.711,34.711,0,0,1,69.422,34.712ZM30.7,53.085,56.448,27.337a2.237,2.237,0,0,0,0-3.168L53.28,21.024a2.233,2.233,0,0,0-3.164,0l-21,21L19.31,32.2a2.246,2.246,0,0,0-3.168,0l-3.168,3.187a2.247,2.247,0,0,0,0,3.168L27.532,53.1a2.237,2.237,0,0,0,3.168,0Z" transform="matrix(0.788, -0.616, 0.616, 0.788, 196.054, 147.189)" fill="#94a3b8"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,32 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="251.014" height="317.588" viewBox="0 0 251.014 317.588">
<defs>
<linearGradient id="linear-gradient" x1="0.505" y1="1.007" x2="0.505" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="gray" stop-opacity="0.251"/>
<stop offset="0.54" stop-color="gray" stop-opacity="0.122"/>
<stop offset="1" stop-color="gray" stop-opacity="0.102"/>
</linearGradient>
</defs>
<g id="Group_1" data-name="Group 1" transform="translate(-125.954 18.154)">
<rect id="Rectangle_77" data-name="Rectangle 77" width="3.785" height="18.307" rx="1.276" transform="translate(287.945 23.197) rotate(-11)" fill="#3f3d56"/>
<rect id="Rectangle_78" data-name="Rectangle 78" width="4.265" height="31.863" rx="1.276" transform="translate(294.207 56.13) rotate(-11)" fill="#3f3d56"/>
<rect id="Rectangle_79" data-name="Rectangle 79" width="4.059" height="32.137" rx="1.276" transform="translate(302.481 98.339) rotate(-11)" fill="#3f3d56"/>
<path id="Path_190" data-name="Path 190" d="M0,0A.7.7,0,0,0,.83.107,2.943,2.943,0,0,1,0,0Z" transform="translate(269.32 191.355) rotate(-11)" fill="url(#linear-gradient)"/>
<path id="Path_192" data-name="Path 192" d="M43.471,0l-.819,4.463L42.02,7.877l-1.15,6.245L29.721,21.927s-1-.86-2.663-2.216C22.489,15.948,12.885,8.288,5.834,4.175A23.753,23.753,0,0,0,0,1.487Z" transform="translate(259.796 205.712) rotate(-11)" fill="#f8d4d4"/>
<path id="Path_194" data-name="Path 194" d="M44.318,12.282s13,2.231,13.761-3.32S51.388.41,45.072.037.849,2.268.849,2.268s-6.686,8.918,17.462,10.41Z" transform="translate(249.226 198.636) rotate(-11)" fill="#1e293b"/>
<rect id="Rectangle_82" data-name="Rectangle 82" width="68.005" height="80.639" transform="translate(221.269 29.959) rotate(-11)" fill="#334155"/>
<path id="Path_198" data-name="Path 198" d="M25.9,6.7a10.913,10.913,0,0,0-4.142,2.932C14.9,16.42,5.088,32.343,5.088,32.343-7.462,17.39,6.426,5.582,12.345,1.523,13.785.541,14.751,0,14.751,0Z" transform="translate(142.668 179.037) rotate(-11)" fill="#f8d4d4"/>
<path id="Path_199" data-name="Path 199" d="M36.043,0s3.346,23.41,14.865,28.984S36.043,44.976,36.043,44.976L0,31.588S20.44,24.9,16.35,5.2Z" transform="translate(236.89 52.85) rotate(-11)" fill="#f8d4d4"/>
<path id="Path_200" data-name="Path 200" d="M43.508,0l-.815,4.448L42.03,7.875,40.88,14.12l-11.149,7.8s-1-.853-2.663-2.208C22.5,15.947,12.9,8.286,5.834,4.174A23.6,23.6,0,0,0,0,1.486Z" transform="translate(218.498 199.899) rotate(-11)" fill="#f8d4d4"/>
<path id="Path_202" data-name="Path 202" d="M43.03,14.445S54.7,14.527,55.4,8.96,49.041.408,43.03.035.5,2.439.5,2.439s-5.181,10.515,17.79,12Z" transform="translate(207.79 192.853) rotate(-11)" fill="#334155"/>
<path id="Path_204" data-name="Path 204" d="M89.962,101.836c-.51-.251-1.021-.51-1.523-.761l-1.371-1.729-17.98-22.8v.4c-.122,3.808-1.416,33.98-11.514,36.767-10.776,2.97-23.775,3.716-29.7-16.35S11.119,33.446,0,30.842L16.54,17.279s10.951,12.809,15.413,25.07c-.167-.761-7.73-36.1-1.523-42.349H84.694s8.377,23.859,14.119,35.678c4.47,9.177,11.179,30.873,15.5,45.555C118.588,95.728,103.563,108.53,89.962,101.836Z" transform="translate(242.03 186.268) rotate(-11)" fill="#475569"/>
<path id="Path_205" data-name="Path 205" d="M60.518,52.029C52.141,56.986,15.366,54.965,3.4,54.161L.14,53.925c-.35,0,.053-.2.053-.2l.335-3.374L9.449,27.69,57.2,0c-.968,2.917-1.287,7.38-1.211,12.489C56.208,28.908,60.518,52.029,60.518,52.029Z" transform="translate(260.759 136.409) rotate(-11)" opacity="0.1"/>
<path id="Path_206" data-name="Path 206" d="M60.916,52.005C52.539,56.963,15.764,54.941,3.8,54.138L.54,53.909,0,53.863l.594-.152L3.53,52.95,9.843,27.682,57.591,0C56.624,2.917,56.3,7.38,56.38,12.489,56.6,28.893,60.916,52.005,60.916,52.005Z" transform="translate(260.16 135.409) rotate(-11)" fill="#cbd5e1"/>
<path id="Path_208" data-name="Path 208" d="M2.406,0,13.555,6.686A10.914,10.914,0,0,0,9.413,9.618C8.484,6.907,2.653,3.122,0,1.508,1.439.541,2.406,0,2.406,0Z" transform="translate(154.789 176.698) rotate(-11)" opacity="0.1"/>
<path id="Path_209" data-name="Path 209" d="M.761.107A.662.662,0,0,1,0,0,2.642,2.642,0,0,0,.761.107Z" transform="translate(268.311 189.076) rotate(-11)" opacity="0.1"/>
<path id="Path_210" data-name="Path 210" d="M135.295,56.833s-10.037,1.112-17.1-6.321c0,0-29.7,8.552-39.76,27.872S0,135.614,0,135.614s11.9,6.686,10.776,10.029L55.744,119.63s15.992-11.514,20.432-14.119,28.992-18.581,34.193,24.895,0,38.275,0,38.275,7.433,2.6,18.208-18.208,44.215-25.641,44.215-25.641l1.858-21.917s6.633-14.53,1.523-20.561a12.946,12.946,0,0,1-2.978-6.29c-.845-5.567-.313-12.841,11.521-20.548,18.208-11.9,37.109-18.43,29.657-27.4s-55.228-42.823-55.228-42.823L148.124,2.658S192,26.711,191.085,30.723c-7.252,5.121-31.631,15.312-45.785,14.961C145.3,45.684,147.928,56.46,135.295,56.833Z" transform="translate(128.76 43.985) rotate(-11)" fill="#94a3b8"/>
<path id="Path_211" data-name="Path 211" d="M22.2,9.081A72.58,72.58,0,0,1,20.143,0L.449,5.2A21.591,21.591,0,0,1,0,16.565,21.179,21.179,0,0,0,22.2,9.078Z" transform="translate(252.344 50.975) rotate(-11)" opacity="0.1"/>
<circle id="Ellipse_10" data-name="Ellipse 10" cx="21.179" cy="21.179" r="21.179" transform="translate(232.292 28.197) rotate(-11)" fill="#f8d4d4"/>
<path id="Path_213" data-name="Path 213" d="M0,0S12.261,24.156,8.544,31.581" transform="translate(312.28 207.976) rotate(-11)" opacity="0.1"/>
<path id="Path_221" data-name="Path 221" d="M57.637.16C63.515.83,69.1,3.35,74.886,4.729a8.56,8.56,0,0,0,4.463.167c2.246-.693,4.074-3.046,6.4-2.978,1.434.046,2.7,1.021,3.884,1.957a47.776,47.776,0,0,1,5.331,4.752c3.99,4.333,6.465,10.167,10.1,14.881S119.433,31.9,124.6,29.733c-3.513,13.712-20.022,24.138-32.488,24.991-4.973.341-10.068-.693-14.881.761a12.894,12.894,0,0,1-3.755.914c-2.772,0-5-2.543-6.732-5.018C62.7,45.563,59.494,38.96,54.932,33.652c-1.523-1.759-3.282-3.427-5.414-3.746s-4.089.7-5.985,1.683c-3.13,1.622-6.328,3.3-8.757,6.092S30.815,44.6,31.82,48.343c.541,2.026,1.775,3.854,1.8,5.97.046,3.381-2.936,5.849-3.526,9.138a34.266,34.266,0,0,0-.145,4.158c-.259,4.506-3.209,8.193-6.282,11.034-1.2,1.1-2.526,2.285-2.879,3.983-.236,1.165,0,2.39-.152,3.572-.449,2.924-3.366,4.409-5.948,4.79A17.157,17.157,0,0,1,5.763,89.93c3.61-1.707,5.834-6.564,5-10.913-1.15-6.032-7.212-10.365-6.691-16.5a19.119,19.119,0,0,1,1.676-5.429A49.745,49.745,0,0,0,8.6,46.8a17.334,17.334,0,0,0-.175-8.2c-.761-2.361-2.353-4.241-3.7-6.237A35.466,35.466,0,0,1,.441,23.578a6.177,6.177,0,0,1-.41-2.909A6,6,0,0,1,1.9,17.661c3.313-3.482,6.854-7.1,11.294-8.1A25.42,25.42,0,0,1,19.6,9.322,75.68,75.68,0,0,0,30.741,8.85c3.046-.358,8.193-.312,10.776-2.285,2.406-1.844,3.762-4.158,6.724-5.331A19.8,19.8,0,0,1,57.641.16Z" transform="translate(202.236 5.622) rotate(-11)" fill="#475569"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

@@ -19,6 +19,7 @@ import { useRoleSwitcher } from '@/composables/useRoleSwitcher'
interface Props {
class?: string
boxClass?: string
open?: boolean
}
const props = defineProps<Props>()
@@ -61,7 +62,9 @@ const handleLogout = async (event: MouseEvent) => {
<template>
<div v-bind="$attrs" :class="[
'invisible opacity-0 scale-95 transition-all duration-200 delay-0 group-hover/profile:visible group-hover/profile:opacity-100 group-hover/profile:scale-100 group-hover/profile:delay-200',
'invisible opacity-0 scale-95 transition-all duration-200 delay-0',
'xl:group-hover/profile:visible xl:group-hover/profile:opacity-100 xl:group-hover/profile:scale-100 xl:group-hover/profile:delay-200',
props.open && 'max-xl:visible max-xl:opacity-100 max-xl:scale-100',
props.class,
]">
<Box
@@ -10,12 +10,19 @@ interface Props {
}
const props = defineProps<Props>()
const open = defineModel<boolean>('open', { default: false })
const authStore = useAuthStore()
function handleClick(event: MouseEvent) {
if (!window.matchMedia('(max-width: 1279px)').matches) return
event.stopPropagation()
open.value = !open.value
}
</script>
<template>
<div :class="['side-menu__account group/profile transition-[width]', props.class]">
<div :class="['flex cursor-pointer items-center transition', props.innerClass]">
<div :class="['flex cursor-pointer items-center transition', props.innerClass]" @click="handleClick">
<div :class="[
'relative flex-none overflow-hidden rounded-full border-4',
props.avatarClass ?? 'h-10 w-10 border-background/20 dark:border-foreground/20',
@@ -11,6 +11,7 @@ import {
interface Props {
class?: string
boxClass?: string
open?: boolean
}
const props = defineProps<Props>()
@@ -74,7 +75,9 @@ async function handleMarkAllAsRead(event: MouseEvent) {
<div
v-bind="$attrs"
:class="[
'invisible opacity-0 scale-95 transition-all duration-200 delay-0 group-hover/notifications:visible group-hover/notifications:opacity-100 group-hover/notifications:scale-100 group-hover/notifications:delay-200',
'invisible opacity-0 scale-95 transition-all duration-200 delay-0',
'xl:group-hover/notifications:visible xl:group-hover/notifications:opacity-100 xl:group-hover/notifications:scale-100 xl:group-hover/notifications:delay-200',
props.open && 'max-xl:visible max-xl:opacity-100 max-xl:scale-100',
props.class,
]"
>
+2 -2
View File
@@ -118,7 +118,7 @@ const markersData = {
</SectionContent>
<PreviewCode title="components/ui/map/Map.vue">
{{`
<script setup lang="ts">
<${""}script setup lang="ts">
import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl, { type MapOptions } from "maplibre-gl";
import { ref, onMounted } from "vue";
@@ -318,7 +318,7 @@ const toggleFullscreen = () => {
}
isFullscreen.value = !isFullscreen.value;
};
</script>
</${""}script>
<template>
<div data-scope="map" data-part="root" ref="mapRef" :class="cn(map, className)">
-11
View File
@@ -141,11 +141,6 @@ const mainMenu: (string | Menu)[] = [
route_name: 'crud-data-list',
title: 'Data List',
},
{
icon: 'CircleGauge',
route_name: 'crud-form',
title: 'Form',
},
],
},
{
@@ -268,12 +263,6 @@ const mainMenu: (string | Menu)[] = [
},
],
},
...authMenu,
{
icon: 'CircleGauge',
route_name: 'error-page',
title: 'Error Page',
},
],
},
'UI COMPONENTS',
@@ -513,7 +513,7 @@ function stepLabelClass(stepId: number) {
<div
class="before:bg-foreground/10 relative flex flex-col justify-center px-5 before:absolute before:bottom-0 before:top-0 before:mt-6 before:hidden before:h-0.5 before:w-[69%] sm:px-10 lg:flex-row before:lg:block">
<div v-for="step in steps" :key="step.id" class="z-10 flex flex-1 items-center lg:block lg:text-center">
<Button :class="stepButtonClass(step.id)" :variant="step.id === currentStep ? 'default' : 'ghost'">
<Button :class="stepButtonClass(step.id)" :variant="step.id === currentStep ? 'primary' : 'ghost'">
{{ step.id }}
</Button>
<div :class="stepLabelClass(step.id)">
@@ -810,7 +810,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">
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { CircleAlert, CircleCheck, Download, Eye, Pencil } from '@lucide/vue'
import { CircleAlert, CircleCheck, Download, Eye, FileText, Pencil } from '@lucide/vue'
import {
AlertRoot,
AlertTitle,
@@ -13,7 +13,7 @@ import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
import { Field, FieldLabel } from '@/components/ui/field'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
@@ -23,10 +23,17 @@ import {
completeMembershipApplication,
downloadMembershipApplicationDocument,
fetchMembershipApplicationDocument,
generateMembershipApplicationResultLetter,
getMembershipApplication,
submitBoardReview,
submitManagementReview,
} from '../services/membership-application.service'
import {
reviewDecisionBadgeLook,
reviewDecisionBadgeVariant,
statusBadgeLook,
statusBadgeVariant,
} from '../utils/membership-application-badge.utils'
import type {
BoardReviewDecision,
ManagementReviewDecision,
@@ -36,6 +43,7 @@ import type {
MembershipApplicationReviewDetail,
MembershipApplicationStatus,
} from '../types/membership-application.types'
import { RESULT_LETTER_DOCUMENT_TYPE } from '../types/membership-application.types'
const WORKFLOW_STEPS = [
{ id: 1, label: 'Dihantar' },
@@ -50,6 +58,7 @@ const DOCUMENT_TYPE_LABELS: Record<string, string> = {
photo: 'Gambar Passport',
salary_slip: 'Slip Gaji',
employer_letter: 'Surat Pengesahan Majikan',
[RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan',
}
const router = useRouter()
@@ -77,6 +86,10 @@ const previewOpen = ref(false)
const previewLoading = ref(false)
const previewUrl = ref<string | null>(null)
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
const generateDialogOpen = ref(false)
const generateSubmitting = ref(false)
const boardMeetingReference = ref('')
const boardMeetingReferenceError = ref<string | null>(null)
function statusLabel(status: MembershipApplicationStatus): string {
const labels: Record<MembershipApplicationStatus, string> = {
@@ -90,13 +103,6 @@ function statusLabel(status: MembershipApplicationStatus): string {
return labels[status] ?? status
}
function statusBadgeVariant(status: MembershipApplicationStatus) {
if (status === 'COMPLETED') return 'success'
if (status === 'MANAGEMENT_REJECTED') return 'danger'
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
return 'outline'
}
function getWorkflowProgress(status: MembershipApplicationStatus) {
switch (status) {
case 'SUBMITTED':
@@ -136,6 +142,20 @@ const showCompleteAction = computed(
application.value?.status === 'PENDING_NOTIFICATION',
)
const resultLetterDocument = computed(() =>
application.value?.documents.find((document) => document.type === RESULT_LETTER_DOCUMENT_TYPE) ?? null,
)
const showGenerateResultLetter = computed(
() =>
hasPermission('jana surat keputusan keahlian') &&
application.value?.status === 'COMPLETED' &&
!resultLetterDocument.value &&
(application.value.board_result === 'PASS' || application.value.board_result === 'FAIL'),
)
const applicant = computed(() => application.value?.applicant ?? null)
const confirmDialogTitle = computed(() => {
if (!pendingAction.value) return 'Sahkan Tindakan'
@@ -208,8 +228,6 @@ function workflowStepLabelClass(stepId: number) {
: 'ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32'
}
const applicant = computed(() => application.value?.applicant ?? null)
const sortedReviews = computed(() => {
if (!application.value?.reviews.length) return []
@@ -220,6 +238,10 @@ const sortedReviews = computed(() => {
})
})
const uploadedDocuments = computed(() =>
application.value?.documents.filter((document) => document.type !== RESULT_LETTER_DOCUMENT_TYPE) ?? [],
)
function displayValue(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') return '-'
return String(value)
@@ -307,20 +329,6 @@ function reviewDecisionLabel(decision: string | null, stage: string): string {
return decision
}
function reviewDecisionBadgeVariant(decision: string | null, stage: string) {
if (stage === 'MANAGEMENT') {
if (decision === 'APPROVED') return 'success'
if (decision === 'REJECTED') return 'danger'
}
if (stage === 'BOARD') {
if (decision === 'PASS') return 'success'
if (decision === 'FAIL') return 'danger'
}
return 'outline'
}
function reviewTimelineDotClass(review: MembershipApplicationReviewDetail): string {
const variant = reviewDecisionBadgeVariant(review.decision, review.stage)
@@ -405,6 +413,49 @@ async function confirmPendingAction() {
}
}
async function openGenerateDialog() {
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
generateDialogOpen.value = true
}
function closeGenerateDialog() {
if (generateSubmitting.value) return
generateDialogOpen.value = false
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
}
async function confirmGenerateLetter() {
if (!application.value || generateSubmitting.value) return
const reference = boardMeetingReference.value.trim()
if (!reference) {
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
return
}
generateSubmitting.value = true
boardMeetingReferenceError.value = null
error.value = null
try {
const response = await generateMembershipApplicationResultLetter(application.value.id, {
board_meeting_reference: reference,
})
application.value = response.data.application
successMessage.value = response.message
closeGenerateDialog()
} catch (err) {
const validationErrors = getApiValidationErrors(err)
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
error.value = getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
} finally {
generateSubmitting.value = false
}
}
async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) {
if (!application.value || downloadingDocumentId.value) return
@@ -500,7 +551,8 @@ onUnmounted(() => {
<div class="text-xl font-semibold">{{ application.application_number }}</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="statusBadgeVariant(application.status)" class="whitespace-nowrap">
<Badge :variant="statusBadgeVariant(application.status)" :look="statusBadgeLook(application.status)"
class="whitespace-nowrap">
{{ statusLabel(application.status) }}
</Badge>
<Badge v-if="application.board_result"
@@ -539,7 +591,7 @@ onUnmounted(() => {
<div v-for="step in WORKFLOW_STEPS" :key="step.id"
class="z-10 flex flex-1 items-center lg:block lg:text-center">
<Button type="button" :class="workflowStepButtonClass(step.id)"
:variant="step.id === workflowProgress?.currentStep && application.status !== 'COMPLETED' && !workflowProgress?.failed ? 'default' : 'ghost'"
:variant="step.id === workflowProgress?.currentStep && application.status !== 'COMPLETED' && !workflowProgress?.failed ? 'primary' : 'ghost'"
disabled>
{{ step.id }}
</Button>
@@ -608,6 +660,46 @@ onUnmounted(() => {
</div>
</Box>
<Box v-if="showGenerateResultLetter" class="p-5 sm:p-6">
<div class="font-medium">Surat Keputusan</div>
<p class="mt-1 text-sm opacity-70">
Jana surat keputusan lembaga untuk permohonan ini. Surat hanya boleh dijana sekali.
</p>
<div class="mt-4 flex flex-wrap gap-2">
<Button type="button" variant="primary" @click="openGenerateDialog">
<FileText class="mr-2 size-4" />
Jana Surat Keputusan
</Button>
</div>
</Box>
<Box v-else-if="resultLetterDocument" class="p-5 sm:p-6">
<div class="font-medium">Surat Keputusan</div>
<p class="mt-1 text-sm opacity-70">
{{ resultLetterDocument.name }} · {{ formatFileSize(resultLetterDocument.file_size) }}
</p>
<div class="mt-4 flex flex-wrap gap-2">
<Button
type="button"
look="outline"
:disabled="previewLoading && previewDocument?.id === resultLetterDocument.id"
@click="handleViewDocument(resultLetterDocument)"
>
<Eye class="mr-2 size-4" />
Lihat
</Button>
<Button
type="button"
look="outline"
:disabled="downloadingDocumentId === resultLetterDocument.id"
@click="handleDownloadDocument(resultLetterDocument)"
>
<Download class="mr-2 size-4" />
{{ downloadingDocumentId === resultLetterDocument.id ? 'Memuat turun...' : 'Muat Turun' }}
</Button>
</div>
</Box>
<TabsRoot defaultValue="personal" class="w-full">
<Box raised="single" class="w-full p-0">
<div class="w-full px-5 py-4">
@@ -797,9 +889,9 @@ onUnmounted(() => {
</TabsContent>
<TabsContent value="documents" class="mt-6">
<div v-if="!application.documents.length" class="opacity-70">Tiada dokumen dimuat naik.</div>
<div v-if="!uploadedDocuments.length" class="opacity-70">Tiada dokumen dimuat naik.</div>
<div v-else class="space-y-3">
<div v-for="document in application.documents" :key="document.id"
<div v-for="document in uploadedDocuments" :key="document.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-foreground/10 p-4">
<div>
<div class="font-medium">{{ documentLabel(document.type, document.name) }}</div>
@@ -840,7 +932,11 @@ onUnmounted(() => {
{{ formatDateTime(review.reviewed_at) }}
</div>
</div>
<Badge :variant="reviewDecisionBadgeVariant(review.decision, review.stage)" class="whitespace-nowrap">
<Badge
:variant="reviewDecisionBadgeVariant(review.decision, review.stage)"
:look="reviewDecisionBadgeLook(review.decision, review.stage)"
class="whitespace-nowrap"
>
{{ reviewDecisionLabel(review.decision, review.stage) }}
</Badge>
</div>
@@ -881,6 +977,43 @@ onUnmounted(() => {
</DialogContent>
</DialogRoot>
<DialogRoot :open="generateDialogOpen" @openChange="(details) => { if (!details.open) closeGenerateDialog() }">
<DialogContent>
<div class="p-5">
<div class="text-2xl font-medium">Jana Surat Keputusan</div>
<p v-if="application" class="mt-2 text-sm opacity-70">
{{ application.application_number }} · {{ application.applicant?.name ?? '-' }}
</p>
<Field class="mt-5">
<FieldLabel for="detail-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
<Input
id="detail-board-meeting-reference"
v-model="boardMeetingReference"
type="text"
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026"
:disabled="generateSubmitting"
@input="boardMeetingReferenceError = null"
/>
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
</Field>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-32" :disabled="generateSubmitting" @click="closeGenerateDialog">
Batal
</DialogCloseTrigger>
<Button
class="w-32"
type="button"
variant="primary"
:disabled="generateSubmitting"
@click="confirmGenerateLetter"
>
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
</Button>
</div>
</DialogContent>
</DialogRoot>
<Teleport to="body">
<div v-if="previewOpen" class="fixed inset-0 z-70 flex items-center justify-center p-4 sm:p-6" role="dialog"
aria-modal="true"
@@ -37,6 +37,12 @@ import {
updateMembershipApplication,
uploadMembershipApplicationDocument,
} from '../services/membership-application.service'
import {
reviewDecisionBadgeLook,
reviewDecisionBadgeVariant,
statusBadgeLook,
statusBadgeVariant,
} from '../utils/membership-application-badge.utils'
import type {
DocumentUploadType,
MembershipApplicationDetail,
@@ -166,13 +172,6 @@ function statusLabel(status: MembershipApplicationStatus): string {
return labels[status] ?? status
}
function statusBadgeVariant(status: MembershipApplicationStatus) {
if (status === 'COMPLETED') return 'success'
if (status === 'MANAGEMENT_REJECTED') return 'danger'
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
return 'outline'
}
function workflowStepButtonClass(stepId: number) {
const progress = workflowProgress.value
if (!progress) return 'mx-2 size-12 rounded-full shadow-none bg-background border border-foreground/15'
@@ -268,20 +267,6 @@ function reviewDecisionLabel(decision: string | null, stage: string): string {
return decision
}
function reviewDecisionBadgeVariant(decision: string | null, stage: string) {
if (stage === 'MANAGEMENT') {
if (decision === 'APPROVED') return 'success'
if (decision === 'REJECTED') return 'danger'
}
if (stage === 'BOARD') {
if (decision === 'PASS') return 'success'
if (decision === 'FAIL') return 'danger'
}
return 'outline'
}
function reviewTimelineDotClass(review: MembershipApplicationReviewDetail): string {
const variant = reviewDecisionBadgeVariant(review.decision, review.stage)
@@ -543,12 +528,8 @@ onUnmounted(() => {
{{ application.application_number }} · {{ application.applicant?.name ?? '-' }}
</p>
</div>
<Button
look="outline"
variant="secondary"
type="button"
@click="router.push({ name: 'view-membership-application', params: { id: applicationId } })"
>
<Button look="outline" variant="secondary" type="button"
@click="router.push({ name: 'view-membership-application', params: { id: applicationId } })">
Lihat
</Button>
<Button look="outline" variant="secondary" type="button"
@@ -581,7 +562,8 @@ onUnmounted(() => {
<div class="text-xl font-semibold">{{ application.application_number }}</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="statusBadgeVariant(application.status)" class="whitespace-nowrap">
<Badge :variant="statusBadgeVariant(application.status)" :look="statusBadgeLook(application.status)"
class="whitespace-nowrap">
{{ statusLabel(application.status) }}
</Badge>
<Badge v-if="application.board_result"
@@ -620,7 +602,7 @@ onUnmounted(() => {
<div v-for="step in WORKFLOW_STEPS" :key="step.id"
class="z-10 flex flex-1 items-center lg:block lg:text-center">
<Button type="button" :class="workflowStepButtonClass(step.id)"
:variant="step.id === workflowProgress?.currentStep && application.status !== 'COMPLETED' && !workflowProgress?.failed ? 'default' : 'ghost'"
:variant="step.id === workflowProgress?.currentStep && application.status !== 'COMPLETED' && !workflowProgress?.failed ? 'primary' : 'ghost'"
disabled>
{{ step.id }}
</Button>
@@ -635,29 +617,39 @@ onUnmounted(() => {
<Box raised="single" class="w-full p-0">
<div class="w-full px-5 py-4">
<TabsList class="mb-0 flex w-full">
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="personal">
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
value="personal">
Maklumat Peribadi
</TabsTrigger>
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="contact">
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
value="contact">
Hubungan & Alamat
</TabsTrigger>
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="employment">
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
value="employment">
Pekerjaan & Caruman
</TabsTrigger>
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="heirs">
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
value="heirs">
Waris
</TabsTrigger>
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="references">
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
value="references">
Pencadang & Penyokong
</TabsTrigger>
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="documents">
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
value="documents">
Dokumen
</TabsTrigger>
<TabsTrigger
v-if="application.reviews.length"
<TabsTrigger v-if="application.reviews.length"
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
value="reviews"
>
value="reviews">
Sejarah Semakan
</TabsTrigger>
</TabsList>
@@ -679,28 +671,25 @@ onUnmounted(() => {
<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" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="birth_date">Tarikh Lahir</FieldLabel>
<Input id="birth_date" v-model="form.applicant.birth_date" type="date" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.birth_date']">{{ fieldErrors['applicant.birth_date'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.birth_date']">{{ fieldErrors['applicant.birth_date'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="birth_place">Tempat Lahir</FieldLabel>
<Input id="birth_place" v-model="form.applicant.birth_place" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.birth_place']">{{ fieldErrors['applicant.birth_place'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.birth_place']">{{ fieldErrors['applicant.birth_place'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Jantina</FieldLabel>
<SelectRoot
:key="`gender-${form.applicant.gender}`"
class="w-full"
:collection="genderCollection"
:default-value="genderInitial"
:disabled="!canEdit"
@value-change="setGenderValue"
>
<SelectRoot :key="`gender-${form.applicant.gender}`" class="w-full" :collection="genderCollection"
:default-value="genderInitial" :disabled="!canEdit" @value-change="setGenderValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.gender']">
<SelectValueText placeholder="Pilih jantina" />
@@ -719,14 +708,9 @@ onUnmounted(() => {
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Status Perkahwinan</FieldLabel>
<SelectRoot
:key="`marriage-${form.applicant.marriage_status}`"
class="w-full"
:collection="marriageStatusCollection"
:default-value="marriageStatusInitial"
:disabled="!canEdit"
@value-change="setMarriageStatusValue"
>
<SelectRoot :key="`marriage-${form.applicant.marriage_status}`" class="w-full"
:collection="marriageStatusCollection" :default-value="marriageStatusInitial" :disabled="!canEdit"
@value-change="setMarriageStatusValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.marriage_status']">
<SelectValueText placeholder="Pilih status" />
@@ -741,7 +725,8 @@ onUnmounted(() => {
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors['applicant.marriage_status']">{{ fieldErrors['applicant.marriage_status'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.marriage_status']">{{ fieldErrors['applicant.marriage_status'] }}
</FieldError>
</Field>
</div>
</TabsContent>
@@ -756,12 +741,14 @@ onUnmounted(() => {
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="phone_number">No. Telefon</FieldLabel>
<Input id="phone_number" v-model="form.applicant.phone_number" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.phone_number']">{{ fieldErrors['applicant.phone_number'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.phone_number']">{{ fieldErrors['applicant.phone_number'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="office_number">No. Pejabat</FieldLabel>
<Input id="office_number" v-model="form.applicant.office_number" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.office_number']">{{ fieldErrors['applicant.office_number'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.office_number']">{{ fieldErrors['applicant.office_number'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="postcode">Poskod</FieldLabel>
@@ -776,51 +763,51 @@ onUnmounted(() => {
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="employer_name">Nama Majikan</FieldLabel>
<Input id="employer_name" v-model="form.applicant.employer_name" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.employer_name']">{{ fieldErrors['applicant.employer_name'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.employer_name']">{{ fieldErrors['applicant.employer_name'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="current_position">Jawatan Semasa</FieldLabel>
<Input id="current_position" v-model="form.applicant.current_position" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.current_position']">{{ fieldErrors['applicant.current_position'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.current_position']">{{ fieldErrors['applicant.current_position']
}}</FieldError>
</Field>
<Field class="col-span-12">
<FieldLabel for="employer_address">Alamat Majikan</FieldLabel>
<Textarea id="employer_address" v-model="form.applicant.employer_address" rows="3" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.employer_address']">{{ fieldErrors['applicant.employer_address'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.employer_address']">{{ fieldErrors['applicant.employer_address']
}}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
<Input id="start_work_date" v-model="form.applicant.start_work_date" type="date" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.start_work_date']">{{ fieldErrors['applicant.start_work_date'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.start_work_date']">{{ fieldErrors['applicant.start_work_date'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="stock_monthly_contribution">Caruman Saham (RM)</FieldLabel>
<Input id="stock_monthly_contribution" v-model="form.applicant.stock_monthly_contribution" type="number"
min="0" step="0.01" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.stock_monthly_contribution']">{{ fieldErrors['applicant.stock_monthly_contribution'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.stock_monthly_contribution']">{{
fieldErrors['applicant.stock_monthly_contribution'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="fee_monthly_contribution">Caruman Yuran (RM)</FieldLabel>
<Input id="fee_monthly_contribution" v-model="form.applicant.fee_monthly_contribution" type="number"
min="0" step="0.01" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.fee_monthly_contribution']">{{ fieldErrors['applicant.fee_monthly_contribution'] }}</FieldError>
<FieldError v-if="fieldErrors['applicant.fee_monthly_contribution']">{{
fieldErrors['applicant.fee_monthly_contribution'] }}</FieldError>
</Field>
</div>
</TabsContent>
<TabsContent value="heirs" class="mt-6">
<div class="space-y-4">
<div v-for="(heir, index) in form.heirs" :key="index"
class="rounded-lg border border-foreground/10 p-4">
<div v-for="(heir, index) in form.heirs" :key="index" class="rounded-lg border border-foreground/10 p-4">
<div class="mb-4 flex items-center justify-between">
<div class="font-medium">Waris {{ index + 1 }}</div>
<Button
v-if="canEdit && form.heirs.length > 1"
type="button"
look="outline"
size="sm"
@click="removeHeir(index)"
>
<Button v-if="canEdit && form.heirs.length > 1" type="button" look="outline" size="sm"
@click="removeHeir(index)">
Buang
</Button>
</div>
@@ -828,23 +815,21 @@ onUnmounted(() => {
<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" :disabled="!canEdit" />
<FieldError v-if="fieldErrors[`heirs.${index}.name`]">{{ fieldErrors[`heirs.${index}.name`] }}</FieldError>
<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" :disabled="!canEdit" />
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">{{ fieldErrors[`heirs.${index}.ic_number`] }}</FieldError>
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">{{ fieldErrors[`heirs.${index}.ic_number`]
}}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Hubungan</FieldLabel>
<SelectRoot
:key="`heir-relationship-${index}-${heir.relationship}`"
class="w-full"
<SelectRoot :key="`heir-relationship-${index}-${heir.relationship}`" class="w-full"
:collection="relationshipCollection"
:default-value="apiValueToLabel(RELATIONSHIP_OPTIONS, heir.relationship)"
:disabled="!canEdit"
@value-change="(details) => setHeirRelationshipValue(index, details)"
>
:default-value="apiValueToLabel(RELATIONSHIP_OPTIONS, heir.relationship)" :disabled="!canEdit"
@value-change="(details) => setHeirRelationshipValue(index, details)">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors[`heirs.${index}.relationship`]">
<SelectValueText placeholder="Pilih hubungan" />
@@ -859,12 +844,14 @@ onUnmounted(() => {
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors[`heirs.${index}.relationship`]">{{ fieldErrors[`heirs.${index}.relationship`] }}</FieldError>
<FieldError v-if="fieldErrors[`heirs.${index}.relationship`]">{{
fieldErrors[`heirs.${index}.relationship`] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-phone-${index}`">No. Telefon</FieldLabel>
<Input :id="`heir-phone-${index}`" v-model="heir.phone_number" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors[`heirs.${index}.phone_number`]">{{ fieldErrors[`heirs.${index}.phone_number`] }}</FieldError>
<FieldError v-if="fieldErrors[`heirs.${index}.phone_number`]">{{
fieldErrors[`heirs.${index}.phone_number`] }}</FieldError>
</Field>
</div>
</div>
@@ -882,15 +869,9 @@ onUnmounted(() => {
<div class="grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel>
<Input
id="proposer_ic_number"
v-model="form.references.proposer.ic_number"
type="text"
placeholder="Contoh: 900101011234"
:disabled="!canEdit || referenceLookupLoading.proposer"
@input="handleReferenceIcInput('proposer')"
@blur="lookupReference('proposer')"
/>
<Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text"
placeholder="Contoh: 900101011234" :disabled="!canEdit || 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,15 +881,9 @@ onUnmounted(() => {
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel>
<Input
id="supporter_ic_number"
v-model="form.references.supporter.ic_number"
type="text"
placeholder="Contoh: 850505055678"
:disabled="!canEdit || referenceLookupLoading.supporter"
@input="handleReferenceIcInput('supporter')"
@blur="lookupReference('supporter')"
/>
<Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text"
placeholder="Contoh: 850505055678" :disabled="!canEdit || 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>
@@ -925,11 +900,7 @@ onUnmounted(() => {
Muat naik fail baharu untuk menggantikan dokumen sedia ada.
</div>
<div class="space-y-4">
<div
v-for="type in DOCUMENT_UPLOAD_TYPES"
:key="type"
class="rounded-lg border border-foreground/10 p-4"
>
<div v-for="type in DOCUMENT_UPLOAD_TYPES" :key="type" class="rounded-lg border border-foreground/10 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="font-medium">{{ DOCUMENT_TYPE_LABELS[type] }}</div>
@@ -939,23 +910,15 @@ onUnmounted(() => {
<div v-else class="mt-1 text-sm opacity-70">Tiada dokumen dimuat naik.</div>
</div>
<div v-if="documentsByType[type]" class="flex flex-wrap items-center gap-2">
<Button
type="button"
look="outline"
size="sm"
<Button type="button" look="outline" size="sm"
:disabled="previewLoading && previewDocument?.id === documentsByType[type]?.id"
@click="documentsByType[type] && handleViewDocument(documentsByType[type]!)"
>
@click="documentsByType[type] && handleViewDocument(documentsByType[type]!)">
<Eye class="mr-2 size-4" />
Lihat
</Button>
<Button
type="button"
look="outline"
size="sm"
<Button type="button" look="outline" size="sm"
:disabled="downloadingDocumentId === documentsByType[type]?.id"
@click="documentsByType[type] && handleDownloadDocument(documentsByType[type]!)"
>
@click="documentsByType[type] && handleDownloadDocument(documentsByType[type]!)">
<Download class="mr-2 size-4" />
Muat Turun
</Button>
@@ -965,13 +928,8 @@ onUnmounted(() => {
<FieldLabel :for="`document-${type}`">
{{ documentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
</FieldLabel>
<Input
:id="`document-${type}`"
type="file"
:accept="documentAccept(type)"
:disabled="uploadingDocumentType === type"
@change="handleDocumentUpload(type, $event)"
/>
<Input :id="`document-${type}`" type="file" :accept="documentAccept(type)"
:disabled="uploadingDocumentType === type" @change="handleDocumentUpload(type, $event)" />
<FieldError v-if="fieldErrors[`documents.${type}`]">{{ fieldErrors[`documents.${type}`] }}</FieldError>
<p v-if="uploadingDocumentType === type" class="mt-1 text-sm opacity-70">Memuat naik...</p>
</Field>
@@ -981,19 +939,11 @@ onUnmounted(() => {
<TabsContent v-if="application.reviews.length" value="reviews" class="mt-6">
<div class="relative ms-3 ps-8">
<div
v-for="(review, index) in sortedReviews"
:key="review.id"
class="relative pb-8 last:pb-0"
>
<span
class="absolute -start-8 top-1.5 flex size-3.5 rounded-full border-2 ring-4 ring-background"
:class="reviewTimelineDotClass(review)"
/>
<span
v-if="index < sortedReviews.length - 1"
class="absolute -start-[1.375rem] top-5 h-[calc(100%-0.25rem)] w-px bg-foreground/15"
/>
<div v-for="(review, index) in sortedReviews" :key="review.id" class="relative pb-8 last:pb-0">
<span class="absolute -inset-s-8 top-1.5 flex size-3.5 rounded-full border-2 ring-4 ring-background"
:class="reviewTimelineDotClass(review)" />
<span v-if="index < sortedReviews.length - 1"
class="absolute -inset-s-5.5 top-5 h-[calc(100%-0.25rem)] w-px bg-foreground/15" />
<div class="rounded-lg border border-foreground/10 p-4">
<div class="flex flex-wrap items-start justify-between gap-2">
@@ -1003,10 +953,8 @@ onUnmounted(() => {
{{ formatDateTime(review.reviewed_at) }}
</div>
</div>
<Badge
:variant="reviewDecisionBadgeVariant(review.decision, review.stage)"
class="whitespace-nowrap"
>
<Badge :variant="reviewDecisionBadgeVariant(review.decision, review.stage)"
:look="reviewDecisionBadgeLook(review.decision, review.stage)" class="whitespace-nowrap">
{{ reviewDecisionLabel(review.decision, review.stage) }}
</Badge>
</div>
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { CircleAlert, CircleCheck, Search, Eye, Pencil } from '@lucide/vue'
import { CircleAlert, CircleCheck, Search, Eye, Pencil, FileText, Download } from '@lucide/vue'
import dayjs from 'dayjs'
import * as select from '@zag-js/select'
import {
@@ -26,16 +26,27 @@ import {
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import DataTable from '@/components/ui/usage/DataTable.vue'
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import axios from 'axios'
import { useMembershipApplicationList } from '../composables/useMembershipApplicationList'
import { usePermissions } from '@/composables/usePermissions'
import { batchCompleteMembershipApplications } from '../services/membership-application.service'
import {
batchCompleteMembershipApplications,
downloadMembershipApplicationDocument,
generateMembershipApplicationResultLetter,
} from '../services/membership-application.service'
import {
boardResultBadgeVariant,
statusBadgeLook,
statusBadgeVariant,
} from '../utils/membership-application-badge.utils'
import type {
BatchCompleteFailedItem,
BatchCompleteResponse,
GenerateResultLetterResponse,
MembershipApplicationBoardResult,
MembershipApplicationListItem,
MembershipApplicationStatus,
@@ -94,11 +105,19 @@ const {
} = useMembershipApplicationList()
const canBatchComplete = computed(() => hasPermission('selesaikan permohonan keahlian'))
const canGenerateResultLetter = computed(() => hasPermission('jana surat keputusan keahlian'))
const selectedIds = ref<string[]>([])
const batchSubmitting = ref(false)
const batchConfirmOpen = ref(false)
const batchSuccessMessage = ref<string | null>(null)
const letterSuccessMessage = ref<string | null>(null)
const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
const generateDialogOpen = ref(false)
const generateSubmitting = ref(false)
const boardMeetingReference = ref('')
const boardMeetingReferenceError = ref<string | null>(null)
const generateTarget = ref<MembershipApplicationListItem | null>(null)
const downloadingResultLetterId = ref<string | null>(null)
const selectableApplications = computed(() =>
applications.value.filter((item) => item.status === 'PENDING_NOTIFICATION'),
@@ -203,25 +222,12 @@ function statusLabel(status: MembershipApplicationStatus): string {
return labels[status] ?? status
}
function statusBadgeVariant(status: MembershipApplicationStatus) {
if (status === 'COMPLETED') return 'success'
if (status === 'MANAGEMENT_REJECTED') return 'danger'
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
return 'outline'
}
function boardResultLabel(result: MembershipApplicationBoardResult | null): string {
if (result === 'PASS') return 'Lulus'
if (result === 'FAIL') return 'Gagal'
return '-'
}
function boardResultBadgeVariant(result: MembershipApplicationBoardResult | null) {
if (result === 'PASS') return 'success'
if (result === 'FAIL') return 'danger'
return 'outline'
}
function formatSubmittedAt(value: string | null): string {
if (!value) return '-'
return dayjs(value).format('DD/MM/YYYY HH:mm')
@@ -235,47 +241,131 @@ function goToApplicationEdit(id: string) {
router.push({ name: 'edit-membership-application', params: { id } })
}
function canGenerateLetter(item: MembershipApplicationListItem): boolean {
return (
canGenerateResultLetter.value &&
item.status === 'COMPLETED' &&
!item.has_result_letter &&
(item.board_result === 'PASS' || item.board_result === 'FAIL')
)
}
function canDownloadLetter(item: MembershipApplicationListItem): boolean {
return !!item.has_result_letter && !!item.result_letter_document
}
function openGenerateDialog(item: MembershipApplicationListItem) {
generateTarget.value = item
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
generateDialogOpen.value = true
}
function closeGenerateDialog() {
if (generateSubmitting.value) return
generateDialogOpen.value = false
generateTarget.value = null
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
}
async function confirmGenerateLetter() {
if (!generateTarget.value || generateSubmitting.value) return
const reference = boardMeetingReference.value.trim()
if (!reference) {
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
return
}
generateSubmitting.value = true
boardMeetingReferenceError.value = null
error.value = null
letterSuccessMessage.value = null
try {
const response = await generateMembershipApplicationResultLetter(generateTarget.value.id, {
board_meeting_reference: reference,
})
letterSuccessMessage.value = response.message
closeGenerateDialog()
await fetchApplications(page.value)
} catch (err) {
if (axios.isAxiosError(err) && err.response?.data) {
const responseData = err.response.data as GenerateResultLetterResponse
const validationErrors = getApiValidationErrors(err)
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
} else {
error.value = getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
}
} finally {
generateSubmitting.value = false
}
}
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 },
{ title: 'No. Permohonan', key: 'application_number', sortable: true },
{
title: 'Nama Pemohon',
key: 'applicant_name',
sortable: false,
exportValue: (item) => item.applicant?.name ?? '',
},
{
title: 'Emel',
key: 'applicant_email',
sortable: false,
exportValue: (item) => item.applicant?.email ?? '',
},
{
title: 'No. IC',
key: 'applicant_ic_number',
sortable: false,
exportValue: (item) => item.applicant?.ic_number ?? '',
},
{
title: 'Status',
key: 'status',
sortable: true,
exportValue: (item) => statusLabel(item.status),
},
{
title: 'Keputusan Lembaga',
key: 'board_result',
sortable: false,
exportValue: (item) => boardResultLabel(item.board_result),
},
{
title: 'Tarikh Hantar',
key: 'submitted_at',
sortable: true,
exportValue: (item) => formatSubmittedAt(item.submitted_at),
},
{ title: 'Tindakan', key: 'actions', sortable: false },
{ title: 'No. Permohonan', key: 'application_number', sortable: true },
{
title: 'Nama Pemohon',
key: 'applicant_name',
sortable: false,
exportValue: (item) => item.applicant?.name ?? '',
},
{
title: 'Emel',
key: 'applicant_email',
sortable: false,
exportValue: (item) => item.applicant?.email ?? '',
},
{
title: 'No. IC',
key: 'applicant_ic_number',
sortable: false,
exportValue: (item) => item.applicant?.ic_number ?? '',
},
{
title: 'Status',
key: 'status',
sortable: true,
exportValue: (item) => statusLabel(item.status),
},
{
title: 'Keputusan Lembaga',
key: 'board_result',
sortable: false,
exportValue: (item) => boardResultLabel(item.board_result),
},
{
title: 'Tarikh Hantar',
key: 'submitted_at',
sortable: true,
exportValue: (item) => formatSubmittedAt(item.submitted_at),
},
{ title: 'Tindakan', key: 'actions', sortable: false },
]
if (canBatchComplete.value) {
@@ -294,6 +384,13 @@ const headers = computed<TableHeader[]>(() => {
<p class="mt-1 text-sm opacity-70">Urus dan semak permohonan keahlian koperasi.</p>
</div>
<AlertRoot v-if="letterSuccessMessage" variant="success">
<CircleCheck />
<AlertTitle>Berjaya</AlertTitle>
<AlertDescription>{{ letterSuccessMessage }}</AlertDescription>
<AlertCloseTrigger @click="letterSuccessMessage = null" />
</AlertRoot>
<AlertRoot v-if="batchSuccessMessage" variant="success">
<CircleCheck />
<AlertTitle>Berjaya</AlertTitle>
@@ -321,41 +418,20 @@ const headers = computed<TableHeader[]>(() => {
<AlertCloseTrigger @click="error = null" />
</AlertRoot>
<DataTable
:headers="headers"
:items="applications"
:loading="loading"
:pagination="pagination"
:current-sort="sortBy"
show-pagination
exportable
export-file-name="permohonan-keahlian"
v-model:page="page"
v-model:items-per-page="itemsPerPage"
@update:sort-by="handleSortUpdate"
>
<DataTable :headers="headers" :items="applications" :loading="loading" :pagination="pagination"
:current-sort="sortBy" show-pagination exportable export-file-name="permohonan-keahlian" v-model:page="page"
v-model:items-per-page="itemsPerPage" @update:sort-by="handleSortUpdate">
<template #toolbar>
<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"
aria-hidden="true"
/>
<Input
v-model="search"
type="search"
placeholder="Cari no. permohonan, nama, emel, IC..."
class="w-full pl-9"
aria-label="Cari permohonan keahlian"
/>
<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="Cari no. permohonan, nama, emel, IC..."
class="w-full pl-9" aria-label="Cari permohonan keahlian" />
</div>
<SelectRoot
class="w-full sm:w-56"
:collection="statusFilterCollection"
:default-value="statusFilterInitial"
@value-change="setStatusFilterValue"
>
<SelectRoot class="w-full sm:w-56" :collection="statusFilterCollection" :default-value="statusFilterInitial"
@value-change="setStatusFilterValue">
<SelectControl>
<SelectTrigger aria-label="Tapis status">
<SelectValueText placeholder="Semua Status" />
@@ -364,11 +440,7 @@ const headers = computed<TableHeader[]>(() => {
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
<SelectItem
v-for="item in statusFilterCollection.items"
:key="item.label"
:item="item"
>
<SelectItem v-for="item in statusFilterCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
@@ -376,21 +448,13 @@ const headers = computed<TableHeader[]>(() => {
</SelectRoot>
<template v-if="canBatchComplete">
<Button
type="button"
look="outline"
variant="secondary"
<Button type="button" look="outline" variant="secondary"
:disabled="!selectableApplications.length || loading"
@click="toggleSelectAllOnPage(!allSelectableSelected)"
>
@click="toggleSelectAllOnPage(!allSelectableSelected)">
{{ allSelectableSelected ? 'Nyahpilih Halaman' : 'Pilih Halaman' }}
</Button>
<Button
type="button"
variant="primary"
:disabled="!selectedIds.length || loading || batchSubmitting"
@click="openBatchConfirm"
>
<Button type="button" variant="primary" :disabled="!selectedIds.length || loading || batchSubmitting"
@click="openBatchConfirm">
Selesaikan Terpilih ({{ selectedIds.length }})
</Button>
</template>
@@ -398,11 +462,9 @@ const headers = computed<TableHeader[]>(() => {
</template>
<template v-if="canBatchComplete" #item.select="{ item }">
<CheckboxRoot
v-if="isSelectable(item as MembershipApplicationListItem)"
<CheckboxRoot v-if="isSelectable(item as MembershipApplicationListItem)"
:checked="isSelected((item as MembershipApplicationListItem).id)"
@checked-change="({ checked }) => toggleSelection((item as MembershipApplicationListItem).id)"
>
@checked-change="({ checked }) => toggleSelection((item as MembershipApplicationListItem).id)">
<CheckboxControl />
</CheckboxRoot>
</template>
@@ -422,6 +484,7 @@ const headers = computed<TableHeader[]>(() => {
<template #item.status="{ item }">
<Badge
:variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
:look="statusBadgeLook((item as MembershipApplicationListItem).status)"
class="whitespace-nowrap"
>
{{ statusLabel((item as MembershipApplicationListItem).status) }}
@@ -429,11 +492,9 @@ const headers = computed<TableHeader[]>(() => {
</template>
<template #item.board_result="{ item }">
<Badge
v-if="(item as MembershipApplicationListItem).board_result"
<Badge v-if="(item as MembershipApplicationListItem).board_result"
:variant="boardResultBadgeVariant((item as MembershipApplicationListItem).board_result)"
class="whitespace-nowrap"
>
class="whitespace-nowrap">
{{ boardResultLabel((item as MembershipApplicationListItem).board_result) }}
</Badge>
<span v-else class="opacity-50">-</span>
@@ -445,36 +506,44 @@ const headers = computed<TableHeader[]>(() => {
<template #item.actions="{ item }">
<div class="flex items-center gap-2">
<Button
v-if="hasPermission('lihat permohonan keahlian')"
type="button"
variant="outline"
size="sm"
class="bg-green-600 text-white"
title="Lihat butiran permohonan"
@click="goToApplicationDetail((item as MembershipApplicationListItem).id)"
>
<Button v-if="hasPermission('lihat permohonan keahlian')" type="button" variant="ghost" size="sm"
class="bg-green-600 text-white" title="Lihat butiran permohonan"
@click="goToApplicationDetail((item as MembershipApplicationListItem).id)">
<Eye class="size-4" aria-hidden="true" />
</Button>
<Button
v-if="hasPermission('kemaskini permohonan keahlian')"
type="button"
variant="outline"
size="sm"
class="bg-blue-600 text-white"
title="Kemaskini permohonan"
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)"
>
<Button v-if="hasPermission('kemaskini permohonan keahlian')" type="button" variant="ghost" size="sm"
class="bg-blue-600 text-white" title="Kemaskini permohonan"
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)">
<Pencil class="size-4" aria-hidden="true" />
</Button>
<Button
v-if="canGenerateLetter(item as MembershipApplicationListItem)"
type="button"
variant="ghost"
size="sm"
class="bg-amber-600 text-white"
title="Jana surat keputusan"
@click="openGenerateDialog(item as MembershipApplicationListItem)"
>
<FileText 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>
<DialogRoot
:open="batchConfirmOpen"
@openChange="(details) => { batchConfirmOpen = details.open }"
>
<DialogRoot :open="batchConfirmOpen" @openChange="(details) => { batchConfirmOpen = details.open }">
<DialogContent>
<div class="p-5 text-center">
<div class="mt-2 text-2xl font-medium">Selesaikan Permohonan Terpilih?</div>
@@ -486,14 +555,49 @@ const headers = computed<TableHeader[]>(() => {
<DialogCloseTrigger class="mr-2 w-32" :disabled="batchSubmitting">
Batal
</DialogCloseTrigger>
<Button class="w-32" type="button" variant="primary" :disabled="batchSubmitting"
@click="confirmBatchComplete">
{{ batchSubmitting ? 'Memproses...' : 'Sahkan' }}
</Button>
</div>
</DialogContent>
</DialogRoot>
<DialogRoot :open="generateDialogOpen" @openChange="(details) => { if (!details.open) closeGenerateDialog() }">
<DialogContent>
<div class="p-5">
<div class="text-2xl font-medium">Jana Surat Keputusan</div>
<p v-if="generateTarget" class="mt-2 text-sm opacity-70">
{{ generateTarget.application_number }} · {{ generateTarget.applicant?.name ?? '-' }}
</p>
<Field class="mt-5">
<FieldLabel for="board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
<Input
id="board-meeting-reference"
v-model="boardMeetingReference"
type="text"
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026"
:disabled="generateSubmitting"
@input="boardMeetingReferenceError = null"
/>
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
</Field>
<p class="mt-3 text-sm opacity-70">
Surat hanya boleh dijana sekali dan akan disimpan sebagai dokumen permohonan.
</p>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-32" :disabled="generateSubmitting" @click="closeGenerateDialog">
Batal
</DialogCloseTrigger>
<Button
class="w-32"
type="button"
variant="primary"
:disabled="batchSubmitting"
@click="confirmBatchComplete"
:disabled="generateSubmitting"
@click="confirmGenerateLetter"
>
{{ batchSubmitting ? 'Memproses...' : 'Sahkan' }}
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
</Button>
</div>
</DialogContent>
@@ -1,7 +1,10 @@
import { api } from '@/core/services/api'
import type { PaginatedApiResponse } from '@/core/types/api'
import type {
BatchCompleteResponse,
DocumentUploadType,
GenerateResultLetterPayload,
GenerateResultLetterResponse,
ListMembershipApplicationsParams,
MembershipApplicationApiResponse,
MembershipApplicationFormState,
@@ -9,7 +12,6 @@ import type {
MembershipApplicationReviewPayload,
MembershipApplicationReviewResponse,
MembershipApplicationSubmitResponse,
BatchCompleteResponse,
MembershipApplicationUpdateResponse,
MemberLookupResponse,
UpdateMembershipApplicationPayload,
@@ -215,6 +217,22 @@ export async function batchCompleteMembershipApplications(
return data
}
export async function generateMembershipApplicationResultLetter(
id: string,
payload: GenerateResultLetterPayload,
): Promise<GenerateResultLetterResponse> {
const { data } = await api.post<GenerateResultLetterResponse>(
`/v1/membership-applications/${id}/result-letter`,
payload,
)
if (!data.success) {
throw new Error(data.message ?? 'Failed to generate result letter')
}
return data
}
// Fetch membership application document/ view in modal
export async function fetchMembershipApplicationDocument(
applicationId: string,
@@ -80,6 +80,8 @@ export type MembershipApplicationStatus =
export type MembershipApplicationBoardResult = 'PASS' | 'FAIL'
export const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter' as const
export interface MembershipApplicationApplicantSummary {
name: string
email: string
@@ -92,6 +94,8 @@ export interface MembershipApplicationListItem {
status: MembershipApplicationStatus
board_result: MembershipApplicationBoardResult | null
submitted_at: string | null
has_result_letter: boolean
result_letter_document: MembershipApplicationDocumentDetail | null
applicant: MembershipApplicationApplicantSummary | null
created_at: string | null
}
@@ -232,3 +236,16 @@ export type BatchCompleteResponse = {
failed: BatchCompleteFailedItem[]
}
}
export interface GenerateResultLetterPayload {
board_meeting_reference: string
}
export type GenerateResultLetterResponse = {
success: boolean
message: string
data: {
application: MembershipApplicationDetail
document: MembershipApplicationDocumentDetail | null
}
}
@@ -0,0 +1,68 @@
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
import type {
MembershipApplicationBoardResult,
MembershipApplicationStatus,
} from '../types/membership-application.types'
export function statusBadgeVariant(
status: MembershipApplicationStatus,
): NonNullable<BadgeVariants['variant']> {
if (status === 'COMPLETED') return 'success'
if (status === 'MANAGEMENT_REJECTED') return 'danger'
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
return 'ghost'
}
export function statusBadgeLook(
status: MembershipApplicationStatus,
): BadgeVariants['look'] {
if (
status === 'COMPLETED' ||
status === 'MANAGEMENT_REJECTED' ||
status === 'PENDING_BOARD' ||
status === 'PENDING_NOTIFICATION'
) {
return undefined
}
return 'outline'
}
export function boardResultBadgeVariant(
result: MembershipApplicationBoardResult | null,
): NonNullable<BadgeVariants['variant']> {
if (result === 'PASS') return 'success'
if (result === 'FAIL') return 'danger'
return 'ghost'
}
export function reviewDecisionBadgeVariant(
decision: string | null,
stage: string,
): NonNullable<BadgeVariants['variant']> {
if (stage === 'MANAGEMENT') {
if (decision === 'APPROVED') return 'success'
if (decision === 'REJECTED') return 'danger'
}
if (stage === 'BOARD') {
if (decision === 'PASS') return 'success'
if (decision === 'FAIL') return 'danger'
}
return 'ghost'
}
export function reviewDecisionBadgeLook(
decision: string | null,
stage: string,
): BadgeVariants['look'] {
if (
(stage === 'MANAGEMENT' && (decision === 'APPROVED' || decision === 'REJECTED')) ||
(stage === 'BOARD' && (decision === 'PASS' || decision === 'FAIL'))
) {
return undefined
}
return 'outline'
}
@@ -156,9 +156,8 @@ onMounted(async () => {
<div
class="mt-6 flex flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-0 lg:pt-0">
<div
class="relative aspect-[1.75/1] w-full max-w-[17rem] overflow-hidden rounded-2xl bg-gradient-to-br from-primary via-primary/95 to-primary/75 p-4 text-primary-foreground shadow-lg ring-1 ring-white/20 sm:max-w-xs sm:p-5"
role="img"
aria-label="Kad digital anggota">
class="relative aspect-1.75/1 w-full max-w-68 overflow-hidden rounded-2xl bg-linear-to-br from-primary via-primary/95 to-primary/75 p-4 text-primary-foreground shadow-lg ring-1 ring-white/20 sm:max-w-xs sm:p-5"
role="img" aria-label="Kad digital anggota">
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
<div class="pointer-events-none absolute -right-10 -top-10 size-36 rounded-full bg-white/10" />
<div class="pointer-events-none absolute -bottom-12 -left-8 size-40 rounded-full bg-white/5" />
@@ -212,9 +211,6 @@ onMounted(async () => {
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="2">
<Lucide class="mr-2 size-4" icon="Lock" /> Kata Laluan
</TabsTrigger>
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="4">
<Lucide class="mr-2 size-4" icon="MoreHorizontal" /> Lain-lain
</TabsTrigger>
</TabsList>
</div>
</Box>
@@ -238,357 +234,6 @@ onMounted(async () => {
<TabsContent value="6" class="mt-8">
<HeirTab embedded />
</TabsContent>
<!-- Perkhidmatan -->
<TabsContent value="4" class="mt-8">
<div class="grid grid-cols-12 gap-x-6 gap-y-8">
<!-- BEGIN: Latest Uploads -->
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
<div class="flex items-center border-b border-foreground/15 px-5 py-5 sm:py-3">
<h2 class="mr-auto text-base font-medium">Latest Uploads</h2>
<MenuRoot class="w-auto ml-auto sm:hidden">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="all">All Files</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
<Button variant="ghost" class="shadow-none border border-foreground/15 hidden sm:flex">All Files</Button>
</div>
<div class="p-5">
<div class="flex items-center">
<FileIcon class="w-12" variant="directory" />
<div class="ml-4">
<a class="font-medium" href="">Documentation</a>
<div class="mt-0.5 text-xs opacity-70">40 KB</div>
</div>
<MenuRoot class="w-auto ml-auto">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="share">
<Lucide class="mr-2 size-4" icon="Users" /> Share File
</MenuItem>
<MenuItem value="delete">
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
</div>
<div class="mt-5 flex items-center">
<FileIcon class="w-12 text-xs" variant="file" type="MP3" />
<div class="ml-4">
<a class="font-medium" href="">Celine Dion - Ashes</a>
<div class="mt-0.5 text-xs opacity-70">40 KB</div>
</div>
<MenuRoot class="w-auto ml-auto">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="share">
<Lucide class="mr-2 size-4" icon="Users" /> Share File
</MenuItem>
<MenuItem value="delete">
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
</div>
<div class="mt-5 flex items-center">
<FileIcon class="w-12" variant="empty-directory" />
<div class="ml-4">
<a class="font-medium" href="">Resources</a>
<div class="mt-0.5 text-xs opacity-70">0 KB</div>
</div>
<MenuRoot class="w-auto ml-auto">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="share">
<Lucide class="mr-2 size-4" icon="Users" /> Share File
</MenuItem>
<MenuItem value="delete">
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
</div>
</div>
</Box>
<!-- END: Latest Uploads -->
<!-- BEGIN: Work In Progress -->
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
<TabsRoot defaultValue="wip-0">
<div class="relative flex items-center border-b border-foreground/15 p-5">
<h2 class="mr-auto text-base font-medium">Work In Progress</h2>
<MenuRoot class="w-auto ml-auto sm:hidden">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="new">New</MenuItem>
<MenuItem value="last-week">Last Week</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
<TabsList class="absolute inset-y-0 h-[2.8rem] right-5 my-auto hidden w-auto sm:flex">
<TabsTrigger value="wip-0">New</TabsTrigger>
<TabsTrigger value="wip-1">Last Week</TabsTrigger>
</TabsList>
</div>
<TabsContent value="wip-0" class="p-5">
<div>
<div class="flex">
<div class="mr-auto">Pending Tasks</div>
<div>20%</div>
</div>
<ProgressRoot :defaultValue="50" class="mt-2">
<ProgressTrack>
<ProgressRange />
</ProgressTrack>
</ProgressRoot>
</div>
<div class="mt-5">
<div class="flex">
<div class="mr-auto">Completed Tasks</div>
<div>2 / 20</div>
</div>
<ProgressRoot :defaultValue="25" class="mt-2">
<ProgressTrack>
<ProgressRange />
</ProgressTrack>
</ProgressRoot>
</div>
<div class="mt-5">
<div class="flex">
<div class="mr-auto">Tasks In Progress</div>
<div>42</div>
</div>
<ProgressRoot :defaultValue="75" class="mt-2">
<ProgressTrack>
<ProgressRange />
</ProgressTrack>
</ProgressRoot>
</div>
<div class="text-center">
<Button variant="ghost" class="border border-foreground/15 shadow-none mx-auto mt-5 inline-block"
as="a" href="">
View More Details
</Button>
</div>
</TabsContent>
</TabsRoot>
</Box>
<!-- END: Work In Progress -->
<!-- BEGIN: Daily Sales -->
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
<div class="flex items-center border-b border-foreground/15 px-5 py-5 sm:py-3">
<h2 class="mr-auto text-base font-medium">Daily Sales</h2>
<MenuRoot class="w-auto ml-auto sm:hidden">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="download">
<Lucide class="mr-2 size-4" icon="File" /> Download Excel
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
<Button variant="ghost" class="shadow-none border border-foreground/15 hidden sm:flex">
<Lucide class="mr-2 size-4" icon="File" /> Download Excel
</Button>
</div>
<div class="p-5">
<div v-for="(faker, index) in fakers.slice(0, 3)" :key="index" :class="{ 'mt-5': index > 0 }"
class="relative flex items-center">
<AvatarRoot class="size-12 bg-background rounded-full">
<AvatarFallback>IM</AvatarFallback>
<AvatarImage :src="faker['photos'][0]" />
</AvatarRoot>
<div class="ml-4 mr-auto">
<a class="font-medium" href="">{{ faker['users'][0]!['name'] }}</a>
<div class="mr-5 opacity-70 sm:mr-5">
{{
index === 0
? 'Bootstrap 4 HTML Admin Template'
: index === 1
? 'Tailwind Admin Dashboard Template'
: 'Vuejs HTML Admin Template'
}}
</div>
</div>
<div class="font-medium">
{{ index === 0 ? '+$19' : index === 1 ? '+$25' : '+$21' }}
</div>
</div>
</div>
</Box>
<!-- END: Daily Sales -->
<!-- BEGIN: Latest Tasks -->
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
<TabsRoot defaultValue="lt-0">
<div class="relative flex items-center border-b border-foreground/15 p-5">
<h2 class="mr-auto text-base font-medium">Latest Tasks</h2>
<MenuRoot class="w-auto ml-auto sm:hidden">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="new">New</MenuItem>
<MenuItem value="last-week">Last Week</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
<TabsList class="absolute inset-y-0 h-[2.8rem] right-5 my-auto hidden w-auto sm:flex">
<TabsTrigger value="lt-0">New</TabsTrigger>
<TabsTrigger value="lt-1">Last Week</TabsTrigger>
</TabsList>
</div>
<div class="p-5">
<TabsContent value="lt-0">
<div class="flex items-center">
<div class="border-l-4 border-primary/20 pl-4">
<a class="font-medium" href="">Create New Campaign</a>
<div class="opacity-70">10:00 AM</div>
</div>
<div class="ml-auto">
<SwitchRoot>
<SwitchControl />
</SwitchRoot>
</div>
</div>
<div class="mt-5 flex items-center">
<div class="border-l-4 border-primary/20 pl-4">
<a class="font-medium" href="">Meeting With Client</a>
<div class="opacity-70">02:00 PM</div>
</div>
<div class="ml-auto">
<SwitchRoot>
<SwitchControl />
</SwitchRoot>
</div>
</div>
<div class="mt-5 flex items-center">
<div class="border-l-4 border-primary/20 pl-4">
<a class="font-medium" href="">Create New Repository</a>
<div class="opacity-70">04:00 PM</div>
</div>
<div class="ml-auto">
<SwitchRoot>
<SwitchControl />
</SwitchRoot>
</div>
</div>
</TabsContent>
</div>
</TabsRoot>
</Box>
<!-- END: Latest Tasks -->
<Box raised="single" class="col-span-12 p-0">
<CarouselRoot :default-page="0" :slide-count="fakers.slice(0, 5).length">
<div class="flex items-center border-b border-foreground/15 px-5 py-3">
<h2 class="mr-auto text-base font-medium">New Products</h2>
<CarouselPrevTrigger as-child>
<Button variant="ghost" class="shadow-none border border-foreground/15 mr-2">
<Lucide class="size-4" icon="ChevronLeft" />
</Button>
</CarouselPrevTrigger>
<CarouselNextTrigger as-child>
<Button variant="ghost" class="shadow-none border border-foreground/15">
<Lucide class="size-4" icon="ChevronRight" />
</Button>
</CarouselNextTrigger>
</div>
<div class="px-5">
<CarouselItemGroup class="py-5">
<CarouselItem v-for="(faker, index) in fakers.slice(0, 5)" :key="index" :index="index" class="px-5"
as-child>
<div>
<div class="flex flex-col items-center pb-5 lg:flex-row">
<div class="flex flex-col items-center pr-5 sm:flex-row lg:border-r border-foreground/15">
<div class="sm:mr-5">
<AvatarRoot class="size-20 bg-background rounded-full">
<AvatarFallback>IM</AvatarFallback>
<AvatarImage :src="faker['images'][0]" />
</AvatarRoot>
</div>
<div class="mr-auto mt-3 text-center sm:mt-0 sm:text-left">
<a class="text-lg font-medium" href="">
{{ faker['products'][0]!['name'] }}
</a>
<div class="mt-1 opacity-70 sm:mt-0">
{{ faker['news'][0]!['shortContent'] }}
</div>
</div>
</div>
<div
class="mt-6 flex w-full flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-4 lg:mt-0 lg:w-auto lg:border-t-0 lg:pt-0">
<div class="w-20 rounded-md py-3 text-center">
<div class="text-xl font-medium">{{ faker['totals'][0] }}</div>
<div class="opacity-70">Orders</div>
</div>
<div class="w-20 rounded-md py-3 text-center">
<div class="text-xl font-medium">{{ faker['totals'][1] }}k</div>
<div class="opacity-70">Purchases</div>
</div>
<div class="w-20 rounded-md py-3 text-center">
<div class="text-xl font-medium">{{ faker['totals'][0] }}</div>
<div class="opacity-70">Reviews</div>
</div>
</div>
</div>
<div class="flex flex-col items-center border-t border-foreground/15 pt-5 sm:flex-row">
<div
class="flex w-full items-center justify-center border-b border-foreground/15 pb-5 sm:w-auto sm:justify-start sm:border-b-0 sm:pb-0">
<Badge look="outline" class="mr-3 px-3 py-2">{{
faker['dates'][0]
}}</Badge>
<div class="opacity-70">Date of Release</div>
</div>
<div class="mt-5 flex sm:ml-auto sm:mt-0">
<Button variant="ghost"
class="border border-foreground/15 shadow-none ml-auto">Preview</Button>
<Button variant="ghost" class="border border-foreground/15 shadow-none ml-2">Details</Button>
</div>
</div>
</div>
</CarouselItem>
</CarouselItemGroup>
</div>
</CarouselRoot>
</Box>
<!-- END: New Products -->
</div>
</TabsContent>
</TabsRoot>
</div>
</template>
-5
View File
@@ -95,11 +95,6 @@ const router = createRouter({
name: 'post',
component: () => import('../views/Post.vue'),
},
{
path: 'crud-data-list',
name: 'crud-data-list',
component: () => import('../views/CrudDataList.vue'),
},
{
path: 'crud-form',
name: 'crud-form',
+72 -22
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useBreadcrumb } from '@/composables/useBreadcrumb'
import { useNotifications } from '@/modules/notification'
import '@/assets/css/themes/enigma/side-menu.css'
import logo from '@/assets/images/logo-kopkb.svg'
import logo from '@/assets/images/logonew-mykopkb.svg'
import { useSideMenu } from '@/composables/useSideMenu'
import { useAuthStore } from '@/stores/auth'
import { Lucide } from '@/components/ui/lucide'
@@ -40,9 +40,59 @@ const { unreadCount, fetchNotifications } = useNotifications()
const appName = import.meta.env.VITE_APP_NAME
const appVersion = import.meta.env.VITE_APP_VERSION
const notificationsOpen = ref(false)
const profileOpen = ref(false)
const sidebarProfileOpen = ref(false)
const notificationsRef = ref<HTMLElement | null>(null)
const profileRef = ref<HTMLElement | null>(null)
const sidebarProfileRef = ref<HTMLElement | null>(null)
function toggleNotifications() {
profileOpen.value = false
sidebarProfileOpen.value = false
notificationsOpen.value = !notificationsOpen.value
}
function toggleProfile() {
notificationsOpen.value = false
sidebarProfileOpen.value = false
profileOpen.value = !profileOpen.value
}
function closeDropdowns() {
notificationsOpen.value = false
profileOpen.value = false
sidebarProfileOpen.value = false
}
function handleDocumentClick(event: MouseEvent) {
const target = event.target as Node
if (
notificationsRef.value?.contains(target) ||
profileRef.value?.contains(target) ||
sidebarProfileRef.value?.contains(target)
) {
return
}
closeDropdowns()
}
watch(sidebarProfileOpen, (isOpen) => {
if (!isOpen) return
notificationsOpen.value = false
profileOpen.value = false
})
onMounted(() => {
authStore.fetchSession()
fetchNotifications()
document.addEventListener('click', handleDocumentClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
</script>
@@ -76,6 +126,7 @@ onMounted(() => {
'z-20 relative w-[320px] duration-100 transition-[width] group-[.side-menu--collapsed]:xl:w-[165px] group-[.side-menu--collapsed]:group-hover:xl:w-[320px] h-screen flex flex-col-reverse xl:flex-col',
'before:absolute before:inset-y-0 before:w-px before:mt-23 before:bg-foreground/11 dark:before:bg-foreground/10 before:right-0 before:mr-8 before:hidden xl:before:block',
]">
<!-- Logo -->
<div
class="relative z-10 mt-3 hidden h-[90px] w-[320px] flex-none items-center overflow-hidden pl-8 pr-14 duration-100 xl:flex group-[.side-menu--collapsed]:group-hover:xl:w-[320px] group-[.side-menu--collapsed]:xl:w-[165px]">
<a class="relative flex items-center transition-[margin] duration-100 xl:ml-1.5 group-[.side-menu--collapsed]:group-hover:xl:ml-1.5 group-[.side-menu--collapsed]:xl:ml-8"
@@ -91,13 +142,15 @@ onMounted(() => {
<Lucide icon="ChevronLeft" />
</button>
</div>
<AccountTrigger class="relative transition-[width] xl:mb-2 xl:mr-8"
innerClass="border-foreground/[.11] dark:border-foreground/[.11] border-t py-3 pl-6 pr-5 opacity-90 hover:opacity-100 xl:border-b xl:border-t-0 xl:pb-6 xl:pl-8 xl:pt-2.5"
avatarClass="h-11 w-11 border-background/70 dark:border-foreground/20 shadow-sm"
textClass="text-primary dark:text-foreground w-full">
<AccountDropdown class="absolute top-0 left-full origin-bottom-left"
boxClass="absolute bottom-0 left-[100%] ml-2 xl:bottom-auto xl:top-0" />
</AccountTrigger>
<div ref="sidebarProfileRef">
<AccountTrigger v-model:open="sidebarProfileOpen" class="relative transition-[width] xl:mb-2 xl:mr-8"
innerClass="border-foreground/[.11] dark:border-foreground/[.11] border-t py-3 pl-6 pr-5 opacity-90 hover:opacity-100 xl:border-b xl:border-t-0 xl:pb-6 xl:pl-8 xl:pt-2.5"
avatarClass="h-11 w-11 border-background/70 dark:border-foreground/20 shadow-sm"
textClass="text-primary dark:text-foreground w-full">
<AccountDropdown :open="sidebarProfileOpen" class="absolute top-0 left-full origin-bottom-left"
boxClass="absolute bottom-0 left-[100%] ml-2 xl:bottom-auto xl:top-0" />
</AccountTrigger>
</div>
<ScrollAreaRoot class="flex-1 min-h-0">
<ScrollAreaViewport :class="[
@@ -145,29 +198,26 @@ onMounted(() => {
<Breadcrumb v-if="breadcrumbItems.length"
class="mr-auto hidden xl:flex [&_ol]:text-(--color-nav-foreground)/70 [&_li]:last:text-(--color-nav-foreground)/90 [&_li]:hover:text-(--color-nav-foreground)/90"
:items="breadcrumbItems" />
<div
<div ref="notificationsRef"
class="group/notifications relative flex size-10 flex-none cursor-pointer items-center justify-center rounded-xl border border-(--color-nav-foreground)/30 bg-(--color-nav-foreground)/10 transition-colors hover:bg-(--color-nav-foreground)/20"
>
<Lucide
class="size-[22px] stroke-[1.75] [--color:var(--color-nav-foreground)]"
icon="Bell"
/>
<span
v-if="unreadCount > 0"
class="bg-danger text-background ring-background absolute -right-1.5 -top-1.5 flex min-w-5 items-center justify-center rounded-full px-1 py-0.5 text-[11px] font-semibold leading-none ring-2"
>
@click.stop="toggleNotifications">
<Lucide class="size-[22px] stroke-[1.75] [--color:var(--color-nav-foreground)]" icon="Bell" />
<span v-if="unreadCount > 0"
class="bg-danger text-background ring-background absolute -right-1.5 -top-1.5 flex min-w-5 items-center justify-center rounded-full px-1 py-0.5 text-[11px] font-semibold leading-none ring-2">
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
<NotificationDropdown class="absolute right-0 top-full mt-2 origin-top-right"
<NotificationDropdown :open="notificationsOpen"
class="absolute right-0 top-full mt-2 origin-top-right"
boxClass="absolute right-0 top-0 -mr-0.5 -mt-0.5" />
</div>
<div class="group/profile relative size-9 flex-none">
<div ref="profileRef" class="group/profile relative size-9 flex-none cursor-pointer"
@click.stop="toggleProfile">
<AvatarRoot class="ring-(--color)/40 size-full [--color:var(--color-nav-foreground)] rounded-full">
<AvatarFallback>{{ authStore.userName }}</AvatarFallback>
<AvatarImage v-if="authStore.userImageUrl" :src="authStore.userImageUrl"
:alt="authStore.userName" />
</AvatarRoot>
<AccountDropdown class="absolute right-0 top-full mt-2 origin-top-right"
<AccountDropdown :open="profileOpen" class="absolute right-0 top-full mt-2 origin-top-right"
boxClass="absolute right-0 top-0 -mr-0.5 -mt-0.5" />
</div>
</div>
-177
View File
@@ -1,177 +0,0 @@
<script lang="ts" setup>
import { ref } from 'vue'
import fakers from '@/utils/faker'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
import {
PaginationContext,
PaginationRoot,
PaginationItem,
PaginationPrevTrigger,
PaginationNextTrigger,
PaginationEllipsis,
} from '@/components/ui/pagination'
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/table'
const deleteConfirmationOpen = ref(false)
</script>
<template>
<div>
<h2 class="text-lg font-medium">Data List Layout</h2>
<div class="mt-5 grid grid-cols-12 gap-6">
<div class="col-span-12 mt-2 flex flex-wrap items-center sm:flex-nowrap">
<Button class="mr-2" look="outline" variant="primary"> Add New Product </Button>
<MenuRoot class="w-auto">
<MenuTrigger as-child>
<Box class="px-2" as-child>
<Button variant="ghost" class="box flex items-center">
<span class="flex h-5 w-5 items-center justify-center">
<Lucide class="h-4 w-4" icon="Plus" />
</span>
</Button>
</Box>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="print">
<Lucide class="mr-2 h-4 w-4" icon="Printer" /> Print
</MenuItem>
<MenuItem value="excel">
<Lucide class="mr-2 h-4 w-4" icon="FileText" /> Export to Excel
</MenuItem>
<MenuItem value="pdf">
<Lucide class="mr-2 h-4 w-4" icon="FileText" /> Export to PDF
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
<div class="mx-auto hidden opacity-70 md:block">Showing 1 to 10 of 150 entries</div>
<div class="mt-3 w-full sm:ml-auto sm:mt-0 sm:w-auto md:ml-0">
<div class="relative w-56">
<Input class="w-56 pr-10" type="text" placeholder="Search..." />
<Lucide class="absolute inset-y-0 right-0 my-auto mr-3 h-4 w-4" icon="Search" />
</div>
</div>
</div>
<!-- BEGIN: Data List -->
<div class="col-span-12 overflow-auto lg:overflow-visible">
<Table class="-mt-2" variant="boxed">
<TableHeader>
<TableRow>
<TableHead> IMAGES </TableHead>
<TableHead> PRODUCT NAME </TableHead>
<TableHead class="text-center"> STOCK </TableHead>
<TableHead class="text-center"> STATUS </TableHead>
<TableHead class="text-center"> ACTIONS </TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="(faker, index) in fakers.slice(0, 9)" :key="index">
<TableCell>
<div class="flex">
<AvatarRoot class="size-11 bg-background rounded-full">
<AvatarFallback>IM</AvatarFallback>
<AvatarImage :src="faker['images'][0]" :alt="'Uploaded at ' + faker['dates'][0]" />
</AvatarRoot>
<AvatarRoot class="size-11 bg-background rounded-full -ms-5">
<AvatarFallback>IM</AvatarFallback>
<AvatarImage :src="faker['images'][1]" :alt="'Uploaded at ' + faker['dates'][1]" />
</AvatarRoot>
<AvatarRoot class="size-11 bg-background rounded-full -ms-5">
<AvatarFallback>IM</AvatarFallback>
<AvatarImage :src="faker['images'][2]" :alt="'Uploaded at ' + faker['dates'][2]" />
</AvatarRoot>
</div>
</TableCell>
<TableCell>
<a class="whitespace-nowrap font-medium" href="">
{{ faker['products'][0]!['name'] }}
</a>
<div class="mt-0.5 whitespace-nowrap text-xs opacity-70">
{{ faker['products'][0]!['category'] }}
</div>
</TableCell>
<TableCell class="text-center">
{{ faker['stocks'][0] }}
</TableCell>
<TableCell>
<div class="flex items-center justify-center"
:class="faker['trueFalse'][0] ? 'text-success' : 'text-danger'">
<Lucide class="mr-2 h-4 w-4" icon="CheckSquare" />
{{ faker['trueFalse'][0] ? 'Active' : 'Inactive' }}
</div>
</TableCell>
<TableCell>
<div class="flex items-center justify-center">
<a class="mr-3 flex items-center" href="">
<Lucide class="mr-1 h-4 w-4" icon="CheckSquare" />
Edit
</a>
<a class="text-danger flex items-center" href="#" @click.prevent="deleteConfirmationOpen = true">
<Lucide class="mr-1 h-4 w-4" icon="Trash" />
Delete
</a>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- END: Data List -->
<!-- BEGIN: Pagination -->
<div class="col-span-12 flex flex-wrap items-center sm:flex-row sm:flex-nowrap">
<PaginationRoot :count="150" :pageSize="10" :siblingCount="1" class="w-full sm:mr-auto sm:w-auto">
<PaginationPrevTrigger>Previous</PaginationPrevTrigger>
<PaginationContext v-slot="{ pagination }">
<template v-for="(page, index) in pagination?.pages" :key="index">
<PaginationItem v-if="page.type === 'page'" v-bind="{ ...page }">
{{ page.value }}
</PaginationItem>
<PaginationEllipsis v-else :index="index" />
</template>
</PaginationContext>
<PaginationNextTrigger>Next</PaginationNextTrigger>
</PaginationRoot>
<NativeSelect class="mt-3 w-20 sm:mt-0">
<NativeSelectOption value="10">10</NativeSelectOption>
<NativeSelectOption value="25">25</NativeSelectOption>
<NativeSelectOption value="35">35</NativeSelectOption>
<NativeSelectOption value="50">50</NativeSelectOption>
</NativeSelect>
</div>
<!-- END: Pagination -->
</div>
<!-- BEGIN: Delete Confirmation Modal -->
<DialogRoot :open="deleteConfirmationOpen" @openChange="(details) => (deleteConfirmationOpen = details.open)">
<DialogContent>
<div class="p-5 text-center">
<Lucide class="text-danger mx-auto mt-3 size-16 stroke-1" icon="CircleX" />
<div class="mt-5 text-2xl font-medium">Are you sure?</div>
<div class="mt-2 opacity-70">
Do you really want to delete these records? <br />
This process cannot be undone.
</div>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-24"> Cancel </DialogCloseTrigger>
<Button class="w-24" type="button" variant="danger" look="outline"> Delete </Button>
</div>
</DialogContent>
</DialogRoot>
<!-- END: Delete Confirmation Modal -->
</div>
</template>